Skip to content

Commit 865ddc5

Browse files
physicsrobclaude
andcommitted
WIP snapshot: ONNX seq-major delta KV-cache export
Sequence-major delta KV-cache export + headless runtime. Under debug: the doom artifact compiled through this path emits a wrong seed on the CUDA EP (correct in-process and on CPU headless tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8c50411 commit 865ddc5

5 files changed

Lines changed: 215 additions & 91 deletions

File tree

tests/compile/forward/test_headless_onnx.py

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,16 @@
3131
def _empty_past_feeds(per_layer_n_heads: list, d_head: int) -> dict:
3232
feeds = {"past_len": np.array(0, dtype=np.int64)}
3333
for i, nh in enumerate(per_layer_n_heads):
34-
feeds[f"past_K_{i}"] = np.zeros((nh, 0, d_head), dtype=np.float32)
35-
feeds[f"past_V_{i}"] = np.zeros((nh, 0, d_head), dtype=np.float32)
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)
3636
return feeds
3737

3838

3939
def _discover_meta(session):
40+
# past_K_i is sequence-major (n_past, n_heads, d_head): heads on axis 1.
4041
inputs = {inp.name: inp for inp in session.get_inputs()}
4142
n_layers = sum(1 for name in inputs if name.startswith("past_K_"))
42-
per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[0]) for i in range(n_layers)]
43+
per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[1]) for i in range(n_layers)]
4344
d_head = int(inputs["past_K_0"].shape[2])
4445
return n_layers, per_layer_n_heads, d_head
4546

@@ -123,7 +124,7 @@ def test_headless_onnx_chunked_decode_matches_full_prefill():
123124
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
124125
out_names = ["outputs"]
125126
for i in range(n_layers):
126-
out_names += [f"new_K_{i}", f"new_V_{i}"]
127+
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
127128

128129
# Full prefill (ground truth)
129130
feeds = {"inputs": inputs_np}
@@ -164,7 +165,7 @@ def test_headless_onnx_decode_step_matches_full_prefill():
164165
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
165166
out_names = ["outputs"]
166167
for i in range(n_layers):
167-
out_names += [f"new_K_{i}", f"new_V_{i}"]
168+
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
168169

169170
# Full prefill
170171
feeds = {"inputs": inputs_np}
@@ -192,6 +193,84 @@ def test_headless_onnx_decode_step_matches_full_prefill():
192193
), f"decode seam diff: {np.abs(full_outputs[-1] - decode_out[0]).max():.6f}"
193194

194195

196+
# ---------------------------------------------------------------------------
197+
# Test 2b: the causal mask follows the BOUND past_K shape, not past_len.
198+
# This is the load-bearing invariant of the in-place (delta) cache: the
199+
# runtime binds past_K = cache[:cache_len] (a contiguous prefix of a larger
200+
# owned buffer), so the mask geometry must come from the bound seq-dim, and
201+
# binding the whole allocation would attend to the dead tail.
202+
# ---------------------------------------------------------------------------
203+
204+
205+
def test_headless_onnx_mask_follows_bound_cache_shape():
206+
out, pos = _build_sample_graph()
207+
a_vals = torch.tensor([[3.0], [5.0], [-2.0], [0.0], [4.0]])
208+
b_vals = torch.tensor([[4.0], [-1.0], [3.0], [7.0], [2.0]])
209+
inputs_np = torch.cat([a_vals, b_vals], dim=1).numpy().astype(np.float32)
210+
n = 4 # rows committed to the cache before the decode step
211+
212+
with tempfile.TemporaryDirectory() as tmpdir:
213+
onnx_path = _export(out, pos, tmpdir)
214+
session = onnxruntime.InferenceSession(onnx_path)
215+
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
216+
out_names = ["outputs"]
217+
for i in range(n_layers):
218+
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
219+
220+
feeds = {"inputs": inputs_np}
221+
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
222+
full_outputs = session.run(["outputs"], feeds)[0]
223+
224+
# Prefill n rows from empty: the deltas ARE the n-row cache (seq-major).
225+
feeds = {"inputs": inputs_np[:n]}
226+
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))
227+
results = session.run(out_names, feeds)
228+
cache_k = [results[1 + 2 * i] for i in range(n_layers)]
229+
cache_v = [results[1 + 2 * i + 1] for i in range(n_layers)]
230+
231+
def decode(past_k, past_v, past_len):
232+
feeds = {
233+
"inputs": inputs_np[n : n + 1],
234+
"past_len": np.array(past_len, dtype=np.int64),
235+
}
236+
for i in range(n_layers):
237+
feeds[f"past_K_{i}"] = past_k[i]
238+
feeds[f"past_V_{i}"] = past_v[i]
239+
return session.run(["outputs"], feeds)[0]
240+
241+
# (1) Exact n-row past reproduces row n of the full prefill.
242+
exact = decode(cache_k, cache_v, n)
243+
assert np.allclose(full_outputs[n], exact[0], atol=1e-3)
244+
245+
# Allocate a larger buffer; [:n] is the real cache, the tail is garbage.
246+
m = n + 3
247+
big_k, big_v = [], []
248+
for i in range(n_layers):
249+
nh = per_layer_n_heads[i]
250+
bk = np.full((m, nh, d_head), 7.0, dtype=np.float32)
251+
bv = np.full((m, nh, d_head), -3.0, dtype=np.float32)
252+
bk[:n] = cache_k[i]
253+
bv[:n] = cache_v[i]
254+
big_k.append(bk)
255+
big_v.append(bv)
256+
257+
# (1') Binding the contiguous prefix VIEW big[:n] is bit-equal — the
258+
# owned-cache in-place pattern is sound.
259+
sliced = decode([bk[:n] for bk in big_k], [bv[:n] for bv in big_v], n)
260+
assert np.allclose(
261+
exact[0], sliced[0], atol=1e-6
262+
), f"prefix-slice bind diverged: {np.abs(exact[0] - sliced[0]).max():.6e}"
263+
264+
# (2) Binding the WHOLE buffer (past_len=n) attends to the dead tail —
265+
# the mask spans m, not n — so it must differ. This is why the runtime
266+
# binds cache[:cache_len], never the full allocation.
267+
full_buf = decode(big_k, big_v, n)
268+
assert not np.allclose(exact[0], full_buf[0], atol=1e-3), (
269+
"binding the full allocation matched the prefix bind — the mask is "
270+
"not following the bound past_K shape"
271+
)
272+
273+
195274
# ---------------------------------------------------------------------------
196275
# Test 3: OnnxHeadlessModule.step API threads the cache correctly
197276
# ---------------------------------------------------------------------------
@@ -241,9 +320,9 @@ def test_onnx_headless_module_empty_past_shape():
241320
assert len(past_K) == module._n_layers
242321
assert len(past_V) == module._n_layers
243322
for i, K in enumerate(past_K):
244-
assert K.shape == (module._per_layer_n_heads[i], 0, module._d_head)
323+
assert K.shape == (0, module._per_layer_n_heads[i], module._d_head)
245324
for i, V in enumerate(past_V):
246-
assert V.shape == (module._per_layer_n_heads[i], 0, module._d_head)
325+
assert V.shape == (0, module._per_layer_n_heads[i], module._d_head)
247326

248327

249328
# ---------------------------------------------------------------------------

tests/compile/forward/test_module.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,16 @@ def _build_1digit():
4343
def _empty_past_feeds(per_layer_n_heads: list, d_head: int) -> dict:
4444
feeds = {"past_len": np.array(0, dtype=np.int64)}
4545
for i, nh in enumerate(per_layer_n_heads):
46-
feeds[f"past_K_{i}"] = np.zeros((nh, 0, d_head), dtype=np.float32)
47-
feeds[f"past_V_{i}"] = np.zeros((nh, 0, d_head), dtype=np.float32)
46+
feeds[f"past_K_{i}"] = np.zeros((0, nh, d_head), dtype=np.float32)
47+
feeds[f"past_V_{i}"] = np.zeros((0, nh, d_head), dtype=np.float32)
4848
return feeds
4949

5050

5151
def _discover_meta(session):
52+
# past_K_i is sequence-major (n_past, n_heads, d_head): heads on axis 1.
5253
inputs = {inp.name: inp for inp in session.get_inputs()}
5354
n_layers = sum(1 for name in inputs if name.startswith("past_K_"))
54-
per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[0]) for i in range(n_layers)]
55+
per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[1]) for i in range(n_layers)]
5556
d_head = int(inputs["past_K_0"].shape[2])
5657
return n_layers, per_layer_n_heads, d_head
5758

@@ -146,7 +147,7 @@ def test_token_onnx_decode_step_matches_full_prefill():
146147
n_layers, per_layer_n_heads, d_head = _discover_meta(session)
147148
out_names = ["logits"]
148149
for i in range(n_layers):
149-
out_names += [f"new_K_{i}", f"new_V_{i}"]
150+
out_names += [f"delta_K_{i}", f"delta_V_{i}"]
150151

151152
feeds = {"token_ids": token_ids}
152153
feeds.update(_empty_past_feeds(per_layer_n_heads, d_head))

0 commit comments

Comments
 (0)