Tick-to-Trade Latency Budget
Every nanosecond accounted for.
Performance · Measure, Profile, Then Go Fast
Why this week
For a trading firm, tick-to-trade is the time from the last bit of a market-data packet arriving at the NIC to the first bit of the resulting order leaving it. For your exchange the mirror image is order-in → ack-out. Either way, the number on its own is useless. "Our tick-to-trade is 2 µs" tells you nothing about where those 2 µs go or which part is about to regress. What does help is a budget: the path split into stages, each with an owner, a measured distribution and a p99 allowance, plus a reserve for the jitter you can't predict.
Weeks 21 and 38 gave you cheap timestamps and a lean hot path. This week you connect them. You'll stamp every order at every stage boundary, attribute the end-to-end tail to the stage that actually causes it, and check each stage against its budget in the report. Expect one finding that surprises you: the stage with the worst p99 of its own is often not the one behind the end-to-end p99.
Public tick-to-trade figures are hard to compare because they're measured in different places. Wire-to-wire numbers from a passive network tap include the NIC and PCIe. Software timestamps leave them out. Vendors may quote the best case rather than p99. When someone quotes a number, ask measured where, at what percentile, under what load? As a rough guide, well-tuned kernel-bypass software is usually discussed in the low single-digit microseconds wire to wire, and FPGA designs in the hundreds of nanoseconds or below.
Day 1 — Anatomy of a tick-to-trade path
Where the nanoseconds go
From photons in the fiber to an order on the wire
Before you can budget a path you have to name every stage, including the ones your code never sees. Walk through the trading-firm version first, because it has the most stages:
- Wire in. Serialization delay depends on the link speed. A 64-byte Ethernet frame also carries 20 bytes of overhead on the wire (7 B preamble, 1 B start-of-frame delimiter, 12 B inter-frame gap), so 84 bytes = 672 bits. At 10 Gb/s, which moves 10 bits per ns, that takes 67.2 ns. At 25 Gb/s it takes about 27 ns. Propagation in fiber adds roughly 5 ns per metre (light in glass travels at about two thirds of c), so a 50 m cross-connect inside a colocation facility costs about 250 ns before any electronics.
- NIC → host memory. The NIC DMAs the frame over PCIe and writes a completion descriptor. A busy-polling thread notices it on its next poll. With kernel bypass (week 33) this is typically a few hundred ns. Through the kernel stack, with interrupts, softirq processing and a socket wakeup (week 31), it's several microseconds.
- Decode + book update. Parse the feed message (week 11) and update a cache-resident book (week 28). Tens to low hundreds of ns if everything stays in L1/L2. An L2 miss that hits in L3 costs roughly 40–80 cycles (about 10–25 ns, depending on the microarchitecture). A miss that goes all the way to DRAM costs around 80–120 ns, and more if the memory is on the remote NUMA node.
- Decision. Anything from a table lookup (tens of ns) to a model evaluation (microseconds). This stage is where firms spend their engineering.
- Pre-trade risk. Week 15's checks: well under 100 ns when the limits are in cache.
- Encode + send. Tens of ns to encode. Then the NIC has to fetch the descriptor and payload over PCIe, which costs hundreds of ns unless the NIC supports pushing small frames directly from the CPU, as some low-latency NICs do.
- Wire out. Another serialization delay before the first bit leaves.
Your exchange's order path has the same shape: NIC → gateway decode → risk → match → publish ack → NIC. In this curriculum you measure the in-process part (gateway decode to ack published), so be explicit that the wire, NIC and kernel stages sit outside it.
Starting values are illustrative for a kernel-bypass setup. The point: a budget only helps if every stage has an owner and a number.
You saw this in week 2 and leaned on it in week 25. Rewatch the part on percentiles and service-level expectations: a budget is a statement about a percentile at a stated load, and saying 'average' is the first sign that a budget isn't real.
Chapter 2 (Methodologies) covers latency analysis as a method: decompose a latency into components and drill into the largest. That's this week's procedure written down. Also skim the definitions of latency, response time and utilization so your report uses them precisely.
Why does a 64-byte frame take about 67 ns on a 10 GbE link rather than 51.2 ns?
Day 2 — Budgets are contracts
From a target to per-stage allowances
Floors first, then slack, then a reserve
A useful budget has three properties:
- It's stated at a percentile and a load. "Gateway decode: 150 ns at p99 at 200k orders/s open-loop" can be tested. "Gateway decode is fast" can't. Means add up neatly, which is why people budget them, but the mean is the wrong number for a trading path. What hurts you is the p99 and p99.9.
- It starts from the floors. Some costs don't move however good your code is: serialization delay, a PCIe round trip, the cache-line transfer when a message crosses from one core to another (on the order of 50–100 ns on one socket and more across sockets; it depends on the microarchitecture). Write those down first. If the floors already exceed the target, no optimization will save you. You need a different architecture.
- It keeps a reserve. Hold back a slice of the total for jitter you can't predict: an interrupt you didn't route away, a cold i-cache after a quiet period. 10–25% is a common heuristic, not a law. Without a reserve, the first unexpected 300 ns fails the whole path.
Once the floors and the reserve are set, share the remaining slack in proportion to how much variable work each stage does. A stage that is pure memcpy gets its floor. The strategy stage gets most of what's left. Each stage then has an owner and a number, so the owners can optimize independently, and CI can fail a change that pushes one stage past its share.
in-process order path (example shape, not targets)
stage floor budget p99 measured p99 owner
--------------- -------- ----------- ------------- --------
gateway decode ~30 ns 200 ns ? gateway
queue: gw → risk ~80 ns 150 ns ? sync
risk ~20 ns 150 ns ? risk
match ~60 ns 500 ns ? engine
publish ack ~40 ns 250 ns ? marketdata
reserve ~20%Notice the queue row. Time an order spends waiting between stages is a stage in its own right. If you lump it into the next stage's service time, you'll go looking for a slow risk check when the real problem is a consumer thread that went to sleep.
Allocate a tick-to-trade budget
/// One stage of the tick-to-trade path.
#[derive(Clone, Copy, Debug)]
pub struct Stage {
pub name: &'static str,
/// Irreducible cost in ns: wire time, a PCIe crossing, a cache miss
/// you cannot design away.
pub floor_ns: u64,
/// Relative share of the slack this stage gets (how much variable
/// work it does). 0 = floor only.
pub weight: u32,
}
/// Serialization delay of one Ethernet frame of `frame_bytes` on a link
/// of `gbps` Gbit/s, including the 20 bytes of per-frame overhead on the
/// wire (7 B preamble + 1 B start-of-frame delimiter + 12 B inter-frame
/// gap). Round UP to the next whole nanosecond.
/// Hint: 1 Gbit/s moves exactly 1 bit per ns.
pub fn wire_ns(frame_bytes: u64, gbps: u64) -> u64 {
todo!()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Allocation {
/// Budget per stage, in the same order as the input.
pub per_stage_ns: Vec<u64>,
/// Unallocated time: the held-back reserve plus rounding leftovers.
/// Invariant: sum(per_stage_ns) + reserve_ns == total_ns.
pub reserve_ns: u64,
}
/// Split `total_ns` into per-stage budgets:
/// 1. hold back reserve = total_ns * reserve_pct / 100 (integer division)
/// for jitter you cannot predict;
/// 2. every stage gets its floor;
/// 3. the remaining slack is shared in proportion to `weight`
/// (slack * weight / sum_of_weights, integer division per stage);
/// whatever rounding leaves over goes back to the reserve.
/// If the floors do not fit in total − reserve, return Err(shortfall_ns).
pub fn allocate(stages: &[Stage], total_ns: u64, reserve_pct: u64) -> Result<Allocation, u64> {
todo!()
}
Gateway p99 is 150 ns, risk p99 is 80 ns and match p99 is 400 ns. Is the end-to-end p99 630 ns?
Day 3 — Stamping stages without disturbing them
Stage timestamps
Cheap clocks, stamps carried with the message, and the observer effect
Put a stamp at every boundary, not inside stages. One clock read marks both the end of stage i and the start of stage i + 1, so five reads cover four stages with no gaps between them. Carry the stamps with the order in a fixed-size array so that later analysis can see all of one order's stages together:
/// Stage boundaries for one order: ingress, decoded, risk-checked,
/// matched, ack published. Raw clock ticks; converted to ns off the hot path.
#[derive(Clone, Copy, Default)]
#[repr(C)]
pub struct Stamps(pub [u64; 5]);
pub struct InFlight<T> {
pub msg: T,
pub stamps: Stamps, // 40 bytes riding along — no allocation, no atomics
}
impl Stamps {
#[inline(always)]
pub fn mark(&mut self, boundary: usize, clock: &Clock) {
self.0[boundary] = clock.raw(); // week 21's raw TSC read, no conversion
}
}
Each stamp has a cost. From week 21: rdtsc takes roughly 20–40 cycles on recent x86 (single-digit ns), and Instant::now() through the vDSO is roughly 15–30 ns with a TSC clocksource. Five reads on a 1–2 µs path is a few percent overhead. You can afford that, but measure it: run the bench with stamping compiled out and compare.
Three details decide whether the stamps can be trusted:
- Ordering.
rdtscisn't a serializing instruction, so the CPU can execute it before earlier loads have finished. That's harmless for coarse stage boundaries. Where it matters, uselfence; rdtscorrdtscp. Intel's Software Developer's Manual documents thelfencepattern for exactly this case. - Cross-core consistency. Each stage may run on a different core. On modern x86 with an invariant TSC (
constant_tscandnonstop_tscin/proc/cpuinfo), the TSCs on one machine are synchronized, so stamps from different cores can be subtracted. Check this once at startup rather than assuming it (week 21). Across machines you need PTP and NIC hardware timestamps, and even then the error is tens of ns at best. - Where the ingress stamp really is. A software stamp taken after
recvreturns misses the NIC, the kernel and any time the packet waited in a socket buffer. That's exactly the part that misbehaves under load. Hardware RX timestamps (SO_TIMESTAMPINGon Linux, or the bypass stack's equivalent) close that gap.
Record the stamps off the hot path. Either copy each finished Stamps into an SPSC ring that a cold thread drains into per-stage histograms (week 19), or have the hot thread record into a thread-local histogram that a background merger reads. Never use a shared, locked histogram.
Why carry the stamps inside the message instead of having each stage record its own latency into its own histogram as it goes?
Where is the earliest point your exchange can stamp an inbound order today? List everything that happens before that stamp (NIC, kernel, socket buffer, tokio wakeup…) that your budget currently cannot see, and how you would make the worst of it visible.
Day 4 — Attributing the tail
Which stage owns the p99?
Conditional analysis on the slow samples
The obvious method gives the wrong answer. Compute each stage's p99, then point at the biggest. The trouble is that a stage can have a fat tail of its own that rarely lines up with the end-to-end tail. Another stage can be quiet 99.5% of the time and then take 5 µs at exactly the moments that make up the end-to-end tail.
The procedure that works is conditional:
- Compute the end-to-end latency of every order and find the threshold at your percentile (nearest rank).
- Keep only the orders at or above it. Those are the tail.
- For each stage, measure how abnormal it was in those orders. One simple measure is the mean excess over that stage's own median across all orders.
- The stage with the largest excess owns the tail. Then confirm it: reproduce the condition, fix it, and check that the end-to-end p99 actually moves.
The drill's data is built to fool the obvious method. The publish stage adds 400 ns jitter to one order in five, so it has the worst per-stage p99. The risk stage is quiet except for five orders in a thousand that take an extra 5 µs. The conditional analysis points at risk.
Attribute p99 to a stage
pub const STAGES: usize = 4;
pub const STAGE_NAMES: [&str; STAGES] = ["gateway", "risk", "match", "publish"];
/// Timestamps (ns) taken at the boundaries of one order's path:
/// t[0] ingress, t[1] after gateway decode, t[2] after risk,
/// t[3] after matching, t[4] after the ack is published.
pub type Stamps = [u64; STAGES + 1];
/// Per-stage latency: d[i] = t[i + 1] − t[i].
pub fn stage_latencies(s: &Stamps) -> [u64; STAGES] {
todo!()
}
/// Nearest-rank percentile of an ASCENDING-sorted slice:
/// index = ceil(q · n) − 1, clamped to [0, n − 1]. Empty slice → 0.
pub fn percentile(sorted: &[u64], q: f64) -> u64 {
todo!()
}
#[derive(Debug, Clone, PartialEq)]
pub struct TailReport {
/// End-to-end latency (t[4] − t[0]) at quantile q, nearest rank.
pub threshold_ns: u64,
/// Number of samples whose end-to-end latency is ≥ threshold_ns.
pub tail_count: usize,
/// Per stage: the MEAN over tail samples of max(0, d[i] − median_i),
/// where median_i is stage i's median (q = 0.5) over ALL samples.
/// Integer division.
pub excess_ns: [u64; STAGES],
/// Index of the largest excess (lowest index wins ties).
pub culprit: usize,
}
/// Which stage is responsible for the end-to-end tail? Not "the stage
/// with the worst p99" — the stage that is unusually slow in the samples
/// that ARE the end-to-end tail.
pub fn attribute_tail(samples: &[Stamps], q: f64) -> TailReport {
todo!()
}Attribution is a lead, not a verdict. Stages are often correlated: a page fault in the gateway leaves risk running on a cold cache, so both look slow. Use the attribution to choose your first experiment, then let the experiment decide.
In the drill, five orders take an extra 5 µs, yet the end-to-end p99 threshold is about 1.17 µs. What does that tell you about which percentiles to put in a budget?
Day 5 — Jitter sources
What else is running on your core
A field guide to microsecond stalls
Once your code is tight, the tail mostly comes from outside it. Typical magnitudes, which vary a lot with hardware, firmware and kernel version:
- Interrupts and softirqs landing on the hot core: a few µs each. Steer them away with
/proc/irq/*/smp_affinityand keep housekeeping work on other cores (week 30). - The scheduler tick. On a core that isn't
nohz_full, the timer interrupt fires atCONFIG_HZ(commonly 250–1000 Hz) and steals on the order of a microsecond each time.nohz_fullplusisolcpus(or cpusets) on the hot cores largely removes it. - System Management Interrupts (SMIs). The firmware takes over the CPU, invisibly to the OS, for tens to hundreds of µs. The only remedy is BIOS settings and choice of platform. Detect them with the kernel's
hwlattracer orhwlatdetectfrom rt-tests. - Page faults. A minor fault costs on the order of a microsecond, a major fault milliseconds. Pre-fault everything at startup and
mlockall(MCL_CURRENT | MCL_FUTURE). - TLB shootdowns. When any thread in your process unmaps or changes the protection of memory, every core running the process takes an inter-processor interrupt. An allocator that returns memory to the OS on a cold thread can stall your hot thread this way.
- Transparent huge pages. Compaction and khugepaged can stall you. Prefer explicit huge pages, or THP in
madvisemode. - Power management. Waking from a deep C-state can take tens of µs. Check
/sys/devices/system/cpu/cpu*/cpuidle/state*/latency. Busy polling keeps the core in C0. Frequency changes add their own wobble. - Neighbours. An SMT sibling sharing your core's execution units and L1/L2, or another process thrashing the shared L3. Disable the sibling or leave it idle. On CPUs that support it,
resctrl(Intel's Cache Allocation Technology or AMD's equivalent) can partition the L3. - Your own cold paths. Allocator slow paths, log formatting, a hash map resize, the first execution of code that fell out of the i-cache during a quiet period. Week 38's hot/cold split exists for these.
For tooling, Linux has the osnoise and timerlat tracers (5.14 and later) for OS noise on a given CPU, cyclictest for wakeup latency, and perf sched for scheduler delays.
Under Documentation/trace in the kernel tree. Run osnoise on one of your isolated cores for a minute. Every line it prints is time your busy-polling thread did not get. Compare an isolated core with a housekeeping core.
A busy-polling thread on an isolated core shows a few multi-microsecond gaps every second. How do you tell a timer tick from an SMI?
Write the jitter checklist for the machine you benchmark on: for each source above, the command that shows whether it is present, what you saw, and what you changed (or why you could not).
Day 6 — Write the budget down
The budget document
A table, owners, and a check that can fail
A budget is only real if it's written down and checked. The document needs:
- The scope: which boundaries the path includes and which it excludes (wire, NIC, kernel).
- The load: the open-loop offered rate and message mix the numbers refer to (week 25). A budget that holds at 10k orders/s and breaks at 200k is two budgets.
- Per stage: floor, budget, measured p50/p99/p99.9, owner, including queueing stages.
- The reserve, and how much of it the current build uses up.
- The tail attribution of the latest run, and what you did about it.
- The environment: CPU model, kernel, isolation and pinning, frequency settings, and the commit.
Draft your exchange's in-process budget table now, before you have numbers: stages (including queue waits), floors from first principles, budgets, and the reserve. After the capstone, fill in the measured columns and write one paragraph on the biggest gap between what you predicted and what you measured.
Capstone
Build stage attribution into crates/telemetry (Attribution in src/attribution.rs), then read it on the in-process pipeline: gateway → risk → match → ack, with exchange v2's journal and publish stages between match and ack. The stamping is already in place from week 24:
TimedPreTradestampsIngress,GatewayandRisk.ExchangeStagestamps the rest.- A
StageLogcollects the stamps.
The repo's w39_ tests (in crates/telemetry) build stamps by hand and check what Attribution makes of them. The w39.tick_to_ack benchmark runs your Attribution over the real pipeline (see "Reading the benchmark target" below).
The stamps. Use one fixed-size stamp array per order, filled at boundaries by the stage that owns the order at that moment. The repo already has it from week 21: StageStamps, a 64-byte Copy value with at most one stamp per Stage (Ingress, Gateway, Risk, Journal, Match, Publish, Ack), kept in the order they were taken. A stamp marks when a stage finished, so a stage's interval is the gap since the previous stamp present in that order. A reject that skips Match has its Risk → Ack time charged to Ack. The stamps hold nanoseconds: stamp_now(stage, &clock) reads Clock::now(), which is one rdtsc plus a multiply and a shift, so there's no separate conversion step when recording. The enum has no queue stages, so in the repo a stage's interval includes any wait in the queue in front of it. If your stages run on different threads, that's exactly the case Day 2 warns about. Seeing queue wait as its own row means stamping both ends of each hand-off, which needs stages the enum doesn't have yet.
Two rows of exchange v2's table need care:
gateway:Ingressis taken when the session's socket read returns, andGatewaywhen the engine thread picks the order up. So this row is decode plus the wait in the sessions → engine queue.journal: exchange v2 stampsJournalafterMatch, because it group-commits once the batch is matched. The report lists stages in enum order, so thejournalrow prints abovematch, but it's the interval after it.
The recorder. Per-stage histograms and an end-to-end histogram (reuse week 2 and week 19). Histograms alone lose the joint distribution, though, so also keep the full stage breakdown for every order whose end-to-end latency exceeds a running threshold. A bounded buffer of the slowest few thousand is plenty. That buffer is what lets the report do this week's conditional attribution. Record from a cold thread or a thread-local structure. The recorder must never allocate or lock on the hot path.
Week 24's StageLog is a simple collector. Once per batch, the engine thread moves the batch's samples in with a try_lock, so a reader copying the log doesn't stall matching. It keeps the most recent samples, not the slowest. A recorder that keeps the slow tail over a long run is the part you design.
The repo's Attribution is the recorder's offline half. Attribution::from_samples(&stamps) takes every order's StageStamps at once (two passes, one u64 allocated per sample). It builds a week 2 Histogram per stage plus one end to end, finds the exact end-to-end p99 (index ⌊q·n⌋ of the sorted totals, the same rule as Histogram::percentile), and sums each stage's time over the orders at or above it. with_tail(samples, q) sets a different tail quantile.
It names the culprit by tail share rather than Day 4's excess over the median. Tail share is the fraction of the tail orders' total time spent in each stage. dominant_stage() returns the stage with the largest share (ties go to the earlier stage), and tail_share and tail_mean_ns give the numbers behind it. The two measures answer different questions. Share says where the slow orders spent their time, and excess says what was unusual about them. A stage with a large constant cost takes a big share of the tail even when it's no slower there than anywhere else, so read both before you pick your first experiment.
The budgets. A small typed config of per-stage p99 (and ideally p99.9) budgets plus an end-to-end budget. A check function returns pass/fail per stage and names the tail's culprit. The report prints the budget table you drafted on Day 6, with the measured columns filled in.
In the repo a budget is Budget { stage, p99_ns }: per stage, and p99 only. check(&budgets) returns a Violation { stage, budget_ns, p99_ns } for each budget exceeded, in the order given, and skips stages with no samples. An empty result is a pass. The culprit comes from dominant_stage(), not from check. report() gives one StageReport per stage (count, p50, p99, p99.9, max, tail mean, tail share), and Display prints them as a table with the dominant stage marked. p99.9 and end-to-end budgets are yours to add on top.
Pitfalls to design against:
- Closed-loop measurement hides queueing (coordinated omission, week 25). Drive the pipeline at a fixed offered rate.
- Clock reads in the wrong place. A stamp taken after a blocking dequeue returns charges the wait to the next stage.
- TSC values from cores whose TSCs aren't synchronized. Verify once at startup and fall back to
Instant. - Reporting means, or a p99.9 from too few samples.
- Forgetting to measure the measurement. Run once with stamping disabled and report the overhead.
Reading the benchmark target. w39.tick_to_ack runs exchange v2's real order path, so it lives in crates/gateway/benches/hft.rs (telemetry can't depend on the gateway). It runs in process on one thread, one order per batch, with no sockets:
- The session decodes the frame.
- The sequencer runs
RiskGate, with limits on and throttles off. - The order is appended to an in-memory journal and matched.
- The L2 publisher is updated.
- The batch is committed (
Fsync::Alwayson in-memory storage) and its market data published to the rings.
The bench sends 200,000 commands of the synthetic flow after 20,000 warm-up ones. Every new order is stamped on Clock::global(), with Ingress taken just before the decode. The reported distribution is Attribution::end_to_end() over those orders, so the bench needs your week 24 pipeline and this week's Attribution. It prints the attribution table too. The target is p99 ≤ 4,000 ns. Expect the journal row to own most of the tail. That's the commit, where the growing in-memory journal takes page faults on fresh memory.
The bench has no thread hand-offs, so treat it as a floor for the running exchange. There, the order also crosses from a session's reader task to the engine thread (the gateway row's queue wait), and its reports cross back to a writer task. With hand-offs, a microsecond-scale p99 is mostly a statement about your threading, not your matching code. Each hand-off moves at least one cache line between cores, roughly 50–100 ns on one socket if the consumer is busy-polling. A park/unpark wakeup (week 30) costs microseconds on its own and would use up the whole budget. So hitting a budget like this in the running exchange means pinned threads, busy-polling consumers and zero allocation, with the matching work fitting comfortably in what's left.
crates/telemetryStage attribution with budgets
Every order carries stage stamps through gateway → risk → match → ack; the report shows per-stage and end-to-end p50/p99/p99.9, the stage that owns the tail, and pass/fail against each stage's p99 budget.
cargo xtask test --week 39 # tests named w39_*
cargo xtask bench --week 39 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 39 tests pass w39_*
- Order in → ack, in-process pipeline w39.tick_to_acktarget p99 ≤ 4,000 ns
Feeds into
- Week 40 turns this budget table into the target list for the performance sprint: every experiment names the stage it attacks and the budget line it should move.
- Week 50 expects you to walk an interviewer through a tick-to-trade budget from memory: stages, floors, typical numbers and the jitter sources behind the tail.