Measuring Latency Correctly
Coordinated omission and the lies averages tell.
Performance · Measure, Profile, Then Go Fast
Why this week
Phase 3 is about making the exchange fast. Before you touch a single hot path, you need a ruler you can trust — and the ruler you built in week 24 is almost certainly bent. Most published latency numbers are wrong, and they are wrong in the same direction (too good) for the same two reasons: they are summarized with averages, and they are collected by a load generator that politely stops sending whenever the system under test stalls. Gil Tene named the second one coordinated omission, and once you see it you will find it in nearly every benchmark you have ever written.
This week you rebuild measurement from first principles: percentiles instead of means, open-loop load instead of closed-loop, a log-linear (HDR-style) histogram that records in a few nanoseconds, and a correction for data that was collected the wrong way. Then you re-measure engine v2 honestly. Every performance claim from week 26 to week 40 should hold up under the harness you build here, so a mistake now silently invalidates sixteen weeks of "speedups".
The one rule to remember: latency is the time from when a request should have been sent to when its response arrived. Anything measured from a later starting point — the moment your client got around to sending, the moment the server read the socket — is service time, and service time hides queueing.
Day 1 — Averages lie
Distributions, not numbers
Why the mean, and even p99, can describe nothing that happened
The canonical talk. Watch for three things: why the max is a real observation and not an outlier, the 'frozen system' thought experiment, and the definition of coordinated omission. Several recordings exist (QCon, Strange Loop); any of them will do.
Written about web services, but the fan-out argument is exactly why an exchange cares about p99.9: a request that touches many components is slow if any one of them is slow.
Latency distributions in a trading system are multi-modal and heavy-tailed. There is a fast mode — warm caches, no contention, the code path you benchmarked — and a zoo of slow modes: a page fault on fresh memory, an interrupt landing on the engine core, a cache-miss storm after a burst, a timer tick, a TLB shootdown, a neighbour thread on the SMT sibling. The mean blends all of them into a number that no request ever experienced.
A concrete example: 99% of orders take 2 µs and 1% take 500 µs. The mean is 0.99 × 2 + 0.01 × 500 ≈ 7 µs. Nothing took 7 µs. The p50 is 2 µs, p99 sits right on the boundary between the modes, and p99.9 is 500 µs. The only honest summary is the shape: p50, p99, p99.9, p99.99, max, and the sample count.
Why the tail matters more here than in most software:
- Frequency. A market maker re-quoting 1,000 times a second sees its p99.9 once every second. The slow update is precisely the one that gets picked off by a faster participant.
- Fan-out. If an order passes through N stages that are each independently slow 1% of the time, the chance that at least one is slow is 1 − 0.99N: about 5% for N = 5, and 63% for N = 100. Tails compound; means don't tell you that.
- Correlation. Slow events cluster. A market-data burst makes everything slow at once — which is exactly when the money is made or lost.
An order crosses 5 independent stages, each slow 1% of the time. What fraction of orders hit at least one slow stage?
Open the week 24 load-test report. Which statistics did you publish, how many samples backed each percentile, and how was each latency sample's start time taken? List every way those numbers could be too optimistic.
Day 2 — Closed loop, open loop, coordinated omission
The load generator is part of the experiment
Throughput as input vs throughput as output
Read the README. It is the shortest complete explanation of why a load tool must send on a schedule and measure from the scheduled time, with before/after output from the same server.
The benchmarking chapter: active vs passive benchmarking, and the checklist of ways a benchmark measures something other than what you think. Skim the methodology sections; you will apply them on Day 5.
A closed-loop load generator has a fixed number of clients, and each sends a request, waits for the response, then sends the next. Throughput is an output of the experiment. An open-loop generator sends requests on a schedule that does not depend on responses — every 2 µs, or with Poisson arrivals at a mean rate — so throughput is an input and queueing is allowed to happen.
Real order flow is open loop. The market does not wait for your engine to finish before the next order arrives.
Now Gil Tene's thought experiment. A system serves 100 requests per second at 1 ms each for 100 seconds, then freezes completely for 100 seconds.
- Closed loop records 10,000 samples of 1 ms and one sample of 100 s. Mean ≈ 11 ms. p99.99 ≈ 1 ms. The report says the system is excellent.
- Open loop knows 10,000 more requests were due during the freeze. They waited 100 s, 99.99 s, …, 0.01 s. With 20,000 samples: p50 ≈ 1 ms, p75 ≈ 50 s, p99 ≈ 98 s, mean ≈ 25 s.
Same system, same freeze. The closed-loop tool coordinated with the system under test: it stopped measuring exactly when things were bad. That is coordinated omission.
Run the same experiment at a smaller scale below. Drag the stall from 0 to a second and watch the closed loop's p99 barely move while the open loop's p99 climbs into milliseconds. The lanes at the top show why: during the stall the closed loop simply has nothing in flight.
Where it hides in code you have written:
// Every microbenchmark loop ever written:
for _ in 0..n {
let t0 = clock.now();
engine.submit(order()); // if this stalls for 1 ms...
hist.record(clock.now() - t0); // ...you record ONE slow sample, and the
} // ~500 orders that would have arrived are never sent
That loop answers "how long does submit take back-to-back" — a perfectly good question, and what criterion-style benchmarks are for. It does not answer "what latency does an order see under load". Two more hiding places: a gateway that timestamps on read() misses the time a request sat in the socket buffer, and a pipeline that records once per batch misses the queueing of every message behind the first.
Define coordinated omission in one or two sentences.
Your gateway timestamps each order when read() returns it and again when the ack is written. Why can this still under-report latency?
Day 3 — HDR histograms and correcting old data
Record in nanoseconds, keep three significant digits
Drill 1: back-fill the samples a stall swallowed
Read the README's description of the bucketing scheme and of recordValueWithExpectedInterval. The Rust port (the hdrhistogram crate on docs.rs) exposes the same correction as record_correct.
The week 2 histogram used logarithmic buckets. An HDR (high dynamic range) histogram makes the idea precise: bucket by power of two (the magnitude), then split each power of two linearly into 2k sub-buckets. With 2,048 sub-buckets you get about three significant decimal digits — a relative error of roughly 0.1% — at every scale from 1 ns to an hour:
const SUB_BITS: u32 = 11; // 2048 sub-buckets ≈ 3 significant digits
const SUB_HALF: usize = 1 << (SUB_BITS - 1);
fn index(v: u64) -> usize {
// Magnitude: how many halvings until v fits below 2^SUB_BITS (0 for small values).
let mag = (64 - (v | ((1 << SUB_BITS) - 1)).leading_zeros()) - SUB_BITS;
let sub = (v >> mag) as usize; // [0, 2048) when mag == 0, else [1024, 2048)
mag as usize * SUB_HALF + sub // contiguous and monotonic in v
}Recording is a lzcnt, a shift, an add and an increment — a few nanoseconds, no allocation, no branches worth mentioning. Capping the range at one hour of nanoseconds needs about 34,000 counters (≈270 KB as u64). Histograms with the same layout merge by adding counts, which is exactly what week 19's per-thread histograms need.
Sometimes you inherit data from a closed-loop tool and cannot re-run it. If you know the interval the tool meant to send at, you can estimate the missing samples: when a sample V exceeds the expected interval I, the requests that should have been sent during the stall would have seen V − I, V − 2I, … So you record those too, down to I. HdrHistogram calls this recordValueWithExpectedInterval.
Two caveats that interviewers like to probe:
- It is an estimate that assumes a constant intended rate. Measuring from the intended send time (Day 4) is always better.
- Never apply it to open-loop data. An open-loop measurement already contains the queued requests; correcting it double-counts the stall.
A closed-loop client meant to send every 20 µs and recorded a 50 µs sample. What does the corrected histogram record?
Correct a histogram for coordinated omission
use std::collections::BTreeMap;
/// Exact latency recorder: value (ns) -> count. A real HDR histogram buckets
/// values; an exact map keeps this drill about the correction, not bucketing.
pub struct LatencyLog {
counts: BTreeMap<u64, u64>,
total: u64,
}
impl LatencyLog {
pub fn new() -> Self {
Self { counts: BTreeMap::new(), total: 0 }
}
pub fn record(&mut self, value: u64) {
*self.counts.entry(value).or_insert(0) += 1;
self.total += 1;
}
pub fn count(&self) -> u64 {
self.total
}
/// Record `value` from a closed-loop client that meant to send one
/// request every `expected_interval` ns, and back-fill the requests the
/// stall swallowed: value - I, value - 2I, ... while still >= I.
/// `expected_interval == 0` means "no correction" (and must not loop).
pub fn record_corrected(&mut self, value: u64, expected_interval: u64) {
todo!()
}
/// Nearest-rank quantile: the smallest recorded value v such that at
/// least ceil(q * count) samples are <= v. Clamp q to [0, 1]; q == 0
/// returns the minimum. An empty log returns 0.
pub fn value_at_quantile(&self, q: f64) -> u64 {
todo!()
}
}
/// Correct a whole closed-loop run after the fact.
pub fn correct_run(raw: &[u64], expected_interval: u64) -> LatencyLog {
todo!()
}
Day 4 — An open-loop generator
Send on schedule, measure from the schedule
Drill 2: the timestamp you subtract from is the whole difference
An open-loop generator has three jobs:
- Compute intended send times that do not drift. At 3 orders per second the interval is 333,333,333.3 ns; add a rounded interval repeatedly and you lose a nanosecond every three orders. Compute request i's time from i directly.
- Send on time, or late — but never skip. Do not
thread::sleepuntil the next send: on Linux a normal thread's default timer slack is 50 µs, and the wake-up itself costs microseconds more. Spin on your week 21 clock on a dedicated core. If the generator falls behind, send immediately and keep the intended time; count late sends as a health metric — a generator that is more than a few microseconds late is measuring itself. - Measure from the intended time. Put the intended timestamp in the message (or in a side table keyed by sequence number) so whoever observes completion can compute
done − intended, no matter how many queues and threads the order crossed.
Constant-rate arrivals are easiest to reason about; Poisson arrivals (week 23) produce bursts and expose queueing earlier. Run both.
The drill models a single FIFO server so you can see the effect with exact numbers. Notice that both models compute the same completion times — the difference between the closed-loop and open-loop reports is purely which timestamp gets subtracted. The closed loop's lost time shows up as send lag, and open-loop latency is exactly closed-loop latency plus that lag.
Open-loop load generator schedule
/// Intended send times for an open-loop generator: `n` requests at
/// `rate_per_sec`, starting at `start_ns`. Request i is due at
/// start_ns + i * 1e9 / rate_per_sec — compute it from i every time so
/// rounding never accumulates (u128 avoids overflow). Rate 0 -> empty.
pub fn schedule(start_ns: u64, rate_per_sec: u64, n: usize) -> Vec<u64> {
todo!()
}
// The system under test is one FIFO server: request i needs service_ns[i]
// and starts when both (a) it has been sent and (b) request i-1 is done.
/// Closed-loop client (what most tools do): it wants to send at intended[i]
/// but cannot send before response i-1 is back, so it sends at
/// max(intended[i], done[i-1]). Report latency from the ACTUAL send time.
pub fn closed_loop_latencies(intended: &[u64], service_ns: &[u64]) -> Vec<u64> {
todo!()
}
/// How late the closed-loop client sent each request: actual send - intended.
pub fn send_lag(intended: &[u64], service_ns: &[u64]) -> Vec<u64> {
todo!()
}
/// Open-loop client: every request is sent exactly at intended[i] and may
/// queue at the server. Report latency = completion - intended[i].
pub fn open_loop_latencies(intended: &[u64], service_ns: &[u64]) -> Vec<u64> {
todo!()
}
Your open-loop generator fell 40 µs behind schedule because its own thread was descheduled. Should those 40 µs count against the engine?
Day 5 — Warmup, steady state and sample counts
Benchmark hygiene
The boring rules that make numbers reproducible
Warmup. The first seconds of any run pay one-off costs: page faults on freshly allocated memory, cold caches and TLBs, untrained branch predictors, lazy initialisation, the CPU ramping its clock up from idle. Discard a warmup period, then check you reached a steady state: compute p50 and p99 per one-second window and make sure they stop drifting before you start recording.
Sample counts. A percentile is only as good as the number of samples beyond it. p99.9 of 1,000 samples is decided by the single largest one or two values. A workable rule: aim for at least ~100 samples in the tail beyond the quantile — about 100,000 samples for p99.9 and a million for p99.99 — and repeat the run several times to see the spread between runs.
Utilization. Latency is a function of offered load. In the simplest queueing model (M/M/1), mean response time is S / (1 − ρ) where S is service time and ρ the utilization: 2S at 50% load, 10S at 90%, 100S at 99%. Real systems are not M/M/1, but the hockey-stick shape is universal. A single latency number without the offered rate is meaningless; a latency-vs-throughput curve is the real result.
Environment. Release build (with debug info for profiling next week), the performance CPU governor, turbo either always on or always off, the generator and the engine on separate dedicated cores, nothing else running. Record the machine, kernel, Rust version and commit in the report.
Roughly how many samples do you need before you publish a p99.9, and why?
Engine v2 sustains roughly R orders/s single-threaded. Choose the offered rates for your sweep (as fractions of R), the duration and warmup of each run, and the arrival process. Which rate will you quote the p99.9 at, and how will you justify that choice in the report?
Day 6 — Re-measuring engine v2
The before/after you will quote for the rest of the year
Compare against your week 24 numbers
Run the week 24 closed-loop benchmark and your new open-loop harness against the same engine build, same machine, same core layout. The gap between them is your coordinated omission — write it down; it is a great interview story.
For each offered rate report: offered and achieved rate, sample count, p50, p99, p99.9, max, and the generator's late-send count. Plot p99 and p99.9 against offered rate and mark the knee. If the curve has a step rather than a knee, you are probably looking at a periodic event (a timer, a housekeeping thread, a background flush) — note it; week 26's profiler will find it.
Write the methodology paragraph for your performance report: load model, intended-time measurement, histogram precision, warmup, run length, repetitions, machine setup. Someone else should be able to reproduce your numbers from it alone.
Capstone
Build the measurement layer that every later week reports through, in crates/telemetry (the load module, plus one method on the week 2 histogram):
- A log-linear histogram: the week 2
Histogramgainsrecord_corrected(value, expected_interval), next torecord,merge,percentile,maxandcount. Its counts array is allocated up front. The default precision is 7 (128 sub-buckets per power of two, under 0.79% error, 58 KiB for the wholeu64range), andHistogram::with_precision(p)goes up to 12 if you want Day 3's three digits. Test the relative error at several magnitudes. - An open-loop driver:
OpenLoop::at_rate(per_sec, requests).with_warmup(n)andrun(&mut time, |time, i| …). Requestiis due atstart + i × interval_ns. The driver spins until it's due (or goes at once if late, never skipping), calls your service closure, and records two histograms in aLoadReport:response(completion − intended, the honest one) andservice(completion − actual send, what a closed-loop tool reports).max_lag_nsrecords the worst lateness, but there's no count of late sends. Two simplifications to know about. The service runs synchronously on the driver's thread, so queueing comes from the schedule falling behind, not from a separate engine thread and completion observer. Andinterval_nsis a whole number of nanoseconds (1e9 / per_sec, rounded down), so the per-step rounding doesn't accumulate, but the offered rate is slightly above the requested one when the rate doesn't divide 10⁹.offered_rate()reports the real rate, and the drill'sscheduleis exact. - A closed-loop tester to compare against:
ClosedLoop::new(interval_ns, slots).run(…)waits for each reply, counts the slots it skipped asomitted, and records every sample both raw and throughrecord_corrected. Both drivers run against aTimeSource: the real week 21Clock(which spins) orSimTime, which jumps instead, for exact tests. - A sweep: three to five offered rates from comfortable to past saturation, each with warmup discarded, reported as a table. The repo's benchmark measures one rate (below), so the sweep is yours to run.
- Tests named
w25_*:record_correctedback-fills exactly and is a plain record when on time, a closed loop omits the samples a stall hides, a stall makes the corrected p99 far larger than the uncorrected one, the open loop measures from the intended start, warmup is scheduled but not recorded, and the open loop keeps its schedule on the real clock.
The decisions that matter:
- Where the clocks are read. Intended time is computed, not read. Completion time is read by whoever observes the ack/event. Both use the same clock (week 21) — never
SystemTime. - Thread placement. If the generator spins on the engine's core you are benchmarking the scheduler. Once the engine runs behind a queue, the generator, the engine and the completion observer each need their own core (week 30 makes this rigorous). The repo's benchmark sidesteps this by calling the engine synchronously from the driver's thread.
- No allocation during the run. The histogram, the intended-time source and any per-order bookkeeping are allocated before warmup ends. If you need the full set of raw samples for debugging, preallocate a
Vecwith the exact capacity. - Per-message, not per-batch. If the engine drains its input queue in batches, every message in the batch gets its own
done − intended.
About the target: w25.engine_open_loop lives in crates/engine/benches/hft.rs, because telemetry can't depend on the engine. It drives the matching engine alone (engine::Engine::process, without exchange v2's gateway, risk, journal or market data), on one thread with the driver: no sockets, no queues. First it runs the week 7 mixed flow back to back through the same harness to measure the cost per command (engine plus the harness's clock reads and histogram records). Then it offers the flow at twice that interval, 50% utilization, for 1M measured commands after 100k of warmup, and reports the response histogram, measured from the intended start. The target is on p50, at 200 ns. p99, p99.9 and max are reported too, but they're very sensitive to interrupts and preemption on the benchmark machine, so treat them as diagnostics. The bench also prints the per-command cost, the offered and achieved rates, the worst lag and the service-time view, so you can see the coordinated-omission gap in one run. Because the rate is derived from the measured cost, the run can't be pushed past saturation by mistake. Missing the target badly usually means one of three things: the per-command cost itself is too high (p50 at 50% load can't be far below it), something allocates or page-faults on the hot path (weeks 26–27 will find it), or the thread is sharing its core. Report the offered rate with the result; a percentile without a rate is not a result.
crates/telemetryHonest numbers for engine v2
An open-loop driver and a log-linear histogram with coordinated-omission correction in crates/telemetry; the engine re-measured under open-loop load at 50% utilization with p50/p99/p99.9, max and sample counts.
cargo xtask test --week 25 # tests named w25_*
cargo xtask bench --week 25 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 25 tests pass w25_*
- Engine under open-loop load at 50% utilization w25.engine_open_looptarget p50 ≤ 200 ns
Feeds into
- Week 26 profiles the engine while this harness drives it — a profile of an idle or closed-loop engine points at the wrong code.
- Week 40 compares the final engine against the numbers you record this week. The repo's
w40.*benchmarks are back-to-back microbenchmarks like week 7's, so rerun this open-loop driver, with the same rates and histogram configuration, for the honest comparison. - Every week in between (27–39) should be judged by this harness too. The repo's per-week benchmarks show that a change is faster in isolation, but if it doesn't move the open-loop tail at a realistic rate, it didn't matter.