7 · The number Draft

Replay records and operating modes

5 min read

On this page

The types

Two small types carry everything that crosses between threads:

#[derive(Clone, Copy)]
#[repr(C)]
pub struct Stamped { pub t0: u64, pub t1: u64, pub u: Update }        // 48 bytes; Ring<Stamped, 4096> = 192 KB
#[derive(Clone, Copy)]
#[repr(C)]
pub struct StampedOrder { pub t3: u64, pub o: Order }                   // Ring<StampedOrder, 1024>

One mismatch between earlier chapters is settled here: the book chapter’s Update has no instrument field, so the capstone uses the data-structures chapter’s 32-byte Update, with instrument fixed at 42, and the book chapter’s small Order.

Stamped is that Update behind two timestamps, the arrival t0 and the end of decode t1, both written by the feed thread. It is 48 bytes, and a Ring<Stamped, 4096> is 192 KB of slots, at home in L2 like the data-structures chapter’s 128 KB ring. The timestamps ride inside the slot so that measuring adds no cache line of its own to the hop. They do add 16 bytes: four 48-byte slots fill three lines and two of the four straddle a line boundary, so a message costs on average three-quarters of a slot-line transfer where a bare Update cost half of one, and a straddling slot waits on two. That is part of what measuring costs.

StampedOrder is the book chapter’s Order, a side, a price and a size in 24 bytes, behind the decision time t3: 32 bytes, two to a line, and 32 KB for 1,024 slots. Its ring is smaller because orders are rare beside updates: at most two per batch and, in most batches, none.

#[repr(C)] fixes the order of the fields and so the sizes above, which Rust’s default layout does not promise. Copy means each message crosses by value, and since neither type owns heap memory, nothing is allocated when a message is made or freed when it is dropped.

The replay file

A replay file is a recording of the feed that the program reads instead of a socket. Each record is 56 bytes: t_arrival_ns, a u64 holding the message’s arrival time in nanoseconds since the file’s first record, then the networking chapter’s 48-byte wire message. The times come from the capturing host’s monotonic clock, or are made up when the file is synthetic. The default is 2,000,000 records, 112 MB.

gen writes the file. Its synthetic mode gives the updates the book chapter’s shape, 95% on a deep level, 4% on the best level’s quantity and 1% moving the best price, with consecutive sequence numbers, at an average of 100,000 messages a second, so the default file covers 20 seconds. Part of the traffic comes in bursts of up to 30 messages a few nanoseconds apart, as a real feed’s does when the price moves. A second mode, left as an exercise, would convert a capture from the networking chapter’s lab into the same record, keeping the real feed’s arrival times and its real bursts.

A file, not the network, for four reasons: every run sees the same input; the arrival times are known, which paced mode needs; a slow message can be found again by its sequence number; and no network noise reaches the number. The price is that the kernel’s receive path is not in the number either.

Before the run the main thread maps the file with mmap, the system call that makes a file’s bytes part of the program’s address space, so the feed thread reads a record with ordinary loads and makes no read system call on the path. It locks the mapping with mlock, which keeps those pages in RAM, so that no page fault lands inside a message: 0.5–1 µs for a page already in memory, 100 µs or more for one that must come from disk. Then it touches the file once, one load per 4 KB page, 27,344 loads, so that any page still missing faults now and not during the run. The feed thread then reads the file front to back at 5.6 MB a second, nothing to a memory system that moves gigabytes, with the prefetcher running ahead of it.

Two modes

The feed thread runs in one of two modes, chosen with --flat or --paced, and they answer different questions.

Flat mode measures throughput. The feed thread pushes as fast as the ring takes messages and reads t0 with now() immediately before each decode. Its number is updates per second through the whole pipeline: 10–30 million a second, one every 33–100 ns, set by whichever thread is slower. Its latencies mean little. One thread outruns the other, the ring fills or empties, and every total is mostly time spent queued; as the data-structures chapter’s lab put it, against a full ring latency is only queueing. Flat mode’s total is not the headline, and the whole default file goes through in a fifth of a second, which matters when a stall is injected below.

Paced mode measures latency. The feed thread releases each message at its recorded arrival time: it spins until rdtsc() >= start + ticks(t_arrival_ns), where start is the tick at which the run began and ticks converts nanoseconds to TSC ticks by the startup calibration, and gives the message a t0 equal to that scheduled tick, not the tick at which its spin happened to end. If the feed thread is late, because the ring was full behind a stalled strategy or because the feed thread itself was held up, t0 is still the scheduled tick, and the lateness is counted in that message’s latency. This is the open-loop rule from the discussion of coordinated omission earlier in the chapter, applied to the capstone’s own feed.

A paced run of the default file takes 20 seconds. Its first 100,000 messages, the first second, are warm-up: they run through the same code and are left out of every histogram.