clock_kit/calibrated.rs
1//! Calibrated hardware-counter clock — REQ-CLK-003.
2//!
3//! The hot path reads a hardware counter (`x86_64` `rdtsc`, aarch64
4//! `cntvct_el0`) and converts with a **multiply-shift** — never a division.
5//! Calibration ([`calibrate`]) is an explicit, fallible cold-path step: it
6//! brackets counter reads with a reference monotonic clock and derives a
7//! 32.32 fixed-point ns-per-tick factor. If calibration fails or the
8//! counter is judged unusable (no invariant-TSC bit, unstable rate),
9//! [`CalibratedClock`] **transparently falls back** to `Instant` (std) /
10//! `CLOCK_MONOTONIC` (libc); [`Clock::source`] reports which path actually
11//! serves reads.
12
13use crate::clock::Clock;
14use crate::monotonic::mono_floor;
15use crate::quality::{ClockSource, SyncQuality};
16use crate::system::{monotonic_nanos, wall_nanos};
17use crate::timestamp::{Timestamp, WallTime};
18
19#[cfg(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64")))]
20use crate::low;
21
22/// How the ns-per-tick factor was derived.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24pub enum CalMethod {
25 /// `x86_64` invariant TSC bracketed against the reference clock.
26 X86TscInvariant,
27 /// aarch64 CNTVCT bracketed against the reference clock.
28 ArmCntvct,
29}
30
31/// A calibrated ns-per-tick conversion factor, held as
32/// `ns_per_tick + frac/2^32` (a 32.32 fixed point).
33///
34/// Conversion ([`Calibration::ticks_to_ns`]) is two multiplies and a shift
35/// — no division on the read path. REQ-CLK-003.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
37pub struct Calibration {
38 /// Integral nanoseconds per counter tick.
39 pub ns_per_tick: u64,
40 /// Fractional nanos per tick, numerator over 2^32.
41 pub frac: u32,
42 /// How the factor was measured.
43 pub method: CalMethod,
44}
45
46impl Calibration {
47 /// Convert raw ticks to nanoseconds (multiply-shift; no division).
48 #[must_use]
49 pub fn ticks_to_ns(&self, ticks: u64) -> u64 {
50 let integral = ticks.wrapping_mul(self.ns_per_tick);
51 // Infallible widening (u64/u32 ⊂ u128), one multiply, one shift.
52 #[allow(clippy::cast_possible_truncation)] // 32.32: low word by construction
53 let frac = ((u128::from(ticks) * u128::from(self.frac)) >> 32) as u64;
54 integral.wrapping_add(frac)
55 }
56}
57
58/// Why calibration refused the hardware counter.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
60pub enum CalError {
61 /// No reference monotonic clock is available (neither `std` nor
62 /// `libc` features).
63 NoReferenceClock,
64 /// The architecture has no supported counter (the `tsc` feature is on,
65 /// but this is not `x86_64/aarch64`), or the invariant/frequency check
66 /// failed.
67 UnsupportedCounter,
68 /// Two bracketed measurements disagreed beyond tolerance — the counter
69 /// is not constant-rate (or the machine is wildly noisy).
70 UnstableCounter,
71 /// The measurement window made no progress (a dead counter).
72 NoProgress,
73}
74
75/// Derive the ns-per-tick calibration factor.
76///
77/// Cold path: brackets `reps` counter reads between two reference
78/// monotonic-clock reads (repeatable — call again to re-calibrate; see the
79/// drift caveat in README). Two independent measurements must agree within
80/// 1% or the counter is rejected ([`CalError::UnstableCounter`]).
81///
82/// # Errors
83///
84/// [`CalError::UnsupportedCounter`] when the architecture has no counter
85/// or the invariant/frequency check fails; [`CalError::UnstableCounter`]
86/// when the two bracketed measurements disagree beyond 1%;
87/// [`CalError::NoProgress`] when the counter does not advance;
88/// [`CalError::NoReferenceClock`] when no reference monotonic clock exists.
89///
90/// REQ-CLK-003.
91pub fn calibrate(reps: u32) -> Result<Calibration, CalError> {
92 #[cfg(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64")))]
93 {
94 #[cfg(target_arch = "x86_64")]
95 let method = {
96 if !low::counter::invariant_counter_supported() {
97 return Err(CalError::UnsupportedCounter);
98 }
99 CalMethod::X86TscInvariant
100 };
101 #[cfg(target_arch = "aarch64")]
102 let method = {
103 // Arch timer frequency must be sane before trusting a factor.
104 match low::counter::counter_frequency_hz() {
105 Some(f) if (1_000..=10_000_000_000).contains(&f) => CalMethod::ArmCntvct,
106 _ => return Err(CalError::UnsupportedCounter),
107 }
108 };
109
110 let a = measure_once(reps)?;
111 let b = measure_once(reps)?;
112 // Constant-frequency check: two factors must agree within 1%.
113 // Cross-multiplied in u128 — no floats, no division.
114 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
115 if (hi - lo) * 100 > lo {
116 return Err(CalError::UnstableCounter);
117 }
118 Ok(split_factor(u128::midpoint(a, b), method))
119 }
120 #[cfg(not(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64"))))]
121 {
122 let _ = reps;
123 Err(CalError::UnsupportedCounter)
124 }
125}
126
127#[cfg(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64")))]
128fn measure_once(reps: u32) -> Result<u128, CalError> {
129 let r0 = monotonic_nanos();
130 let t0 = low::counter::read();
131 for _ in 0..reps {
132 core::hint::black_box(low::counter::read());
133 }
134 let t1 = low::counter::read();
135 let r1 = monotonic_nanos();
136
137 let dns = u128::from(r1) - u128::from(r0);
138 let dticks = u128::from(t1) - u128::from(t0);
139 if dticks == 0 {
140 return Err(CalError::NoProgress);
141 }
142 if dns == 0 {
143 // Reference clock too coarse to calibrate against.
144 return Err(CalError::UnstableCounter);
145 }
146 // 32.32 fixed point: (ns << 32) / ticks. One division, cold path only.
147 Ok((dns << 32) / dticks)
148}
149
150#[cfg(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64")))]
151fn split_factor(factor: u128, method: CalMethod) -> Calibration {
152 Calibration {
153 // 32.32 split: by construction `factor < 2^96` (ns < 2^64), so the
154 // high word fits u64 and the low word is u32 by definition.
155 #[allow(clippy::cast_possible_truncation)]
156 ns_per_tick: (factor >> 32) as u64,
157 #[allow(clippy::cast_possible_truncation)]
158 frac: factor as u32,
159 method,
160 }
161}
162
163/// Precision clock over a hardware counter, with transparent fallback.
164///
165/// Construct via [`CalibratedClock::new`] (falls back silently when the
166/// counter is unusable — the requested precision stays observable through
167/// [`Clock::source`]) or [`CalibratedClock::try_new`] (strict: construction
168/// fails when the counter cannot be calibrated). REQ-CLK-003.
169#[derive(Clone, Copy, Debug)]
170pub struct CalibratedClock {
171 cal: Option<Calibration>,
172 fallback_source: ClockSource,
173}
174
175impl CalibratedClock {
176 /// Calibrate and construct; on any calibration failure construct the
177 /// reference-clock fallback instead (never fails). `reps` scales the
178 /// calibration workload (e.g. `1_000..=1_000_000`).
179 #[must_use]
180 pub fn new(reps: u32) -> Self {
181 match Self::try_new(reps) {
182 Ok(clock) => clock,
183 // Transparent fallback: source() reports what actually serves.
184 Err(_) => Self {
185 cal: None,
186 fallback_source: fallback_source(),
187 },
188 }
189 }
190
191 /// Strict construction: `Err` when the hardware counter is unusable.
192 ///
193 /// # Errors
194 ///
195 /// Whatever [`calibrate`] reports (see its `# Errors` section) — the
196 /// fallback constructor [`CalibratedClock::new`] is the transparent
197 /// counterpart.
198 pub fn try_new(reps: u32) -> Result<Self, CalError> {
199 Ok(Self {
200 cal: Some(calibrate(reps)?),
201 fallback_source: fallback_source(),
202 })
203 }
204
205 /// Construct the explicit reference-clock fallback — opting out of the
206 /// counter path entirely (useful when a consumer wants the
207 /// conservative source by policy, not by calibration outcome).
208 #[must_use]
209 pub fn with_fallback() -> Self {
210 Self {
211 cal: None,
212 fallback_source: fallback_source(),
213 }
214 }
215
216 /// Construct directly from a pre-computed calibration (e.g. re-using a
217 /// factor calibrated earlier in the process).
218 #[must_use]
219 pub fn with_calibration(cal: Calibration) -> Self {
220 Self {
221 cal: Some(cal),
222 fallback_source: fallback_source(),
223 }
224 }
225
226 /// The active calibration, if the counter path is in use.
227 #[must_use]
228 pub fn calibration(&self) -> Option<Calibration> {
229 self.cal
230 }
231}
232
233impl Clock for CalibratedClock {
234 fn now(&self) -> Timestamp {
235 let mono = match self.cal {
236 #[cfg(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64")))]
237 Some(cal) => mono_floor(cal.ticks_to_ns(low::counter::read())),
238 // `Some` is unreachable where the counter path does not exist
239 // (calibrate() only succeeds on supported arches), but this arm
240 // must still compile: the fallback serves there too.
241 _ => mono_floor(monotonic_nanos()),
242 };
243 Timestamp {
244 wall: WallTime::from_unix_nanos(wall_nanos()),
245 mono,
246 }
247 }
248
249 fn quality(&self) -> SyncQuality {
250 #[cfg(feature = "ptp")]
251 {
252 crate::system::cached_quality()
253 }
254 #[cfg(not(feature = "ptp"))]
255 {
256 SyncQuality::Fallback
257 }
258 }
259
260 fn source(&self) -> ClockSource {
261 match self.cal {
262 Some(Calibration {
263 method: CalMethod::X86TscInvariant,
264 ..
265 }) => ClockSource::Tsc,
266 Some(Calibration {
267 method: CalMethod::ArmCntvct,
268 ..
269 }) => ClockSource::Cntvct,
270 None => self.fallback_source,
271 }
272 }
273}
274
275fn fallback_source() -> ClockSource {
276 #[cfg(feature = "std")]
277 {
278 ClockSource::Instant
279 }
280 #[cfg(not(feature = "std"))]
281 {
282 ClockSource::LibcMono
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 #[cfg(not(miri))]
289 use super::calibrate;
290 use super::{CalMethod, Calibration};
291
292 #[cfg(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64")))]
293 use super::split_factor;
294
295 /// Multiply-shift conversion is exact at the integral level and
296 /// monotone in ticks. Pure logic — runs under miri.
297 #[test]
298 fn ticks_to_ns_is_linear_and_monotone() {
299 // 3 GHz-ish: 0.333.. ns/tick → ns_per_tick = 0, frac ≈ 0x5555_5555.
300 let cal = Calibration {
301 ns_per_tick: 0,
302 frac: 0x5555_5555,
303 method: CalMethod::X86TscInvariant,
304 };
305 let ns1 = cal.ticks_to_ns(1);
306 let ns9 = cal.ticks_to_ns(9);
307 // 1 tick ≈ 0.3333 ns → truncates to 0; 9 ticks ≈ 3 ns.
308 #[allow(clippy::cast_possible_truncation)] // 32.32 low word by construction
309 {
310 assert_eq!(ns1, (0x5555_5555_u128 >> 32) as u64);
311 assert_eq!(ns9, ((9_u128 * 0x5555_5555) >> 32) as u64);
312 }
313
314 // 10 ns/tick exactly: integral dominates.
315 let cal10 = Calibration {
316 ns_per_tick: 10,
317 frac: 0,
318 method: CalMethod::ArmCntvct,
319 };
320 assert_eq!(cal10.ticks_to_ns(0), 0);
321 assert_eq!(cal10.ticks_to_ns(1_000), 10_000);
322
323 // Monotone in ticks across the wrap-safe path.
324 let mut prev = 0;
325 for t in [0_u64, 1, 2, 100, u64::MAX / 8] {
326 let ns = cal10.ticks_to_ns(t);
327 assert!(ns >= prev);
328 prev = ns;
329 }
330 }
331
332 /// Factor splitting keeps the 32.32 round trip.
333 #[cfg(all(feature = "tsc", any(target_arch = "x86_64", target_arch = "aarch64")))]
334 #[test]
335 fn split_factor_round_trips() {
336 let cal = split_factor((3_u128 << 32) / 10, CalMethod::X86TscInvariant);
337 assert_eq!(cal.ns_per_tick, 0);
338 // 0.3 ns/tick as 32.32.
339 assert_eq!(cal.frac, 0x4CCC_CCCC);
340 }
341
342 /// Calibration is repeatable where a counter exists, and cleanly
343 /// refused otherwise. Touches the reference clock — skipped under
344 /// miri (FFI/clock syscalls are out of miri scope, spec §Gate plan).
345 #[cfg(not(miri))]
346 #[test]
347 fn calibrate_is_repeatable_or_cleanly_refused() {
348 if let Ok(a) = calibrate(1_000) {
349 // Repeatable: a second calibration must also succeed and the
350 // factor must be usable (some conversion output exists).
351 let b = calibrate(1_000).unwrap_or(a);
352 assert!(b.ns_per_tick > 0 || b.frac > 0);
353 assert!(a.ticks_to_ns(u64::MAX / 8) > 0);
354 }
355 }
356
357 /// Advisory surface without hardware: the explicit fallback reports
358 /// the reference source, and both counter sources are nameable via
359 /// `with_calibration` (no hardware needed to check observability).
360 #[test]
361 fn source_is_observable_per_construction() {
362 use crate::clock::Clock;
363 use crate::quality::ClockSource;
364
365 let fallback = super::CalibratedClock::with_fallback();
366 assert!(fallback.calibration().is_none());
367 #[cfg(feature = "std")]
368 assert_eq!(fallback.source(), ClockSource::Instant);
369 #[cfg(not(feature = "std"))]
370 assert_eq!(fallback.source(), ClockSource::LibcMono);
371 // Quality is total: any classification, never a panic.
372 let _ = fallback.quality();
373
374 #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
375 {
376 let tsc = super::CalibratedClock::with_calibration(Calibration {
377 ns_per_tick: 1,
378 frac: 0,
379 method: CalMethod::X86TscInvariant,
380 });
381 assert_eq!(tsc.source(), ClockSource::Tsc);
382 assert!(tsc.calibration().is_some());
383
384 let cnt = super::CalibratedClock::with_calibration(Calibration {
385 ns_per_tick: 0,
386 frac: 40,
387 method: CalMethod::ArmCntvct,
388 });
389 assert_eq!(cnt.source(), ClockSource::Cntvct);
390 }
391 }
392}