Skip to content

Commit ec86f68

Browse files
pjdurdenclaude
andcommitted
Day 25: the fused read dispatches to the kernel backend, paged goes flash-close to naive, 186 green
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5517086 commit ec86f68

6 files changed

Lines changed: 308 additions & 28 deletions

File tree

docs/daily/day-25.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
title: "Day 25: the fused read dispatches to the kernel backend"
3+
parent: Daily log
4+
nav_order: 25
5+
---
6+
7+
# Day 25: the fused read dispatches to the kernel backend
8+
9+
Date: 2026-07-16 · Week 6 · Phase 2 Paged memory
10+
11+
## What I added today
12+
One line moved in `src/nanoserve/cache.py`, and it is the line the whole of Week 6
13+
was building toward. `PagedKVCache.paged_attention`, the cache's fused read, called
14+
`paged_attention_reference` directly through Day 24: the byte-exact torch oracle,
15+
the same SDPA over the assembled slots the tests compare against. Day 25 routes it
16+
through `paged_attention_dispatch`, the Day-23 entry point that asks
17+
`select_backend(q.device)` which read this device can actually run. On a CUDA tensor
18+
with Triton installed that is the `@triton.jit` kernel; on a CPU it is the Day-22
19+
tlsim model, the same streaming loop written in torch. The engine gets the kernel on
20+
a card and the correct-and-slow model on a laptop, and the cache never learns which.
21+
22+
Two new tests in `tests/test_cache.py` pin the wiring. One spies on the dispatcher
23+
symbol the cache imports and asserts the read calls it exactly once, handed this
24+
layer's own K/V pools and the slot mapping for the whole history, then hands its
25+
result straight back. The other feeds the cache's pool through both the dispatched
26+
read and `paged_attention_reference` and pins them to `atol=1e-5`, the same tolerance
27+
the kernel itself is held to. The consequence rippled into three older equality tests
28+
that had pinned the cache read `torch.equal` against a contiguous SDPA: they now carry
29+
the flash tolerance, because the model no longer runs the byte-exact reference. Suite
30+
**186 green** (5 GPU-gated skips), ruff clean.
31+
32+
## Why it matters
33+
Until today Week 6 was a kernel in a file. Day 22 wrote the fused CPU model, Day 23
34+
wrote the real Triton kernel, Day 24 built the instrument to read its scaling, and
35+
every one of them was reachable only from a test. The model's actual attention still
36+
ran `paged_attention_reference`, the oracle. This is the wiring step that turns the
37+
kernel into the engine's real attention: the paged forward now dispatches, so the
38+
same code path picks up the GPU kernel when a card is present and falls back to the
39+
model that runs anywhere when it is not. The reference did not disappear, it changed
40+
jobs. It is the oracle both backends are graded against, not the path either one runs.
41+
42+
The honest cost is that paged attention is no longer bit-identical to the naive
43+
contiguous path. Both backends stream the online softmax, which reassociates the
44+
exponent sums, so the output lands a few ulps off a plain SDPA (about 7e-6 on the
45+
two-layer test model) rather than exactly on it. That is not a regression, it is the
46+
accuracy trade every flash-attention kernel makes, and it belongs on the model's path
47+
precisely because that is where the kernel now lives. The token-level tests still hold
48+
because argmax over these logits does not flip at 1e-5; the logit-level test that
49+
asked for 1e-6 now asks for 1e-5, the tolerance the streaming softmax actually
50+
delivers.
51+
52+
## What I learned
53+
1. **Byte-identity was a property of the oracle, not of paging.** Every "paging
54+
changes nothing" test through Day 24 passed `torch.equal` because the cache read
55+
*was* the reference, running the identical causal fp32 softmax by exact index. The
56+
moment the model runs the streaming kernel instead, the scatter and gather stay
57+
exact (same K/V, same slots) but the softmax summation order moves, and equality
58+
becomes closeness. The tests that flipped from `equal` to `allclose` were not
59+
loosened to hide a bug; they caught exactly the numerics change the dispatch
60+
introduced, which is what a good test does.
61+
2. **The right tolerance is the one the backend is already held to.** The kernel
62+
tests pin tlsim and Triton to `paged_attention_reference` at `atol=1e-5, rtol=1e-4`.
63+
When the cache read started running that same backend, the correct new tolerance
64+
for the cache tests was not a number I picked to make green, it was that same
65+
1e-5, propagated up one level. A tolerance that means something is a tolerance
66+
borrowed from where the approximation is defined.
67+
3. **A dispatcher makes the fallback the default and the kernel the exception.** The
68+
branch reads `"triton" if device.type == "cuda" and has_triton() else "tlsim"`, so
69+
the CPU model is what runs unless both a card and the package are present. That is
70+
the right default for a repo that must run on a laptop: the slow-and-correct path
71+
is the one you get for free, and the fast path is the one that has to prove its
72+
preconditions. The engine is portable first and fast where it can be, not the
73+
other way around.
74+
75+
## Diagram
76+
[paged-read-dispatch.png](../diagrams/paged-read-dispatch.png). The cache read at the
77+
top, the `select_backend` pill below it, and the two branches: the Triton kernel on
78+
`cuda & has_triton()`, the tlsim model on `else`. The reference sits dashed in the
79+
middle, with both backends held to it at `atol=1e-5`, no longer on the path. Both
80+
converge to the one attention output that flows back to `gqa_attention`. The banner
81+
carries the trade: Day 24 ran the byte-exact reference, Day 25 runs the streaming
82+
softmax, so paged is flash-close to naive, not bit-identical.
83+
84+
## Tomorrow
85+
Measure the dispatch where it matters. The read benchmark and the scaling fit
86+
(Days 20 and 24) time `gather` and `fused` as standalone functions; wire the runner
87+
to time the cache's *dispatched* read end to end, so the sweep reports the path the
88+
engine actually takes rather than a function called in isolation. On CPU that pins the
89+
tlsim constant against the gather constant under the real slot mapping; on a card it is
90+
the first number that shows the kernel bending the constant down, which is the whole
91+
claim Week 6 has been setting up to make. Keep the reference as the correctness gate
92+
the timed path is checked against before any number is trusted.
93+
94+
## Post angle
95+
Day 25 of building an LLM inference engine from scratch. Today was one moved line and
96+
it is the one the whole kernel week was for. My paged cache's attention read was still
97+
calling the byte-exact torch reference, the oracle. The real Triton kernel and its CPU
98+
model existed but only the tests ever touched them. Today the cache read dispatches:
99+
`select_backend(q.device)` picks the Triton kernel on a card and the streaming CPU
100+
model on a laptop, and the model's actual attention runs whichever the box can run.
101+
The reference did not go away, it changed jobs, from the path the model runs to the
102+
oracle both backends are graded against. The honest part: paged attention is no longer
103+
bit-identical to the naive path. Both backends stream the online softmax, which
104+
reassociates the exponent sums, so the output lands about 7e-6 off a plain SDPA rather
105+
than exactly on it. That is not a bug, it is the accuracy trade every flash-attention
106+
kernel makes, and three of my "paging changes nothing" tests flipped from `torch.equal`
107+
to `allclose` to carry it. The tolerance I gave them was not invented, it is the same
108+
1e-5 the kernel is already held to against the reference, propagated up one level. This
109+
is the shape vLLM and SGLang ship: a dispatcher that runs the fast kernel where the
110+
hardware allows and a correct fallback everywhere else, with the reference kept as the
111+
gate, not the path. 186 green.
395 KB
Loading
Lines changed: 70 additions & 0 deletions
Loading

src/nanoserve/cache.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import torch
2020

21-
from .kernels.paged_attention import paged_attention_reference
21+
from .kernels.triton_paged_attention import paged_attention as paged_attention_dispatch
2222

2323

2424
class NaiveKVCache:
@@ -301,8 +301,10 @@ class PagedKVCache:
301301
the tests still compare against. `paged_attention` is the Week-6 fused read: it
302302
attends directly over the scattered blocks through the Day-18 reference and
303303
hands `gqa_attention` the attention output, never rebuilding the history. The
304-
model runs on the fused read now; a Triton kernel later slots in behind the same
305-
signature, held to `paged_attention_reference`.
304+
model runs on the fused read now, and since Day 25 that read dispatches through
305+
`select_backend`: the Triton kernel on a card, the tlsim CPU model on a laptop,
306+
both held to `paged_attention_reference` as the oracle. The reference is the
307+
check the dispatch is graded against, no longer the path the model runs.
306308
"""
307309

308310
def __init__(self, config, allocator: BlockAllocator):
@@ -401,24 +403,33 @@ def paged_attention(
401403
402404
This is Week 6's payoff wired into the cache. Rather than hand attention the
403405
contiguous history to score itself (`append`), the cache does the read: it
404-
writes the K/V (see `_write`), then runs `paged_attention_reference` over its
405-
own scattered pool, reading each past token through its slot and never
406-
rebuilding the contiguous buffer. `gqa_attention` calls this and takes the
407-
attention output straight back.
406+
writes the K/V (see `_write`), then dispatches the paged read over its own
407+
scattered pool, reading each past token through its slot and never rebuilding
408+
the contiguous buffer. `gqa_attention` calls this and takes the attention
409+
output straight back.
410+
411+
Since Day 25 the read goes through `paged_attention_dispatch`, the Day-23
412+
entry point that asks `select_backend` which read the query's device can run:
413+
the Triton kernel on a CUDA tensor, the Day-22 tlsim model on the CPU. The
414+
engine gets the kernel on a card and the correct-and-slow model on a laptop
415+
without the cache knowing which, and `paged_attention_reference` steps back to
416+
being the oracle both backends are graded against, not the path either runs.
408417
409418
k, v: [1, num_kv_heads, new_seq, head_dim] this step's compact K/V.
410419
q: [1, num_attention_heads, new_seq, head_dim] this step's rotated query.
411420
n_rep: GQA repeat factor (`config.num_kv_groups`).
412421
scale: softmax scale; defaults to `head_dim ** -0.5`, matching `gqa_attention`.
413422
414423
Returns [1, num_attention_heads, new_seq, head_dim], the attention output
415-
before o_proj, byte-identical to a contiguous SDPA over the same K/V. The
416-
pool and table are left exactly as `append` would leave them; only the
417-
return value differs.
424+
before o_proj. It matches a contiguous SDPA over the same K/V to a few ulps
425+
rather than bit for bit: both backends stream the online softmax, which
426+
reassociates the exponent sums, the accuracy trade every flash-attention
427+
kernel makes. The pool and table are left exactly as `append` would leave
428+
them; only the return value differs.
418429
"""
419430
self._write(layer, k, v)
420431
slot_mapping = self._slots_for(range(self.table.num_tokens), q.device)
421-
return paged_attention_reference(
432+
return paged_attention_dispatch(
422433
q, self.k_pool[layer], self.v_pool[layer], slot_mapping, n_rep, scale
423434
)
424435

0 commit comments

Comments
 (0)