← Back to roadmap
Phase 3Week 25~120 min read
0%·0/4

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".

💡
Tip

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

Day 01

Distributions, not numbers

Why the mean, and even p99, can describe nothing that happened

Videoby Gil Tene · ~1h
How NOT to Measure Latency

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.

Paperby Jeffrey Dean and Luiz André Barroso (CACM, 2013)
The Tail at Scale

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.
Recall#fan-out-tail

An order crosses 5 independent stages, each slow 1% of the time. What fraction of orders hit at least one slow stage?

Design note#week24-audit

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.

0 chars

Day 2 — Closed loop, open loop, coordinated omission

Day 02

The load generator is part of the experiment

Throughput as input vs throughput as output

Codeby Gil Tene
wrk2 — a constant-throughput, correct-latency-recording variant of wrk

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.

Bookby Brendan Gregg · ch. 12
Systems Performance (2nd ed.)

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.

Coordinated omission: one stall, measured closed-loop and open-loopOpen full screen ↗

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.

Recall#define-co

Define coordinated omission in one or two sentences.

Recall#server-side-timestamps

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

Day 03

Record in nanoseconds, keep three significant digits

Drill 1: back-fill the samples a stall swallowed

Code
HdrHistogram

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.
Recall#co-correct-example

A closed-loop client meant to send every 20 µs and recorded a 50 µs sample. What does the corrected histogram record?

Drill

Correct a histogram for coordinated omission

Target: 6/6 cases passNot yet run
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!()
}
Rust

Day 4 — An open-loop generator

Day 04

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:

  1. 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.
  2. Send on time, or late — but never skip. Do not thread::sleep until 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.
  3. 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.

Drill

Open-loop load generator schedule

Target: 6/6 cases passNot yet run
/// 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!()
}
Rust
Recall#why-intended-time

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

Day 05

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.

Recall#samples-for-p999

Roughly how many samples do you need before you publish a p99.9, and why?

Design note#rate-sweep

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?

0 chars

Day 6 — Re-measuring engine v2

Day 06

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.

Design note#methodology

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.

0 chars

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 Histogram gains record_corrected(value, expected_interval), next to record, merge, percentile, max and count. 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 whole u64 range), and Histogram::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) and run(&mut time, |time, i| …). Request i is due at start + 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 a LoadReport: response (completion − intended, the honest one) and service (completion − actual send, what a closed-loop tool reports). max_lag_ns records 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. And interval_ns is 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's schedule is exact.
  • A closed-loop tester to compare against: ClosedLoop::new(interval_ns, slots).run(…) waits for each reply, counts the slots it skipped as omitted, and records every sample both raw and through record_corrected. Both drivers run against a TimeSource: the real week 21 Clock (which spins) or SimTime, 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_corrected back-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 Vec with 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.

Capstone · in your exchange · crates/telemetry

Honest 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.