Part C · Substrate › Off-chip data movement

Module 7·Part C — Substrate·17 min

Off-chip data movement

Posted versus non-posted, why the round trip and not the bandwidth is what kills you, and the rigorous answer to “why not just use a GPU?”

The core mental model

Off-chip is where latency budgets die, and the reason is that bandwidth is abundant while round trips are not. A PCIe Gen5 x16 link moves 64 GB/s, so a kilobyte crosses it in about 16 nanoseconds of wire time — and a single small read of a device register costs one to two microseconds, a hundred times more. The number that matters is never the width of the pipe.

3 crossingsoutbackoutbackoutback3 × latency1 crossingoutbackthe other two never happen1 × latencytime →
Bandwidth is a rate; crossings are a count. A design that moves the same total bytes in one round trip instead of three is three times better on the axis that actually binds a latency path, and identical on the datasheet. This is why “we have plenty of PCIe bandwidth” and “we are PCIe-bound” are both routinely true of the same system.

The exploitable asymmetry is posted versus non-posted. A write is posted: fire and forget, visible in a couple of hundred nanoseconds, and the core does not wait. A read is non-posted: it requires a completion to come back, and the core stalls on it, because an uncached load has no out-of-order cover. So every well-built device interface is arranged so the critical path contains only writes. You place descriptors in host memory, ring a doorbell with a posted write, and let the device DMA on its own schedule; you never make the host read a device register to find out what happened, you make the device write status into host memory and read that. The same rule inverts for the device: it should not have to read host memory synchronously either.

Then there is the software stack, which for a normal socket costs 5–15 µs of syscall, copy, interrupt, softirq and wakeup. Kernel bypass maps the NIC’s descriptor rings into user space, so a poll loop reads packets directly: 1–2 µs wire to application. You give up the kernel’s TCP stack (you link your own), its firewall, tcpdump, and easy sharing of the NIC. And because software timestamps now measure everything you were trying to eliminate, real measurement moves to the NIC: hardware timestamping at the MAC, disciplined by PTP, comparing RX and TX stamps to get a true wire-to-wire number. Anything measured in software is measuring your own instrumentation.

Numbers worth memorizing

PathLatencyBandwidth
PCIe Gen5 x16, posted write150–300 ns64 GB/s per dir
PCIe Gen5 x16, round trip1–2 µs
PCIe Gen4 x16similar latency32 GB/s per dir
DMA descriptor + doorbell → first byte~1 µs
DRAM, local socket~80 ns200–400 GB/s
DRAM, one NUMA hop130–160 nslower, contended
Kernel socket receive path5–15 µs
Kernel bypass (ef_vi, DPDK)1–2 µs
FPGA on-path (no bus crossing)50–200 ns

Wire and switch, which people consistently under-weight:

QuantityValueNote
Light in fibre~5 ns per metre1 km ≈ 5 µs each way
Microwave through air~3.3 ns per metre~1.5× faster than glass — the reason microwave links exist
Cut-through switch hop50–100 nsforwards on header
Store-and-forward switch hop1–10 µswaits for the whole frame
64 B frame serialisation, 10 GbE51 nsat 25 GbE, 20 ns
PTP sync accuracy100 ns – 1 µsbounds what you can measure

Tick-to-trade, end to end:

ImplementationBudget
FPGA, on-path, full parse-to-send350–900 ns
FPGA trigger path only20–100 ns
CPU + kernel bypass1–5 µs
CPU + kernel network stack15–50 µs
GPUnot viable — see below

Critical thinking

Why is reading a device register so much worse than writing one?

Two independent reasons that compound.

Protocol. A write is a posted transaction: the CPU hands it to the root complex and retires it. A read is non-posted — it generates a request, the device must respond with a completion, and that is a full traversal of the fabric in both directions, 1–2 µs on PCIe Gen5.

Microarchitecture. Device memory is mapped uncacheable, so the load cannot be speculated, cannot be reordered around, and cannot be covered by the out-of-order window. The core genuinely stalls for the whole round trip, retiring nothing. A 2 µs MMIO read on a 3 GHz core throws away about 6000 cycles of issue capacity.

The design consequences are concrete: never poll a device register in a loop; have the device DMA a status word into host memory and poll that (a cached read of ~1 ns when hot). Batch state into one DMA rather than several reads. And where you must write, write — doorbells, not handshakes. This is why descriptor rings look the way they do across every high-performance device: they are structured so that the fast path contains only posted writes and cacheable host-memory reads.

You moved to kernel bypass. p50 improved 10× and p99 barely moved.

You removed a mean cost and the tail was never in the stack. The stack contributed a fairly consistent 5–15 µs, so removing it shifts the whole distribution left without narrowing it. What remains is Module 5’s territory, and the candidates are ranked by how often they turn out to be the answer:

  1. C-states. If your poll loop ever yields, sleeps, or hits a pause path that lets the core drop into C6, you pay 50–100 µs to come back. Pin the frequency, disable deep C-states, and verify with residency counters rather than configuration files.
  2. Interrupts still arriving. Bypass does not automatically stop the NIC from raising interrupts on that core, and /proc/interrupts will tell you in seconds. Move IRQ affinity off the polling core entirely.
  3. NUMA misplacement. Buffer, thread and device on different nodes. Each is independently wrong-able, so check all three.
  4. The other side. Exchange microbursts queue in the switch, and no amount of host tuning addresses that. NIC hardware timestamps versus your application timestamps will separate “the packet arrived late” from “we handled it late” — and that distinction is the single most useful measurement in this whole area.
  5. Your own cache misses. The poll loop is now the entire program; if its working set does not stay resident, you have simply moved where the misses happen.

Account for a tick-to-trade budget, stage by stage, and say where the two orders of magnitude between CPU and FPGA come from.

CPU with kernel bypass, roughly:

StageCost
Wire → NIC PHY/MAC50–100 ns
NIC → host over PCIe (DMA)~1 µs
Poll loop notices0–500 ns
Parse market data100–500 ns
Book update100 ns – 1 µs
Feature computation100–400 ns
Model evaluation20–100 ns
Order encode + risk50–150 ns
Host → NIC over PCIe~300 ns – 1 µs
NIC → wire50–100 ns
Total~2–5 µs

An FPGA sitting in the datapath does parse, book, features, model, risk and encode as the bits stream through, and never crosses PCIe at all. Total 350–900 ns.

So the two orders of magnitude decompose into three things, and none of them is arithmetic: the two PCIe crossings (~1.3–2 µs), the software stack and poll latency, and the fact that the FPGA overlaps parsing with reception instead of waiting for a complete packet before starting.

One honesty note that separates people who have built these from people who have read about them: quoted “sub-100 ns tick-to-trade” figures are almost always a trigger path — a precomputed order released by a single comparator, with book and feature work done in parallel or in advance. That is a real and legitimate technique, but it is not the same measurement as the full path, and conflating them makes budgets impossible to reason about.

Give the rigorous answer to “why not just use a GPU?” — and steelman it first.

The steelman deserves stating, because the lazy dismissal is wrong in an instructive way.

The strong version of the GPU case: kernel launch overhead can be eliminated with a persistent kernel — a block that never exits, spinning on a flag in mapped memory. GPUDirect RDMA lets the NIC DMA straight into GPU memory, removing the host hop. So you can get to: packet lands in GPU memory (~1 µs over PCIe), a spinning SM notices, does the math in nanoseconds, writes a response. No launch, no host. This is a real technique used in HPC, and anyone who says “kernel launch is 3–5 µs, therefore no” has not engaged with it.

Why it still loses, in the order that actually decides it:

  1. The bus is still there. GPUDirect removes the host bounce, not the PCIe crossing. You pay ~1 µs in and ~1 µs out, which already exceeds a full FPGA path that never crosses a bus.
  2. Determinism. This is the decisive one. The GPU’s clocks vary with temperature and power; the memory system is shared with whatever else is resident; there is no priority mechanism that guarantees your spinning block runs unmolested; the driver may intervene. You cannot state a worst case, and Module 5’s whole argument is that a system without a stated worst case has no deadline.
  3. You are using one SM of 132. At batch 1 there is nothing to parallelise. Every architectural feature you are paying for — the warp scheduler, the huge register file, the memory hierarchy built for thousands of concurrent threads — is dead weight. A GPU is a machine for hiding latency with parallelism, and you have no parallelism.
  4. Power and cost for a job a small FPGA does in a fraction of the envelope.

The clean statement: a GPU is a throughput engine, and this is a latency problem at batch 1. The architecture is not slow, it is aimed at a different quantity. The right answer is compute placed in the datapath — the packet is parsed and the decision made as bits arrive, and the bus crossing never appears in the equation at all.

Why does NUMA misplacement hurt more than the +50 ns figure implies?

Because +50 ns is the idle number, and three things degrade it under load.

Bandwidth. The inter-socket link is narrower than local memory, so under pressure you queue on it — Module 4’s 1/(1ρ)1/(1-\rho) applied to a link you did not know you were saturating.

Outstanding-request credits. Remote transactions consume limited buffering in the interconnect, so effective memory-level parallelism to a remote node is lower than to local memory. You lose concurrency as well as latency, which Module 1 says is the more expensive loss.

Multiplicity. Thread placement, memory placement and interrupt affinity are three independent decisions, and each can be wrong on its own. A buffer allocated by a thread that later migrates, an IRQ steered by irqbalance, and a NIC attached to the other socket’s root complex compose into something much worse than any single +50 ns.

And it is silent. Nothing fails; the number is just worse than it should be, forever, and it does not show up as an obvious counter. The remedies are all “decide once, explicitly”: numactl for memory and thread, IRQ affinity pinned by hand, and buffers allocated on the node attached to the device that will DMA into them.

Self-check

Explain posted versus non-posted and give the design rule that follows.

A write is posted — handed off and retired, visible in 150–300 ns. A read is non-posted: it needs a completion, costing a 1–2 µs round trip, and because device memory is uncacheable the core stalls for all of it with no out-of-order cover. The rule: put only writes on the critical path. Have the device DMA status into host memory and poll that cached location; ring doorbells rather than exchanging handshakes.

Where do the two orders of magnitude between a CPU and an FPGA tick-to-trade path come from?

Two PCIe crossings at roughly 1 µs each, the software stack and poll latency, and the FPGA’s ability to parse while receiving instead of waiting for a complete packet. Not arithmetic — the model evaluation is 20–100 ns in both cases. Also worth flagging: quoted sub-100 ns figures are usually a trigger path releasing a precomputed order, not the full parse-to-send path.

Kernel bypass improved p50 tenfold and left p99 unchanged. What is the explanation and what do you check first?

The stack was a roughly constant 5–15 µs, so removing it shifted the distribution without narrowing it; the tail was always elsewhere. Check C-state residency first, since a poll loop that ever sleeps pays 50–100 µs to wake. Then IRQ affinity, NUMA placement of thread, memory and device, and finally compare NIC hardware timestamps with application timestamps to separate “arrived late” from “handled late”.

Steelman the GPU for a 1 µs deadline, then give the decisive objection.

The steelman: a persistent kernel spinning on mapped memory removes launch overhead entirely, and GPUDirect RDMA lets the NIC DMA straight into GPU memory, removing the host hop. That is a real technique, not a strawman.

The decisive objection is determinism, not speed. Clocks vary with power and temperature, the memory system is shared, nothing guarantees your block runs unmolested, and the driver can intervene — so no worst case can be stated. Add that you still pay ~1 µs each way across PCIe and are using one SM of 132 because batch 1 offers nothing to parallelise. A GPU is a throughput engine aimed at a different quantity.

Why is fibre distance a real design variable inside a single datacenter?

Light in fibre travels about 5 ns per metre, so 10 m of extra cross-connect is 50 ns each way and 100 ns round trip — comparable to an entire FPGA trigger path. Microwave through air at ~3.3 ns per metre is roughly 1.5× faster, which is why long-haul microwave links exist despite far lower bandwidth: on these routes the only quantity being bought is propagation delay.

Name three ways NUMA misplacement degrades worse than its idle latency number.

The inter-socket link is narrower and queues under load, so you meet 1/(1ρ)1/(1-\rho) on a link you did not know you were using; remote transactions consume limited interconnect credits, lowering achievable memory-level parallelism as well as latency; and thread, memory and IRQ placement are three independent decisions that compose multiplicatively when more than one is wrong. It also fails silently — nothing breaks, the number is just permanently worse.