SIMD for Market Data
Sixteen prices at a time.
Performance · Measure, Profile, Then Go Fast
Why this week
A 512-bit AVX-512 register holds sixteen 32-bit prices. One compare instruction can ask "which of these sixteen levels is at or above my limit?"; one add can accumulate sixteen quantities. Market data is full of loops shaped exactly like that — summing depth over N levels, finding the first level that satisfies a condition, scanning a text message for delimiters, checksumming bytes — and a dense price ladder, week 28's idea applied this week to market-data quantities, lays the data out as arrays, which is what SIMD needs.
But the honest version of this week has two halves. The first is that the compiler already vectorizes many of these loops — if the data layout allows it and the build targets a CPU with wide vectors. The second is that the places where hand-written SIMD wins are specific: searches with early exit, mask tricks the optimizer cannot discover, byte-level parsing. You will write both kinds, check what the compiler emitted, and benchmark them in a way you could defend in a code review.
Day 1 — The SIMD mental model
Registers, lanes and the horizontal tax
What vector hardware is good and bad at
Manual 1 on that page. Read the chapter on vector operations: how compilers vectorize, data layout, alignment, and the cost of shuffles and horizontal operations. The C++ examples translate directly to Rust.
The searchable reference for every x86 intrinsic: what it does, which CPU feature it needs, and its latency/throughput on recent cores. Look up _mm256_cmpgt_epi32, _mm256_movemask_ps and _mm256_sad_epu8 — you will use all three this week.
A SIMD register is split into lanes: 128 bits (SSE2 on every x86-64, NEON on Arm) holds four i32s, 256 bits (AVX2) eight, 512 bits (AVX-512) sixteen. Vertical operations work lane by lane — add, compare, min, max — and are as cheap as their scalar versions, so you get 4–16× the work per instruction. Horizontal operations combine lanes — sum all lanes, find the first set lane — and need shuffles or special instructions. The rule of thumb: stay vertical inside the loop, go horizontal once at the end.
Three practical caveats:
- Layout decides everything. Lanes load contiguous memory. Summing
qtyfrom a struct-of-arrays ladder is one load per eight levels; summing it from an array of 24-byteLevelstructs needs gathers or shuffles and often is not worth vectorizing. Week 28 was this week's prerequisite for a reason. - Width is not free. On some Intel server generations (Skylake-SP in particular), sustained heavy 512-bit work lowers the core's clock, which can slow down the scalar code around it; newer Intel cores reduce the effect, and AMD's Zen 4 executes AVX-512 on 256-bit datapaths. This is hardware-specific — measure on your target.
- The baseline matters. A default
x86_64-unknown-linux-gnubuild may only assume SSE2 (128-bit). AVX2 needs-C target-cpu=...,-C target-feature=+avx2, or runtime detection (Day 4).
Why do vectorized reductions keep several independent accumulators and only combine them at the end?
Day 2 — Let the compiler do it
Auto-vectorization and how to check it
Know when the compiler already did it for you
Read the module overview: static (compile-time) vs dynamic (runtime-detected) CPU feature use, #[target_feature], is_x86_feature_detected!, and why calling a target_feature function on a CPU without the feature is undefined behaviour.
LLVM vectorizes loops when it can prove it is safe and profitable: a countable loop, no early exit, no aliasing it cannot rule out, operations that have vector forms. Rust helps because slices carry their length and &mut guarantees no aliasing.
// Vectorizes: counted loop, integer reduction (reassociation is exact).
pub fn depth(qty: &[u32]) -> u64 {
qty.iter().map(|&q| q as u64).sum()
}
// Does NOT vectorize as a reduction: f64 addition is not associative, and
// LLVM may not reorder it without fast-math permission.
pub fn notional_f64(px: &[f64], qty: &[f64]) -> f64 {
px.iter().zip(qty).map(|(p, q)| p * q).sum()
}
// Does NOT vectorize: data-dependent early exit.
pub fn first_ge(px: &[i32], t: i32) -> Option<usize> {
px.iter().position(|&p| p >= t)
}
The float case deserves a second look: summing in lanes changes the result in the last bits, because floating-point addition is not associative. That is why the compiler refuses, and why prices and quantities in an exchange are integers (week 8): integer sums — even wrapping ones — give the identical answer in any order.
How to check: cargo asm (cargo-show-asm) or Compiler Explorer. Look for vector registers (xmm = 128-bit, ymm = 256-bit, zmm = 512-bit) and packed instructions (vpaddq, vpcmpgtd). Then rebuild with -C target-cpu=native (or -C target-cpu=x86-64-v3, the level that includes AVX2) and watch xmm become ymm.
Your integer depth sum vectorized but the f64 notional sum did not. Why, and what are your options?
You see only xmm registers in the assembly of a loop you expected to use AVX2. What is the likely reason?
Day 3 — Vectorized depth aggregation
Lanes, widening, and a two-phase sweep
Drill 1: shape loops for the vectorizer
Depth aggregation answers questions market-data consumers and the risk layer ask constantly: how much quantity rests in the first N levels, and how many levels does a market order of size Q sweep? Three techniques make these loops vector-friendly:
- Explicit lanes. Accumulate into
[u64; 8]overchunks_exact(8), then the remainder, then one horizontal sum. This spells out the independence the vectorizer needs, and it is portable — nounsafe, no intrinsics. - Widening. Quantities are
u32; sixty-four levels ofu32::MAXoverflow au32sum. Widen each element tou64before adding. (In intrinsics this is an explicit zero-extend; in the portable version it isas u64.) - Two-phase search. "How many levels until the cumulative quantity reaches Q" has an early exit, which blocks vectorization. Split it: sum whole blocks of eight with the lane trick until the block that crosses Q, then walk that one block level by level.
Run the drill in release mode and look at the timing case. In our runs the plain iterator sum() in the starter was already vectorized by LLVM, and the explicit-lane version was only modestly faster. That is the "know when the compiler already did it" lesson in one number.
Vectorized depth aggregation
/// Scalar reference: total quantity in the first `n` levels (n clamped).
pub fn depth_scalar(qty: &[u32], n: usize) -> u64 {
qty[..n.min(qty.len())].iter().map(|&q| q as u64).sum()
}
/// Same answer, shaped for the vectorizer: eight independent u64
/// accumulators over chunks_exact(8), then the remainder, then one
/// horizontal sum. Widen before adding — no lane may overflow.
pub fn depth_lanes(qty: &[u32], n: usize) -> u64 {
todo!()
}
/// How many levels a market order of `target` quantity sweeps: the smallest
/// k with qty[0] + ... + qty[k-1] >= target. Some(0) when target is 0;
/// None when the whole book holds less. Sum whole 8-level blocks first and
/// walk level by level only inside the block that crosses the target.
pub fn levels_to_fill(qty: &[u32], target: u64) -> Option<usize> {
todo!()
}
/// Notional of the first n levels: sum of px[i] * qty[i] (n clamped to both
/// lengths) with WRAPPING multiply and add, accumulated in 4 lanes, so the
/// result equals the scalar wrapping sum exactly.
pub fn notional_lanes(px: &[i64], qty: &[u32], n: usize) -> i64 {
todo!()
}
Day 4 — Intrinsics: compare, movemask, popcount
When you need the instruction the compiler won't pick
Drill 2: branch-free level search with AVX2
Finding a level by price in a sorted window is a search, and searches have early exits. The SIMD idiom turns the search into counting: in a sorted array, the index of the first element ≥ target equals the number of elements < target. Compare eight prices against the target in one instruction, collapse the comparison into an 8-bit mask, popcount it, add — no branches, no early exit, the same work for every query.
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn count_below_avx2(px: &[i32], target: i32) -> usize {
use std::arch::x86_64::*;
let t = _mm256_set1_epi32(target); // broadcast target to 8 lanes
let mut n = 0;
for c in px.chunks_exact(8) {
let v = _mm256_loadu_si256(c.as_ptr() as *const __m256i);
let lt = _mm256_cmpgt_epi32(t, v); // lane = all ones where v < target
let m = _mm256_movemask_ps(_mm256_castsi256_ps(lt)) as u32; // 1 bit per lane
n += m.count_ones() as usize;
}
n // + the remainder, scalar
}The safety contract: a #[target_feature] function may only be called on a CPU that has the feature, so it is unsafe to call, and you check first with is_x86_feature_detected!("avx2") (the result is cached, so the check is cheap but not free). Two details that bite:
- AVX2 integer compares are signed.
_mm256_cmpgt_epi32compares asi32. Foru32quantities abovei32::MAX, flip the sign bit of both operands (XOR with0x8000_0000) before comparing, or use a portable path. - Dispatch cost. A
#[target_feature]function cannot be inlined into a caller compiled without that feature, so per-call dispatch adds a check and a real call to every lookup. For a 64-level window that overhead is comparable to the work. Dispatch once — pick a function pointer at startup, or compile the whole hot path with the feature enabled.
How close is it? On one AMD Zen 3 server, a 64-level lookup took about 5–7 ns with Rust's partition_point (which is already a branchless binary search), about 10 ns with the AVX2 count behind per-call runtime dispatch, and under 5 ns with the same AVX2 count compiled with -C target-cpu=native so it inlined. Your numbers will differ; the ranking is the lesson.
Find first level ≥ price with SIMD compare
/// Scalar reference: first index i with px[i] >= target in an ascending
/// array (px.len() if there is none).
pub fn lower_bound_scalar(px: &[i32], target: i32) -> usize {
px.iter().position(|&p| p >= target).unwrap_or(px.len())
}
/// Branch-free lower bound for a SORTED array: the answer equals how many
/// elements are < target. Count them 8 lanes at a time, no early exit.
pub fn lower_bound_count(px: &[i32], target: i32) -> usize {
todo!()
}
/// AVX2: _mm256_set1_epi32, _mm256_loadu_si256, _mm256_cmpgt_epi32,
/// _mm256_movemask_ps(_mm256_castsi256_ps(..)), count_ones; scalar tail.
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn lower_bound_avx2(px: &[i32], target: i32) -> usize {
todo!()
}
/// Dispatch: call lower_bound_avx2 when is_x86_feature_detected!("avx2"),
/// otherwise lower_bound_count. Must compile on non-x86 targets too.
pub fn lower_bound(px: &[i32], target: i32) -> usize {
todo!()
}
/// UNSORTED scan: first level whose quantity is >= min_qty. Per chunk of 8,
/// build a hit mask (bit i set when chunk[i] >= min_qty); if non-zero, the
/// answer is in this chunk at mask.trailing_zeros(). Mind the remainder.
pub fn first_at_least(qty: &[u32], min_qty: u32) -> Option<usize> {
todo!()
}
Your AVX2 lower_bound is slower than binary search in the benchmark, but faster when you build with -C target-cpu=native. What is going on?
Day 5 — Vectorized parsing
Bytes at a time
Where SIMD helps protocols, and where it does not
The simdjson paper. Read the first stage — classifying structural characters 64 bytes at a time into bitmasks, then iterating set bits with tzcnt. That 'compare, movemask, walk the bits' pattern is the core of every fast text parser.
SIMD-accelerated byte search with runtime CPU detection. Before writing your own delimiter scanner, benchmark this one.
Text protocols are where SIMD earns its keep. FIX (week 14) is a sequence of tag=value fields separated by the SOH byte (0x01). A scalar parser looks at one byte at a time. A SIMD scanner loads 32 bytes, compares all of them against 0x01 with _mm256_cmpeq_epi8, movemasks the result into a 32-bit integer whose set bits are the field boundaries, and walks them with trailing_zeros — the simdjson pattern. FIX's CheckSum is the sum of all bytes modulo 256, and _mm256_sad_epu8 against a zero vector sums each group of eight bytes in one instruction.
Fixed-layout binary protocols (ITCH, SBE, your week 11 wire format) usually gain little: every field is at a known offset, so "parsing" is already a handful of loads. Do not vectorize something that is not a loop.
Go through the market-data and gateway code paths of your exchange. List every loop over prices, quantities or bytes, and for each say: does it already auto-vectorize (check the asm), would explicit SIMD help, and how many elements it processes in the common case. Which one is worth doing first?
Day 6 — Benchmarking SIMD honestly
The ways a speedup can be fake
Sizes, caches, flags and correctness
A SIMD benchmark is easy to win and easy to fake. The checklist:
- Same flags for both sides. Compare against the scalar code compiled in release with the same
target-cpu— not a debug build, not a build where you disabled the vectorizer. - Realistic sizes. A book with ten levels and a window with 4,096 have different winners; fixed costs (dispatch, horizontal reduction, remainder handling) dominate small inputs. Benchmark the sizes your feed actually produces.
- Cache residency. If the data streams from DRAM, the loop is memory-bound and wider vectors barely help. State whether your benchmark is L1-resident and whether production is.
- Identical results. Property-test the SIMD path against the scalar path on random inputs, including lengths that are not multiples of the vector width, negative values and extremes.
- End-to-end effect. A 4× faster depth sum that was 1% of the publish path changes nothing. Re-run the week 25 open-loop measurement.
Write the benchmark section for the SIMD change you plan to ship: input sizes and distribution, cache state, build flags and target CPU, correctness checks, the scalar baseline, and the end-to-end latency result. What result would make you NOT ship it?
Capstone
Vectorize the hot loops of market-data generation in crates/marketdata, over a dense ladder of quantities:
- Depth aggregation: total quantity over the first N levels, quantity at a price or better, and the sweep-to-fill level count.
- Level search: the first level at or beyond a price, and the first level whose quantity passes a threshold.
- A scalar fallback for every kernel with identical results, used when the CPU lacks the feature and as the reference in tests.
The ladder is new this week. Week 28's LadderBook lives in crates/book and its window holds level indices, not quantities. In the repo it's LadderDepth (src/ladder.rs), a second implementation of the week 10 DepthBook trait next to MapDepth:
- Layout. Each side is a
Vec<u64>of quantities over a window of consecutive prices. The best level is at index 0 on both sides (asks atprice - lo, bids athi - price), so walking outward from the touch is a forward scan on either side. Prices outside the window go to a sparseBTreeMap. When a level outside the window becomes its side's best, the window is re-centred on the touch. - Queries.
depth_qty(side, ticks),qty_at_or_better,sweep_price,first_level_at_leastandnext_level_from. Because the ladder is indexed by price, "the first level at or beyond a price" is index arithmetic plus a first-non-zero scan, not a lower-bound search. - Kernels. The kernels are in
src/simd.rs:sum,first_nonzero,first_at_leastandlevels_to_fillover&[u64]. Sums wrap, so lane order can't change the answer, andlevels_to_fillaccumulates exactly in 128 bits.Kernels::scalar()is the reference.Kernels::avx2()uses 4 ×u64lanes and 16 levels per iteration, with unsigned compares done by flipping the sign bit before_mm256_cmpgt_epi64.Kernels::detect()picks one table of function pointers the first time it's called, and each ladder stores that table, so the hot path never checks CPU features.
The w29_ tests (tests/w29_simd.rs) cover:
- every kernel against the scalar one on random shapes, lengths 0 to 4,099 and unaligned starts;
- lane and block boundaries, unsigned compares and overflowing sums;
- the ladder against
MapDepthover 20k random operations with spill and re-centring; - every query against a reference with each kernel set;
- window edges, re-centring and
i64extremes; - the publisher and builder on a ladder against the map.
The decisions that matter:
- Portable lanes or intrinsics. Start with portable lane-shaped code and check the assembly. Reach for
std::archonly where the compiler cannot produce what you need (movemask-style searches, unsigned compares, byte classification) — and keep theunsafesurface tiny. - Dispatch once. Detect CPU features at startup and store function pointers (or select an implementation through a generic parameter, which week 37 will formalise). No per-call feature checks in the hot path.
- Equivalence tests. Property tests that run every kernel against its scalar twin on random books, lengths 0 to a few hundred, extreme quantities, negative prices.
About the benchmark: the target is a ratio — SIMD over scalar for depth aggregation. w29.depth_sum_speedup builds two identical 4,096-price ladders, one with Kernels::detect() and one with Kernels::scalar(). The book is full near the touch and thins to about 5% filled 2,000 ticks out. Each round runs 1,024 queries (depth over N ticks, sweep-to-fill and quantity at or better, reaching 8 to 2,048 ticks deep) on both ladders, and the result is scalar time ÷ SIMD time, the median of interleaved rounds. On a CPU without AVX2, detect() falls back to scalar and the ratio is about 1.0, so the target assumes the AVX2 kernels. It only means something if both sides are compiled with the same flags and the scalar side is the straightforward code you would otherwise ship. In the repo, LLVM already vectorizes the scalar sum with baseline SSE2, so most of the AVX2 gain comes from the searches, whose early exits the compiler won't vectorize. If the compiler already vectorized your scalar loop, you will see a ratio near 1; then either your explicit kernel must do something the compiler could not (wider vectors via a dispatched AVX2 path, a better algorithm such as the two-phase sweep), or the honest conclusion is that the compiler did it for you — and that belongs in the report too. Record the CPU and its vector features next to the number.
crates/marketdataVectorized depth and level search
SIMD depth aggregation and level search over the ladder arrays in crates/marketdata, with a scalar fallback that gives identical results, one-time runtime dispatch, and an honest benchmark against the scalar code under the same build flags.
cargo xtask test --week 29 # tests named w29_*
cargo xtask bench --week 29 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 29 tests pass w29_*
- SIMD vs scalar depth aggregation w29.depth_sum_speeduptarget ≥ 1.5 ×
Feeds into
No later week lists this one as a hard prerequisite, but the kernels come back:
- Week 35 generates decoders from a schema; the byte-scanning and checksum ideas from Day 5 apply to any text-protocol adapter you put in front of them.
- Week 37 turns runtime dispatch into compile-time specialisation. In the repo that covers the book and the risk policy; the kernel table stays a runtime choice, made once.
- Week 40's performance sprint is where you decide, with numbers, which of this week's kernels earned their place in the final engine.