Skip to main content

t2t_core/
tick_rate.rs

1//! A counter's rate, and the conversion between its ticks and nanoseconds.
2
3use core::num::NonZeroU64;
4use core::str::FromStr;
5
6use derive_more::{Debug, Display};
7#[cfg(feature = "zerocopy-08")]
8use zerocopy::{Immutable, KnownLayout};
9
10use crate::ParseTickRateError;
11use crate::consts::NANOS_PER_SECOND;
12use crate::spelling::{Count, read_count};
13
14/// How many ticks a counter advances in a second: what turns [`Tickdelta`](crate::Tickdelta) into a
15/// [`Timedelta`](crate::Timedelta) and back.
16///
17/// A conversion is a multiply and a shift, with factors [`from_hertz`](Self::from_hertz) works out
18/// once: no division. An exact multiple converts exactly, any other count to within one unit of
19/// its exact quotient, and the range's ends saturate. It is written as its hertz, `24000000 Hz`,
20/// and parses back from it.
21///
22/// # Examples
23/// ```
24/// use t2t_core::{TickRate, Tickdelta, Timedelta};
25///
26/// let rate: TickRate = "24000000 Hz".parse()?;
27/// assert_eq!(Tickdelta::from_ticks(24).to_timedelta(rate), Timedelta::MICROSECOND, "at 24 MHz");
28/// assert_eq!(Timedelta::SECOND.to_tickdelta(rate), Tickdelta::from_ticks(24_000_000), "and back");
29/// # Ok::<(), t2t_core::ParseTickRateError>(())
30/// ```
31#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[display("{}", Count(hertz.get(), " Hz"))]
33#[debug("{self}")]
34#[cfg_attr(feature = "zerocopy-08", derive(Immutable, KnownLayout))]
35pub struct TickRate {
36    /// Ticks a second.
37    hertz: NonZeroU64,
38    /// Ticks to nanoseconds.
39    ticks_to_nanos: Scale,
40    /// Nanoseconds to ticks.
41    nanos_to_ticks: Scale,
42}
43
44impl TickRate {
45    /// A tick a nanosecond, the rate the Arm generic timer runs at from Armv8.6 on.
46    pub const GIGAHERTZ: Self = Self::from_hertz(1_000_000_000).expect("a billion is no zero");
47
48    /// A counter advancing `hertz` ticks a second, or `None` for zero.
49    #[inline]
50    #[must_use]
51    pub const fn from_hertz(hertz: u64) -> Option<Self> {
52        let Some(nonzero) = NonZeroU64::new(hertz) else {
53            return None;
54        };
55        let nanos_per_second = NANOS_PER_SECOND.unsigned_abs();
56        Some(Self {
57            hertz: nonzero,
58            ticks_to_nanos: Scale::new(nanos_per_second, hertz),
59            nanos_to_ticks: Scale::new(hertz, nanos_per_second),
60        })
61    }
62
63    /// Ticks a second.
64    #[inline]
65    #[must_use]
66    pub const fn as_hertz(self) -> u64 {
67        self.hertz.get()
68    }
69
70    /// `ticks` in nanoseconds, saturating.
71    #[inline]
72    pub(crate) const fn ticks_to_nanos(self, ticks: i64) -> i64 {
73        self.ticks_to_nanos.apply(ticks)
74    }
75
76    /// `nanos` in ticks, saturating.
77    #[inline]
78    pub(crate) const fn nanos_to_ticks(self, nanos: i64) -> i64 {
79        self.nanos_to_ticks.apply(nanos)
80    }
81}
82
83/// Reads what [`Display`](core::fmt::Display) writes: a count of hertz above zero, then ` Hz`.
84impl FromStr for TickRate {
85    type Err = ParseTickRateError;
86
87    fn from_str(text: &str) -> Result<Self, Self::Err> {
88        let hertz = text.strip_suffix(" Hz").and_then(read_count);
89        hertz.and_then(Self::from_hertz).ok_or(ParseTickRateError)
90    }
91}
92
93/// A ratio in fixed point: a count times `factor`, shifted right by `shift`.
94///
95/// The factor is rounded up, so an exact multiple converts exactly, and any other count to within
96/// one unit of its exact quotient.
97#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
98#[cfg_attr(feature = "zerocopy-08", derive(Immutable, KnownLayout))]
99struct Scale {
100    /// The ratio times two to the `shift`, rounded up.
101    factor: u64,
102    /// The power of two `factor` is scaled by.
103    shift: u32,
104}
105
106impl Scale {
107    /// `numerator / denominator`, with the largest shift whose factor still fits 64 bits, so the
108    /// factor carries every bit of precision it can.
109    const fn new(numerator: u64, denominator: u64) -> Self {
110        let numerator = widen(numerator);
111        let denominator = widen(denominator);
112        // `numerator << shift` lands near `denominator << 64` here, and one step below fits.
113        let mut shift = 64_u32
114            .saturating_add(numerator.leading_zeros())
115            .saturating_sub(denominator.leading_zeros());
116        while shift > 0 && !fits_u64(quotient(numerator, denominator, shift)) {
117            shift = shift.saturating_sub(1);
118        }
119        Self { factor: narrow(quotient(numerator, denominator, shift)), shift }
120    }
121
122    /// `count` scaled by the ratio, saturating at the range's ends.
123    #[inline]
124    const fn apply(self, count: i64) -> i64 {
125        // Neither step wraps: a 64-bit product fits 128 bits, and the shift is below 128.
126        let magnitude =
127            widen(count.unsigned_abs()).wrapping_mul(widen(self.factor)).wrapping_shr(self.shift);
128        let magnitude = if fits_u64(magnitude) { narrow(magnitude) } else { u64::MAX };
129        if count < 0 {
130            0_i64.saturating_sub_unsigned(magnitude)
131        } else {
132            0_i64.saturating_add_unsigned(magnitude)
133        }
134    }
135}
136
137/// `numerator << shift`, divided by a nonzero `denominator`, rounded up.
138const fn quotient(numerator: u128, denominator: u128, shift: u32) -> u128 {
139    numerator.wrapping_shl(shift).div_ceil(denominator)
140}
141
142/// `value`, widened.
143const fn widen(value: u64) -> u128 {
144    #[expect(clippy::as_conversions, reason = "widening is lossless; `From` is not const")]
145    let widened = value as u128;
146    widened
147}
148
149/// Whether `value` fits a `u64`.
150const fn fits_u64(value: u128) -> bool {
151    value <= widen(u64::MAX)
152}
153
154/// `value`, which fits a `u64`, narrowed to one.
155const fn narrow(value: u128) -> u64 {
156    #[expect(
157        clippy::as_conversions,
158        clippy::cast_possible_truncation,
159        reason = "every caller checks the value fits; `TryFrom` is not const"
160    )]
161    let narrowed = value as u64;
162    narrowed
163}
164
165#[cfg(test)]
166mod tests {
167    use alloc::format;
168    use alloc::string::ToString as _;
169
170    use proptest::prelude::{any, prop_assert, proptest};
171    use rstest::rstest;
172
173    use crate::{ParseTickRateError, TickRate, Tickdelta, Timedelta};
174
175    /// A rate of `hertz`.
176    fn rate(hertz: u64) -> TickRate {
177        TickRate::from_hertz(hertz).expect("a nonzero rate")
178    }
179
180    /// `count * numerator / denominator`, exactly, truncated toward zero.
181    fn exact(count: i64, numerator: u64, denominator: u64) -> i128 {
182        i128::from(count)
183            .checked_mul(i128::from(numerator))
184            .and_then(|product| product.checked_div(i128::from(denominator)))
185            .expect("a 64-bit product fits 128 bits, over a nonzero denominator")
186    }
187
188    #[rstest]
189    #[case::apple_silicon(24_000_000)]
190    #[case::armv8_6(1_000_000_000)]
191    #[case::a_tsc(3_000_000_000)]
192    #[case::a_tsc_from_its_crystal(3_379_200_000)]
193    fn a_second_of_ticks_is_a_second(#[case] hertz: u64) {
194        let second = Tickdelta::from_ticks(i64::try_from(hertz).expect("a rate below `i64::MAX`"));
195        assert_eq!(second.to_timedelta(rate(hertz)), Timedelta::SECOND, "a second of ticks");
196        assert_eq!(Timedelta::SECOND.to_tickdelta(rate(hertz)), second, "and back, exactly");
197    }
198
199    #[test]
200    fn a_backwards_count_converts_backwards() {
201        let span = Tickdelta::from_ticks(-24).to_timedelta(rate(24_000_000));
202        assert_eq!(span, -Timedelta::MICROSECOND, "ticks to nanoseconds");
203        let ticks = (-Timedelta::MICROSECOND).to_tickdelta(rate(3_000_000_000));
204        assert_eq!(ticks, Tickdelta::from_ticks(-3_000), "and nanoseconds to ticks");
205    }
206
207    #[test]
208    fn a_conversion_past_the_range_saturates() {
209        assert_eq!(Tickdelta::MAX.to_timedelta(rate(1)), Timedelta::MAX, "forwards");
210        assert_eq!(Tickdelta::MIN.to_timedelta(rate(1)), Timedelta::MIN, "backwards");
211        assert_eq!(
212            Timedelta::MAX.to_tickdelta(rate(u64::MAX)),
213            Tickdelta::MAX,
214            "the other way about"
215        );
216    }
217
218    #[test]
219    fn a_rate_reads_back_from_its_hertz() {
220        assert_eq!(rate(24_000_000).to_string(), "24000000 Hz", "written as its hertz");
221        assert_eq!("24000000 Hz".parse(), Ok(rate(24_000_000)), "and read back");
222        assert_eq!(TickRate::GIGAHERTZ.as_hertz(), 1_000_000_000, "a gigahertz in hertz");
223        assert_eq!(TickRate::from_hertz(0), None, "a counter that never ticks");
224    }
225
226    #[test]
227    fn a_width_pads_the_rate_with_its_unit() {
228        assert_eq!(format!("[{:>8}]", rate(24)), "[   24 Hz]", "to the right");
229        assert_eq!(format!("{:?}", rate(24)), "24 Hz", "and debugged as displayed");
230    }
231
232    #[rstest]
233    #[case::zero("0 Hz")]
234    #[case::no_unit("24000000")]
235    #[case::a_sign_display_never_writes("+24 Hz")]
236    #[case::kilohertz("24 kHz")]
237    #[case::past_the_range("18446744073709551616 Hz")]
238    fn a_malformed_rate_is_refused(#[case] text: &str) {
239        assert_eq!(text.parse::<TickRate>(), Err(ParseTickRateError), "{text:?} is refused");
240    }
241
242    proptest! {
243        #[test]
244        fn a_conversion_is_within_one_unit_of_the_exact_quotient(
245            count in any::<i64>(),
246            hertz in 1_u64..,
247        ) {
248            let rate = rate(hertz);
249            let nanos = Tickdelta::from_ticks(count).to_timedelta(rate).as_nanos();
250            let ticks = Timedelta::from_nanos(count).to_tickdelta(rate).as_ticks();
251            for (actual, exact) in [
252                (nanos, exact(count, 1_000_000_000, hertz)),
253                (ticks, exact(count, hertz, 1_000_000_000)),
254            ] {
255                let exact = exact.clamp(i128::from(i64::MIN), i128::from(i64::MAX));
256                prop_assert!(i128::from(actual).abs_diff(exact) <= 1, "{actual} against {exact}");
257            }
258        }
259    }
260}