The CPU Counter
Counter reads the CPU’s free-running counter in one
instruction that touches no memory: cntvct_el0 on aarch64, the time-stamp
counter on x86_64. A reading is a Tickstamp, the
difference of two a Tickdelta, and the counter’s
TickRate turns that into a Timedelta.
Ticks and Rates
A tick’s length is the counter’s: Apple silicon’s counter runs at 24 MHz, an Arm
core’s from Armv8.6 on at 1 GHz, which is TickRate::GIGAHERTZ, and an x86_64
core’s at the rate its time-stamp counter is rated for. A reading’s origin is
the hardware’s own and means nothing, so only a span converts:
Tickdelta::to_timedelta and Timedelta::to_tickdelta, each at a rate.
fn a_rate_turns_ticks_into_a_span_and_back() {
use t2t::{TickRate, Tickdelta, Tickstamp, Timedelta};
// Apple silicon's counter runs at 24 MHz.
let rate = TickRate::from_hertz(24_000_000).expect("a rate above zero");
let (start, end) = (Tickstamp::from_ticks(1_000_000), Tickstamp::from_ticks(1_000_024));
let ticks = end - start;
assert_eq!(ticks, Tickdelta::from_ticks(24), "a reading minus a reading is a span of ticks");
assert_eq!(ticks.to_timedelta(rate), Timedelta::MICROSECOND, "worth a microsecond at 24 MHz");
assert_eq!(Timedelta::SECOND.to_tickdelta(rate), Tickdelta::from_ticks(24_000_000), "and back");
assert_eq!(TickRate::from_hertz(0), None, "a counter that never ticks has no rate");
}
Discovering the Counter
Counter::discover finds the counter’s rate, or refuses with a
CounterError that says why there is none to read
as time:
- On
aarch64it readscntfrq_el0, the rate the firmware wrote. - On
x86_64it refuses a time-stamp counter that is not invariant, by CPUID leaf0x8000_0007. It takes the rate from CPUID leaf0x15, a crystal’s frequency times a ratio; where that has none, from the hypervisor’s leaf0x4000_0010; and where neither has one, withstdon 64-bit Linux or macOS, it measures the rate over 10 ms againstRawMonotonicClock. - On either, a rate outside 1 MHz to 10 GHz is refused.
| Refusal | When | Why |
|---|---|---|
NotInvariant | the time-stamp counter’s rate follows the core’s | its ticks have no fixed length, so no rate makes them time |
NoRate | the CPU reports no rate, and none could be measured | nothing says what a tick is worth |
ImplausibleRate | the rate read or measured is outside 1 MHz to 10 GHz | below a tick a microsecond a counter measures nothing worth measuring, and none runs past 10 GHz, so a rate outside is taken for wrong |
NoRate comes only on x86_64, where CPUID reports no rate and none could be
measured, as without std, or off 64-bit Linux and macOS. Each refusal’s
message says which it is: counter error: a rate of 42 Hz is outside 1 MHz to 10 GHz.
fn the_counter_is_discovered_or_refused_with_its_reason() {
use t2t::Timedelta;
use t2t::clock::{Clock, Counter};
match Counter::discover() {
Ok(counter) => {
let start = counter.now();
let ticks = counter.now() - start;
let took = ticks.to_timedelta(counter.rate());
println!("two reads {ticks} apart at {}: {took}", counter.rate());
assert!(took < Timedelta::from_millis(1), "two reads back to back");
},
// A virtual machine may promise no invariant counter, and a CPU may report no rate.
Err(refusal) => println!("no counter to time with: {refusal}"),
}
}
A measurement takes 10 ms, so a program discovers its counter once, at start-up,
and keeps it: a Counter is Copy, and holds only its rate.
Virtual Machines
A hypervisor chooses the CPUID bits its guest sees. One that does not promise an
invariant counter makes discover refuse with NotInvariant, even where the
host’s counter is invariant: the Intel macOS virtual machines t2t’s CI runs on
are such guests, and refuse. One that reports the counter’s rate in leaf
0x4000_0010 spares the guest the measurement.
Where the rate is known, from a config or from a discovery made elsewhere,
Counter::new takes it and checks nothing: neither that the counter is
invariant, nor that the rate is plausible. The caller vouches for both. Where
discover refuses and no rate is known, read MonotonicClock instead.
fn a_counter_at_a_known_rate_needs_no_discovery() {
use t2t::TickRate;
use t2t::clock::{Clock, Counter};
// A rate taken from elsewhere: a config, or a discovery another process made.
let rate: TickRate = "1000000000 Hz".parse().expect("a rate above zero");
let counter = Counter::new(rate);
let start = counter.now();
assert!(counter.now() >= start, "the counter never runs backwards");
assert_eq!(counter.rate(), TickRate::GIGAHERTZ, "and runs at the rate it was given");
}
One Counter for the Machine
Every core and every process on a machine reads the same counter, so a
Tickstamp one process takes, another may subtract from. t2t-clock’s test
counter_cross_process
starts a child process, and checks that the reading the child prints lies
between two the parent took around it. A reading crosses as its spelling, 1000 ticks, or as its count. Since only spans convert, two processes that measured
slightly different rates still agree on every reading; they differ only in what
a span is worth.
The read is not ordered against the instructions around it, which is right for a
stamp, and for timing anything much longer than the read itself. Clock::now on
a Counter is #[inline(always)], since a call around the read would move it.
The Conversion
TickRate::from_hertz works out, once, a factor and a shift for each
direction, so a conversion is a 128-bit multiply and a shift on the count’s
magnitude, with its sign put back, and no division. For a ratio of to ,
to the rate for ticks to nanoseconds and the rate to the other
way:
The shift is the largest whose factor still fits 64 bits, so the factor carries
every bit of precision it can. Rounding it up makes an exact multiple of the
ratio convert exactly, and any other count land within one unit of its exact
quotient, which a property test checks against an i128 division, on counts
drawn from every i64 and rates from every u64 above zero. A result past the
range saturates.
fn a_conversion_is_within_one_unit_of_the_exact_quotient() {
use t2t::{TickRate, Tickdelta, Timedelta};
// 3 GHz: a nanosecond is three ticks, and a tick a third of a nanosecond.
let rate = TickRate::from_hertz(3_000_000_000).expect("a rate above zero");
let exact = Tickdelta::from_ticks(3_000).to_timedelta(rate);
assert_eq!(exact, Timedelta::MICROSECOND, "a multiple of the ratio converts exactly");
// 1,000 ticks are 333.33 ns: the result is 333 or 334, never further off.
let nanos = Tickdelta::from_ticks(1_000).to_timedelta(rate).as_nanos();
assert!((333..=334).contains(&nanos), "within a nanosecond of 333.33: {nanos}");
// A count whose span is past what nanoseconds hold saturates, as every operator does.
let slow = TickRate::from_hertz(1).expect("a rate above zero");
assert_eq!(Tickdelta::MAX.to_timedelta(slow), Timedelta::MAX, "a tick a second, for ever");
}
A conversion takes about 1.2 ns from ticks to nanoseconds and 1.0 ns the other
way, at 24 MHz on a Graviton4: 1,024 of them in 1.206 µs and 1.034 µs, in the
run under
crates/t2t-core/benches/results/2026-10-02T23-14Z-9776c95-span-divisors/.