← Back to roadmap
Phase 4Week 50~120 min read
0%·0/7

Low-Latency Interview Deep Dives

Memory, concurrency, OS and networking questions, answered precisely.

Mastery · Markets, Strategies & Getting Hired

Why this week

After the design round comes the deep dive. An interviewer picks one line from your CV ("built a lock-free SPSC ring buffer") and pulls on it until something gives. Why Acquire there and not Relaxed? What does the CPU actually do with a Release store? Would it work on ARM? How much does it cost when the consumer reads a line the producer just wrote? Why is the head on its own cache line, and why 128 bytes and not 64? Each answer earns the next, harder question. Candidates fail here not because they don't know the topic, but because they answer approximately: "it's for memory safety", "it's faster", "the cache".

This week you rehearse answers that are precise and come with numbers, from your own measurements wherever possible. Two timed drills rebuild classic interview code from a blank editor. Use the site's quick drills daily: the memory-ordering litmus tests, spot-the-concurrency-bug and latency-number estimation are short and exactly the shape of these questions. Run a full mock with the AI interviewer at the end of the week, and ask it to go deep on one component.

💡
Tip

The shape of a strong deep-dive answer is mechanism → consequence → number → evidence. For example: "A Release store prevents earlier writes from being reordered after it. So the consumer that Acquire-loads the index sees the slot fully written. On x86 it's a plain mov, so the cost is the cache-line transfer, about 50–100 ns on one socket. I measured 70 ns on my machine in week 5."

Day 1 — Memory-ordering questions

Day 01

Litmus tests, said precisely

What the language allows, what the hardware does, and why they differ

Most memory-ordering questions reduce to four litmus tests. Know each one's forbidden outcome, the weakest orderings that forbid it, and what x86 and ARM do without help.

  • Message passing (MP). Thread 1 writes data, then sets a flag. Thread 2 reads the flag, then the data. Can it see the flag set and stale data? In Rust it can, unless the flag store is Release and the flag load is Acquire. Those two form a synchronises-with edge, so the data write happens-before the data read. x86 hardware never shows the stale outcome, because stores aren't reordered with stores and loads aren't reordered with loads, but the compiler can still reorder Relaxed operations. ARM and POWER hardware do show it.
  • Store buffering (SB). Each thread stores to its own flag and then loads the other's. Can both loads see 0? Yes, even on x86, because a store can sit in the store buffer while a later load to a different address completes. This is the one reordering x86-TSO allows. Acquire/Release doesn't help, because no load reads from the other thread's store, so no synchronises-with edge exists. Only SeqCst on all four operations, or SeqCst fences between store and load, forbids it. This is Dekker's and Peterson's problem, and why a SeqCst store on x86 compiles to xchg (or mov + mfence).
  • Load buffering (LB). Each thread loads one location and then stores to the other. Can both loads see the other's later store? The C++/Rust model allows it with Relaxed. x86 forbids it in hardware. In the language you need Acquire loads and Release stores: then each load synchronises with the other thread's store, and the two outcomes together would form a happens-before cycle.
  • IRIW (independent reads of independent writes). Two writers each write a different variable, and two readers read both in opposite orders. Can the readers disagree about which write happened first? With Acquire/Release, yes. Only SeqCst gives a single total order everyone agrees on. Hardware differs: POWER can show it even with ordered reads, while x86 and ARMv8 (which is "other-multi-copy atomic") don't once each reader's two loads are ordered.
Litmus · Message passing (MP)
Initially data = flag = 0
Thread 1
data.store(1, Relaxed);
flag.store(1, Relaxed);
Thread 2
let r1 = flag.load(Relaxed);
let r2 = data.load(Relaxed);
Can r1 == 1 && r2 == 0 happen?
⚠ Allowed by the Rust/C++20 modelNot observable on x86-64

Nothing orders the two stores or the two loads, so the reader can see the flag before the data. x86 never shows it (TSO keeps stores in order and loads in order), which is exactly why this bug hides until you run on ARM.

Litmus · Store buffering (SB)
Initially x = y = 0
Thread 1
x.store(1, Relaxed);
let r1 = y.load(Relaxed);
Thread 2
y.store(1, Relaxed);
let r2 = x.load(Relaxed);
Can r1 == 0 && r2 == 0 happen?
⚠ Allowed by the Rust/C++20 modelObservable on x86-64

Each store can sit in its core’s store buffer while the following load reads memory. This is the one reordering x86-TSO itself performs (store→load).

Bookby Mara Bos · ch. 3, 7
Rust Atomics and Locks

Chapter 3 for happens-before, Acquire/Release and SeqCst with examples you can quote. Chapter 7 for what the processor does: x86 vs ARM instructions for each ordering, caches and why 'free' orderings on x86 still constrain the compiler.

Paperby Jeff Preshing
Memory Reordering Caught in the Act

A tiny program that shows store-buffer reordering happening on real x86 hardware, and the mfence that stops it. Port it to Rust with Relaxed and with SeqCst, run it, and cite your own result in interviews.

Recall#mp-on-x86

Your message-passing code uses Relaxed for both the flag store and load. It has never failed on your x86 laptop in a billion runs. Is it correct? Answer in terms of the language model and the hardware.

Recall#sb-needs-seqcst

Why doesn't Acquire/Release forbid the r1 = r2 = 0 outcome in the store-buffering test, and what does?

Day 2 — Caches and NUMA

Day 02

The numbers behind 'cache-friendly'

Lines, coherence, prefetchers, TLBs and sockets

Know these orders of magnitude for a current x86 server, and know that every one varies by microarchitecture:

  • Cache line. 64 bytes on x86 (Apple's M-series uses 128-byte lines). Intel's spatial prefetcher fetches lines in adjacent pairs, which is why crossbeam's CachePadded aligns to 128 bytes on x86-64.
  • Hit latencies. L1 ~4–5 cycles (~1 ns), L2 ~12–16 cycles (~3–5 ns), L3 ~40–70 cycles (~10–20 ns), local DRAM ~80–100 ns, remote-socket DRAM ~1.5–2× that.
  • Coherence. Writing a line requires exclusive ownership (MESI and its variants), so writing a line another core holds means invalidating its copy and transferring the line: roughly 50–100 ns between cores on one socket, often over 100 ns across sockets. That's the real cost of a contended atomic and of false sharing, not the instruction itself.
  • Prefetchers reward sequential and strided access and can't help a pointer chase. That's why a linked list of orders loses to an array even when big-O says otherwise.
  • TLB. With 4 KiB pages, an L1 data TLB of ~64 entries covers ~256 KiB. A random walk over a 1 GiB book misses the TLB constantly and pays page walks. 2 MiB huge pages cut that by 512×.
  • Branch mispredict ~15–20 cycles: invisible on average, visible in a tight matching loop.
Paperby Ulrich Drepper
What Every Programmer Should Know About Memory

Sections 3 (CPU caches), 4 (virtual memory and TLBs) and 5 (NUMA). The hardware details have moved on since 2007, but the measurements and the reasoning haven't. Re-derive one of its graphs on your own machine.

Docsby Agner Fog
The microarchitecture of Intel, AMD and VIA CPUs

Look up your own CPU's cache latencies, branch-misprediction penalty and store-forwarding rules. Quoting the right number for the machine you measured on is what separates a strong answer from a memorised one.

Recall#cachepadded-128

Why does crossbeam's CachePadded use 128-byte alignment on x86-64 when the cache line is 64 bytes?

Design note#numbers-sheet

Measure your own latency-numbers sheet on the machine you'll talk about: L1/L2/L3/DRAM latency (pointer chase), core-to-core cache-line transfer (ping-pong), an uncontended atomic RMW, a syscall, a futex wake, loopback UDP and TCP round trips. Put each next to the published ballpark and explain any big difference.

0 chars

Day 3 — OS and networking

Day 03

Where the microseconds go

Syscalls, scheduling, and a packet's journey

The canonical networking deep dive is "walk me through what happens when a packet arrives". A strong answer names the stages and roughly what each costs:

  1. The NIC DMAs the frame into a receive-ring buffer in host memory, and may timestamp it.
  2. It raises an interrupt, possibly delayed by interrupt coalescing (ethtool -C, often tens of µs by default), or the driver is already busy-polling.
  3. The kernel's NAPI poll runs in softirq context, builds an sk_buff, and runs IP and UDP/TCP processing.
  4. The data is queued on the socket's receive buffer, and the waiting thread is woken: a scheduler wakeup, a context switch if it was asleep, possibly on another core.
  5. The thread's recv copies the data to user space and returns.

On a tuned host, a one-way kernel UDP path from wire to application is typically a few microseconds, with wakeups and coalescing dominating the tail. With kernel bypass (week 33) the application polls the NIC ring directly from user space and the same path is around a microsecond or less.

Other perennials, with numbers:

  • Syscall. The bare entry and exit costs on the order of 100 ns on modern CPUs, more with some side-channel mitigations. A context switch costs a few µs once you count the cache and TLB damage.
  • Page faults. A minor fault costs around a microsecond. A major fault (from disk) costs milliseconds. Pre-fault and mlock the hot path.
  • Nagle plus delayed ACK. Small writes held back by Nagle's algorithm while the peer delays its ACK produce stalls of tens of milliseconds (Linux's minimum delayed-ACK timeout is 40 ms). Set TCP_NODELAY on order sessions.
  • The scheduler. Linux moved from CFS to EEVDF in 6.6, but on a hot core the answer is the same: pin the thread, isolate the core and don't let the scheduler make decisions there.
Bookby Brendan Gregg · ch. 6, 7, 10
Systems Performance: Enterprise and the Cloud (2nd edition)

CPUs, Memory and Network. Use the methodology sections (USE method, latency analysis) to structure answers, and the tool sections to say how you'd prove each claim on a live box.

Recall#nagle-40ms

A gateway shows occasional ~40 ms latency spikes on small order acknowledgements over TCP, and otherwise runs in microseconds. What's your first hypothesis, and how do you confirm and fix it?

Design note#packet-walk

Write the packet walk for your own exchange: an order arrives at the gateway's NIC and its ack leaves again. Name every stage (hardware, kernel, your threads and queues), estimate each from your measurements, and mark which stages kernel bypass, busy polling or batching would change.

0 chars

Day 4 — Timed: SPSC ring from memory

Day 04

45 minutes, blank editor, talk while you type

The most common low-latency coding question

Rules for timed coding: state the invariants before writing code, narrate your ordering choices as you write them, test as you go, and leave five minutes for the questions you know are coming (cached indices, padding, batching, why &mut self).

This version is harder than week 1's: generic over non-Copy T, with runtime capacity rounded up to a power of two (so index & mask replaces %), MaybeUninit slots, and a Drop that drops exactly the values still in the ring. The harness counts drops, so leaks and double drops both fail.

Drill

Timed: SPSC ring from memory

Target: 6/6 in under 45 minutesNot yet run
use std::cell::UnsafeCell;
use std::mem::MaybeUninit;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

struct Shared<T> {
    slots: Box<[UnsafeCell<MaybeUninit<T>>]>,
    mask: usize,       // capacity - 1 (capacity is a power of two)
    head: AtomicUsize, // next index to write; only the producer stores it
    tail: AtomicUsize, // next index to read; only the consumer stores it
}

// SAFETY: write your argument here before you write the code.
unsafe impl<T: Send> Send for Shared<T> {}
unsafe impl<T: Send> Sync for Shared<T> {}

pub struct Producer<T> {
    shared: Arc<Shared<T>>,
}

pub struct Consumer<T> {
    shared: Arc<Shared<T>>,
}

/// Capacity is rounded up to a power of two (minimum 1).
pub fn spsc<T>(capacity: usize) -> (Producer<T>, Consumer<T>) {
    let _ = capacity;
    todo!()
}

impl<T> Producer<T> {
    /// Err(value) when full, handing the value back.
    pub fn try_push(&mut self, value: T) -> Result<(), T> {
        let _ = value;
        todo!()
    }

    pub fn capacity(&self) -> usize {
        self.shared.mask + 1
    }
}

impl<T> Consumer<T> {
    pub fn try_pop(&mut self) -> Option<T> {
        todo!()
    }

    /// Items currently in the ring, as seen by the consumer.
    pub fn len(&self) -> usize {
        todo!()
    }
}

impl<T> Drop for Shared<T> {
    fn drop(&mut self) {
        // TODO: drop every value still in [tail, head), exactly once.
    }
}
Rust
Recall#why-mut-self

Why should try_push take &mut self rather than &self, even though it only touches atomics and UnsafeCells?

Day 5 — Timed: allocation-free LRU

Day 05

60 minutes, and the allocator is watching

An intrusive list in a slab, and a hash map that never grows

The LRU is a classic for a reason. It combines a hash index with an ordered structure, and the low-latency twist is no allocation after construction. Allocation on the hot path is a tail-latency problem: malloc is usually tens of nanoseconds, but occasionally it must fetch memory from the OS and fault pages in, and that's microseconds. The usual design is an array-backed slab of nodes linked by u32 indices (no Box, no Rc<RefCell>), with eviction reusing the least recently used slot in place.

The harness installs a counting global allocator and runs 50,000 operations after warm-up. Try the obvious HashMap::with_capacity(cap) first and watch it fail. With today's std HashMap, steady remove-then-insert churn leaves deleted-slot tombstones that eat the table's growth budget, and a table sized for exactly cap entries eventually resizes. Explaining why, and the fixes (headroom, or your own open-addressing table with backward-shift deletion), is exactly the follow-up an interviewer wants.

Drill

Timed: allocation-free LRU

Target: 5/5 · zero allocations in steady stateNot yet run
use std::collections::HashMap;

const NIL: u32 = u32::MAX;

struct Node {
    key: u64,
    value: u64,
    prev: u32, // towards the most recently used end; NIL at the head
    next: u32, // towards the least recently used end; NIL at the tail
}

/// Fixed-capacity LRU cache that never allocates after `with_capacity`.
pub struct Lru {
    nodes: Vec<Node>,         // slab; grows to `cap` once, then slots are reused
    index: HashMap<u64, u32>, // key -> slot
    head: u32,                // most recently used (NIL when empty)
    tail: u32,                // least recently used (NIL when empty)
    cap: usize,
}

impl Lru {
    /// cap >= 1. Allocate everything you will ever need here.
    pub fn with_capacity(cap: usize) -> Self {
        let _ = cap;
        todo!()
    }

    /// The value for `key`, marking it most recently used.
    pub fn get(&mut self, key: u64) -> Option<u64> {
        let _ = key;
        todo!()
    }

    /// Insert or update `key` as most recently used. If a new key doesn't
    /// fit, evict the least recently used entry and return it.
    pub fn put(&mut self, key: u64, value: u64) -> Option<(u64, u64)> {
        let _ = (key, value);
        todo!()
    }

    pub fn len(&self) -> usize {
        todo!()
    }
}
Rust

Day 6 — Your stories, with numbers

Day 06

Answer from experience, not from blog posts

Six stories you can tell in three minutes each

The strongest deep-dive answers come from your own system: "In my exchange, the matching loop's p99 was 1.4 µs until I found false sharing between the sequence counter and the stats counter. Padding took it to 600 ns, and here's the perf c2c output." Prepare six such stories: one each on memory ordering, cache behaviour, allocation, kernel networking, a correctness bug, and a measurement mistake you made and caught. For each: the symptom, the hypothesis, the evidence, the fix and the number before and after.

Then run a full mock with the AI interviewer, asking it to deep-dive one component of your exchange, and do a round of spot-the-concurrency-bug at /hft/drills to warm up.

Design note#story-bank

Write your six stories in symptom → hypothesis → evidence → fix → numbers form, three to five sentences each. Mark the follow-up question you most fear for each, and write the answer.

0 chars

Capstone

Deliverable

Deep-dive ready

Precise, numbered answers across ordering, caches, OS and networking; both timed drills done from a blank editor.

  • Memory-ordering litmus tests explained out loud#ordering-answersmark when done

    Record yourself explaining MP, SB, LB and IRIW: the forbidden outcome, the weakest Rust orderings that forbid it, and what x86 and ARMv8 do without help. Then pass a full memory-ordering litmus round at /hft/drills without a mistake.

  • Your own latency-numbers sheet, measured#numbers-sheetmark when done

    The Day 2 sheet, measured on one named machine (CPU, kernel, governor), with each number next to its published ballpark and a sentence on every large difference.

  • A packet's journey, NIC to handler and back, with costs#packet-walkmark when done

    The Day 3 packet walk for your gateway, with a cost per stage from your own measurements, plus what kernel bypass, busy polling and batching would each change.

  • Both timed drills from a blank editor, within time#timed-codingmark when done

    Delete your solutions and redo both drills from the starter: the SPSC ring in 45 minutes and the LRU in 60, talking out loud as you go. Note what you forgot and redo it two days later.

  • Six stories from your exchange, with numbers#story-bankmark when done

    The Day 6 story bank, rehearsed in at least one deep-dive mock at /hft/interview, and revised after it.

Feeds into

No later week depends on this one formally. The story bank and numbers sheet feed straight into week 51's interview narrative, and the measured numbers make your README's performance section credible.