Time and Clocks
Monotonic, wall, TSC — and which one an exchange may use.
Production · Gateway, Protocols, Risk & Resilience
Why this week
Last week you put five timestamps on every order. Each one is a clock read, and clock reads aren't free: Instant::now() costs about 20 ns on a well-configured Linux box and several times that on a badly configured one. Five reads per order can cost more than the risk check. That is the small problem.
The big problem is meaning. "This order took 14 µs" is a statement about two readings of one clock on one machine. "The exchange received it 30 µs after the client sent it" compares two clocks on two machines and is only as good as their synchronization. "Transact time 09:30:00.000123" is a regulatory record that must be traceable to UTC within a bound the law specifies. And "the order expired at 16:00" is a decision inside the matching engine, which must replay identically next week, so it may not come from a clock at all.
This week you learn the clocks a Linux machine actually has, how to read the fastest one safely, how two machines agree on time and how wrong they can be, and the one rule that keeps the engine deterministic: the engine never reads a clock; time is an input.
Day 1 — The clocks you have
Wall, monotonic, raw
What each clock promises, what it costs, and how it can lie
Linux exposes several clocks through clock_gettime, and Rust wraps two of them:
CLOCK_REALTIME, which isSystemTimein Rust. Wall time, roughly UTC. It can jump: an operator runsdate -s, NTP steps a large error, a leap second is applied by stepping. Never compute a duration from it.CLOCK_MONOTONIC, which isInstanton Linux. Never goes backwards, starts at an arbitrary point (boot), doesn't count suspend. NTP may slew its rate slightly to converge on true time, so a monotonic second isn't exactly an SI second, but it never jumps.CLOCK_MONOTONIC_RAW: monotonic and not slewed. It's the hardware's rate, including the hardware's error.CLOCK_TAI: International Atomic Time, without leap seconds, if your time daemon has told the kernel the TAI-UTC offset.
On x86 with the TSC as the kernel's clocksource, clock_gettime for the common clocks runs in the vDSO: a small piece of kernel-provided code mapped into your process that reads the TSC and the kernel's conversion parameters from shared memory, with no system call. That's where the ~20 ns comes from. If the kernel has decided the TSC is unreliable (you'll see "Marking TSC unstable" in dmesg, and some virtual machines do this), it falls back to a clocksource like hpet or acpi_pm, every read becomes a real system call plus a slow device access, and the cost grows by an order of magnitude or more. One file tells you which you have:
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tscPut that check in your deployment checklist and your startup code. A host that silently fell back to hpet will make every latency number you collect wrong in an interesting way: dominated by the cost of measuring it.
Why must you never compute a latency from two SystemTime readings, even on a machine running NTP?
Read the definitions of CLOCK_REALTIME, CLOCK_MONOTONIC, CLOCK_MONOTONIC_RAW, CLOCK_BOOTTIME and CLOCK_TAI, paying attention to which are affected by discontinuous jumps and which by NTP slewing.
The platform table (which OS clock backs Instant) and the warnings about monotonicity guarantees on buggy hardware. Note what Rust promises and what it only tries to do.
Day 2 — The TSC
Reading the CPU's own counter
Invariant TSC, rdtsc ordering, and fixed-point conversion
rdtsc reads the processor's 64-bit Time Stamp Counter. On any x86 CPU from the last decade or so it is invariant: it ticks at a constant rate regardless of frequency scaling, turbo or sleep states. That rate is the TSC frequency, usually close to the nominal base clock. CPUID leaf 0x80000007 reports this in EDX bit 8, and Linux shows it as the constant_tsc and nonstop_tsc flags in /proc/cpuinfo. Reading it costs roughly 20–40 cycles depending on the microarchitecture, a few nanoseconds, and it never enters the kernel.
#[inline(always)]
pub fn rdtsc() -> u64 {
// SAFETY: rdtsc has no preconditions on x86_64.
unsafe { core::arch::x86_64::_rdtsc() }
}Two properties to respect:
rdtsc is not serializing. The CPU may execute it before earlier instructions have finished, or let later instructions start first. Intel's manual spells out the fixes: an lfence before rdtsc waits for earlier instructions to complete, an lfence after it stops later instructions from starting early. rdtscp waits for earlier instructions, though not later ones, and also returns a processor id (Linux puts the CPU number there). For timing a 30-cycle code fragment the fences matter. For stage timestamps on an order path measured in microseconds they cost more than they buy, so use plain rdtsc.
Ticks are not nanoseconds. You need the TSC frequency, and the honest way to get it is to measure it against the kernel's monotonic clock at startup. The conversion on the hot path should then be one multiply and one shift: ns = ns0 + ((ticks − tsc0) × mult) >> shift. That is exactly how the Linux kernel's own clocksource code converts cycles (its mult and shift fields). Two traps:
- The product overflows 64 bits after only a few seconds of GHz ticks. Widen to
u128. On x86-64 that's still a singlemulproducing a 128-bit result. - A single calibration reading can be interrupted between reading the reference clock and reading the TSC, and an interrupted reading has a misleading midpoint. Bracket each TSC read with two reference reads and keep the reading with the narrowest bracket. It's the one least likely to have been interrupted. Then use a long window (hundreds of milliseconds or more) between the start and end bursts, because the frequency error is the endpoint error divided by the window.
Calibrate ticks to nanoseconds
/// One calibration reading: the reference clock (CLOCK_MONOTONIC, ns) read
/// immediately before and immediately after one TSC read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Sample {
pub before_ns: u64,
pub tsc: u64,
pub after_ns: u64,
}
/// ns(tsc) = ns0 + (((tsc - tsc0) * mult) >> shift)
/// One multiply and one shift on the hot path: no division, no floats.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Calibration {
pub tsc0: u64,
pub ns0: u64,
pub mult: u64,
pub shift: u32,
}
pub const SHIFT: u32 = 32;
/// Pick the reading with the NARROWEST [before_ns, after_ns] bracket (the
/// one least likely to have been interrupted between the two clock reads)
/// and return (its tsc, the bracket midpoint). Ties go to the earliest.
pub fn best_point(burst: &[Sample]) -> (u64, u64) {
let _ = burst;
todo!()
}
/// Calibrate from a burst taken at the start and a burst taken at the end
/// of a window (the longer the window, the smaller the error).
/// tsc0, ns0 = best point of `start`
/// mult = round((Δns << SHIFT) / Δtsc), computed in u128
pub fn calibrate(start: &[Sample], end: &[Sample]) -> Calibration {
let _ = (start, end);
todo!()
}
impl Calibration {
/// Ticks → ns. Must stay right for hours: (tsc - tsc0) * mult overflows
/// u64 after a few seconds of GHz ticks, so widen to u128. A tsc earlier
/// than tsc0 maps to ns0.
pub fn to_ns(&self, tsc: u64) -> u64 {
let _ = tsc;
todo!()
}
/// Estimated TSC frequency in ticks per ns (= GHz).
pub fn ghz(&self) -> f64 {
(1u128 << self.shift) as f64 / self.mult as f64
}
}You time a 40-cycle function with two plain rdtsc reads and get numbers that vary wildly, sometimes near zero. What is going on, and what do you change?
Why store raw TSC ticks in the order's stage timestamps and convert to nanoseconds later, rather than converting at each stamp?
Read the instruction reference entries for RDTSC and RDTSCP (the ordering notes with LFENCE), and the Time-Stamp Counter section of Volume 3 on invariant TSC. Chapter numbers move between revisions; search for 'invariant TSC'.
Day 3 — Timestamping at stage boundaries
One trait, three clocks
TSC in production, Instant as a fallback, a manual clock in tests
Put the clock behind a trait and make every component generic over it, so the hot path inlines the read (static dispatch, week 37 goes deeper) and tests can control time exactly as week 17's token bucket did:
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
pub struct Ticks(pub u64);
pub trait Clock {
fn now(&self) -> Ticks;
fn to_ns(&self, t: Ticks) -> u64;
}
/// Production on x86_64 with an invariant TSC the kernel also trusts.
pub struct TscClock { cal: Calibration }
/// Fallback: Instant, expressed as ns since an origin.
pub struct MonoClock { origin: std::time::Instant }
/// Tests and simulation: time moves only when the test says so.
pub struct ManualClock { now: std::cell::Cell<u64> }
pub struct Gateway<C: Clock> { clock: C, /* … */ }
Choose the implementation once, at startup: TSC only if the CPU reports an invariant TSC and the kernel's clocksource is tsc, since the kernel has information about cross-core synchronization and virtualization that a CPUID bit doesn't. Otherwise fall back to MonoClock and log loudly that you did.
Where the stamps go matters as much as how they're taken. Stamp where an order changes hands: when the read syscall returns with its bytes, when it's pushed to the engine queue, when the engine pops it, when matching finishes, when the ack's write returns. A gap between two stamps is then attributable to exactly one owner. Even the first stamp is late, though. The packet waited in the NIC and the kernel before your read returned. The SO_TIMESTAMPING socket option can give you kernel or NIC receive timestamps for that earlier moment, and week 33 comes back to hardware timestamping.
Budget it: five rdtsc reads cost a few tens of nanoseconds per order, five Instant::now() around a hundred. Both are acceptable for an order path measured in microseconds. The second is not acceptable for a matching path measured in hundreds of nanoseconds, which is one more reason the engine itself takes only one stamp, or none.
List every stage timestamp from socket read to ack write in your exchange. For each, name the thread that takes it, what the gap since the previous stamp measures (service time or queueing), and what would be invisible if you removed it.
Day 4 — Clock synchronization
How two machines agree on time
Two-way exchanges, asymmetry, NTP vs PTP, and the regulatory bound
Two machines agree on time the same way whether the protocol is NTP or PTP: a two-way exchange of four timestamps. The client sends at t1 (its clock), the server receives at t2 and replies at t3 (its clock), the client receives at t4. Then:
offset = ((t2 − t1) + (t3 − t4)) / 2 server clock minus client clock
delay = (t4 − t1) − (t3 − t2) round-trip time on the wireThe derivation assumes the path takes the same time in each direction. If it doesn't, the offset estimate is wrong by half the asymmetry, and nothing in the four timestamps can reveal it. Asymmetry comes from queueing in switches, from different routes each way, and from software timestamping (time spent in the kernel before the timestamp is taken). Two defenses:
- Filter by delay. Of many exchanges, the one with the smallest round trip had the least queueing, so the least room for asymmetry. NTP's clock filter works on this principle, and so does the drill.
- Take the timestamps in hardware and correct for the network. PTP (IEEE 1588) uses the same math, but with timestamps taken by the NIC as packets cross the wire, and with switches that either act as boundary clocks (terminate and re-originate time) or transparent clocks (add their own residence time into the packet). On Linux,
linuxptp'sptp4ldisciplines the NIC's hardware clock to the grandmaster andphc2sysdisciplines the system clock to the NIC.
Rough orders of magnitude: NTP over the internet gives milliseconds. NTP on a LAN with a good daemon (chrony) gives tens of microseconds to better. PTP with hardware timestamping and PTP-aware switches gives sub-microsecond, down to tens of nanoseconds with good hardware. The grandmaster is usually disciplined by GNSS with a pulse-per-second signal.
This isn't optional for a European venue. Under MiFID II's RTS 25, trading venues whose gateway-to-gateway latency is under a millisecond, and members using high-frequency algorithmic techniques, must keep business clocks within 100 µs of UTC and timestamp with 1 µs or finer granularity. You can't meet that with NTP across the internet, and you can't demonstrate it without monitoring your own offset.
Estimate offset from two-way timestamps
/// One request/response exchange between a client and a time server:
/// t1 client sends (client clock)
/// t2 server receives (server clock)
/// t3 server replies (server clock)
/// t4 client receives (client clock)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Exchange {
pub t1: u64,
pub t2: u64,
pub t3: u64,
pub t4: u64,
}
/// offset = server clock − client clock = ((t2 − t1) + (t3 − t4)) / 2
/// delay = time on the wire, both ways = (t4 − t1) − (t3 − t2)
/// Use signed arithmetic (the server may be BEHIND the client, so t2 < t1
/// is normal) and i64 division by 2.
pub fn offset_and_delay(x: &Exchange) -> (i64, i64) {
let _ = x;
todo!()
}
/// NTP's clock filter in miniature: of several exchanges, trust the one
/// with the SMALLEST delay (least queueing, so least room for asymmetry).
/// Skip exchanges whose delay is negative (a bad timestamp). Returns that
/// exchange's (offset, delay), or None if nothing is usable.
pub fn best_offset(xs: &[Exchange]) -> Option<(i64, i64)> {
let _ = xs;
todo!()
}
/// One-way latency of a message sent at `sent_a` on host A's clock and
/// received at `recv_b` on host B's clock, given offset = B − A.
pub fn one_way(sent_a: u64, recv_b: u64, offset_b_minus_a: i64) -> i64 {
let _ = (sent_a, recv_b, offset_b_minus_a);
todo!()
}The path from client to server takes 3 µs and the path back takes 7 µs; the clocks are actually perfectly synchronized. What offset does the two-way calculation report, and could the client detect the error?
Section 8 has the on-wire offset and delay equations you just implemented. Skim the clock filter algorithm: it chooses the sample with the lowest delay for the same reason the drill does.
ptp4l and phc2sys: how a host's NIC clock is disciplined to a grandmaster and the system clock to the NIC. Look at the logged 'master offset' values; that is the number your RTS 25 monitoring would track.
Day 5 — Which clock may an exchange use?
Time is an input
Determinism, sequencer timestamps and timer commands
Everything above is for measurement. The matching engine has a stricter rule: it never reads a clock. If matching logic consults now() (order expiry, auction end, the week 17 throttle refill, a "minimum resting time" rule), then replaying the journal tomorrow gives different answers, and week 9's replay, week 18's recovery and week 20's standby all silently diverge.
Instead, time enters through the log like everything else:
- The sequencer stamps each command with a transact time, read once from a PTP-disciplined realtime clock when the command is sequenced. That timestamp is journaled with the command, replicated with it, and is the only "now" the engine knows about. It is also the time you send back to clients (FIX
TransactTime, tag 60) and publish on market data. - Timers are commands. "The opening auction ends", "GTD orders expire at 16:00", "advance the throttle clock" are injected into the sequenced stream by a timer source as ordinary commands with sequence numbers. The engine reacts to them, and on replay it reacts identically.
- Measurement clocks stay outside. TSC ticks for stage latencies ride alongside the command (week 19) but are never journaled as input and never touch state.
The sequencer's realtime clock can still misbehave. If NTP or PTP steps it backwards, the sequencer must not emit transact times that go backwards. Clamp to the previous value (and alarm), so the log's time is monotonic by construction. Leap seconds deserve one sentence in your runbook too. Smearing (spreading the second over hours) and stepping both exist in the wild, and the international standards bodies voted in 2022 to stop adding leap seconds by 2035, so this problem is slowly going away.
Why must the matching engine never call Clock::now() to decide, say, whether a good-till-time order has expired? Where does its notion of time come from instead?
For each of these, name the clock you would use and why: stage latency in the gateway, the TransactTime sent to clients, the lease in the week 20 arbiter, the timestamp in a market-data packet, the one-way latency from a client's server to your gateway, and the end of the closing auction.
Day 6 — Build and reflect
Ship the capstone
Then check what your clock is doing on this machine
Build the clock layer below. Before you benchmark, check current_clocksource, the constant_tsc and nonstop_tsc flags, and dmesg for TSC warnings on the machine you measure on, and record them in the report.
Which of your latency numbers from earlier weeks were measured with a clock that could have lied (SystemTime, a fallback clocksource, unsynchronized hosts)? Which ones would you now re-measure, and how?
Capstone
In crates/telemetry, build the clock layer the rest of the exchange depends on:
- One
Clocktype, not a trait.telemetry::Clockis a smallCopystruct whoseClockSource(TscorInstant) is chosen at construction.now()matches on it and returns nanoseconds since the clock's epoch.raw()returns the TSC value (or nanoseconds, for the Instant source), andticks_to_nsconverts a difference of raw reads with one 64×64→128-bit multiply and a shift, rounded and saturating. Copies share the epoch and calibration, soClock::global()gives one clock whose readings compare across threads. There's no manual clock in this crate. Deterministic test time lives where it's used: week 17'sFakeClockbehind the risk crate's ownClocktrait, and week 25'sSimTime. Day 3's trait is still the right shape when you want static dispatch and a test clock in one place. - Startup selection and calibration.
Clock::new()uses the TSC if CPUID leaf0x8000_0007reports an invariant TSC, andClock::instant()otherwise. It doesn't check the kernel's clocksource, so on a host whose kernel marked the TSC unstable, constructClock::instant()yourself, as the module docs say.Clock::tsc(window)calibrates againstInstantwith one paired reading at each end of the window. Each reading is the best of 16 tries of TSC,Instant, TSC, keeping the narrowest TSC bracket (the reverse of the drill's bracketing). The window is 10 ms by default, which the module docs report as tens of nanoseconds of drift per second on the reference machine.Clock::tsc_at(hz)skips calibration when the frequency is known. The crate logs nothing:source()andfrequency_hz()report the decision, and the benchmark prints both. - Stage timestamps:
StageStamps, a 64-byteCopyvalue carried with the order. It holds up to seven(Stage, ns)stamps in the order they were taken (Ingress,Gateway,Risk,Journal,Match,Publish,Ack), each stage at most once.stamp_now(stage, &clock)recordsclock.now(), so the repo's stamps are nanoseconds, not raw ticks: it pays the multiply and shift at each stamp, a nanosecond or two, and gets stamps that need no conversion later. Raw ticks plusticks_to_nsremain available for paths where that matters.interval(stage)is the gap since the previous stamp, saturating at zero when two cores disagree by a few nanoseconds, andtotal()is end to end. - Sequencer time isn't in the repo. The engine reads no clock because nothing in it depends on time: commands carry no timestamp, and there are no time-driven rules (no GTD expiry, no auctions). If you add them, follow Day 5: journal a clamped transact time with each command and inject timers as commands.
- Tests named
w21_*:now()is monotonic and continues across copies; the calibrated clock agrees withInstantwithin 1% over 30 ms (and is the TSC exactly when the CPU has an invariant one); the Instant fallback is a 1 GHz nanosecond clock; conversion at a known 3 GHz is exact, an hour of ticks is within 1 ns, and a fullu64saturates instead of wrapping; stage stamps record the path taken, reject duplicates and fit one cache line.
On the benchmark, w21.clock_now is one now() call on a Clock::new(). rdtsc itself is a few nanoseconds, and a vDSO clock_gettime is around 20 ns when the TSC is the clocksource. Compare the 15 ns p50 target with those two costs and you'll see which path it expects you to be on. It also tells you how to convert: ticks to nanoseconds with a precomputed fixed-point multiply and shift costs a nanosecond or two and fits the budget, but a division or a float conversion on every read does not. Expose the raw ticks too (raw() in the repo), so the hottest paths can stamp ticks and convert later. Whichever path you end up on, make sure the benchmark reports which clock it measured, so the number means something on a machine where the TSC was rejected.
crates/telemetryA clock worth trusting
A calibrated TSC clock with an Instant fallback chosen once at startup, nanosecond now() plus raw ticks, and 64-byte stage stamps carried with each order through seven named stages.
cargo xtask test --week 21 # tests named w21_*
cargo xtask bench --week 21 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 21 tests pass w21_*
- Clock::now cost w21.clock_nowtarget p50 ≤ 15 ns
Feeds into
- Week 39 builds the tick-to-trade latency budget on top of these stage timestamps: per-stage budgets, tail attribution, and jitter sources. All of it assumes the clock you built this week is cheap, monotonic and honest about which clock it is.