Skip to content

Commit c5bb4c3

Browse files
physicsrobclaude
andcommitted
Emit 2-D weight-matrix occupancy in the debug sidecar
The floor-plan viz currently colors sublayers by a 1-D signal (which residual columns an op writes). The real object is 2-D: each op occupies a region of the layer's actual weight matrices. Record that during weight writing and emit it in <stem>.debug.json for the viz to join by canonical node id. - PlacementRecorder (forward/weight_writer.py): accumulates each op's 2-D region per weight matrix as weights are written. Holds only ints (O(ops), never pins trimmed tensors). One hook in _scatter_attn_head covers all attention ops; three cover the MLP write sites. Dense weight blocks vs. paired identity diagonals are recorded from the actual write pattern, so identity passthroughs don't overstate area. - compile.py / transformer.py: recorder created once, set_layer per sublayer (incl. the overlay delta layer), attached as net.placements beside residual_assignment. Always on, so a schedule-cache hit still yields it (weight writing always runs). - export._write_debug_sidecar: emits a `matrices` table (six per layer, heads flattened into one `head` axis = d), `placements` keyed by canonical id (dense run-rectangles; diagonals coalesced with a `diag` flag), and globals n_heads + per-layer d_hidden. Describes the full logical (untrimmed) matrices, consistent with the sidecar's existing residual view. Additive keys; no format bump. embed/unembed deferred. Tests (test_matrix_occupancy.py): pure-function dense/diagonal encoding, completeness (every nonzero compiled weight is covered by a placement), within-shape bounds, canonical-id-or-bucket keying, ONNX-sidecar integration, and cache-hit stability. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0cf9206 commit c5bb4c3

5 files changed

Lines changed: 610 additions & 19 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
"""2-D weight-matrix occupancy instrumentation (floor-plan viz).
2+
3+
Covers the ``matrices`` table + ``placements`` map emitted in the debug
4+
sidecar (see ``_build_matrix_occupancy`` in compiler/export.py) and the
5+
``PlacementRecorder`` that feeds it from weight writing.
6+
7+
The load-bearing invariant is **completeness**: the union of an op's
8+
recorded placements must cover every nonzero entry the compile actually
9+
wrote into that weight matrix. If instrumentation misses a write site,
10+
this fails.
11+
"""
12+
13+
import json
14+
import os
15+
import tempfile
16+
17+
import pytest
18+
import torch
19+
20+
from torchwright.compiler.export import (
21+
DEBUG_META_FORMAT,
22+
_build_matrix_occupancy,
23+
_dense_rects,
24+
_diag_rects,
25+
compile_headless_to_onnx,
26+
debug_meta_path_for,
27+
)
28+
from torchwright.compiler.forward.compile import forward_compile
29+
from torchwright.compiler.graph_identity import canonical_ids, unwrap_debug
30+
from torchwright.ops.arithmetic_ops import add, signed_multiply
31+
from torchwright.ops.inout_nodes import create_input, create_pos_encoding
32+
33+
D = 256
34+
D_HEAD = 16
35+
N_HEADS = D // D_HEAD
36+
37+
38+
def _build_graph():
39+
"""A small graph that needs multiple layers (attention + MLP chains)."""
40+
a = create_input("a", 1)
41+
b = create_input("b", 1)
42+
c = create_input("c", 1)
43+
prod = signed_multiply(a, b, max_abs1=10, max_abs2=10)
44+
out = add(prod, c)
45+
return out, create_pos_encoding()
46+
47+
48+
def _compile():
49+
out, pos = _build_graph()
50+
net = forward_compile(D, D_HEAD, out, pos, verbose=False, device="cpu")
51+
canon = canonical_ids(unwrap_debug(out))
52+
return net, canon, out, pos
53+
54+
55+
# ---------------------------------------------------------------------------
56+
# Pure rectangle encoding — pins dense (cross product) vs diagonal (paired)
57+
# ---------------------------------------------------------------------------
58+
59+
60+
def _expand(rects):
61+
"""Expand placement rectangles to the set of (row, col) cells they fill."""
62+
cells = set()
63+
for r in rects:
64+
a0s, a0l = r["a0"]
65+
a1s, a1l = r["a1"]
66+
if r.get("diag"):
67+
assert a0l == a1l, "diagonal rect must have equal-length axes"
68+
cells.update((a0s + k, a1s + k) for k in range(a0l))
69+
else:
70+
for i in range(a0l):
71+
for j in range(a1l):
72+
cells.add((a0s + i, a1s + j))
73+
return cells
74+
75+
76+
def test_dense_rects_are_full_cross_product():
77+
rows = [0, 1, 2, 5, 6] # runs (0,3) and (5,2)
78+
cols = [10, 11] # run (10,2)
79+
rects = _dense_rects("M", rows, cols)
80+
# One rectangle per row-run x col-run pair; together the full product.
81+
assert len(rects) == 2
82+
assert all("diag" not in r for r in rects)
83+
assert _expand(rects) == {(r, c) for r in rows for c in cols}
84+
85+
86+
def test_dense_rects_order_independent():
87+
# Unsorted input still yields the same cell set (dense = set semantics).
88+
assert _expand(_dense_rects("M", [6, 0, 1], [3, 2])) == {
89+
(r, c) for r in (0, 1, 6) for c in (2, 3)
90+
}
91+
92+
93+
def test_diag_rects_pair_in_lockstep():
94+
rows = [0, 1, 2, 5]
95+
cols = [3, 4, 5, 9]
96+
rects = _diag_rects("M", rows, cols)
97+
# (0,3)(1,4)(2,5) coalesce into one len-3 segment; (5,9) is its own.
98+
assert len(rects) == 2
99+
assert all(r["diag"] for r in rects)
100+
assert _expand(rects) == {(0, 3), (1, 4), (2, 5), (5, 9)}
101+
102+
103+
def test_diag_rects_break_when_only_one_axis_advances():
104+
# rows advance by 1 but cols jump -> no coalescing.
105+
rects = _diag_rects("M", [0, 1], [0, 5])
106+
assert len(rects) == 2
107+
assert _expand(rects) == {(0, 0), (1, 5)}
108+
109+
110+
# ---------------------------------------------------------------------------
111+
# Matrices table — shapes and axis labels
112+
# ---------------------------------------------------------------------------
113+
114+
115+
def test_matrices_table_shapes_and_axes():
116+
net, canon, _, _ = _compile()
117+
matrices, placements, n_heads, d_hidden = _build_matrix_occupancy(
118+
net, canon, D, D_HEAD
119+
)
120+
121+
assert n_heads == N_HEADS
122+
assert isinstance(d_hidden, list)
123+
assert len(d_hidden) == len(net.layers)
124+
assert all(isinstance(h, int) and h > 0 for h in d_hidden)
125+
126+
for i, dh in enumerate(d_hidden):
127+
expect = {
128+
f"L{i}.attn.W_Q": ([D, D], "residual_in", "head"),
129+
f"L{i}.attn.W_K": ([D, D], "residual_in", "head"),
130+
f"L{i}.attn.W_V": ([D, D], "residual_in", "head"),
131+
f"L{i}.attn.W_O": ([D, D], "head", "residual_out"),
132+
f"L{i}.mlp.W_in": ([D, dh], "residual_in", "hidden"),
133+
f"L{i}.mlp.W_out": ([dh, D], "hidden", "residual_out"),
134+
}
135+
for mid, (shape, a0, a1) in expect.items():
136+
rec = matrices[mid]
137+
assert rec["layer"] == i
138+
assert rec["shape"] == shape, mid
139+
assert rec["axis0"] == a0 and rec["axis1"] == a1, mid
140+
141+
142+
# ---------------------------------------------------------------------------
143+
# Completeness — placements cover every nonzero weight the compile wrote
144+
# ---------------------------------------------------------------------------
145+
146+
147+
def _flatten_weight(net, layer_i, kind):
148+
"""The compiled weight tensor for one matrix kind, in flat 2-D coords."""
149+
layer = net.layers[layer_i]
150+
attn = layer.attn.attn
151+
if kind == "attn.W_Q":
152+
return attn.query_matrix.permute(1, 0, 2).reshape(D, N_HEADS * D_HEAD)
153+
if kind == "attn.W_K":
154+
return attn.key_matrix.permute(1, 0, 2).reshape(D, N_HEADS * D_HEAD)
155+
if kind == "attn.W_V":
156+
return attn.value_matrix.permute(1, 0, 2).reshape(D, N_HEADS * D_HEAD)
157+
if kind == "attn.W_O":
158+
return attn.output_matrix.reshape(N_HEADS * D_HEAD, D)
159+
if kind == "mlp.W_in":
160+
return layer.mlp.linear1.output_matrix
161+
if kind == "mlp.W_out":
162+
return layer.mlp.linear2.output_matrix
163+
raise ValueError(kind)
164+
165+
166+
def test_placements_cover_all_nonzero_weights():
167+
# trim_heads=False keeps the full (n_heads, d, d_head) tensors so the flat
168+
# layout matches the sidecar's full-matrix description.
169+
out, pos = _build_graph()
170+
net = forward_compile(
171+
D, D_HEAD, out, pos, verbose=False, device="cpu", trim_heads=False
172+
)
173+
canon = canonical_ids(unwrap_debug(out))
174+
matrices, placements, _, _ = _build_matrix_occupancy(net, canon, D, D_HEAD)
175+
176+
# Gather every rectangle per matrix id (across node keys AND reserved
177+
# "_<op_type>" buckets — completeness needs the node-less ops too).
178+
rects_by_matrix: dict[str, list] = {}
179+
for rects in placements.values():
180+
for r in rects:
181+
rects_by_matrix.setdefault(r["matrix"], []).append(r)
182+
183+
n_checked = 0
184+
for mid, rec in matrices.items():
185+
layer_i, kind = rec["layer"], rec["kind"]
186+
weight = _flatten_weight(net, layer_i, kind)
187+
rows_n, cols_n = rec["shape"]
188+
assert tuple(weight.shape) == (rows_n, cols_n), mid
189+
190+
covered = _expand(rects_by_matrix.get(mid, []))
191+
# All claimed cells are inside the matrix.
192+
for r, c in covered:
193+
assert 0 <= r < rows_n and 0 <= c < cols_n, f"{mid} cell ({r},{c}) OOB"
194+
195+
nz = torch.nonzero(weight, as_tuple=False)
196+
for r, c in nz.tolist():
197+
assert (r, c) in covered, (
198+
f"{mid}: nonzero weight at ({r},{c}) not covered by any "
199+
f"placement — instrumentation missed a write site."
200+
)
201+
n_checked += 1
202+
203+
assert n_checked > 0, "expected at least some nonzero weights to verify"
204+
205+
206+
# ---------------------------------------------------------------------------
207+
# Keying — every placement key is a canonical id or a reserved bucket
208+
# ---------------------------------------------------------------------------
209+
210+
211+
def test_placement_keys_are_canonical_or_bucket():
212+
net, canon, _, _ = _compile()
213+
_, placements, _, _ = _build_matrix_occupancy(net, canon, D, D_HEAD)
214+
215+
valid_ids = set(canon.values())
216+
real_node_keys = 0
217+
for key in placements:
218+
if key.startswith("_"):
219+
continue # reserved bucket (e.g. "_cancel", "_unreachable")
220+
assert int(key) in valid_ids, f"placement key {key!r} is not a canonical id"
221+
real_node_keys += 1
222+
assert real_node_keys > 0, "expected some placements keyed by real graph nodes"
223+
224+
225+
# ---------------------------------------------------------------------------
226+
# Sidecar integration — the ONNX export carries the occupancy data
227+
# ---------------------------------------------------------------------------
228+
229+
230+
def _read_sidecar(tmpdir, name="model.onnx"):
231+
out, pos = _build_graph()
232+
onnx_path = os.path.join(tmpdir, name)
233+
compile_headless_to_onnx(
234+
out, pos, onnx_path, d=D, d_head=D_HEAD, max_seq_len=32, verbose=False
235+
)
236+
with open(debug_meta_path_for(onnx_path)) as f:
237+
return json.load(f)
238+
239+
240+
def test_sidecar_carries_occupancy():
241+
with tempfile.TemporaryDirectory() as tmpdir:
242+
data = _read_sidecar(tmpdir)
243+
244+
assert data["format"] == DEBUG_META_FORMAT
245+
assert data["n_heads"] == N_HEADS
246+
assert isinstance(data["d_hidden"], list)
247+
assert len(data["d_hidden"]) == data["n_layers"]
248+
assert data["matrices"], "matrices table should be non-empty"
249+
assert data["placements"], "placements should be non-empty"
250+
# Every placement references a declared matrix id.
251+
matrix_ids = set(data["matrices"])
252+
for rects in data["placements"].values():
253+
for r in rects:
254+
assert r["matrix"] in matrix_ids
255+
256+
257+
def test_occupancy_stable_across_cache_hit(monkeypatch):
258+
# The schedule cache replays the CP-SAT solve, but weight writing (and thus
259+
# placement recording) always runs — a cache hit must still yield identical
260+
# occupancy data.
261+
with tempfile.TemporaryDirectory() as cache_dir:
262+
monkeypatch.setenv("TW_SCHEDULE_CACHE_DIR", cache_dir)
263+
with tempfile.TemporaryDirectory() as tmpdir:
264+
first = _read_sidecar(tmpdir, "a.onnx") # cold: solves + caches
265+
second = _read_sidecar(tmpdir, "b.onnx") # warm: cache hit
266+
267+
for key in ("n_heads", "d_hidden", "matrices", "placements"):
268+
assert first[key] == second[key], f"{key} differs across cache hit"

0 commit comments

Comments
 (0)