Skip to content

Commit 6ef7a46

Browse files
physicsrobclaude
andcommitted
OnnxDebugSession: windowed-cache (cache_window=C) support via identity placement
The session debugs windowed artifacts under identity slot placement — slot j = position j, nothing ever evicted, hard cap at C committed rows. Identity placement satisfies the windowed exporter's host contract (committed slots filled in slot order, never wrapping), so the exporter's own equivalence guarantee makes debug output token-identical to the unbounded export's; the new tests pin that parity rather than assume it. - _feeds: windowed bindings are exactly C + n_new wide (the protocol requirement — the staging scatter indices are baked at [C, C+n_new)), committed rows at identity slots [0, base), zeros elsewhere (the never-written band is masked to weight exactly 0.0; staging is overwritten in-graph). Overrun raises naming the identity-placement cap and the way out (slices, or an unbounded export). - capture_attention: the one real remap. The wire key axis is C + n_new; the session splices committed [0, base) + staging [C, C+n_new) back into the unbounded base + n_new layout, so key index == absolute position on every backend and protocol and probe_attention/AttentionProbe need zero changes. The dropped never-written band is asserted to carry weight exactly 0.0 first — a free runtime check of the windowed writtenness mask (an emission bug there is exactly the class this backend exists to catch). - step / residual snapshots / self-consistency / asserts / debug_value / probe_compiled / probe_residual / probe_layer_diff: unchanged — snapshots are (n_new, d) row tensors; slot layout never touches them. - Host eviction policies stay out of scope by design: probing a post-eviction state needs the host's own slot contents (a possible later affordance), not a policy reimplementation in torchwright. Tests: test_windowed_cache_models_are_rejected (the NotImplementedError pin) is replaced by three windowed tests — probe-clean + one-shot and prefill/decode-seam parity against the unbounded export, attention probes translated at base=0 and base>0, and the loud cap error. Docs: module docstring + CLAUDE.md caveat updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6d6094d commit 6ef7a46

3 files changed

Lines changed: 226 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,14 @@ Requirements and caveats:
425425
put it on a hot path.
426426
- Fetching all snapshots costs `n_pos × d × 2·n_layers` floats per
427427
run — probe very long prefills in slices.
428-
- Windowed-cache models (`cache_window=C`) are not supported yet.
428+
- Windowed-cache models (`cache_window=C`) are debugged under
429+
identity slot placement (slot j = position j, nothing evicted),
430+
capped at `C` committed rows — that satisfies the exporter's host
431+
contract, so debug output is token-identical to the unbounded
432+
export's. Probe attention key indices are absolute positions on
433+
both protocols (the session splices out the windowed wire layout).
434+
Debug longer runs in slices, or export an unbounded variant; the
435+
session never reproduces a host eviction policy.
429436

430437
## probe_compiled — full oracle comparison
431438

tests/debug/test_onnx_debug_session.py

Lines changed: 146 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,22 +317,158 @@ def test_headless_kind_probe_and_residual(tmp_path):
317317
assert rp.layers, "output node never materialised in any snapshot"
318318

319319

320-
def test_windowed_cache_models_are_rejected(tmp_path):
321-
onnx_path = str(tmp_path / "windowed.onnx")
322-
out1, pos1 = _build_headless_graph()
320+
# ---------------------------------------------------------------------------
321+
# Windowed-cache (cache_window=C) support — identity slot placement
322+
# ---------------------------------------------------------------------------
323+
324+
_C = 8 # cache_window for every windowed test below
325+
326+
327+
@pytest.fixture(scope="module")
328+
def windowed_pair(tmp_path_factory):
329+
"""The same graph exported both ways: (unbounded_path, windowed_path)."""
330+
tmpdir = tmp_path_factory.mktemp("onnx_debug_windowed")
331+
out, pos = _build_headless_graph()
332+
unbounded = str(tmpdir / "unbounded.onnx")
333+
windowed = str(tmpdir / "windowed.onnx")
323334
compile_headless_to_onnx(
324-
out1,
325-
pos1,
326-
onnx_path,
335+
out, pos, unbounded, d=256, d_head=D_HEAD, max_seq_len=32, verbose=False
336+
)
337+
compile_headless_to_onnx(
338+
out,
339+
pos,
340+
windowed,
327341
d=256,
328342
d_head=D_HEAD,
329343
max_seq_len=32,
330-
cache_window=8,
344+
cache_window=_C,
331345
verbose=False,
332346
)
333-
out2, pos2 = _build_headless_graph()
334-
with pytest.raises(NotImplementedError, match="windowed"):
335-
OnnxDebugSession(onnx_path, out2, pos2)
347+
return unbounded, windowed
348+
349+
350+
def test_windowed_session_matches_unbounded(windowed_pair):
351+
"""Identity placement satisfies the exporter's host contract, so the
352+
windowed equivalence guarantee makes parity with the unbounded export
353+
a theorem — this pins it: probe clean, one-shot outputs equal, and a
354+
debug=True step across the prefill/decode seam (base > 0) equal too."""
355+
unbounded_path, windowed_path = windowed_pair
356+
iv = {
357+
"a": torch.tensor([[1.0], [2.0], [3.0], [-4.0]]),
358+
"b": torch.tensor([[5.0], [6.0], [-7.0], [8.0]]),
359+
}
360+
361+
out_w, pos_w = _build_headless_graph()
362+
sess_w = OnnxDebugSession(windowed_path, out_w, pos_w)
363+
report = probe_compiled(sess_w, out_w, iv, n_pos=4, atol=1e-2)
364+
assert report.first_divergent is None, report.format_short()
365+
366+
out_u, pos_u = _build_headless_graph()
367+
sess_u = OnnxDebugSession(unbounded_path, out_u, pos_u)
368+
369+
prefill = sess_w.build_prefill(iv, 4)
370+
one_shot_w = sess_w(prefill, debug=True)
371+
one_shot_u = sess_u(prefill, debug=True)
372+
assert torch.allclose(one_shot_w, one_shot_u, atol=1e-4), (
373+
f"windowed one-shot diverged from unbounded: max diff "
374+
f"{float((one_shot_w - one_shot_u).abs().max()):.3e}"
375+
)
376+
377+
# Seam: 2 committed rows, then a 2-row debug step at base=2 — the
378+
# binding now has a never-written committed band [2, C).
379+
past = sess_w.empty_past()
380+
head, past = sess_w.step(prefill[:2], past)
381+
tail, _ = sess_w.step(prefill[2:], past, debug=True)
382+
seam = torch.cat([head, tail])
383+
assert torch.allclose(seam, one_shot_w, atol=1e-4), (
384+
f"windowed seam diverged from one-shot: max diff "
385+
f"{float((seam - one_shot_w).abs().max()):.3e}"
386+
)
387+
388+
389+
def test_windowed_attention_probe_translated(tmp_path):
390+
"""The one real windowed remap: the wire key axis is C + n_new, the
391+
probe's key axis must stay base + n_new with key index == absolute
392+
position. Pins prefill (base=0) and decode (base>0, never-written
393+
band inside the binding) against the unbounded export's probe."""
394+
395+
def build():
396+
torch.manual_seed(0)
397+
x = create_input("x", 4)
398+
attn = Attn(
399+
query_in=x,
400+
key_in=x,
401+
value_in=x,
402+
query_matrix=torch.randn(4, 4),
403+
key_matrix=torch.randn(4, 4),
404+
value_matrix=torch.randn(4, 4),
405+
output_matrix=torch.randn(4, 4),
406+
)
407+
return attn, create_pos_encoding()
408+
409+
unbounded = str(tmp_path / "unbounded.onnx")
410+
windowed = str(tmp_path / "windowed.onnx")
411+
out, pos = build()
412+
compile_headless_to_onnx(
413+
out, pos, unbounded, d=256, d_head=D_HEAD, max_seq_len=32, verbose=False
414+
)
415+
compile_headless_to_onnx(
416+
out,
417+
pos,
418+
windowed,
419+
d=256,
420+
d_head=D_HEAD,
421+
max_seq_len=32,
422+
cache_window=_C,
423+
verbose=False,
424+
)
425+
attn_u, pos_u = build()
426+
sess_u = OnnxDebugSession(unbounded, attn_u, pos_u)
427+
attn_w, pos_w = build()
428+
sess_w = OnnxDebugSession(windowed, attn_w, pos_w)
429+
430+
torch.manual_seed(1)
431+
inp = torch.randn(3, 4)
432+
433+
# Prefill probe (base=0: the dropped band is the whole committed window).
434+
ap_u = probe_attention(sess_u, inp, attn_u, query_pos=2)
435+
ap_w = probe_attention(sess_w, inp, attn_w, query_pos=2)
436+
assert ap_w.weights.shape == ap_u.weights.shape # n_keys = 3, both
437+
assert torch.allclose(ap_w.weights, ap_u.weights, atol=1e-5)
438+
assert torch.allclose(ap_w.logits, ap_u.logits, atol=1e-4)
439+
440+
# Decode probe (base=2: never-written band [2, C) inside the binding).
441+
past_u = sess_u.empty_past()
442+
_, past_u = sess_u.step(inp[:2], past_u)
443+
past_w = sess_w.empty_past()
444+
_, past_w = sess_w.step(inp[:2], past_w)
445+
ap_u2 = probe_attention(
446+
sess_u, inp[2:], attn_u, query_pos=0, past_len=2, past_kvs=past_u
447+
)
448+
ap_w2 = probe_attention(
449+
sess_w, inp[2:], attn_w, query_pos=0, past_len=2, past_kvs=past_w
450+
)
451+
assert ap_w2.weights.shape == ap_u2.weights.shape # n_keys = 2 + 1, both
452+
assert torch.allclose(ap_w2.weights, ap_u2.weights, atol=1e-5)
453+
454+
455+
def test_windowed_cap_error(windowed_pair):
456+
"""Identity placement never evicts: committed rows are capped at C,
457+
and the overrun error names the cap and the way out."""
458+
_unbounded_path, windowed_path = windowed_pair
459+
out, pos = _build_headless_graph()
460+
sess = OnnxDebugSession(windowed_path, out, pos)
461+
462+
iv = {"a": torch.ones(_C, 1) * 0.5, "b": torch.ones(_C, 1) * 0.25}
463+
prefill = sess.build_prefill(iv, _C) # exactly C rows: allowed
464+
past = sess.empty_past()
465+
_, past = sess.step(prefill, past)
466+
467+
one_more = sess.build_prefill(
468+
{"a": torch.tensor([[0.5]]), "b": torch.tensor([[0.25]])}, 1
469+
)
470+
with pytest.raises(RuntimeError, match="cache_window"):
471+
sess.step(one_more, past)
336472

337473

338474
# ---------------------------------------------------------------------------

torchwright/debug/onnx_debug.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@
4040
* Fetching every residual snapshot costs
4141
``n_pos × d × 2·n_layers`` floats on host per run — fine for
4242
debug-sized runs; probe very long prefills in slices.
43-
* Windowed-cache models (``cache_window=C``) are not supported yet.
43+
* Windowed-cache models (``cache_window=C``) are debugged under
44+
**identity slot placement**: slot ``j`` = position ``j``, nothing is
45+
ever evicted, and runs are capped at ``C`` committed rows (debug
46+
longer runs in slices, or export an unbounded variant). Identity
47+
placement satisfies the exporter's host contract, so the windowed
48+
equivalence guarantee applies and debug output is token-identical to
49+
the unbounded export's. The session never reproduces a host eviction
50+
policy — probing a post-eviction state needs the host's own slot
51+
contents and is out of scope here.
4452
"""
4553

4654
from __future__ import annotations
@@ -138,12 +146,18 @@ def __init__(
138146
import onnxruntime as ort
139147

140148
sidecar = _sidecar_or_raise(onnx_path)
141-
if sidecar.get("cache_window") is not None:
142-
raise NotImplementedError(
143-
"OnnxDebugSession does not support windowed-cache models yet "
144-
"(cache_window is set in the sidecar); export an unbounded-"
145-
"protocol model for debugging"
146-
)
149+
# Windowed-cache artifacts are debugged under IDENTITY slot
150+
# placement: slot j = position j, nothing ever evicted. That
151+
# satisfies the exporter's host contract (committed slots filled
152+
# in slot order, never wrapping), so by the exporter's own
153+
# equivalence guarantee the debug output is token-identical to
154+
# the unbounded export's — at the cost of a hard cap at C
155+
# committed rows (enforced in _feeds).
156+
self._cache_window: Optional[int] = (
157+
int(sidecar["cache_window"])
158+
if sidecar.get("cache_window") is not None
159+
else None
160+
)
147161

148162
self._onnx_path = onnx_path
149163
self._kind: str = sidecar["kind"] # "token" | "headless"
@@ -332,9 +346,25 @@ def _feeds(self, prefill: torch.Tensor, base: int, past: Optional[tuple]) -> dic
332346
(valid under stride bucketing) so debug runs never materialize
333347
the full stride buffer; old static-dim exports get full-width
334348
zero-padded feeds instead.
349+
350+
Windowed-cache artifacts bind exactly ``C + n_new`` wide (the
351+
protocol requirement): committed rows at identity slots
352+
``[0, base)``, never-written slots ``[base, C)`` zeroed (the
353+
writtenness mask gives them weight exactly 0.0), staging tail
354+
``[C, C+n_new)`` zeroed (the in-graph scatter overwrites it).
335355
"""
336356
n_new = int(prefill.shape[0])
337-
if base + n_new > self._cache_stride:
357+
if self._cache_window is not None:
358+
if base + n_new > self._cache_window:
359+
raise RuntimeError(
360+
f"windowed-cache debug overrun: base {base} + n_new "
361+
f"{n_new} exceeds cache_window {self._cache_window}. "
362+
f"This debug session uses identity slot placement and "
363+
f"never evicts, so a windowed artifact is debuggable up "
364+
f"to cache_window committed positions — debug longer "
365+
f"runs in slices, or export an unbounded variant"
366+
)
367+
elif base + n_new > self._cache_stride:
338368
raise RuntimeError(
339369
f"static cache overrun: base {base} + n_new {n_new} exceeds "
340370
f"cache_stride {self._cache_stride}"
@@ -349,11 +379,12 @@ def _feeds(self, prefill: torch.Tensor, base: int, past: Optional[tuple]) -> dic
349379
feeds["inputs"] = (
350380
prefill.detach().cpu().numpy().astype(np.float32, copy=False)
351381
)
352-
bind_width = (
353-
self._static_slot_dim
354-
if self._static_slot_dim is not None
355-
else base + n_new
356-
)
382+
if self._cache_window is not None:
383+
bind_width = self._cache_window + n_new
384+
elif self._static_slot_dim is not None:
385+
bind_width = self._static_slot_dim
386+
else:
387+
bind_width = base + n_new
357388
for i, nh in enumerate(self._per_layer_n_heads):
358389
buf = np.zeros((bind_width, nh, self._d_head), dtype=np.float32)
359390
if past is not None and base > 0:
@@ -573,6 +604,16 @@ def capture_attention(
573604
``(n_heads, n_queries, n_keys)`` with ``n_keys = past + new`` —
574605
the same shape the in-process ``attention_capture`` hook
575606
produces.
607+
608+
On windowed-cache artifacts the wire key axis is ``C + n_new``
609+
(committed window + staging tail); under this session's identity
610+
placement the committed keys ``[0, base)`` already sit at their
611+
positions and the new rows live at ``[C, C+n_new)``, so the two
612+
bands are spliced back into the unbounded ``base + n_new`` layout
613+
— key index == absolute position on every backend and protocol.
614+
The dropped never-written band ``[base, C)`` is asserted to carry
615+
weight exactly 0.0 first; anything else is a windowed-mask
616+
emission bug.
576617
"""
577618
if prefill.ndim == 1:
578619
prefill = prefill.reshape(-1, 1)
@@ -581,4 +622,22 @@ def capture_attention(
581622
weights_np, logits_np = self._session.run(
582623
[f"l{layer_index}_weights", f"l{layer_index}_logits_masked"], feeds
583624
)
625+
if self._cache_window is not None:
626+
C = self._cache_window
627+
dead = weights_np[:, :, base:C]
628+
if dead.size and float(np.abs(dead).max()) != 0.0:
629+
raise RuntimeError(
630+
f"windowed mask leak at layer {layer_index}: never-written "
631+
f"committed slots [{base}, {C}) received attention weight "
632+
f"(max {float(np.abs(dead).max()):.3e}, expected exactly "
633+
f"0.0) — the writtenness mask in the windowed preamble is "
634+
f"broken (ONNX emission bug in "
635+
f"torchwright/compiler/export.py — D1: stop and report)"
636+
)
637+
weights_np = np.concatenate(
638+
[weights_np[:, :, :base], weights_np[:, :, C:]], axis=2
639+
)
640+
logits_np = np.concatenate(
641+
[logits_np[:, :, :base], logits_np[:, :, C:]], axis=2
642+
)
584643
return torch.from_numpy(weights_np), torch.from_numpy(logits_np)

0 commit comments

Comments
 (0)