Skip to main content

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}