Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

t2t is a set of Rust crates for time: the points a program reads off its clocks, the spans between them, and the clocks themselves. Each timeline has a point type of its own, so a reading of the wall clock is never subtracted from a reading of the monotonic clock: the compiler refuses it.

It is for any program that needs time values or clocks: a server’s deadlines, a log’s stamps, a benchmark’s spans, a test that moves time by hand. It is built for code where a nanosecond counts: every point and span is one i64, the CPU’s counter is read in one instruction, and no conversion on a reading’s or a spelling’s path divides by a number known only at run time.

What It Holds

  • Points, one per timeline: Timestamp on the wall clock, TaiTimestamp on International Atomic Time, Uptime, RawUptime and BootUptime on the clocks that count from boot, and Tickstamp on the CPU’s counter.
  • Spans, one per kind of count: Timedelta counts nanoseconds and Tickdelta a counter’s ticks; a TickRate turns one into the other.
  • Two views: UtcDateTime reads a timestamp as a date and a time of day, and Timed holds a value with the stamp it was captured with.
  • Clocks, each of which implements Clock, whose one verb, now, gives a point on the clock’s timeline, or a span for a clock of CPU time.

A point minus a point is a span, a point plus a span is a point, and every operator saturates at the ends of the range, with a checked_* twin. Every value has one spelling, which Display writes and FromStr reads back.

The Crates

CrateWhat it holds
t2teverything, the values at its root and the clocks under t2t::clock
t2t-corethe values; no_std, and never reaches the operating system
t2t-clockthe clocks: the operating system’s, the CPU’s counter, manual clocks

A program needs t2t alone; the other two are for a crate that wants the values without the clocks, or the clocks without the facade.

What It Leaves Out

  • Time zones beyond UTC and TAI. A timestamp is written and read in UTC, and a spelling with an offset is refused. A zone or an offset from chrono, jiff or time converts as the instant it names.
  • Calendars beyond the proleptic Gregorian. A date is a day of that calendar in UTC, from 1677-09-21 to 2262-04-11, with no leap seconds, as Unix time has none.
  • Formatting beyond RFC 3339 and its own spellings. There are no format patterns and no names of months or days.

How t2t Compares names the crates that do these, and Working with Other Crates shows the conversions to and from them.

How to Read This Book

The guide teaches the pieces in the order a program meets them, from a first program to testing with a manual clock; the reference holds what each path costs, what builds where, and how t2t compares with its neighbours. The API documentation, under API Reference in the menu bar, holds each item’s detail.

Every listing on these pages is part of a test that cargo builds and runs, so the code shown compiles against the crates as they are. Most are under crates/t2t/tests/book/; those that need serde_json or another crate’s types are under crates/t2t-core/tests/book/, and name the crate t2t_core, whose items the facade re-exports under the same names.

Getting Started

Add the crate, with the operating system’s clocks:

cargo add t2t --features std

The values, the CPU’s counter and the manual clocks need no feature, and build without std; the operating system’s clocks need std, on 64-bit Linux or macOS. t2t builds on stable Rust 1.98 or later. Platforms and Features lists every feature and what it adds.

A First Program

The wall clock stamps the moment, since its reading names a moment another machine can name too; the monotonic clock times the pause, since no correction to the wall clock moves it.

fn the_wall_clock_stamps_and_the_monotonic_clock_times() {
    use core::time::Duration;
    use std::thread;

    use t2t::Timedelta;
    use t2t::clock::{Clock, MonotonicClock, SystemClock};

    // A stamp another machine can read: the wall clock's.
    let captured = SystemClock.now();
    // A start that no correction to the wall clock moves: the monotonic clock's.
    let start = MonotonicClock.now();

    let pause = Timedelta::from_millis(20);
    thread::sleep(Duration::try_from(pause).expect("a span forwards"));

    let took = MonotonicClock.now() - start;
    println!("captured at {captured:.6}, then slept {took}");
    assert!(took >= pause, "at least the pause: {took}");
}

Timestamp’s {:.6} writes RFC 3339 to the microsecond, and a span is written coarsest unit first, as 20ms83us. Choosing a Clock says which clock to read for what.

Two Tours

In a checkout of the repository, two examples walk through the rest. The first prints the values: points and spans, the calendar, a stamped value, and a counter’s rate.

cargo run -p t2t --example time-tour

The second prints every clock’s reading, and what one costs on the machine it runs on.

cargo run --release -p t2t --features std --example clock-tour

Every listing in this book runs as a test too:

cargo test -p t2t --all-features --test book
cargo test -p t2t-core --all-features --test book

From there, clock lists every clock, and the crate’s page, t2t, says which to choose for what.

Points and Spans

A point is a moment on one timeline, and a span is how far apart two points are. Each timeline has a point type of its own, and each kind of count a span type of its own, so a sum or a difference that mixes two timelines does not compile.

A Type per Timeline

PointTimelineOriginSpan
Timestampthe wall clock, in UTC1970-01-01T00:00:00ZTimedelta
TaiTimestampInternational Atomic Time1970-01-01T00:00:00 TAITimedelta
Uptimethe monotonic clocknear bootTimedelta
RawUptimethe monotonic clock at the hardware’s ratenear bootTimedelta
BootUptimethe boot clock, suspensions countedbootTimedelta
Tickstampa hardware counterthe hardware’s ownTickdelta

Every point and span is an i64 and nothing else: nanoseconds for all but the counter’s, whose unit is its tick. Each is Copy, ordered and hashable, and its Default is zero. A count of nanoseconds holds about 292 years either side of its origin, so a Timestamp holds 1677-09-21 to 2262-04-11. Choosing a Clock says which clock reads which point.

Arithmetic

A point minus a point is a span, a point plus or minus a span is a point, and spans add, subtract, negate, scale by an i64 and sum. Two points do not add.

fn a_point_minus_a_point_is_a_span() {
    use t2t::{Timedelta, Timestamp};

    let published = Timestamp::from_secs(1_700_000_000);
    let captured = published + Timedelta::from_micros(850);
    let latency = captured - published;

    assert_eq!(latency, Timedelta::from_micros(850), "a point minus a point is a span");
    assert_eq!(captured - latency, published, "and a point minus a span is a point");
    assert_eq!(latency * 3, Timedelta::from_micros(2_550), "a span scales by a count");
    assert!(latency < Timedelta::MILLISECOND, "and compares with another span");
}

Two Timelines Never Mix

Two timelines differ by an amount that only the machine knows, and that moves: a time service steps the wall clock and never the monotonic clock; TAI runs ahead of UTC by the offset the kernel holds; the raw monotonic clock drifts from the monotonic clock by whatever slewing a time service applies; the boot clock gains every suspension; and a counter’s origin is the hardware’s own. A difference taken across two of them would be a number with no meaning, so the types refuse it. A wall-clock instant minus an uptime does not compile:

//! Points of two timelines never mix: a wall-clock instant minus an uptime does not compile.

use t2t_core::{Timestamp, Uptime};

fn main() {
    let _span = Timestamp::from_secs(10) - Uptime::from_secs(10);
}
error[E0277]: cannot subtract `Uptime` from `Timestamp`
 --> tests/compile_fail/a_timestamp_minus_an_uptime.rs:6:42
  |
6 |     let _span = Timestamp::from_secs(10) - Uptime::from_secs(10);
  |                                          ^ no implementation for `Timestamp - Uptime`
  |
  = help: the trait `Sub<Uptime>` is not implemented for `Timestamp`

Nor does a counter’s reading moved by nanoseconds, since a tick’s length is the counter’s rate, which the type does not know:

//! Spans of two kinds never mix: a counter's reading moves by its ticks, not by nanoseconds.

use t2t_core::{Tickstamp, Timedelta};

fn main() {
    let _later = Tickstamp::from_ticks(10) + Timedelta::SECOND;
}
error[E0308]: mismatched types
 --> tests/compile_fail/a_tickstamp_plus_a_timedelta.rs:6:46
  |
6 |     let _later = Tickstamp::from_ticks(10) + Timedelta::SECOND;
  |                                              ^^^^^^^^^^^^^^^^^ expected `Tickdelta`, found `Timedelta`

Each of these is a fixture that t2t’s tests compile, and that must fail to compile. A program that does need to compare two timelines says how: a counter’s span crosses to nanoseconds through its rate, as The CPU Counter shows, and two clocks’ counts compare as plain i64s, as the TAI clock’s listing in Choosing a Clock does.

Units

Each nanosecond point has from_nanos, from_micros, from_millis and from_secs, and an as_* for each; a Timedelta adds from_mins, from_hours and from_days, and a constant for each unit from NANOSECOND to DAY, a day being 24 hours, with no calendar consulted. A counter’s point and span have from_ticks and as_ticks. A coarser unit’s constructor saturates where its count passes the range.

A Timedelta’s accessor counts whole units toward zero, as std’s Duration does, and subsec_nanos, subsec_micros and subsec_millis give the rest, signed as the span is. A point’s accessor counts the whole units since its origin, so a point before the origin rounds down, to the unit it falls in.

fn each_unit_has_a_constructor_and_an_accessor() {
    use t2t::{Timedelta, Timestamp, Uptime};

    assert_eq!(Timedelta::from_mins(90), Timedelta::HOUR + Timedelta::from_mins(30), "a span");
    assert_eq!(Uptime::from_secs(90).as_millis(), 90_000, "a point, counted in a finer unit");

    // A span counts whole units toward zero, as std's `Duration` does.
    assert_eq!(Timedelta::from_millis(1_500).as_secs(), 1, "forwards");
    assert_eq!(Timedelta::from_millis(-1_500).as_secs(), -1, "and backwards");
    assert_eq!(Timedelta::from_millis(-1_500).subsec_millis(), -500, "the rest has its sign");

    // A point counts the whole units since its origin, so one before it rounds down.
    assert_eq!(Timestamp::from_millis(-1_500).as_secs(), -2, "the second it falls in");
}

Saturation and the Checked Twins

Every operator saturates at the ends of the range, so none overflows or panics, and each has a checked_* twin that returns None where the operator would saturate:

OperatorChecked twin
point + spanchecked_add
point - spanchecked_sub
point - pointchecked_since
span + spanchecked_add
span - spanchecked_sub
span * countchecked_mul
-spanchecked_neg
span.abs()checked_abs
none: a span has no /checked_div

The operator suits code where the end of the range is out of reach, or where stopping there is right; the twin suits code to which reaching it is a bug. A span has no /: checked_div divides it, refusing zero parts, and MIN in -1.

fn an_operator_saturates_and_its_checked_twin_refuses() {
    use t2t::{Timedelta, Timestamp};

    let latest = Timestamp::MAX;
    assert_eq!(latest + Timedelta::SECOND, Timestamp::MAX, "the sum stops at the end");
    assert_eq!(latest.checked_add(Timedelta::SECOND), None, "where its twin refuses");

    let widest = Timestamp::MAX - Timestamp::MIN;
    assert_eq!(widest, Timedelta::MAX, "a span past its range stops at its end too");
    assert_eq!(Timestamp::MAX.checked_since(Timestamp::MIN), None, "or is refused");

    assert_eq!(Timedelta::from_secs(i64::MAX), Timedelta::MAX, "a constructor saturates");
    assert_eq!(-Timedelta::MIN, Timedelta::MAX, "and so does a negation");
    assert_eq!(Timedelta::MIN.checked_neg(), None, "which its twin refuses");

    // A span has no `/`; `checked_div` divides it, and refuses zero parts.
    let third = Timedelta::SECOND.checked_div(3);
    assert_eq!(third, Some(Timedelta::from_nanos(333_333_333)), "truncated toward zero");
    assert_eq!(Timedelta::SECOND.checked_div(0), None, "and refused for no parts");
}

A workspace that denies clippy’s arithmetic_side_effects lint names each point and span in its clippy configuration’s arithmetic-side-effects-allowed, since the lint cannot see that they saturate; t2t’s own clippy.toml shows the list.

Floor and Ceil

floor and ceil take a point or a span to the nearest multiple of a unit at or below it, or at or above it: the minute an event falls in, or the end of the bucket a histogram counts it in. They work on both sides of the origin, take the unit’s magnitude, and leave the value as it is for a zero unit.

fn floor_and_ceil_find_the_bucket_a_point_falls_in() {
    use t2t::{Timedelta, Timestamp};

    let event: Timestamp = "2026-09-16T07:45:35.123Z".parse().expect("an instant in UTC");
    let start = event.floor(Timedelta::MINUTE);
    let end = event.ceil(Timedelta::MINUTE);

    assert_eq!(format!("{start:.0}"), "2026-09-16T07:45:00Z", "the minute it falls in");
    assert_eq!(format!("{end:.0}"), "2026-09-16T07:46:00Z", "and the next");
    assert_eq!(start.floor(Timedelta::MINUTE), start, "a multiple stays where it is");
    assert_eq!(event.floor(Timedelta::ZERO), event, "and a zero unit leaves the point");

    let budget = Timedelta::from_micros(1_250);
    assert_eq!(budget.ceil(Timedelta::MILLISECOND), Timedelta::from_millis(2), "a span too");
}

Code Generic Over Points

TimePoint is what every point shares: its span type, Span, the operators between the two, and from_count and count, the point as its i64. Code generic over it works on every timeline, as the manual clocks and Timed::elapsed do, and a type of your own that wraps a point may implement it to join them.

fn code_generic_over_points_takes_every_timeline() {
    use t2t::{Tickdelta, Tickstamp, TimePoint, Timedelta, Uptime};

    /// How long ago `stamp` is, at `now`, on whichever timeline the two are on.
    fn age<P: TimePoint>(stamp: P, now: P) -> P::Span {
        now - stamp
    }

    let span = age(Uptime::from_secs(5), Uptime::from_secs(7));
    assert_eq!(span, Timedelta::from_secs(2), "a span of nanoseconds on the monotonic clock");
    let ticks = age(Tickstamp::from_ticks(100), Tickstamp::from_ticks(124));
    assert_eq!(ticks, Tickdelta::from_ticks(24), "and a span of ticks on a counter");
    assert_eq!(Uptime::from_count(span.as_nanos()), Uptime::from_secs(2), "from a count");
}

Choosing a Clock

Every clock implements Clock, whose one verb, now, gives the clock’s Reading: a point on its timeline, or a span for a clock of CPU time. Choosing a clock is choosing the timeline its readings are on.

The Clocks

ClockReadsLinuxmacOSSteps
SystemClockTimestampCLOCK_REALTIMECLOCK_REALTIMEyes
CoarseSystemClockTimestampCLOCK_REALTIME_COARSECLOCK_REALTIMEyes
TaiClockTaiTimestampCLOCK_TAInonewith the wall clock
MonotonicClockUptimeCLOCK_MONOTONICCLOCK_UPTIME_RAWnever
CoarseMonotonicClockUptimeCLOCK_MONOTONIC_COARSECLOCK_UPTIME_RAW_APPROXnever
RawMonotonicClockRawUptimeCLOCK_MONOTONIC_RAWCLOCK_UPTIME_RAWnever
BootClockBootUptimeCLOCK_BOOTTIMECLOCK_MONOTONICnever
ProcessCpuClockTimedeltaCLOCK_PROCESS_CPUTIME_IDCLOCK_PROCESS_CPUTIME_IDnever
ThreadCpuClockTimedeltaCLOCK_THREAD_CPUTIME_IDCLOCK_THREAD_CPUTIME_IDnever
CounterTickstampcntvct_el0 or rdtsccntvct_el0 or rdtscnever

The operating system’s clocks read clock_gettime with the id the table gives, and need the std feature, on 64-bit Linux or macOS; TaiClock is Linux’s alone. Each id is one its system has, so a reading never fails. The counter reads cntvct_el0 on aarch64 and rdtsc on x86_64, on any system, with or without std; The CPU Counter has the rest of it.

Each clock is a unit struct, so SystemClock.now() reads one. A reference to a clock is a clock too, so code takes &C where C: Clock.

The Wall Clock

SystemClock is the clock whose readings mean something off this machine, so it stamps a capture another machine will compare. A time service disciplines it, and may step it backwards across a correction: a span that must never run backwards is measured on MonotonicClock or the counter instead.

CoarseSystemClock hands back the value the kernel’s timer last wrote, and reads no counter: the same moment, to the timer’s period, a few milliseconds. It answers whether a heartbeat or an expiry is due; it never stamps a capture. macOS has no coarse wall clock, so there it reads CLOCK_REALTIME.

fn the_wall_clock_stamps_a_capture() {
    use t2t::clock::{Clock, CoarseSystemClock, SystemClock};
    use t2t::{Timedelta, Timestamp};

    let captured = SystemClock.now();
    assert!(captured > Timestamp::from_secs(1_700_000_000), "a moment past November 2023");
    println!("captured at {captured}");

    // The coarse clock names the same moment, to the kernel's timer period.
    let gap = SystemClock.now() - CoarseSystemClock.now();
    assert!(gap.abs() < Timedelta::from_millis(100), "within a period or two: {gap}");
}

TAI

TaiClock reads International Atomic Time: the wall clock plus the kernel’s TAI offset, which a time service such as chrony or ptp4l sets to the leap seconds since 1972. Until one does, the offset is zero, and the clock reads the wall clock’s time. A leap second does not step it, but it steps with the wall clock when the wall clock is set, and when a time service sets the offset. Linux alone has the clock.

fn the_tai_clock_runs_ahead_of_the_wall_clock_by_the_kernels_offset() {
    use t2t::Timedelta;
    use t2t::clock::{Clock, SystemClock, TaiClock};

    // The two are different timelines, so their counts are compared, not the points.
    let wall = SystemClock.now().as_nanos();
    let tai = TaiClock.now().as_nanos();
    let offset = Timedelta::from_nanos(tai.saturating_sub(wall)).floor(Timedelta::SECOND);
    println!("TAI runs {offset} ahead of UTC here");
    let leap_seconds = Timedelta::ZERO..Timedelta::from_secs(60);
    assert!(leap_seconds.contains(&offset), "zero, or the leap seconds since 1972: {offset}");
}

The Monotonic Clocks

MonotonicClock never steps, and every process on the machine reads the same clock, so a deadline is MonotonicClock.now() plus a timeout. It stops while the machine sleeps. Linux lets a time service slew its rate; macOS does not. CoarseMonotonicClock reads the same timeline to the timer’s period, for a deadline that is far off, or polled often; both read an Uptime, so their readings compare.

fn the_monotonic_clock_keeps_a_deadline() {
    use core::time::Duration;
    use std::thread;

    use t2t::Timedelta;
    use t2t::clock::{Clock, CoarseMonotonicClock, MonotonicClock};

    // Sleep for what is left until the deadline, however early a sleep returns.
    let deadline = MonotonicClock.now() + Timedelta::from_millis(5);
    let mut left = deadline - MonotonicClock.now();
    while left.is_positive() {
        thread::sleep(Duration::try_from(left).expect("a span forwards is a duration"));
        left = deadline - MonotonicClock.now();
    }

    // A deadline far off, checked often, is cheaper on the coarse clock: both read `Uptime`.
    let expiry = CoarseMonotonicClock.now() + Timedelta::from_secs(30);
    assert!(MonotonicClock.now() < expiry, "the two read one timeline, so they compare");
}

RawMonotonicClock runs at the hardware’s own rate, which no time service adjusts: the clock to measure a span free of a time service’s corrections, and the one t2t measures an x86_64 counter’s rate against. It stops while the machine sleeps too. Its readings are a RawUptime, so they never mix with the monotonic clock’s. On macOS it reads the same clock as MonotonicClock, which no time service slews there.

BootClock counts the time the machine was suspended, which MonotonicClock does not: the clock for a lease or a timeout that must run down across a suspension.

fn the_raw_and_boot_clocks_read_timelines_of_their_own() {
    use t2t::Timedelta;
    use t2t::clock::{BootClock, Clock, RawMonotonicClock};

    // A span no time service's slewing bends.
    let start = RawMonotonicClock.now();
    let span = RawMonotonicClock.now() - start;
    assert!(span >= Timedelta::ZERO, "it never runs backwards");

    // A lease that a suspension of the machine runs down too.
    let lease = BootClock.now() + Timedelta::from_mins(5);
    assert!(BootClock.now() < lease, "the lease lies ahead");
}

CPU Time

ProcessCpuClock reads the CPU time the process has used, on every thread, and ThreadCpuClock the time the calling thread has used, so two threads’ readings count two different times. Each reads a Timedelta, the CPU time used so far, and each takes a system call, not the fast path the clocks above take.

fn the_cpu_clocks_count_the_work_done() {
    use core::hint::black_box;

    use t2t::Timedelta;
    use t2t::clock::{Clock, ProcessCpuClock, ThreadCpuClock};

    // The process's clock is read first and last, so its span holds the thread's.
    let (process_start, thread_start) = (ProcessCpuClock.now(), ThreadCpuClock.now());
    for value in 0_u64..1_000_000 {
        black_box(value);
    }
    let thread_time = ThreadCpuClock.now() - thread_start;
    let process_time = ProcessCpuClock.now() - process_start;
    assert!(thread_time > Timedelta::ZERO, "the loop took this thread's time: {thread_time}");
    assert!(process_time >= thread_time, "and the process counts every thread's: {process_time}");
}

What a Reading Costs

One reading of each, on an AWS Graviton4 under Linux 6.12, pinned to an isolated core, the median of each of two runs:

ClockOne reading
CoarseSystemClock, CoarseMonotonicClock9.8 to 9.9 ns
Counter11.8 ns
SystemClock, TaiClock, MonotonicClock, RawMonotonicClock, BootClock33.2 ns
ThreadCpuClock269 to 273 ns
ProcessCpuClock388 to 394 ns

The run is under crates/t2t-clock/benches/results/2026-10-03T10-21Z-8aeae1f-clock-reads/; Performance has the rest, and how to measure on your own machine. The coarse clocks cost least because they read no counter, and for the same reason are right only to the timer’s period.

Which to Read

  • To stamp a capture, read SystemClock: its reading names a moment another machine names too.
  • To wait, read MonotonicClock: it never steps, so a deadline holds.
  • To measure, read the Counter: one instruction, and one counter for every core and process on the machine.
  • To poll, read CoarseSystemClock or CoarseMonotonicClock: either costs least, and is right to the timer’s period.
  • To run a test or a replay, read a ManualClock, or an AtomicManualClock across threads, as Testing with Manual Clocks shows.

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 aarch64 it reads cntfrq_el0, the rate the firmware wrote.
  • On x86_64 it refuses a time-stamp counter that is not invariant, by CPUID leaf 0x8000_0007. It takes the rate from CPUID leaf 0x15, a crystal’s frequency times a ratio; where that has none, from the hypervisor’s leaf 0x4000_0010; and where neither has one, with std on 64-bit Linux or macOS, it measures the rate over 10 ms against RawMonotonicClock.
  • On either, a rate outside 1 MHz to 10 GHz is refused.
RefusalWhenWhy
NotInvariantthe time-stamp counter’s rate follows the core’sits ticks have no fixed length, so no rate makes them time
NoRatethe CPU reports no rate, and none could be measurednothing says what a tick is worth
ImplausibleRatethe rate read or measured is outside 1 MHz to 10 GHzbelow 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/.

Dates and Spellings

Every value has one spelling: Display writes it, FromStr reads it back, and Serialization takes the same text. A timestamp’s spelling is RFC 3339 in UTC, read off the calendar view this chapter starts with.

A Timestamp as a Date

Timestamp::to_utc reads a point as a UtcDateTime: its year, month and day in the proleptic Gregorian calendar, its hour, minute and second in UTC, and the nanoseconds past the second. Unix time has no leap seconds, so the second is never 60.

A UtcDateTime is a view: arithmetic stays on the Timestamp it came from. Its fields are public, so one may be built by hand; is_valid checks that every field names a real date and time, and to_timestamp gives the point back, or None for one that is not valid or is outside 1677-09-21 to 2262-04-11.

fn a_timestamp_reads_as_a_date_and_back() {
    use t2t::{Timestamp, UtcDateTime};

    let instant = Timestamp::from_nanos(1_789_544_735_123_456_789);
    let date_time = instant.to_utc();
    assert_eq!((date_time.year, date_time.month, date_time.day), (2026, 9, 16), "the date");
    assert_eq!((date_time.hour, date_time.minute, date_time.second), (7, 45, 35), "the time");
    assert_eq!(date_time.nanosecond, 123_456_789, "to the nanosecond");
    assert_eq!(date_time.to_timestamp(), Some(instant), "and back to the instant");

    // The fields are public, so a date and time can be built by hand, and checked.
    let leap_day = UtcDateTime { year: 2028, month: 2, day: 29, ..date_time };
    assert!(leap_day.is_valid(), "2028 is a leap year");
    let no_such_day = UtcDateTime { day: 30, ..leap_day };
    assert!(!no_such_day.is_valid(), "February has no 30th");
    assert_eq!(no_such_day.to_timestamp(), None, "so it names no instant");
    let too_late = UtcDateTime { year: 2263, ..date_time };
    assert_eq!(too_late.to_timestamp(), None, "nor does a date past 2262");
}

RFC 3339

A Timestamp is written as RFC 3339 in UTC, with Z, and nine fraction digits: 2026-09-16T07:45:35.123456789Z. A precision chooses the digits: {:.3} writes milliseconds, {:.0} none, and nine is the most. A width pads the whole spelling, with the fill and alignment given, and never cuts it. Debug writes what Display does.

fn an_instant_is_written_as_rfc_3339() {
    use t2t::Timestamp;

    let instant = Timestamp::from_nanos(1_789_544_735_123_456_789);
    assert_eq!(instant.to_string(), "2026-09-16T07:45:35.123456789Z", "nine digits");
    assert_eq!(format!("{instant:.3}"), "2026-09-16T07:45:35.123Z", "to the millisecond");
    assert_eq!(format!("{instant:.0}"), "2026-09-16T07:45:35Z", "to the second");
    assert_eq!(format!("{instant:.12}"), instant.to_string(), "never past the nanosecond");
    assert_eq!(format!("[{instant:>24.0}]"), "[    2026-09-16T07:45:35Z]", "padded to a width");
    assert_eq!(format!("{instant:?}"), instant.to_string(), "and debugged as written");
}

FromStr reads what Display writes: T and Z in either case, and a fraction of one to nine digits, or none. It refuses an offset, a 60th second, a date that does not exist, an instant outside the range, and any text after the Z.

fn an_instant_is_read_from_rfc_3339_in_utc() {
    use t2t::{ParseTimestampError, Timestamp};

    let read = "2026-09-16T07:45:35.5Z".parse::<Timestamp>();
    assert_eq!(read, Ok(Timestamp::from_millis(1_789_544_735_500)), "a fraction of any length");
    let lower = "1970-01-01t00:00:00z".parse::<Timestamp>();
    assert_eq!(lower, Ok(Timestamp::UNIX_EPOCH), "and either case");

    for (refused, reason) in [
        ("2026-09-16T09:45:35+02:00", "an offset: an instant is read in UTC alone"),
        ("2026-09-16T07:45:60Z", "a leap second, which Unix time has none of"),
        ("2026-02-30T07:45:35Z", "a date that does not exist"),
        ("2263-01-01T00:00:00Z", "a date past 2262"),
        ("2026-09-16T07:45:35.1234567890Z", "a tenth fraction digit"),
        ("2026-09-16 07:45:35Z", "a space for the `T`"),
    ] {
        assert_eq!(refused.parse::<Timestamp>(), Err(ParseTimestampError), "{reason} is refused");
    }
}

TAI

A TaiTimestamp is written as RFC 3339’s date and time, then the zone TAI: 2026-09-16T07:46:12.123456789 TAI. The precision and the width work as they do for a Timestamp, and FromStr reads the zone back, and refuses a Z, since an instant in UTC is not one in TAI.

fn a_tai_instant_is_written_in_its_own_zone() {
    use t2t::{ParseTaiTimestampError, TaiTimestamp, Timedelta};

    let synced: TaiTimestamp = "2026-09-16T07:46:12.5 TAI".parse().expect("an instant in TAI");
    let next = synced + Timedelta::from_millis(500);
    assert_eq!(next.to_string(), "2026-09-16T07:46:13.000000000 TAI", "written in TAI");
    assert_eq!(format!("{next:.0}"), "2026-09-16T07:46:13 TAI", "to the precision asked");

    let utc = "2026-09-16T07:46:12Z".parse::<TaiTimestamp>();
    assert_eq!(utc, Err(ParseTaiTimestampError), "an instant in UTC is not one in TAI");
}

Spans

A Timedelta is written the way a config spells a span: counts with units, coarsest first, from d, h, m, s, ms, us and ns. Once a unit is written, every finer one with a remainder is too, a zero among them, so a minute and 123 ms is 1m0s123ms. Zero is 0s, and a span backwards starts with -. The longest spelling is Timedelta::MIN’s, -106751d23h47m16s854ms775us808ns.

FromStr reads that, the bare 0, and any count in each unit, so 48h reads as the span 2d spells. It takes each unit at most once and coarsest first, and refuses a span past the range.

An Uptime, a RawUptime and a BootUptime are written as the span since their origin, and read back from it.

fn a_span_is_spelled_coarsest_unit_first() {
    use t2t::{ParseTimedeltaError, Timedelta, Uptime};

    assert_eq!(Timedelta::from_secs(90).to_string(), "1m30s", "coarsest first");
    assert_eq!(Timedelta::from_millis(60_123).to_string(), "1m0s123ms", "a zero inside is written");
    assert_eq!(Timedelta::from_micros(-3).to_string(), "-3us", "a sign for a span backwards");
    assert_eq!(Timedelta::ZERO.to_string(), "0s", "and zero in seconds");
    assert_eq!(Uptime::from_millis(42_050).to_string(), "42s50ms", "a point from boot alike");

    assert_eq!("48h".parse(), Ok(Timedelta::from_days(2)), "a count may pass the next unit");
    assert_eq!("0".parse(), Ok(Timedelta::ZERO), "and zero may be bare");
    for refused in ["90 seconds", "1s1m", "1m1m", "+1s", "-0", "1"] {
        assert_eq!(refused.parse::<Timedelta>(), Err(ParseTimedeltaError), "{refused} refused");
    }
}

Counts

A Tickstamp and a Tickdelta are written as their count, then ticks: 24 ticks, -24 ticks. A TickRate is written as its count of hertz, then Hz: 24000000 Hz. A width pads the count and its unit together. FromStr reads what Display writes, and nothing else: no +, no 1 tick, no kHz, and no rate of zero.

fn a_count_is_spelled_with_its_unit() {
    use t2t::{ParseTickRateError, ParseTickdeltaError, TickRate, Tickdelta, Tickstamp};

    assert_eq!(Tickdelta::from_ticks(-24).to_string(), "-24 ticks", "a span of ticks");
    assert_eq!(Tickstamp::from_ticks(1_000).to_string(), "1000 ticks", "a counter's reading");
    assert_eq!(TickRate::GIGAHERTZ.to_string(), "1000000000 Hz", "and a rate");
    assert_eq!(format!("[{:>10}]", Tickdelta::from_ticks(24)), "[  24 ticks]", "padded whole");

    assert_eq!("24 ticks".parse(), Ok(Tickdelta::from_ticks(24)), "read back");
    assert_eq!("24".parse::<Tickdelta>(), Err(ParseTickdeltaError), "a count names its unit");
    assert_eq!("0 Hz".parse::<TickRate>(), Err(ParseTickRateError), "and a rate is above zero");
}

Refusals

Each spelling has its refusal, and values that share a spelling share it:

RefusalRefuses the text of
ParseTimestampErrora Timestamp
ParseTaiTimestampErrora TaiTimestamp
ParseTimedeltaErrora Timedelta, an Uptime, a RawUptime, a BootUptime
ParseTickdeltaErrora Tickdelta, a Tickstamp
ParseTickRateErrora TickRate

Each is a unit struct, whose message names the spelling it expected, with an example of it.

fn values_that_share_a_spelling_share_its_refusal() {
    use t2t::{BootUptime, ParseTickdeltaError, ParseTimedeltaError, Tickstamp, Uptime};

    assert_eq!(
        "soon".parse::<Uptime>(),
        Err(ParseTimedeltaError),
        "an uptime is a span's spelling"
    );
    assert_eq!("soon".parse::<BootUptime>(), Err(ParseTimedeltaError), "and so is a boot uptime");
    assert_eq!("soon".parse::<Tickstamp>(), Err(ParseTickdeltaError), "a reading is a count's");
    assert_eq!(
        ParseTimedeltaError.to_string(),
        "parse timedelta error: expected `0`, or counts with units from coarsest to finest, as \
         `1m30s`",
        "each refusal names the spelling it expected"
    );
}

Serialization

With the serde feature, every value serializes as its spelling where a person reads the format, and as its count where none does. The modules under t2t::serde read and write a count in a named unit, for a field that holds one. The schemars feature adds each spelling’s JSON Schema, and the zerocopy-08 feature reads and writes values as bytes.

This chapter’s listings are t2t-core’s tests, since they depend on serde_json, serde_test and the other crates’ types; they name the crate t2t_core, and the facade has the same items under t2t.

Two Forms

A serializer that says it is human-readable, as serde_json and TOML’s do, takes each value’s spelling; one that does not, as bincode and postcard do, takes its count:

ValueWhere a person reads the formatWhere none does
Timestamp"2026-09-16T07:45:35.123456789Z"i64
TaiTimestamp"2026-09-16T07:46:12.123456789 TAI"i64
Timedelta, Uptime, RawUptime, BootUptime"1m30s"i64
Tickstamp, Tickdelta"24 ticks"i64
TickRate"24000000 Hz"u64

Each is read from the form it is written in. A rate of zero is refused, as serde refuses a zero for a NonZeroU64.

fn a_value_is_written_as_its_spelling_where_a_person_reads_the_format() {
    use serde::{Deserialize, Serialize};
    use serde_json::{from_str, to_string};
    use t2t_core::{Timedelta, Timestamp};

    /// A job's settings, as a config file holds them.
    #[derive(Debug, PartialEq, Serialize, Deserialize)]
    struct Job {
        /// When the job first runs.
        start: Timestamp,
        /// How often it runs after that.
        every: Timedelta,
    }

    let job: Job = from_str(r#"{"start":"2026-09-16T07:45:00Z","every":"1m30s"}"#).expect("a job");
    assert_eq!(job.every, Timedelta::from_secs(90), "a span, from its spelling");

    let written = to_string(&job).expect("a timestamp and a span always serialize");
    let expected = r#"{"start":"2026-09-16T07:45:00.000000000Z","every":"1m30s"}"#;
    assert_eq!(written, expected, "and written back as the spellings `Display` writes");
}

serde_test shows both forms, its readable and compact standing for the two kinds of format:

fn a_format_no_person_reads_takes_the_count() {
    use serde_test::{Configure as _, Token, assert_tokens};
    use t2t_core::{TickRate, Timedelta, Timestamp};

    let span = Timedelta::from_secs(90);
    assert_tokens(&span.readable(), &[Token::Str("1m30s")]);
    assert_tokens(&span.compact(), &[Token::I64(90_000_000_000)]);

    assert_tokens(&Timestamp::UNIX_EPOCH.compact(), &[Token::I64(0)]);
    let rate = TickRate::from_hertz(24_000_000).expect("a rate above zero");
    assert_tokens(&rate.compact(), &[Token::U64(24_000_000)]);
}

Counts in a Named Unit

A service or a peer that sends a count names its unit at the field, and the field’s #[serde(with = …)] names the module:

ModuleReads and writes
timestamp::secs, millis, microswhole units since the Unix epoch
timestamp::nanosnanoseconds since the Unix epoch
timedelta::secs, millis, micros, nanosa span’s count in the unit

Each has an option module, for an Option field, as timedelta::secs::option. Where a person reads the format, a module reads a count as a number or as a decimal string; where none does, as an i64. It writes a number, but for nanoseconds since the epoch where a person reads the format: those pass what an f64 holds exactly, so a JSON reader backed by one would round them, and they are written as a string.

fn a_count_in_a_named_unit_takes_a_module() {
    use serde::{Deserialize, Serialize};
    use serde_json::{from_str, to_string};
    use t2t_core::serde::{timedelta, timestamp};
    use t2t_core::{Timedelta, Timestamp};

    /// A job, as a peer's API sends it.
    #[derive(Debug, PartialEq, Serialize, Deserialize)]
    struct Job {
        /// When the peer received it, in milliseconds since the epoch.
        #[serde(with = "timestamp::millis")]
        received: Timestamp,
        /// When the peer started it, in nanoseconds since the epoch.
        #[serde(with = "timestamp::nanos")]
        started: Timestamp,
        /// How long it may run, in seconds, where it has a limit at all.
        #[serde(with = "timedelta::secs::option")]
        timeout: Option<Timedelta>,
    }

    let sent = r#"{"received":1789544735123,"started":"1789544735123456789","timeout":"30"}"#;
    let job: Job = from_str(sent).expect("a job");
    assert_eq!(job.received, Timestamp::from_millis(1_789_544_735_123), "a number");
    assert_eq!(job.timeout, Some(Timedelta::from_secs(30)), "or a decimal string");

    let written = to_string(&job).expect("each field serializes as a number or a string");
    let expected = r#"{"received":1789544735123,"started":"1789544735123456789","timeout":30}"#;
    assert_eq!(written, expected, "nanoseconds since the epoch in a string, the rest as numbers");
}

A count past what the type holds is refused, with the reason OutOfRangeError gives. Writing a coarser unit drops what is finer: a timestamp is written as the unit it falls in, rounded down, and a span as its whole units toward zero.

fn a_count_past_the_range_is_refused_and_a_finer_one_rounded_down() {
    use serde::{Deserialize, Serialize};
    use serde_json::{from_str, to_string};
    use t2t_core::Timestamp;
    use t2t_core::serde::timestamp;

    /// A stamp, in whole seconds since the epoch.
    #[derive(Debug, Serialize, Deserialize)]
    struct Stamp {
        /// When the stamped thing was published.
        #[serde(with = "timestamp::secs")]
        published: Timestamp,
    }

    let refused = from_str::<Stamp>(r#"{"published":253402300799}"#).expect_err("the year 9999");
    let reason = refused.to_string();
    assert!(reason.starts_with("out of range error"), "past 2262: {reason}");

    let stamp = Stamp { published: Timestamp::from_millis(1_789_544_735_999) };
    let written = to_string(&stamp).expect("a count of seconds always serializes");
    assert_eq!(written, r#"{"published":1789544735}"#, "the second the moment falls in");
}

JSON Schema

With schemars, which turns serde on, every value with a spelling implements JsonSchema: an inlined string schema with a description and a pattern, and for a Timestamp the format date-time. Each pattern takes what its parser reads, which t2t’s tests check on spellings the parser takes and on ones it refuses; a date that does not exist, or a count past the range, matches the pattern and is the parser’s alone to refuse.

fn an_instant_and_a_span_have_the_json_schemas_of_their_spellings() {
    use schemars::schema_for;
    use t2t_core::{Timedelta, Timestamp};

    let instant = schema_for!(Timestamp);
    let field = |name: &str| instant.get(name).and_then(|value| value.as_str());
    assert_eq!(field("type"), Some("string"), "an instant is a string");
    assert_eq!(field("format"), Some("date-time"), "in RFC 3339's format");

    let span = schema_for!(Timedelta);
    let pattern = span.get("pattern").and_then(|value| value.as_str());
    let expected = "^(0|-?(?=[0-9])([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s)?([0-9]+ms)?([0-9]+us)?([0-9]+ns)?)$";
    assert_eq!(pattern, Some(expected), "a span's pattern takes what its parser reads");
}

A span’s pattern holds a lookahead, which JSON Schema’s ECMA-262 regular expressions read; Rust’s regex crate refuses a lookahead, so a validator built on it cannot use the pattern.

Bytes

With zerocopy-08, each type derives the zerocopy traits it can honour. A type’s bytes are native-endian, and with the feature on, its layout is part of its API.

TypeTraitsLeft out, and why
every point, Timedelta, TickdeltaFromBytes, IntoBytes, Immutable, KnownLayoutUnaligned: each is an 8-byte-aligned i64
Timed<T, S>FromBytes, Immutable, KnownLayoutIntoBytes: zerocopy’s derive takes a generic struct only where every field is Unaligned, and no stamp is
UtcDateTimeFromBytes, Immutable, KnownLayoutIntoBytes: three bytes of padding sit between its seconds and its nanoseconds
TickRateImmutable, KnownLayoutFromBytes and TryFromBytes: a derived check would test that the rate is not zero, and take factors that do not match it; a rate crosses as its hertz

A Timed<u64> is still read from bytes, its stamp’s then its value’s, and any sixteen bytes make a UtcDateTime, so one read from bytes is checked with is_valid, or by to_timestamp, before it is used.

fn a_value_is_read_from_bytes_and_a_point_written_to_them() {
    use t2t_core::{Timed, Timestamp, UtcDateTime};
    use zerocopy::{FromBytes, IntoBytes};

    // A point is its count, native-endian, both ways.
    let instant = Timestamp::from_secs(1_700_000_000);
    let bytes = instant.as_bytes();
    assert_eq!(bytes, 1_700_000_000_000_000_000_i64.to_ne_bytes(), "the count's bytes");
    assert_eq!(Timestamp::read_from_bytes(bytes), Ok(instant), "and back");

    // A stamped value is read from bytes: the stamp's, then the value's.
    let mut record = [0_u8; 16];
    record[..8].copy_from_slice(bytes);
    record[8..].copy_from_slice(&101_u64.to_ne_bytes());
    let sample = Timed::<u64>::read_from_bytes(&record).expect("sixteen bytes");
    assert_eq!((sample.stamp, sample.value), (instant, 101), "a stamped word");

    // Any bytes make a date and time, so one read from them is checked before it is used.
    let date_time = UtcDateTime::read_from_bytes(&[0xFF; 16]).expect("sixteen bytes");
    assert!(!date_time.is_valid(), "month 255 is no month");
    assert_eq!(date_time.to_timestamp(), None, "so it names no instant");
}

Working with Other Crates

A Timestamp and a Timedelta convert to and from std’s time types and those of chrono, jiff and time. Where every t2t value fits the other type, the conversion is a From; the way back is a TryFrom, refusing with OutOfRangeError what t2t’s range does not hold. The other points, a TaiTimestamp and those counted from boot or by a counter, have no counterpart in these crates, and no conversion.

Other typeFeatureInto itFrom it
core::time::Durationnonea Timedelta forwardsa Duration up to Timedelta::MAX
std::time::SystemTimestdevery Timestampa moment from 1677-09-21 to 2262-04-11
chrono 0.4’s DateTime<Utc>chrono-04every Timestampa DateTime in any zone, within the range
chrono 0.4’s TimeDeltachrono-04every Timedeltaa TimeDelta within the range
jiff 0.2’s Timestampjiff-02every Timestampa Timestamp within the range
jiff 0.2’s SignedDurationjiff-02every Timedeltaa SignedDuration within the range
time 0.3’s OffsetDateTimetime-03every Timestamp, in UTCan OffsetDateTime at any offset, within the range
time 0.3’s Durationtime-03every Timedeltaa Duration within the range

A zone or an offset converts as the instant it names, read in UTC. Each feature is named for the version of the crate it converts with, and with std on, it turns on that crate’s own std too. The listings are t2t-core’s tests, which have each crate as a dependency, so they name the crate t2t_core.

std

A Timedelta crosses to std’s Duration and back by TryFrom both ways: a Duration never runs backwards, so a negative span is refused, and a Timedelta holds about 292 years, so a longer Duration is refused. The conversion is core’s, and needs no feature.

fn a_span_crosses_to_a_duration_where_it_runs_forwards() {
    use core::time::Duration;

    use t2t_core::{OutOfRangeError, Timedelta};

    let span = Timedelta::from_millis(5);
    assert_eq!(Duration::try_from(span), Ok(Duration::from_millis(5)), "a span forwards");
    assert_eq!(Timedelta::try_from(Duration::from_millis(5)), Ok(span), "and back");
    assert_eq!(Duration::try_from(-span), Err(OutOfRangeError), "no duration runs backwards");
    assert_eq!(Timedelta::try_from(Duration::MAX), Err(OutOfRangeError), "nor past 292 years");
}

A Timestamp becomes a SystemTime with From, since a SystemTime on 64-bit Linux and macOS reaches past both ends of a timestamp’s range, and comes back with TryFrom, refusing a moment outside it. These need std, on 64-bit Linux or macOS.

fn a_timestamp_crosses_to_a_system_time_and_back_within_its_range() {
    use core::time::Duration;
    use std::time::{SystemTime, UNIX_EPOCH};

    use t2t_core::{OutOfRangeError, Timestamp};

    let instant = Timestamp::from_secs(1_700_000_000);
    let system_time = SystemTime::from(instant);
    let since_epoch = system_time.duration_since(UNIX_EPOCH).ok();
    assert_eq!(since_epoch, Some(Duration::from_secs(1_700_000_000)), "the same moment");
    assert_eq!(Timestamp::try_from(system_time), Ok(instant), "and back");

    let far_future = UNIX_EPOCH
        .checked_add(Duration::from_secs(1 << 40))
        .expect("a 64-bit SystemTime reaches 2^40 seconds past 1970");
    assert_eq!(Timestamp::try_from(far_future), Err(OutOfRangeError), "past 2262 is refused");
}

std’s Instant has no conversion: it is opaque, and std gives no way to read its count or build one from a count. On Linux and macOS it reads CLOCK_MONOTONIC and CLOCK_UPTIME_RAW, the clocks MonotonicClock reads, so code that would convert an Instant reads MonotonicClock in its place.

chrono

chrono’s years reach far past 1677 and 2262, so a DateTime<Utc> holds every timestamp, and a TimeDelta every span; a DateTime in another zone comes back as the instant it names.

fn a_timestamp_and_a_span_cross_to_chrono() {
    use chrono::{DateTime, FixedOffset, TimeDelta, Utc};
    use t2t_core::{OutOfRangeError, Timedelta, Timestamp};

    let instant = Timestamp::from_nanos(1_789_544_735_123_456_789);
    let theirs = DateTime::<Utc>::from(instant);
    assert_eq!(theirs.timestamp_nanos_opt(), Some(instant.as_nanos()), "the same instant");

    let paris = theirs.with_timezone(&FixedOffset::east_opt(7_200).expect("two hours east"));
    assert_eq!(Timestamp::try_from(paris), Ok(instant), "in any zone, read as its instant");
    let latest = DateTime::<Utc>::MAX_UTC;
    assert_eq!(Timestamp::try_from(latest), Err(OutOfRangeError), "past 2262 is refused");

    let span = Timedelta::from_millis(-250);
    assert_eq!(TimeDelta::from(span), TimeDelta::milliseconds(-250), "every span crosses");
    assert_eq!(Timedelta::try_from(TimeDelta::MAX), Err(OutOfRangeError), "but not every back");
}

jiff

jiff’s Timestamp reaches the years -9999 to 9999, so it holds every timestamp, and its SignedDuration every span.

fn a_timestamp_and_a_span_cross_to_jiff() {
    use jiff::{SignedDuration, Timestamp as JiffTimestamp};
    use t2t_core::{OutOfRangeError, Timedelta, Timestamp};

    let instant = Timestamp::from_nanos(1_789_544_735_123_456_789);
    let theirs = JiffTimestamp::from(instant);
    assert_eq!(theirs.to_string(), "2026-09-16T07:45:35.123456789Z", "the same instant");
    assert_eq!(Timestamp::try_from(theirs), Ok(instant), "and back");
    let latest = JiffTimestamp::MAX;
    assert_eq!(Timestamp::try_from(latest), Err(OutOfRangeError), "past 2262 is refused");

    let span = Timedelta::from_millis(-250);
    assert_eq!(SignedDuration::from(span), SignedDuration::from_millis(-250), "a span");
    let longest = SignedDuration::MAX;
    assert_eq!(Timedelta::try_from(longest), Err(OutOfRangeError), "and one too long for it");
}

time

time’s OffsetDateTime takes every timestamp, at the UTC offset; one at another offset comes back as the instant it names. Its Duration holds every span.

fn a_timestamp_and_a_span_cross_to_time() {
    use t2t_core::{OutOfRangeError, Timedelta, Timestamp};
    use time::{Duration, OffsetDateTime, UtcOffset};

    let instant = Timestamp::from_nanos(1_789_544_735_123_456_789);
    let theirs = OffsetDateTime::from(instant);
    assert_eq!(theirs.offset(), UtcOffset::UTC, "in UTC");
    let east = theirs.to_offset(UtcOffset::from_hms(2, 0, 0).expect("two hours east"));
    assert_eq!(Timestamp::try_from(east), Ok(instant), "at any offset, read as its instant");

    let span = Timedelta::from_millis(-250);
    assert_eq!(Duration::from(span), Duration::milliseconds(-250), "a span");
    assert_eq!(Timedelta::try_from(Duration::MAX), Err(OutOfRangeError), "and one too long");
}

Stamped Values

Timed<T, S> holds a value T and the stamp S its writer captured it with, a Timestamp unless a stamp of another timeline is named. It says how old the value is at any later point, and reaches the value through Deref, so a stamped sample’s fields read as the sample’s own.

fn a_stamped_value_says_how_old_it_is() {
    use t2t::{Timed, Timedelta, Timestamp};

    /// What a weather station measured.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    struct Weather {
        /// The temperature, in degrees Celsius.
        celsius: i16,
        /// The relative humidity, in percent.
        humidity: u8,
    }

    let captured = Timestamp::from_secs(1_700_000_000);
    let sample = Timed::new(captured, Weather { celsius: 21, humidity: 40 });

    assert_eq!(sample.celsius, 21, "the value's fields, through `Deref`");
    let now = captured + Timedelta::from_millis(1_500);
    assert_eq!(sample.elapsed(now), Timedelta::from_millis(1_500), "its age at `now`");
    assert!(sample.elapsed(now) > Timedelta::SECOND, "so it is stale after a second");
    let early = captured - Timedelta::MILLISECOND;
    assert!(sample.elapsed(early).is_negative(), "and its age is negative before its stamp");
}

elapsed is the span from the stamp to the point it is given, on the stamp’s own timeline, and negative for a stamp still ahead of it.

The Writer Stamps

The code that captures a value stamps it, and whatever reads it keeps that stamp: by the time a reader holds the value, the moment it was captured has gone, and a clock read on the reading side would time the reader. map makes a new value under the same stamp, and map_stamp a new stamp over the same value.

fn the_writer_stamps_and_the_reader_keeps_the_stamp() {
    use t2t::clock::{Clock, ManualClock};
    use t2t::{Timed, Timedelta, Timestamp};

    /// A value, stamped by `clock` as it is written.
    fn publish<C: Clock<Reading = Timestamp>, T>(clock: &C, value: T) -> Timed<T> {
        Timed::new(clock.now(), value)
    }

    let clock = ManualClock::new(Timestamp::from_secs(1_700_000_000));
    let sample = publish(&clock, 101_u64);
    clock.advance(Timedelta::from_millis(3));

    // Whatever the reader makes of the value, the stamp stays the writer's.
    let doubled = sample.map(|count| count.saturating_mul(2));
    assert_eq!((doubled.stamp, doubled.value), (sample.stamp, 202), "a new value, the same stamp");
    let in_seconds = sample.map_stamp(Timestamp::as_secs);
    assert_eq!(in_seconds.stamp, 1_700_000_000, "or a new stamp over the same value");
    assert_eq!(sample.elapsed(clock.now()), Timedelta::from_millis(3), "and its age, read late");
    assert_eq!(sample.into_parts(), (sample.stamp, 101), "and its parts, the stamp first");
}

as_ref and as_mut borrow the value under the same stamp; into_inner gives the value, into_parts the stamp and the value, and a Timed converts to and from the pair (S, T).

Order

The stamp is the first field, so the order a Timed derives is chronological, and the value decides only between equal stamps.

fn stamped_values_sort_by_their_stamps() {
    use t2t::{Timed, Uptime};

    let mut events = [
        Timed::new(Uptime::from_secs(3), "third"),
        Timed::new(Uptime::from_secs(1), "first"),
        Timed::new(Uptime::from_secs(2), "second"),
    ];
    events.sort();
    let order = events.map(Timed::into_inner);
    assert_eq!(order, ["first", "second", "third"], "the stamp decides the order");
}

Layout

A Timed is #[repr(C)]: the stamp at offset zero, then the value. A Timed<u64> is 16 bytes, with no padding, on any timeline, since every stamp is an i64. With zerocopy-08, a Timed is read from bytes, as Serialization shows.

fn a_stamped_word_is_two_words() {
    use core::mem::offset_of;

    use t2t::{Tickstamp, Timed};

    assert_eq!(size_of::<Timed<u64>>(), 16, "a stamp and a word, no padding");
    assert_eq!(offset_of!(Timed<u64>, stamp), 0, "the stamp first");
    assert_eq!(size_of::<Timed<u64, Tickstamp>>(), 16, "on any timeline");
}

Testing with Manual Clocks

Code that takes its clock as a parameter, generic over Clock, reads SystemClock in production and a manual clock in a test. A manual clock reads whatever point it was last set or moved to, so a test decides what time it is.

fn code_generic_over_its_clock_is_tested_on_a_manual_one() {
    use t2t::clock::{Clock, ManualClock};
    use t2t::{Timed, Timedelta, Timestamp};

    /// Whether `sample` is older than a second by `clock`: `SystemClock` in production.
    fn is_stale<C: Clock<Reading = Timestamp>>(clock: &C, sample: Timed<u64>) -> bool {
        sample.elapsed(clock.now()) > Timedelta::SECOND
    }

    let clock = ManualClock::new(Timestamp::from_secs(1_700_000_000));
    let sample = Timed::new(clock.now(), 101);
    assert!(!is_stale(&clock, sample), "fresh when captured");
    clock.advance(Timedelta::from_millis(1_500));
    assert!(is_stale(&clock, sample), "stale a second and a half on");
}

ManualClock

ManualClock<P> holds a point of any timeline, a Timestamp unless another is named. set moves it to a point, earlier or later, and advance moves it by a span, back for a negative one, saturating as the point’s own addition does. Between moves it stands still, and reading it costs a load.

fn a_manual_clock_moves_only_when_moved() {
    use t2t::clock::{Clock, ManualClock};
    use t2t::{Timedelta, Uptime};

    let clock = ManualClock::new(Uptime::from_secs(10));
    assert_eq!(clock.now(), clock.now(), "it stands still between moves");

    clock.advance(Timedelta::from_millis(250));
    assert_eq!(clock.now(), Uptime::from_millis(10_250), "forwards by a span");
    clock.advance(-Timedelta::SECOND);
    assert_eq!(clock.now(), Uptime::from_millis(9_250), "backwards by a negative one");
    clock.set(Uptime::MAX);
    clock.advance(Timedelta::SECOND);
    assert_eq!(clock.now(), Uptime::MAX, "saturating at the end, as the point's addition does");
}

A manual clock is not Clone: a copy would fork time, the copy and the original each moving alone. It is shared by reference, and the code under test takes &C.

Replays

A replay drives the clock from what a capture recorded: the clock is set to each stamp in turn, and the code under test reads it as it would read the wall clock.

fn a_replay_sets_the_clock_to_each_recorded_stamp() {
    use t2t::clock::{Clock, ManualClock};
    use t2t::{Timedelta, Timestamp};

    /// Whether a heartbeat last seen at `last` has lapsed by `clock`, past `limit`.
    fn has_lapsed<C: Clock<Reading = Timestamp>>(
        clock: &C, last: Timestamp, limit: Timedelta,
    ) -> bool {
        clock.now() - last > limit
    }

    // The stamps a capture recorded, one a heartbeat.
    let recorded = ["2026-09-16T07:45:35Z", "2026-09-16T07:45:36.5Z", "2026-09-16T07:45:40Z"];
    let beats: Vec<Timestamp> =
        recorded.iter().map(|text| text.parse().expect("an instant")).collect();

    let clock = ManualClock::new(beats[0]);
    let mut lapses = Vec::new();
    for pair in beats.windows(2) {
        clock.set(pair[1]);
        lapses.push(has_lapsed(&clock, pair[0], Timedelta::from_secs(2)));
    }
    assert_eq!(lapses, [false, true], "the second gap, of three and a half seconds, lapsed");
}

Threads

A ManualClock keeps its point in a Cell, so it moves to another thread but two threads never share one: the compiler refuses it.

//! A manual clock may move to another thread, but two threads never share one.

use std::thread;

use t2t_clock::{Clock, ManualClock};
use t2t_core::Timestamp;

fn main() {
    let clock = ManualClock::new(Timestamp::UNIX_EPOCH);
    thread::scope(|scope| {
        scope.spawn(|| clock.now());
    });
}
error[E0277]: `Cell<Timestamp>` cannot be shared between threads safely
  --> tests/compile_fail/a_manual_clock_is_not_shared_across_threads.rs:11:21
   |
11 |         scope.spawn(|| clock.now());
   |               ----- ^^^^^^^^^^^^^^ `Cell<Timestamp>` cannot be shared between threads safely
   |               |
   |               required by a bound introduced by this call
   |
   = help: within `ManualClock`, the trait `Sync` is not implemented for `Cell<Timestamp>`

AtomicManualClock<P> is the one to share, across scoped threads by reference or across any by Arc. It keeps its point in an AtomicI64, and a thread that reads a point another thread set or moved the clock to also sees what that thread wrote before it moved the clock. Two threads that advance it at once both count. It builds where the target has 64-bit atomics.

fn an_atomic_manual_clock_is_shared_across_threads() {
    use std::thread;

    use t2t::clock::{AtomicManualClock, Clock};
    use t2t::{Timedelta, Timestamp};

    let clock = AtomicManualClock::new(Timestamp::UNIX_EPOCH);
    thread::scope(|scope| {
        for _ in 0..4 {
            scope.spawn(|| {
                for _ in 0..1_000 {
                    clock.advance(Timedelta::MICROSECOND);
                }
            });
        }
    });
    assert_eq!(clock.now(), Timestamp::from_millis(4), "every thread's moves count");
}

Loom models check those promises, trying every interleaving of two threads that the memory model allows; Platforms and Features says where they run.

A Clock of Your Own

A manual clock holds a point, so a clock of CPU time, which reads a span, has no manual twin. Clock is one associated type and one method, so a test writes the clock it needs:

fn a_test_writes_the_clock_it_needs() {
    use core::cell::Cell;

    use t2t::Timedelta;
    use t2t::clock::Clock;

    /// A CPU-time clock for a test: a span, which no manual clock reads, set by hand.
    #[derive(Debug, Default)]
    struct TestCpuClock(Cell<Timedelta>);

    impl Clock for TestCpuClock {
        type Reading = Timedelta;

        fn now(&self) -> Timedelta {
            self.0.get()
        }
    }

    let clock = TestCpuClock::default();
    clock.0.set(Timedelta::from_millis(3));
    assert_eq!(clock.now(), Timedelta::from_millis(3), "what the test set");
}

Performance

Every figure on this page is from a run committed under a crate’s benches/results/: a directory of two runs’ transcripts and a manifest.toml that says how they were taken. Each run was taken on an AWS Graviton4 (Arm Neoverse V2, a c8g.8xlarge) under Linux 6.12.53, built by rustc 1.101.0-nightly of 2026-09-27 in the bench profile (release’s settings, fat LTO, one codegen unit) unless a row says it ran without LTO, pinned with taskset to a core the kernel keeps other work off. Each figure is a median of 100 samples, and each pair a run’s two medians.

RunCrateWhat it compares
2026-10-02T23-14Z-9776c95-span-divisorst2t-corea span’s spelling with each unit divided by a constant, against dividing by a unit read from a table
2026-10-03T08-54Z-635b659-calendar-and-digit-pairst2t-core#[inline] on the calendar’s conversions with and without LTO, and the digit-pair table against write!
2026-10-03T10-21Z-8aeae1f-clock-readst2t-clockone reading of each clock

The name is the time the run was taken, in UTC, the commit it measured, and what it measured.

Reading a Clock

One reading, on one thread, in the clock-reads run:

ClockNanoseconds
CoarseSystemClock9.849, 9.836
CoarseMonotonicClock9.897, 9.891
Counter11.82, 11.82
RawMonotonicClock33.16, 33.16
MonotonicClock33.18, 33.18
BootClock33.19, 33.19
TaiClock33.19, 33.19
SystemClock33.21, 33.19
ThreadCpuClock272.8, 269.3
ProcessCpuClock394.2, 387.7

The bench reads each clock on 1, 2, 4 and 8 threads at once, so that a clock whose reading shares a cache line with another core’s would show the rise. This run pinned every thread to one core, so its rows for more threads time the threads taking turns, and show nothing of what cores cost each other; the manifest has them.

The counter’s read is one instruction, mrs of cntvct_el0 on aarch64 and rdtsc on x86_64, in inline assembly that tells the compiler it touches no memory, no stack and no flags. now is #[inline(always)], since a call around the read would move it. The coarse clocks hand back a value the kernel’s timer last wrote, without reading the counter, and cost the least; the CPU-time clocks take a system call.

Converting a Value

Per 1,024 values, varied over the range, in microseconds:

ConversionMicroseconds per 1,024Run
ticks to nanoseconds, at 24 MHz1.206, 1.207span divisors, after
nanoseconds to ticks, at 24 MHz1.034, 1.034span divisors, after
Timestamp::to_utc0.9816, 0.9818calendar, fat LTO, #[inline]
UtcDateTime::to_timestamp5.184, 5.184calendar, fat LTO, #[inline]
writing a Timestamp24.22, 24.24calendar, fat LTO, #[inline]
reading a Timestamp20.46, 20.44calendar, fat LTO, #[inline]
writing a Timedelta46.27, 46.86span divisors, after
reading a Timedelta31.33, 30.74span divisors, after

Each row is measured at the commit its run names; the code of each path is the variant the row names, the one the crates have.

How Each Path Was Chosen

No Division by a Number Known at Run Time

On every path a reading or a spelling takes, a division divides by a constant, which the compiler turns into a multiply. A TickRate works out its factors once, so a conversion is a multiply and a shift, as The CPU Counter shows. Timestamp::to_utc counts the seconds from the first day a timestamp reaches, so every split of them is an unsigned division by a constant, the time of day’s in 32 bits.

A span’s spelling writes each unit through a function generic over the unit, write_unit::<UNIT>, so each unit’s division is by a constant: 46.27 and 46.86 µs per 1,024 spans, against 51.59 and 51.83 µs dividing by a unit read from a table of the seven, a tenth faster.

The Digit-Pair Table

A timestamp’s date and time of day are written two digits at a time, from a table of the hundred numbers below 100 as ASCII. Writing 1,024 instants takes 24.22 and 24.23 µs through the table, and 108.8 and 108.5 µs through write!’s {:02}, four and a half times as long, under fat LTO; without LTO, 29.15 and 29.15 µs against 114.4 and 114.4.

Inlining Across Crates

The calendar’s conversions are #[inline], so a build without link-time optimization inlines them across crates. That is the build a dependency gets in a default release profile, which the run stood in for with CARGO_PROFILE_BENCH_LTO=false and 16 codegen units. The run timed the conversions with #[inline] on the calendar’s five functions and without it, each with fat LTO and without:

Per 1,024, µsfat LTO, plainfat LTO, #[inline]no LTO, plainno LTO, #[inline]
Timestamp::to_utc0.9875, 0.98510.9816, 0.98188.009, 8.0020.9788, 0.9786
UtcDateTime::to_timestamp4.559, 4.5685.184, 5.1846.912, 6.9125.091, 5.087
reading a Timestamp20.58, 20.6020.46, 20.4429.03, 29.0319.51, 19.50

Without LTO, #[inline] makes to_utc eight times as fast, and reading a timestamp half as fast again. Under fat LTO, which inlines across crates anyway, to_utc gains nothing, and to_timestamp is 13% slower for it, 5.18 µs against 4.56. Per conversion, a fat-LTO build pays 0.6 ns in to_timestamp for it, and a build without LTO saves 6.9 ns in to_utc, 1.8 ns in to_timestamp and 9.3 ns in reading a timestamp. The crates keep #[inline], for the build without LTO.

Measuring on Your Own Machine

The benches are divan’s, one for t2t-core’s conversions and one for the clocks’ reads:

cargo bench -p t2t-core --bench convert
cargo bench -p t2t-clock --features std --bench read

A name after -- runs the benches it matches, as -- timestamp. For figures that hold still, build first, so the run times no compilation, then pin the run to a core the kernel keeps other work off, one that isolcpus and nohz_full name on the kernel’s command line, and run it twice:

cargo bench -p t2t-clock --features std --bench read --no-run
taskset -c 10 cargo bench -p t2t-clock --features std --bench read

The clock tour prints a rough figure for every clock in one command, timing 100,000 readings of each on the counter:

cargo run --release -p t2t --features std --example clock-tour

A run worth keeping goes under the crate’s benches/results/, in a directory named for its time in UTC, its commit and what it measures, with its transcripts and a manifest.toml in the shape of those there: the hardware, the environment, the toolchain, the parameters, and the results.

Platforms and Features

The values build without std, bare metal included; the CPU’s counter builds on aarch64 and x86_64; and the operating system’s clocks build with std, on 64-bit Linux and macOS.

What Builds Where

PartBuilds
the values, t2t-corewithout std, bare metal included
ManualClockwithout std, bare metal included
AtomicManualClockon a target with 64-bit atomics
Counteron aarch64 and x86_64, on any system, with or without std
the operating system’s clockswith std, on 64-bit Linux and macOS
TaiClockwith std, on 64-bit Linux
the SystemTime conversionswith std, on 64-bit Linux and macOS
an x86_64 counter’s measured ratewith std, on 64-bit Linux and macOS

On another target the parts it cannot build are left out, and the rest builds. The operating system’s clocks read a 64-bit timespec, which is why they need 64-bit pointers. On aarch64, Linux and macOS let a program read the counter and its rate; a system that does not traps the read, which stops the process. On x86_64 without std, or off 64-bit Linux and macOS, a counter whose rate CPUID does not report is refused with NoRate, since there is no clock to measure it against.

Without std

Every crate is #![no_std], and nothing reaches the operating system unless std is on. t2t-core never reaches it at all: its std adds the SystemTime conversions, and turns on its dependencies’ own std. The schemars feature needs alloc, for the schemas it builds. CI builds all three crates for aarch64-unknown-none and x86_64-unknown-none with no feature, and the facade there with every feature that needs no std: chrono-04, jiff-02, schemars, serde, time-03 and zerocopy-08.

32-Bit Linux

On 32-bit Linux the operating system’s clocks and the SystemTime conversions are left out. On x32, x86_64 with 32-bit pointers, the counter builds, its rate from CPUID alone; on i686 there is no counter. CI checks every crate with every feature on both.

Crate Features

None is on by default, and nothing reaches the operating system unless std is named; what std adds is on 64-bit Linux and macOS.

FeatureAdds
stdthe OS clocks, an x86_64 counter’s measured rate, SystemTime conversions
serdeevery value’s spelling, and the serde modules for counts in a named unit
schemarsJsonSchema for every value with a spelling; turns serde on
zerocopy-08the zerocopy traits each type can honour, native-endian
chrono-04conversions to and from chrono 0.4’s DateTime and TimeDelta
jiff-02conversions to and from jiff 0.2’s Timestamp and SignedDuration
time-03conversions to and from time 0.3’s OffsetDateTime and Duration

That is the facade’s table. t2t-core has every feature but its std adds the SystemTime conversions alone, and t2t-clock has std alone, which adds the OS clocks and an x86_64 counter’s measured rate. Features are named with cargo add, as cargo add t2t --features std,serde.

Rust Version

Every crate builds on stable Rust 1.98 or later, the workspace’s rust-version, and uses no nightly feature. The repository pins a nightly toolchain for rustfmt’s and the lints’ nightly options alone, and just check-rust-msrv builds every crate on 1.98.

What CI Checks

check.yml runs every just check recipe on x86_64 Linux. platforms.yml runs what it cannot:

  • the tests on arm64 Linux, and on macOS on arm64 and on x86_64;
  • the bare-metal builds, and the 32-bit Linux checks, above;
  • every combination of every crate’s features, each crate built alone;
  • the loom models of AtomicManualClock, which try every interleaving of two threads its orderings allow;
  • the two parsers fuzzed, from the corpus committed under crates/t2t-core/fuzz/.

How t2t Compares

t2t sits beside std’s time types, two crates that read the CPU’s counter, quanta and minstant, and three calendar crates, chrono, jiff and time. Each section says what the other does, from its own documentation, where it and t2t differ, and where it is the better choice. The versions read are those docs.rs served on 2026-10-03: std 1.99, quanta 0.13.0, minstant 0.1.7, chrono 0.4.45, jiff 0.2.37 and time 0.3.55. Where a crate’s documentation and its source disagree, the section says so.

t2tstdquantaminstantchronojifftime
Pointsone type per timeline, sixInstant, SystemTimeone Instantone Instantdate and time typesdate and time typesdate and time types
A signed spanTimedeltanonenonenoneTimeDeltaSignedDuration, SpanDuration
Reads the CPU’s counteron aarch64 and x86_64noon aarch64 and x86_64on x86 Linuxnonono
Time zonesUTC and TAInonenonenoneUtc, Local, fixed offsets; IANA zones through chrono-tz or tzfileIANA zonesUTC offsets
Years1677 to 2262not a calendarnot a calendarnot a calendarabout ±262,000-9999 to 9999±9999, or ±999,999 with large-dates
no_stdyesDuration alone, in corenonoyesyes, with fixed zones alone without allocmostly

std::time

std has Instant, a monotonic clock’s reading, which reads CLOCK_MONOTONIC on Unix and CLOCK_UPTIME_RAW on Darwin; SystemTime, the wall clock’s, which is not monotonic; and Duration, an unsigned span of up to about 584 billion years, with no Display. An Instant is opaque: it compares with another and subtracts from one, but has no count to read and no constructor from one. std has no signed span, no calendar and no RFC 3339.

t2t’s MonotonicClock reads the clocks Instant reads, and its SystemClock the one SystemTime reads; beside them it has TAI, the raw and boot clocks, and the counter, each a type, with signed spans, counts to read, spellings and manual clocks.

std is the better choice where a program times spans inside one process and hands them to std’s own APIs, which take a Duration: it needs no dependency. t2t’s Timedelta converts to and from a Duration.

quanta

quanta reads the time-stamp counter on x86_64 and the system counter on aarch64, and falls back to the operating system’s clock where the counter is missing or unreliable. Its documentation asks for a constant or an invariant time-stamp counter; its source requires an invariant one, and rdtscp. It calibrates the counter against the reference clock once a process, lazily, at the first Clock::new or Instant::now, in up to 200 ms. It has one Instant, a count of nanoseconds; a cached Clock::recent that an upkeep thread keeps current; and a mock clock, Clock::mock. It converts a reading with an integer multiply and a shift, as t2t does. It builds on Windows, the BSDs and WebAssembly as well as Linux and macOS, and its source needs std.

t2t reads the counter’s rate where the CPU reports one, and measures it only on x86_64 where it does not; it refuses a counter it cannot read as time where quanta falls back. Its readings are the raw register, so two processes agree on every one, and it builds without std.

quanta is the better choice for a program that runs on Windows, a BSD or WebAssembly; that wants the operating system’s clock taken in silently where the counter cannot serve; or that reads the time often enough to want a reading an upkeep thread caches.

minstant

minstant reads the time-stamp counter on Linux on x86 and x86_64, calibrated as the process starts. Its documentation says that elsewhere it falls back to coarse time; its source falls back to SystemTime, the wall clock, unless its fallback-coarse feature is on. It has one Instant, and an Anchor that turns a reading into nanoseconds since the Unix epoch; is_tsc_available says which it reads. Its source converts a reading with floating point, and needs std.

t2t reads the counter on aarch64 too, and on macOS; it never falls back to another clock, and converts with integers. It has no anchor from a counter’s reading to a Timestamp: a program that needs one takes both readings itself.

minstant is the better choice for a program on Linux on x86 that wants one Instant that builds everywhere, falling back off x86 Linux, and readings turned into Unix time by its Anchor.

chrono

chrono is a calendar crate: a DateTime in a time zone, Utc, Local or a fixed offset, with the IANA zones through chrono-tz or tzfile; naive dates and times without a zone; and a signed TimeDelta of seconds and nanoseconds. It formats with strftime-style patterns, RFC 3339 and RFC 2822, reaches about 262,000 years either side of the common era, and has no monotonic clock of its own: its documentation points to std’s Instant.

chrono is the better choice for local time, time zones, dates far outside 1677 to 2262, and formatting to a pattern. t2t converts a Timestamp and a Timedelta to and from chrono’s DateTime and TimeDelta.

jiff

jiff is a calendar crate built around the IANA time zone database, the system’s copy or one it embeds where the system has none. Its Zoned does arithmetic that follows daylight saving time, its Span holds calendar units such as months, and its Timestamp and SignedDuration are 96-bit counts of nanoseconds over the years -9999 to 9999. It reads and writes RFC 3339, RFC 9557 and RFC 2822, and strftime-style patterns; it has nearly all of this without std.

jiff is the better choice for anything that follows a time zone: local deadlines, daylight saving time, calendar arithmetic in months and days. t2t converts a Timestamp and a Timedelta to and from jiff’s Timestamp and SignedDuration.

time

time is a calendar crate of dates and times, at a UTC offset or in UTC, with a signed Duration of seconds and nanoseconds. It formats and parses with format descriptions of any layout, and with RFC 3339, RFC 2822 and ISO 8601; it holds years ±9999, or ±999,999 with its large-dates feature; and it is mostly no_std.

time is the better choice for dates and times at an offset, and for formats beyond RFC 3339. t2t converts a Timestamp and a Timedelta to and from time’s OffsetDateTime and Duration.

Where t2t Fits

t2t is for the time a program reads off its clocks and carries: every value one i64, every timeline a type, every operator saturating, and the counter read in one instruction. It has no time zones but UTC and TAI, no calendar arithmetic, and no formatting beyond its spellings; a program that needs those reads its clocks with t2t and converts to chrono, jiff or time at the edge.