Skip to content

Commit d310d45

Browse files
physicsrobclaude
andcommitted
Stride bucketing: symbolic cache_slots dim + Shape-derived mask width
The cached-graph contract's past_K_i/past_V_i first dim becomes the symbolic "cache_slots" (the bound prefix length S_eff) instead of the baked cache_stride, so one exported graph serves any prefix window cache[:S_eff] of the runtime's full-stride buffer — the stride-bucketing affordance (one captured CUDA graph per (S_eff, width) bucket; see plan_stride_bucketing.md). The preamble derives the mask width in-graph via Shape(past_K_0) -> shape-Slice -> Slice(arange_S): the shape chain is CPU-resident with CPU-native consumers, so NO Memcpy is introduced (verified at adder scale, ORT 1.26: "MemcpyTransformer modified: 0" + capture enabled; the real capture invariant is no-Memcpy, not no-CPU- node — docstrings updated). arange_S stays baked at the full stride; binding the full buffer reproduces the old static-dim behavior exactly. S now travels in the sidecar meta (top-level "cache_stride" in both exporters); loaders get a shared dual-path discover_cache_stride() (static int dim = old artifact, symbolic = read sidecar, past_len input = ancient pre-static-cache error). New test pins prefix-window/full-S bit-equality on CPU; _discover_meta helpers updated to the new contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1a2d5f1 commit d310d45

5 files changed

Lines changed: 282 additions & 98 deletions

File tree

tests/compile/forward/test_headless_onnx.py

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,23 @@
2828
D_HEAD = 16
2929

3030

31-
def _discover_meta(session):
32-
# past_K_i is sequence-major with a STATIC slot count: (S, n_heads, d_head).
31+
def _discover_meta(session, onnx_path):
32+
# past_K_i is sequence-major (cache_slots, n_heads, d_head) with a
33+
# SYMBOLIC slot dim — the bound prefix length S_eff (stride bucketing).
34+
# The full stride S comes from the sidecar meta.
35+
from torchwright.compiler.onnx_load import discover_cache_stride
36+
3337
inputs = {inp.name: inp for inp in session.get_inputs()}
3438
n_layers = sum(1 for name in inputs if name.startswith("past_K_"))
3539
per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[1]) for i in range(n_layers)]
3640
d_head = int(inputs["past_K_0"].shape[2])
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}"
41+
slot_dim = inputs["past_K_0"].shape[0]
42+
assert not isinstance(slot_dim, int), (
43+
f"past_K_0 first dim must be the symbolic cache_slots, got {slot_dim!r}"
4044
)
45+
with open(meta_path_for(onnx_path)) as f:
46+
sidecar = json.load(f)
47+
cache_stride = discover_cache_stride(inputs, sidecar.get("cache_stride"), onnx_path)
4148
return n_layers, per_layer_n_heads, d_head, cache_stride
4249

4350

@@ -144,7 +151,7 @@ def test_headless_onnx_prefill_matches_compute():
144151
with tempfile.TemporaryDirectory() as tmpdir:
145152
onnx_path = _export(out, pos, tmpdir)
146153
session = onnxruntime.InferenceSession(onnx_path)
147-
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
154+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session, onnx_path)
148155

149156
inputs_np = torch.cat([a_vals, b_vals], dim=1).numpy().astype(np.float32)
150157
past_k, past_v = _zero_past(per_layer_n_heads, d_head, S)
@@ -173,7 +180,7 @@ def test_headless_onnx_chunked_decode_matches_full_prefill():
173180
with tempfile.TemporaryDirectory() as tmpdir:
174181
onnx_path = _export(out, pos, tmpdir)
175182
session = onnxruntime.InferenceSession(onnx_path)
176-
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
183+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session, onnx_path)
177184
out_names = ["outputs"]
178185
for i in range(n_layers):
179186
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
@@ -208,7 +215,7 @@ def test_headless_onnx_decode_step_matches_full_prefill():
208215
with tempfile.TemporaryDirectory() as tmpdir:
209216
onnx_path = _export(out, pos, tmpdir)
210217
session = onnxruntime.InferenceSession(onnx_path)
211-
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
218+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session, onnx_path)
212219
out_names = ["outputs"]
213220
for i in range(n_layers):
214221
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
@@ -254,7 +261,7 @@ def test_headless_onnx_static_tail_is_inert():
254261
with tempfile.TemporaryDirectory() as tmpdir:
255262
onnx_path = _export(out, pos, tmpdir)
256263
session = onnxruntime.InferenceSession(onnx_path)
257-
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
264+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session, onnx_path)
258265
out_names = ["outputs"]
259266
for i in range(n_layers):
260267
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
@@ -294,6 +301,72 @@ def decode(pk, pv):
294301
)
295302

296303

304+
# ---------------------------------------------------------------------------
305+
# Test 2c: prefix-window (stride-bucket) bindings are equivalent.
306+
# The symbolic cache_slots dim lets a feeder bind any prefix cache[:S_eff]
307+
# with base + n_new <= S_eff <= S; the in-graph mask derives its width from
308+
# Shape(past_K_0), so every covering window must produce the same outputs.
309+
# On CPU with the same n_new the kernels are identical, so the comparison
310+
# is bit-level, not just allclose.
311+
# ---------------------------------------------------------------------------
312+
313+
314+
def test_headless_onnx_prefix_window_binding():
315+
out, pos = _build_sample_graph()
316+
a_vals = torch.tensor([[3.0], [5.0], [-2.0], [0.0], [4.0]])
317+
b_vals = torch.tensor([[4.0], [-1.0], [3.0], [7.0], [2.0]])
318+
inputs_np = torch.cat([a_vals, b_vals], dim=1).numpy().astype(np.float32)
319+
n = 4 # committed rows before the decode step
320+
321+
with tempfile.TemporaryDirectory() as tmpdir:
322+
onnx_path = _export(out, pos, tmpdir)
323+
session = onnxruntime.InferenceSession(onnx_path)
324+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session, onnx_path)
325+
out_names = ["outputs"]
326+
for i in range(n_layers):
327+
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
328+
329+
# Prefill n rows into the full-S cache.
330+
past_k, past_v = _zero_past(per_layer_n_heads, d_head, S)
331+
results = session.run(out_names, _feeds(inputs_np[:n], past_k, past_v, 0))
332+
_write_deltas(past_k, past_v, results, 0, n)
333+
334+
def decode_window(s_eff):
335+
pk = [k[:s_eff] for k in past_k] # contiguous prefix views
336+
pv = [v[:s_eff] for v in past_v]
337+
return session.run(
338+
out_names, _feeds(inputs_np[n : n + 1], pk, pv, n)
339+
)
340+
341+
# Full-S binding is the reference; every covering prefix window
342+
# (smallest legal = base + n_new = n + 1) must match bit-for-bit.
343+
ref = decode_window(S)
344+
for s_eff in (n + 1, n + 3, S - 1):
345+
got = decode_window(s_eff)
346+
for r, g, name in zip(ref, got, out_names):
347+
assert np.array_equal(r, g), (
348+
f"S_eff={s_eff}: {name} differs from the full-S binding "
349+
f"(max diff {np.abs(r - g).max():.6e})"
350+
)
351+
352+
# Prefill itself also rides a window: prefill at the smallest
353+
# covering bucket equals prefill at full S.
354+
pk_a, pv_a = _zero_past(per_layer_n_heads, d_head, S)
355+
full = session.run(out_names, _feeds(inputs_np, pk_a, pv_a, 0))
356+
pk_b, pv_b = _zero_past(per_layer_n_heads, d_head, S)
357+
win = session.run(
358+
out_names,
359+
_feeds(
360+
inputs_np,
361+
[k[: len(inputs_np)] for k in pk_b],
362+
[v[: len(inputs_np)] for v in pv_b],
363+
0,
364+
),
365+
)
366+
for r, g, name in zip(full, win, out_names):
367+
assert np.array_equal(r, g), f"prefill window: {name} differs"
368+
369+
297370
# ---------------------------------------------------------------------------
298371
# Test 3: OnnxHeadlessModule.step API threads the cache correctly
299372
# ---------------------------------------------------------------------------
@@ -373,6 +446,9 @@ def test_headless_onnx_sidecar_schema():
373446

374447
assert data["format"] == HEADLESS_META_FORMAT
375448
assert data["input_names"] == ["alpha", "middle", "zebra"]
449+
# The full stride S travels in the sidecar (the symbolic cache_slots
450+
# input dim is not readable as an int).
451+
assert data["cache_stride"] == 32 # _export passes max_seq_len=32
376452
# The sidecar should not carry any "cached" discriminator — there's
377453
# only one protocol now.
378454
assert "cached" not in data
@@ -450,7 +526,7 @@ def test_headless_onnx_trim_heads_shrinks_kv_cache():
450526
with tempfile.TemporaryDirectory() as tmpdir:
451527
onnx_path = _export(out, pos, tmpdir, trim_heads=True)
452528
session = onnxruntime.InferenceSession(onnx_path)
453-
_, per_layer_n_heads, _, _ = _discover_meta(session)
529+
_, per_layer_n_heads, _, _ = _discover_meta(session, onnx_path)
454530

455531
assert per_layer_n_heads, "no layers discovered"
456532
assert all(1 <= nh <= max_heads for nh in per_layer_n_heads)
@@ -472,7 +548,7 @@ def test_headless_onnx_no_trim_preserves_full_width():
472548
with tempfile.TemporaryDirectory() as tmpdir:
473549
onnx_path = _export(out, pos, tmpdir, trim_heads=False)
474550
session = onnxruntime.InferenceSession(onnx_path)
475-
_, per_layer_n_heads, _, _ = _discover_meta(session)
551+
_, per_layer_n_heads, _, _ = _discover_meta(session, onnx_path)
476552

477553
assert per_layer_n_heads, "no layers discovered"
478554
assert all(nh == max_heads for nh in per_layer_n_heads), (
@@ -518,8 +594,8 @@ def test_headless_onnx_trim_is_numerical_noop():
518594
sess_trim = onnxruntime.InferenceSession(trim_path)
519595
sess_notrim = onnxruntime.InferenceSession(notrim_path)
520596

521-
_, heads_trim, d_head, S_trim = _discover_meta(sess_trim)
522-
_, heads_notrim, _, S_notrim = _discover_meta(sess_notrim)
597+
_, heads_trim, d_head, S_trim = _discover_meta(sess_trim, trim_path)
598+
_, heads_notrim, _, S_notrim = _discover_meta(sess_notrim, notrim_path)
523599

524600
pk, pv = _zero_past(heads_trim, d_head, S_trim)
525601
out_trim = sess_trim.run(["outputs"], _feeds(inputs_np, pk, pv, 0))[0]

tests/compile/forward/test_module.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,26 @@ def _build_1digit():
4040
return output_node, pos_encoding, embedding
4141

4242

43-
def _discover_meta(session):
44-
# past_K_i is sequence-major with a STATIC slot count: (S, n_heads, d_head).
43+
def _discover_meta(session, onnx_path):
44+
# past_K_i is sequence-major (cache_slots, n_heads, d_head) with a
45+
# SYMBOLIC slot dim (stride bucketing); the full stride S comes from
46+
# the sidecar meta.
47+
import json
48+
49+
from torchwright.compiler.export import meta_path_for
50+
from torchwright.compiler.onnx_load import discover_cache_stride
51+
4552
inputs = {inp.name: inp for inp in session.get_inputs()}
4653
n_layers = sum(1 for name in inputs if name.startswith("past_K_"))
4754
per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[1]) for i in range(n_layers)]
4855
d_head = int(inputs["past_K_0"].shape[2])
49-
cache_stride = inputs["past_K_0"].shape[0]
50-
assert isinstance(
51-
cache_stride, int
52-
), f"past_K_0 first dim must be static, got {cache_stride!r}"
56+
slot_dim = inputs["past_K_0"].shape[0]
57+
assert not isinstance(
58+
slot_dim, int
59+
), f"past_K_0 first dim must be the symbolic cache_slots, got {slot_dim!r}"
60+
with open(meta_path_for(onnx_path)) as f:
61+
sidecar = json.load(f)
62+
cache_stride = discover_cache_stride(inputs, sidecar.get("cache_stride"), onnx_path)
5363
return n_layers, per_layer_n_heads, d_head, cache_stride
5464

5565

@@ -119,7 +129,7 @@ def test_token_onnx_prefill_matches_compute():
119129
ref_logits = _reference_logits(output_node, pos_encoding, embedding, tokens)
120130

121131
session = onnxruntime.InferenceSession(onnx_path)
122-
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
132+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session, onnx_path)
123133
token_ids = np.array(
124134
[embedding.tokenizer.get_token_id(t) for t in tokens],
125135
dtype=np.int64,
@@ -159,7 +169,7 @@ def test_token_onnx_decode_step_matches_full_prefill():
159169
)
160170

161171
session = onnxruntime.InferenceSession(onnx_path)
162-
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session)
172+
n_layers, per_layer_n_heads, d_head, S = _discover_meta(session, onnx_path)
163173
out_names = ["logits"]
164174
for i in range(n_layers):
165175
out_names += [f"delta_K_{i}", f"delta_V_{i}"]

0 commit comments

Comments
 (0)