Quantitative Finance · Book 13 · Technology

Low-Latency Software

Low-Latency Software · Technology

9Rust for Low Latency

The Rust twin of this book’s feed decoder decodes a message in about eleven nanoseconds on this laptop, within a fifth of the C++ original from one run to the next. Both check every length before reading, neither allocates per message, and the Rust version was written without a single unsafe block. What Rust changes is not speed but where mistakes are caught: a data race or a dangling reference is a compile error, an out-of-bounds index a controlled panic instead of a silent read of someone else’s memory. This chapter looks at Rust on the hot path the way chapters 6 to 8 looked at C++: which abstractions really cost nothing, where the checks are and when the compiler removes them, how to write unsafe code that stays sound, how to control allocation and threads, and where Rust’s async machinery belongs in a trading system and where it does not.

9.1 Zero-cost abstractions, checked

Definition 9.1 (Zero-cost abstraction)

A zero-cost abstraction compiles to the code a programmer would have written by hand without it: the abstraction’s convenience is resolved at compile time and leaves no run-time trace. The formulation comes from C++: “what you don’t use, you don’t pay for; and what you do use, you couldn’t hand code any better” (Stroustrup).

Iterators, closures, generics and Option are designed to be zero-cost in Rust: they are monomorphised like C++ templates and inlined. The claim is checked the same way as in chapter 7, in the assembly. Safe Rust adds one thing C++ does not: every slice index is checked against the slice’s length.

Definition 9.2 (Bounds-check elimination)

Bounds-check elimination is the compiler’s removal of an index check it can prove always passes: a loop counter bounded by the slice’s own length, an index already compared, an iterator that yields only valid positions.

Listing 9.1 is the assembly of a loop for i in 0..v.len() { s += v[i] } compiled by rustc -O: no comparison against the length and no call to the panic handler remain, and LLVM has vectorised the loop four elements at a time. Listing 9.2 reads one element at an index the compiler knows nothing about: a comparison, a predicted branch, and the panic path out of line. When the check cannot be removed it costs little, as long as it is predicted and the load it guards is the slow part: in Figure 9.1 a gather of 65 536 elements through a table of scattered indices costs the same, within noise, with and without the check, in both languages.

	test	rsi, rsi
	je	.LBB16_1
	cmp	rsi, 4
	jae	.LBB16_5
	xor	eax, eax
	xor	ecx, ecx
	jmp	.LBB16_4
.LBB16_1:
	xor	eax, eax
	ret
.LBB16_5:
	mov	rcx, rsi
	and	rcx, -4
	pxor	xmm1, xmm1
	xor	eax, eax
	pxor	xmm2, xmm2
	pxor	xmm0, xmm0
.LBB16_6:
	movq	xmm3, qword ptr [rdi + 4*rax]
	movq	xmm4, qword ptr [rdi + 4*rax + 8]
	punpckldq	xmm3, xmm1
	paddq	xmm2, xmm3
	punpckldq	xmm4, xmm1
	paddq	xmm0, xmm4
	add	rax, 4
	cmp	rcx, rax
	jne	.LBB16_6
	paddq	xmm0, xmm2
	pshufd	xmm1, xmm0, 238
	paddq	xmm1, xmm0
	movq	rax, xmm1
	jmp	.LBB16_8
.LBB16_4:
	mov	edx, dword ptr [rdi + 4*rcx]
	inc	rcx
	add	rax, rdx
.LBB16_8:
	cmp	rsi, rcx
	jne	.LBB16_4
	ret
Listing 9.1. A counted loop over a slice, rustc 1.97 -O: no bounds check, vectorised. code/low-latency/09-rust-for-low-latency/rust/asm/sum_counted.s
	push	rax
	cmp	rdx, rsi
	jae	.LBB15_1
	mov	eax, dword ptr [rdi + 4*rdx]
	pop	rcx
	ret
.LBB15_1:
	lea	rax, [rip + .Lanon.3be006c39583d7e749c2975be3997e3f.5]
	mov	rdi, rdx
	mov	rdx, rax
	call	qword ptr [rip + _RNvNtCs4NRVxsYgnAr_4core9panicking18panic_bounds_check@GOTPCREL]
	ud2
Listing 9.2. One indexed read at an unknown index: a comparison and a branch to the panic handler. code/low-latency/09-rust-for-low-latency/rust/asm/get_checked.s
The C++ and Rust twins: decoding Book 1’s sample with each language’s feed decoder (left), and summing 65 536 integers in order, and through a table of scattered indices with and without bounds checks (right). Measured on a laptop (Intel Core Ultra 7 155H) under WSL2, no isolated cores, pinned to one CPU. Data: bench_rust.py.
Figure 9.1. The C++ and Rust twins: decoding Book 1’s sample with each language’s feed decoder (left), and summing 65 536 integers in order, and through a table of scattered indices with and without bounds checks (right). Measured on a laptop (Intel Core Ultra 7 155H) under WSL2, no isolated cores, pinned to one CPU. Data: bench_rust.py.

9.2 Unsafe code and its boundaries

Rust’s guarantees hold for safe code; unsafe blocks are where the programmer takes over the compiler’s proof obligations: dereferencing raw pointers, calling C functions, skipping bounds checks, implementing lock-free structures (chapter 11).

Definition 9.3 (Sound abstraction)

An abstraction built on unsafe code is sound if no sequence of calls to its safe interface, however unusual, can cause undefined behaviour: the invariants the unsafe code relies on are established and maintained by the abstraction itself, not assumed of its callers.

The discipline is to keep the unsafe region small and to write down, next to each block, why it is sound. The gather of Listing 9.3 skips the per-element check only after the indices have been validated once, and refuses a slice whose length is not the one they were validated against: however it is called, it cannot read out of bounds. A function whose safety depends on its caller is itself marked unsafe, with a # Safety section stating the contract; clippy rejects a public function that dereferences a raw pointer argument without it. The same questions apply to C++ code, where every line is unsafe; Rust makes them local.

/// A sound abstraction over the unchecked gather: it validates the indices once, then gathers without checks.
pub struct CheckedIndex<'a> {
    idx: &'a [usize],
}

impl<'a> CheckedIndex<'a> {
    pub fn new(idx: &'a [usize], len: usize) -> Option<CheckedIndex<'a>> {
        idx.iter().all(|&i| i < len).then_some(CheckedIndex { idx })
    }
    pub fn sum(&self, v: &[u32], len: usize) -> Option<u64> {
        // The indices were checked against `len`; refuse a slice of another length rather than trust it.
        // SAFETY: every index < len == v.len().
        (v.len() == len).then(|| unsafe { sum_gather_unchecked(v, self.idx) })
    }
}
Listing 9.3. A sound interface over an unchecked gather. code/low-latency/09-rust-for-low-latency/rust/src/lib.rs

9.3 Controlling allocation

Definition 9.4 (Global allocator)

The global allocator of a Rust program is the allocator behind Box, Vec, String and every standard collection; a program can replace it with any type implementing GlobalAlloc, declared with #[global_allocator], for example one that counts allocations in tests.

Rust’s standard types allocate in slightly different places from C++’s, and the differences matter on a hot path. A String has no inline buffer, so even a four-letter symbol allocates; a Vec reserves room for four small elements at its first push, so three fills cost one allocation instead of three; HashMap::entry moves the key into the map instead of copying it. Counted with firm_arena::CountingAlloc, the Rust version of chapter 6’s first-draft handler (Listing 9.4) allocates five times per message where the C++ one allocated nine, and the fixed version none. The remedy is the same as in C++: fixed arrays, pools of indices, byte arrays for identifiers, no boxed closures on the hot path, and a test that asserts the count.

impl NaiveHandler {
    pub fn handle(&mut self, symbol: &str, client_id: &str, fills: &[Fill]) -> usize {
        let sym = symbol.to_string(); // a String always allocates (no inline buffer)
        let cid = client_id.to_string();
        let mut fs = Vec::new(); // the first push reserves room for four fills
        for f in fills {
            fs.push(*f);
        }
        *self.state.entry(cid).or_insert(0) += fills.len() as u32;
        let copy = fs.clone();
        self.on_done = Some(Box::new(move |_| copy.len() + sym.len()));
        self.on_done.as_ref().map_or(0, |f| f(&fs))
    }
}
Listing 9.4. Chapter 6’s first draft in Rust: five allocations per message. code/low-latency/09-rust-for-low-latency/rust/src/lib.rs

9.4 Threads, pinning and what the type system guarantees

Definition 9.5 (Data race)

A data race occurs when two threads access the same memory location concurrently, at least one access is a write, and at least one is not atomic. In C++ it is undefined behaviour; in safe Rust it cannot be written: the traits Send and Sync, which mark the types that may be moved or shared between threads, and the rule that a mutable reference is exclusive, are checked when compiling.

The chapter’s crate carries a documentation test marked compile_fail: a thread that increments an element of a vector while its owner also writes it. The test passes only if the compiler rejects the program, which it does, because the spawned thread borrows the vector mutably while it is still in use. What the type system does not provide is placement: the standard library cannot pin a thread to a CPU. firm.affinity declares the two C functions it needs, calls them inside unsafe blocks with the reason stated, and verifies the result with sched_getcpu (Listing 9.5); its C++ twin does the same with pthread_setaffinity_np. Both read the role-to-CPU plan that firm.coreplan writes (chapter 4).

pub fn pin_current(cpu: usize) -> Result<(), String> {
    if cpu >= SET_WORDS * 64 {
        return Err(format!("cpu {cpu} out of range"));
    }
    let mut set = CpuSet { bits: [0; SET_WORDS] };
    set.bits[cpu / 64] |= 1u64 << (cpu % 64);
    // SAFETY: `set` is a live, properly sized cpu_set_t; pid 0 means the calling thread.
    let rc = unsafe { sched_setaffinity(0, std::mem::size_of::<CpuSet>(), &set) };
    if rc == 0 { Ok(()) } else { Err(format!("sched_setaffinity({cpu}) failed")) }
}
Listing 9.5. Pinning the calling thread from Rust through the C library. code/firm/affinity/rust/src/lib.rs

9.5 Where async does and does not belong

Definition 9.6 (Async runtime)

An async runtime runs many tasks written as async functions on a few threads: a task that would wait (for a socket, a timer) returns control to the runtime’s scheduler, which resumes it when the event arrives, possibly on another thread.

Async code is how Rust serves many slow connections cheaply, and a trading firm has many: venue REST interfaces, websocket market data from dozens of crypto venues (chapter 17), drop-copy sessions, internal services. The hot path is the opposite case: one busy thread per role, pinned, spinning on its input, never yielding. A scheduler that may move a task between threads, or delay it behind another, adds exactly the jitter the hot path exists to avoid. The authors of the most widely used runtime say so themselves: it is designed for applications whose tasks spend most of their time waiting for input and output, not for speeding up computation. The usual split is a synchronous, pinned hot path, fed and drained by lock-free rings (chapter 12), with async services around it for everything that can wait.

9.6 Tutorial: the Rust twin, measured

Goal. Check Rust’s abstractions in the assembly, count its allocations, pin a thread, and compare the twins. End state: the two listings of assembly, the allocation counts, and Figure 9.1.

  1. Assembly. python ll_rustasm.py compiles the crate with rustc -O –emit asm and extracts sum_counted and get_checked, exported with #[no_mangle] so that their names are stable.
  2. Soundness and a compile-time race. cargo test runs the unit tests and the compile_fail documentation test; clippy runs with -D warnings.
  3. Allocations. The test installs CountingAlloc as the global allocator and asserts five allocations per message for the draft and none for the fixed handler.
  4. Pinning. firm_affinity::apply(plan, role) pins a thread and verifies its CPU.
  5. Measure with python bench_rust.py: it builds the crate’s benchmark with cargo build –release and the C++ twin with g++ and runs both pinned.

What to change next. Replace the fixed handler’s linear search with a small open-addressing table and keep the allocation count at zero; compile the C++ twin with -O3 and see which language’s counted loop is faster then.

9.7 Build: thread placement by role

Purpose. Every hot thread of Part IV is pinned to the CPU the placement plan gives its role, and says so; the measurements of chapter 26 record where each thread ran.

Interface. Rust firm_affinity: read_plan(text), read_plan_file(path), pin_current(cpu), current_cpu(), allowed(), apply(plan, role). C++20 firm::affinity: read_plan(path), pin_current, current_cpu, name_current, apply. Python firm_coreplan.write_plan and read_plan produce and read the plan file.

Rules. A thread is pinned before it allocates or touches its data; apply fails, rather than continuing unpinned, if the role is missing, the kernel refuses, or the thread is not on its CPU afterwards; every unsafe call carries its safety argument.

Acceptance tests. code/firm/affinity/: the example plan read in both languages; a thread pinned to an allowed CPU and verified; a missing role refused. code/firm/coreplan/: the plan file round trip.

Stretch. Set the thread’s name for top and perf; refuse to start when the plan puts two hot roles on siblings.

Sources and further reading

  • B. Stroustrup, “Abstraction and the C++ machine model”, 2005.
  • The Rustonomicon, “Send and Sync”; The Rust Reference, “Unsafety”.
  • Tokio, tutorial, “When not to use Tokio”.

9.8 Exercises

Exercise 9.1 ★

From Figure 9.1, by what factor is the Rust decoder slower or faster than the C++ one, and what does that cost at 5 million messages a second?

Solution

Solution of Exercise 9.1.

On the committed measurement about 10.3 and 10.6 ns10.6\,\mathrm{n}\mathrm{s}: Rust within a few percent of C++ (a fifth at most between runs). At 5 million messages a second a difference of 0.3 ns0.3\,\mathrm{n}\mathrm{s} is 1.5 milliseconds a second, noise next to the path’s other costs.

Exercise 9.2 ★

Which of these allocate in Rust: "ESZ6".to_string(); Vec::<u64>::new(); the first push into it; [0u8; 24]; Box::new(3)?

Solution

Solution of Exercise 9.2.

"ESZ6".to_string() allocates (a String has no inline buffer); Vec::new() does not; its first push does (room for four small elements); [0u8; 24] is on the stack; Box::new(3) allocates.

Exercise 9.3 ★

Why does the Rust draft handler allocate five times where the C++ one allocated nine? Account for the difference item by item.

Solution

Solution of Exercise 9.3.

Rust: the symbol’s String (1, where C++’s inline buffer avoided it), the identifier’s String (1, as in C++), the vector (1: the first push reserves four, against three growth steps in C++), the map entry (0: the key is moved in and the table was already allocated, against a node and a key copy in C++), the clone of the fills (1, as C++’s lambda copy), the boxed closure (1, against two in std::function). 1+1+1+0+1+1=51+1+1+0+1+1 = 5 against 0+1+3+2+1+2=90+1+3+2+1+2 = 9.

Exercise 9.4 ★★

Read Listing 9.1: how many elements does the vector loop add per iteration, and what handles lengths that are not a multiple of four?

Solution

Solution of Exercise 9.4.

Four: two 64-bit loads of two u32 each, widened to 64 bits and added into two vector accumulators of two lanes. The remainder of zero to three elements goes through the scalar loop at .LBB16_4.

Exercise 9.5 ★★

Why does removing the bounds check from the gather not make it faster here? When would it?

Solution

Solution of Exercise 9.5.

Each iteration waits for a load at a scattered address; the check is an independent comparison with a predicted branch, executed in the shadow of that wait. It would show in a loop bound by the processor rather than by memory, over data in the first-level cache, and above all where the check prevents vectorisation.

Exercise 9.6 ★★

Is CheckedIndex still sound if sum does not compare v.len() with len? Give a calling sequence that would break it.

Solution

Solution of Exercise 9.6.

No. CheckedIndex::new(&[99], 100) followed by sum(&v, 100) with v of length 10 would read v[99] out of bounds, in safe code: the abstraction would have trusted its caller about len. Comparing the slice’s actual length is what makes it sound.

Exercise 9.7 ★★★

Coding. Write the Rust decoder’s hot loop over decode(&data).flatten() with an explicit match on the result instead, count allocations with CountingAlloc, and check the assembly for panics. What differs?

Solution

Solution of Exercise 9.7.

The explicit match makes the error case visible: flatten() silently drops a decode error, while the match can count it and stop. Neither version allocates (the decoder borrows the buffer), and neither contains a panic path in the loop body when the lengths are checked by the decoder’s own comparisons; the difference is behaviour on bad input, not speed.

Exercise 9.8 ★★★

Find the flaw. “Our market-data handler is an async task on a multi-threaded runtime so that it scales with the number of cores.”

Solution

Solution of Exercise 9.8.

A market-data handler is a single ordered stream: it cannot be spread over cores without losing order, and a work-stealing scheduler adds queueing, migration between threads and cache misses, exactly the jitter the hot path avoids. Use a pinned thread spinning on its socket or ring; keep async for the many slow connections.

9.9 Problem: The Port

Problem 9.1

Weekend problem — rewriting the feed path in Rust

A firm considers porting its feed handler and order-book builder from C++ to Rust. The team measures the twins (Figure 9.1), counts allocations, and reads the assembly.

Part I — Speed.

  1. What does each decoder cost per message, and what is the ratio?
  2. Why is the Rust counted loop faster than the C++ one with the flags used?
  3. What would make the C++ loop match it?
  4. What do the gather measurements say about bounds checks on this workload?

Part II — Allocation.

  1. How many allocations per message does each draft make, and each fixed handler?
  2. Which Rust type allocates where the C++ one did not?
  3. How does the test enforce zero allocations in Rust?
  4. What happens if a dependency allocates on the hot path without the team knowing?

Part III — Safety.

  1. Which class of bugs does the port remove at compile time?
  2. Where does unsafe remain in the firm’s Rust code, and why?
  3. What does a sound abstraction promise its callers?
  4. What does a panic on the hot path mean in production, and how should it be handled?

Part IV — The decision.

  1. State the named result: the Rust-to-C++ ratio of time per message for the decoder, and the allocations per message of each draft and fixed handler.
  2. Is the speed difference a reason not to port?
  3. What would you port first, and why?
  4. Where does async Rust belong in the firm’s systems?
  5. How do the two languages share the firm’s placement plan and fixtures?
  6. What would the team lose by porting, apart from speed?
  7. How would you keep both implementations equivalent during the transition?
  8. In one sentence: what does Rust change for a low-latency team?
Solution

Solution of Problem 9.1.

  1. About 10.3 ns10.3\,\mathrm{n}\mathrm{s} (C++) and 10.6 ns10.6\,\mathrm{n}\mathrm{s} (Rust) on the committed measurement: a ratio of about 1.03, and within a fifth run to run.
  2. LLVM vectorises the counted loop at -O; g++ 11 does not vectorise at -O2 (chapter 14).
  3. -O3, or g++ 12 or later at -O2, or an explicitly vectorised loop.
  4. That they cost nothing measurable there: checked and unchecked gathers take the same time within noise in both languages.
  5. Five (Rust) and nine (C++) for the drafts; none for either fixed handler.
  6. String, which always allocates, even for a short symbol.
  7. A counting global allocator installed in the test binary, and an assertion on the count per message after warm-up.
  8. The count rises and the test fails, provided the test drives the dependency’s hot paths with production-shaped data.
  9. Data races, use-after-free and dangling references, and out-of-bounds reads (turned into panics).
  10. In calls to the C library (affinity), in lock-free structures (chapter 11) and in unchecked indexing where a sound abstraction justifies it.
  11. That no sequence of calls through its safe interface can cause undefined behaviour.
  12. A bug has been caught before corrupting memory; the process should stop quoting (cancel orders, chapter 22) and restart, not continue.
  13. Named result. The Rust decoder runs at about 1.03 times the C++ decoder’s time per message on the committed measurement (within a fifth run to run); the drafts allocate five (Rust) and nine (C++) times per message, the fixed handlers none.
  14. No: the difference is within run-to-run noise and small against the path’s other stages.
  15. The feed decoder and book builder: stateful parsing of untrusted input, where memory errors are likeliest and most costly.
  16. Around the hot path: venue REST and websocket connections, drop copies, internal services.
  17. Through the plan file written by firm.coreplan and read by both firm.affinity twins, and through shared fixtures that both implementations must reproduce.
  18. The existing C++ code, tooling and experience; some libraries; and time spent on the port instead of features.
  19. Run both on the same recorded inputs and compare outputs message by message (differential testing, chapter 25).
  20. It moves memory and concurrency errors from production to the compiler, at the same speed.

9.10 Interview questions

Interview question 9.1 ★ developer

What does “zero-cost abstraction” mean, and how would you verify the claim for an iterator chain?

Solution

Solution of Interview question 9.1.

The abstraction compiles to what one would write by hand. Verify by reading the optimised assembly of the chain and of the hand-written loop, and by benchmarking both on realistic data.

What the interviewer is looking for: assembly and measurement, not faith.

Interview question 9.2 ★★ developer

Do bounds checks make Rust slow? When do they cost, and how do you remove them safely?

Solution

Solution of Interview question 9.2.

Rarely: a predicted check is cheap, and the compiler removes checks it can prove. They cost when they prevent vectorisation or sit in a processor-bound loop. Remove them safely with iterators, with an assertion on the length before the loop, or behind a sound abstraction that validates indices once.

What the interviewer is looking for: elimination by proof, and sound unchecked access.

Interview question 9.3 ★★ developer

What makes an unsafe abstraction sound?

Solution

Solution of Interview question 9.3.

Its safe interface cannot be used to cause undefined behaviour: every invariant the unsafe code needs is established and checked by the abstraction itself, not assumed of callers; each unsafe block states its argument.

What the interviewer is looking for: invariants owned by the abstraction.

Interview question 9.4 ★★ developer

How does Rust prevent data races, and what does it not prevent?

Solution

Solution of Interview question 9.4.

Mutable references are exclusive and only Send/Sync types cross threads, checked at compile time. It does not prevent race conditions in logic (two atomic operations that should have been one), deadlocks, or races inside unsafe code.

What the interviewer is looking for: data races versus race conditions.

Interview question 9.5 ★★ developer

Would you use async Rust on a trading hot path? Why?

Solution

Solution of Interview question 9.5.

No: a runtime schedules tasks across threads and interleaves them, adding latency and jitter; the hot path is a pinned thread spinning on its input. Async fits the many waiting connections around it.

What the interviewer is looking for: scheduling jitter versus a dedicated thread.

Interview question 9.6 ★★★ developer

How would you prove that a Rust component makes no heap allocation after start-up, including inside its dependencies?

Solution

Solution of Interview question 9.6.

Install a counting global allocator in a test binary (it sees every allocation, including those of dependencies), drive the component with production-shaped inputs beyond warm-up, and assert that the count does not change; run it in continuous integration.

What the interviewer is looking for: the global allocator as the single choke point.

Terms defined in this chapter

See all 2333 terms in the glossary