Skip to content

Commit d2ec768

Browse files
physicsrobclaude
andcommitted
Static-cache ONNX decode contract: cache_position + arange_S mask + ScatterND
Replaces the past_len/Concat dynamic-cache wire contract with the vanilla static-cache pattern (HF StaticCache / vLLM), making the n_new=1 decode step CUDA-graph capturable (plan_cuda_graph_decode.md, steps 1-3): - _emit_cached_preamble: the CPU Shape/Gather/Range/Greater/Slice/Add control plane (the source of the 3 capture-blocking Memcpy nodes) is gone. New graph input cache_position (int64, (n_new,)) drives both the causal mask (Greater against a BAKED arange_S initializer - a constant, so no shape op for any n_new) and the positional encoding (Gather) - all in-graph, on GPU. - _emit_cached_layer_nodes: Concat(past, new) -> ScatterND(past, cache_position, delta). past_K_i/past_V_i are the FULL static (S, nh, d_head) cache (S = new cache_stride param, default max_seq_len, asserted <= max_seq_len); zero-filled slots beyond the committed length get softmax weight exactly 0.0 via the -1e6 sentinel. Equivalence to the old graph is TOKEN-level, not float-bit-level (cuBLAS reselects kernels at width S). - Feeders reworked (static [S,...] declarations REJECT zero-row/short feeds): OnnxHeadlessModule gets an OnnxPast dataclass (full-S zero buffers + explicit committed length; slot writes, no concat-grow); repl.py generate() writes deltas into absolute slots. Both fail loudly on pre-static-cache models. - Fix pre-existing exporter KeyError on Assert-wrapped output nodes (compare/select): the residual assignment only carries the wrapped node; unwrap like compile_headless does. Was breaking test_headless_module/test_module locally since the cond_gate/select sizing change. - Tests: static-S feed helpers; the old mask-follows-bound-shape test asserted the exact INVERSE invariant of the static cache and is replaced by test_headless_onnx_static_tail_is_inert (garbage in masked slots must not change output). The (unused) trimmed-cache sliding- window affordance is retired with the docstrings that advertised it. - Pin onnxruntime-gpu==1.26.0 (capture mechanics are version-specific). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87a4a87 commit d2ec768

6 files changed

Lines changed: 501 additions & 370 deletions

File tree

pyproject.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@ dependencies = [
1111
]
1212

1313
[project.optional-dependencies]
14-
gpu = ["onnxruntime-gpu"]
14+
# Pinned: the CUDA-graph capture path (torchwright_doom render runtime)
15+
# depends on 1.26.0 mechanics — per-annotation-id run counters,
16+
# gpu_graph_id "-1" skip semantics, capture-on-3rd-run. (1.26.0 needs
17+
# Python >=3.11; the <3.11 branch exists only to keep universal resolution
18+
# alive for torchwright's requires-python floor.)
19+
gpu = [
20+
"onnxruntime-gpu==1.26.0; python_version >= '3.11'",
21+
"onnxruntime-gpu; python_version < '3.11'",
22+
]
1523

1624
[dependency-groups]
1725
dev = [

tests/compile/forward/test_headless_onnx.py

Lines changed: 122 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,45 @@
2828
D_HEAD = 16
2929

3030

31-
def _empty_past_feeds(per_layer_n_heads: list, d_head: int) -> dict:
32-
feeds = {"past_len": np.array(0, dtype=np.int64)}
33-
for i, nh in enumerate(per_layer_n_heads):
34-
feeds[f"past_K_{i}"] = np.zeros((0, nh, d_head), dtype=np.float32)
35-
feeds[f"past_V_{i}"] = np.zeros((0, nh, d_head), dtype=np.float32)
36-
return feeds
37-
38-
3931
def _discover_meta(session):
40-
# past_K_i is sequence-major (n_past, n_heads, d_head): heads on axis 1.
32+
# past_K_i is sequence-major with a STATIC slot count: (S, n_heads, d_head).
4133
inputs = {inp.name: inp for inp in session.get_inputs()}
4234
n_layers = sum(1 for name in inputs if name.startswith("past_K_"))
4335
per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[1]) for i in range(n_layers)]
4436
d_head = int(inputs["past_K_0"].shape[2])
45-
return n_layers, per_layer_n_heads, d_head
37+
cache_stride = inputs["past_K_0"].shape[0]
38+
assert isinstance(cache_stride, int), (
39+
f"past_K_0 first dim must be static, got {cache_stride!r}"
40+
)
41+
return n_layers, per_layer_n_heads, d_head, cache_stride
42+
43+
44+
def _zero_past(per_layer_n_heads: list, d_head: int, S: int):
45+
"""Full static-S zero-filled cache buffers, one (k, v) pair per layer."""
46+
k = [np.zeros((S, nh, d_head), dtype=np.float32) for nh in per_layer_n_heads]
47+
v = [np.zeros((S, nh, d_head), dtype=np.float32) for nh in per_layer_n_heads]
48+
return k, v
49+
50+
51+
def _feeds(inputs_np, past_k, past_v, base: int) -> dict:
52+
"""Static-cache feeds: full-S past buffers + cache_position for the rows."""
53+
n_new = int(inputs_np.shape[0])
54+
feeds = {
55+
"inputs": inputs_np,
56+
"cache_position": np.arange(base, base + n_new, dtype=np.int64),
57+
}
58+
for i, (k, v) in enumerate(zip(past_k, past_v)):
59+
feeds[f"past_K_{i}"] = k
60+
feeds[f"past_V_{i}"] = v
61+
return feeds
62+
63+
64+
def _write_deltas(past_k, past_v, results, base: int, n_new: int):
65+
"""Persist a run's delta outputs into the cache slots [base : base+n_new)."""
66+
n_layers = len(past_k)
67+
for i in range(n_layers):
68+
past_k[i][base : base + n_new] = results[1 + 2 * i]
69+
past_v[i][base : base + n_new] = results[1 + 2 * i + 1]
4670

4771

4872
def _build_sample_graph():
@@ -120,12 +144,11 @@ def test_headless_onnx_prefill_matches_compute():
120144
with tempfile.TemporaryDirectory() as tmpdir:
121145
onnx_path = _export(out, pos, tmpdir)
122146
session = onnxruntime.InferenceSession(onnx_path)
123-
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
147+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
124148

125149
inputs_np = torch.cat([a_vals, b_vals], dim=1).numpy().astype(np.float32)
126-
feeds = {"inputs": inputs_np}
127-
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
128-
onnx_out = session.run(["outputs"], feeds)[0]
150+
past_k, past_v = _zero_past(per_layer_n_heads, d_head, S)
151+
onnx_out = session.run(["outputs"], _feeds(inputs_np, past_k, past_v, 0))[0]
129152

130153
assert np.allclose(
131154
onnx_out, expected, atol=1e-3
@@ -150,32 +173,26 @@ def test_headless_onnx_chunked_decode_matches_full_prefill():
150173
with tempfile.TemporaryDirectory() as tmpdir:
151174
onnx_path = _export(out, pos, tmpdir)
152175
session = onnxruntime.InferenceSession(onnx_path)
153-
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
176+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
154177
out_names = ["outputs"]
155178
for i in range(n_layers):
156179
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
157180

158181
# Full prefill (ground truth)
159-
feeds = {"inputs": inputs_np}
160-
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
161-
full_outputs = session.run(["outputs"], feeds)[0]
162-
163-
# Prefill 2 rows
164-
feeds = {"inputs": inputs_np[:2]}
165-
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
166-
results = session.run(out_names, feeds)
167-
past_K = [results[1 + 2 * i] for i in range(n_layers)]
168-
past_V = [results[1 + 2 * i + 1] for i in range(n_layers)]
169-
170-
# Decode a chunk of 3 rows (past_len=2, n_new=3)
171-
feeds = {
172-
"inputs": inputs_np[2:5],
173-
"past_len": np.array(2, dtype=np.int64),
174-
}
175-
for i in range(n_layers):
176-
feeds[f"past_K_{i}"] = past_K[i]
177-
feeds[f"past_V_{i}"] = past_V[i]
178-
chunk_out = session.run(["outputs"], feeds)[0]
182+
pk_full, pv_full = _zero_past(per_layer_n_heads, d_head, S)
183+
full_outputs = session.run(
184+
["outputs"], _feeds(inputs_np, pk_full, pv_full, 0)
185+
)[0]
186+
187+
# Prefill 2 rows, persisting the deltas into slots [0:2)
188+
past_k, past_v = _zero_past(per_layer_n_heads, d_head, S)
189+
results = session.run(out_names, _feeds(inputs_np[:2], past_k, past_v, 0))
190+
_write_deltas(past_k, past_v, results, 0, 2)
191+
192+
# Decode a chunk of 3 rows (base=2, n_new=3)
193+
chunk_out = session.run(
194+
["outputs"], _feeds(inputs_np[2:5], past_k, past_v, 2)
195+
)[0]
179196

180197
assert np.allclose(full_outputs[2:5], chunk_out, atol=1e-3), (
181198
f"chunked decode diff: " f"{np.abs(full_outputs[2:5] - chunk_out).max():.6f}"
@@ -191,47 +208,43 @@ def test_headless_onnx_decode_step_matches_full_prefill():
191208
with tempfile.TemporaryDirectory() as tmpdir:
192209
onnx_path = _export(out, pos, tmpdir)
193210
session = onnxruntime.InferenceSession(onnx_path)
194-
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
211+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
195212
out_names = ["outputs"]
196213
for i in range(n_layers):
197214
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
198215

199216
# Full prefill
200-
feeds = {"inputs": inputs_np}
201-
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
202-
full_outputs = session.run(["outputs"], feeds)[0]
217+
pk_full, pv_full = _zero_past(per_layer_n_heads, d_head, S)
218+
full_outputs = session.run(
219+
["outputs"], _feeds(inputs_np, pk_full, pv_full, 0)
220+
)[0]
203221

204222
# Prefill 4 rows + decode 1 row
205-
feeds = {"inputs": inputs_np[:4]}
206-
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
207-
results = session.run(out_names, feeds)
208-
past_K = [results[1 + 2 * i] for i in range(n_layers)]
209-
past_V = [results[1 + 2 * i + 1] for i in range(n_layers)]
210-
211-
feeds = {
212-
"inputs": inputs_np[4:5],
213-
"past_len": np.array(4, dtype=np.int64),
214-
}
215-
for i in range(n_layers):
216-
feeds[f"past_K_{i}"] = past_K[i]
217-
feeds[f"past_V_{i}"] = past_V[i]
218-
decode_out = session.run(["outputs"], feeds)[0]
223+
past_k, past_v = _zero_past(per_layer_n_heads, d_head, S)
224+
results = session.run(out_names, _feeds(inputs_np[:4], past_k, past_v, 0))
225+
_write_deltas(past_k, past_v, results, 0, 4)
226+
227+
decode_out = session.run(
228+
["outputs"], _feeds(inputs_np[4:5], past_k, past_v, 4)
229+
)[0]
219230

220231
assert np.allclose(
221232
full_outputs[-1], decode_out[0], atol=1e-3
222233
), f"decode seam diff: {np.abs(full_outputs[-1] - decode_out[0]).max():.6f}"
223234

224235

225236
# ---------------------------------------------------------------------------
226-
# Test 2b: the causal mask follows the BOUND past_K shape, not past_len.
227-
# This is the load-bearing invariant of the in-place (delta) cache: the
228-
# runtime binds past_K = cache[:cache_len] (a contiguous prefix of a larger
229-
# owned buffer), so the mask geometry must come from the bound seq-dim, and
230-
# binding the whole allocation would attend to the dead tail.
237+
# Test 2b: slots at positions > cache_position are INERT.
238+
# This is the load-bearing invariant of the static cache (the INVERSE of the
239+
# old dynamic-cache test that lived here): the runtime always binds the full
240+
# (S, nh, d_head) allocation, and the in-graph mask must give the
241+
# not-yet-committed tail slots softmax weight exactly 0.0 — finite garbage
242+
# in those slots cannot change the output. (Zero-init remains required in
243+
# production because 0 * NaN = NaN; this test uses finite garbage.)
231244
# ---------------------------------------------------------------------------
232245

233246

234-
def test_headless_onnx_mask_follows_bound_cache_shape():
247+
def test_headless_onnx_static_tail_is_inert():
235248
out, pos = _build_sample_graph()
236249
a_vals = torch.tensor([[3.0], [5.0], [-2.0], [0.0], [4.0]])
237250
b_vals = torch.tensor([[4.0], [-1.0], [3.0], [7.0], [2.0]])
@@ -241,62 +254,43 @@ def test_headless_onnx_mask_follows_bound_cache_shape():
241254
with tempfile.TemporaryDirectory() as tmpdir:
242255
onnx_path = _export(out, pos, tmpdir)
243256
session = onnxruntime.InferenceSession(onnx_path)
244-
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
257+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
245258
out_names = ["outputs"]
246259
for i in range(n_layers):
247260
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
248261

249-
feeds = {"inputs": inputs_np}
250-
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
251-
full_outputs = session.run(["outputs"], feeds)[0]
252-
253-
# Prefill n rows from empty: the deltas ARE the n-row cache (seq-major).
254-
feeds = {"inputs": inputs_np[:n]}
255-
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
256-
results = session.run(out_names, feeds)
257-
cache_k = [results[1 + 2 * i] for i in range(n_layers)]
258-
cache_v = [results[1 + 2 * i + 1] for i in range(n_layers)]
259-
260-
def decode(past_k, past_v, past_len):
261-
feeds = {
262-
"inputs": inputs_np[n : n + 1],
263-
"past_len": np.array(past_len, dtype=np.int64),
264-
}
265-
for i in range(n_layers):
266-
feeds[f"past_K_{i}"] = past_k[i]
267-
feeds[f"past_V_{i}"] = past_v[i]
268-
return session.run(["outputs"], feeds)[0]
269-
270-
# (1) Exact n-row past reproduces row n of the full prefill.
271-
exact = decode(cache_k, cache_v, n)
262+
pk_full, pv_full = _zero_past(per_layer_n_heads, d_head, S)
263+
full_outputs = session.run(
264+
["outputs"], _feeds(inputs_np, pk_full, pv_full, 0)
265+
)[0]
266+
267+
# Prefill n rows into a zero cache.
268+
past_k, past_v = _zero_past(per_layer_n_heads, d_head, S)
269+
results = session.run(out_names, _feeds(inputs_np[:n], past_k, past_v, 0))
270+
_write_deltas(past_k, past_v, results, 0, n)
271+
272+
def decode(pk, pv):
273+
return session.run(["outputs"], _feeds(inputs_np[n : n + 1], pk, pv, n))[0]
274+
275+
# (1) Zero-tail cache reproduces row n of the full prefill (the
276+
# prefill/decode seam under the static mask).
277+
exact = decode(past_k, past_v)
272278
assert np.allclose(full_outputs[n], exact[0], atol=1e-3)
273279

274-
# Allocate a larger buffer; [:n] is the real cache, the tail is garbage.
275-
m = n + 3
276-
big_k, big_v = [], []
280+
# (2) Fill every slot at positions > n with finite garbage. Slot n
281+
# itself is overwritten by the in-graph ScatterND (the decode row's
282+
# own key — the causal diagonal), so garbage there is inert too.
283+
garbage_k = [pk.copy() for pk in past_k]
284+
garbage_v = [pv.copy() for pv in past_v]
277285
for i in range(n_layers):
278-
nh = per_layer_n_heads[i]
279-
bk = np.full((m, nh, d_head), 7.0, dtype=np.float32)
280-
bv = np.full((m, nh, d_head), -3.0, dtype=np.float32)
281-
bk[:n] = cache_k[i]
282-
bv[:n] = cache_v[i]
283-
big_k.append(bk)
284-
big_v.append(bv)
285-
286-
# (1') Binding the contiguous prefix VIEW big[:n] is bit-equal — the
287-
# owned-cache in-place pattern is sound.
288-
sliced = decode([bk[:n] for bk in big_k], [bv[:n] for bv in big_v], n)
289-
assert np.allclose(
290-
exact[0], sliced[0], atol=1e-6
291-
), f"prefix-slice bind diverged: {np.abs(exact[0] - sliced[0]).max():.6e}"
292-
293-
# (2) Binding the WHOLE buffer (past_len=n) attends to the dead tail —
294-
# the mask spans m, not n — so it must differ. This is why the runtime
295-
# binds cache[:cache_len], never the full allocation.
296-
full_buf = decode(big_k, big_v, n)
297-
assert not np.allclose(exact[0], full_buf[0], atol=1e-3), (
298-
"binding the full allocation matched the prefix bind — the mask is "
299-
"not following the bound past_K shape"
286+
garbage_k[i][n:] = 7.0
287+
garbage_v[i][n:] = -3.0
288+
289+
dirty = decode(garbage_k, garbage_v)
290+
assert np.allclose(exact[0], dirty[0], atol=1e-6), (
291+
"garbage in masked static-cache slots changed the output "
292+
f"(max diff {np.abs(exact[0] - dirty[0]).max():.6e}) — the mask is "
293+
"not zeroing the tail weights exactly"
300294
)
301295

302296

@@ -345,13 +339,18 @@ def test_onnx_headless_module_empty_past_shape():
345339
with tempfile.TemporaryDirectory() as tmpdir:
346340
onnx_path = _export(out, pos, tmpdir)
347341
module = OnnxHeadlessModule(onnx_path)
348-
past_K, past_V = module.empty_past()
349-
assert len(past_K) == module._n_layers
350-
assert len(past_V) == module._n_layers
351-
for i, K in enumerate(past_K):
352-
assert K.shape == (0, module._per_layer_n_heads[i], module._d_head)
353-
for i, V in enumerate(past_V):
354-
assert V.shape == (0, module._per_layer_n_heads[i], module._d_head)
342+
past = module.empty_past()
343+
S = module.cache_stride
344+
assert S == 32 # _export passes max_seq_len=32; cache_stride defaults to it
345+
assert past.length == 0
346+
assert len(past.k) == module._n_layers
347+
assert len(past.v) == module._n_layers
348+
for i, K in enumerate(past.k):
349+
assert K.shape == (S, module._per_layer_n_heads[i], module._d_head)
350+
assert (K == 0).all(), "static cache must be zero-initialized"
351+
for i, V in enumerate(past.v):
352+
assert V.shape == (S, module._per_layer_n_heads[i], module._d_head)
353+
assert (V == 0).all(), "static cache must be zero-initialized"
355354

356355

357356
# ---------------------------------------------------------------------------
@@ -451,7 +450,7 @@ def test_headless_onnx_trim_heads_shrinks_kv_cache():
451450
with tempfile.TemporaryDirectory() as tmpdir:
452451
onnx_path = _export(out, pos, tmpdir, trim_heads=True)
453452
session = onnxruntime.InferenceSession(onnx_path)
454-
_, per_layer_n_heads, _ = _discover_meta(session)
453+
_, per_layer_n_heads, _, _ = _discover_meta(session)
455454

456455
assert per_layer_n_heads, "no layers discovered"
457456
assert all(1 <= nh <= max_heads for nh in per_layer_n_heads)
@@ -473,7 +472,7 @@ def test_headless_onnx_no_trim_preserves_full_width():
473472
with tempfile.TemporaryDirectory() as tmpdir:
474473
onnx_path = _export(out, pos, tmpdir, trim_heads=False)
475474
session = onnxruntime.InferenceSession(onnx_path)
476-
_, per_layer_n_heads, _ = _discover_meta(session)
475+
_, per_layer_n_heads, _, _ = _discover_meta(session)
477476

478477
assert per_layer_n_heads, "no layers discovered"
479478
assert all(nh == max_heads for nh in per_layer_n_heads), (
@@ -519,16 +518,14 @@ def test_headless_onnx_trim_is_numerical_noop():
519518
sess_trim = onnxruntime.InferenceSession(trim_path)
520519
sess_notrim = onnxruntime.InferenceSession(notrim_path)
521520

522-
_, heads_trim, d_head = _discover_meta(sess_trim)
523-
_, heads_notrim, _ = _discover_meta(sess_notrim)
521+
_, heads_trim, d_head, S_trim = _discover_meta(sess_trim)
522+
_, heads_notrim, _, S_notrim = _discover_meta(sess_notrim)
524523

525-
feeds_trim = {"inputs": inputs_np}
526-
feeds_trim.update(_empty_past_feeds(heads_trim, d_head))
527-
out_trim = sess_trim.run(["outputs"], feeds_trim)[0]
524+
pk, pv = _zero_past(heads_trim, d_head, S_trim)
525+
out_trim = sess_trim.run(["outputs"], _feeds(inputs_np, pk, pv, 0))[0]
528526

529-
feeds_notrim = {"inputs": inputs_np}
530-
feeds_notrim.update(_empty_past_feeds(heads_notrim, d_head))
531-
out_notrim = sess_notrim.run(["outputs"], feeds_notrim)[0]
527+
pk, pv = _zero_past(heads_notrim, d_head, S_notrim)
528+
out_notrim = sess_notrim.run(["outputs"], _feeds(inputs_np, pk, pv, 0))[0]
532529

533530
assert np.allclose(out_trim, out_notrim, atol=1e-4), (
534531
f"trim changed the output: max diff "

0 commit comments

Comments
 (0)