Skip to content

Commit 8067a01

Browse files
physicsrobclaude
andcommitted
Emit per-node metadata table in the debug sidecar
Replace the flat annotations map with a nodes table keyed by canonical id (the same key space as placements/states), one entry per reachable node: op type, annotation path, output width, baked-weight param count/shapes, input ids, and the layer/sublayer the node is scheduled into. layer/sublayer comes from the placement recorder where it logs the op, falling back to first residual-state appearance for literals and concatenations; Embedding nodes report sublayer "embed". Also add a root "optimize" field (the compile-optimization level the artifact was built at) and pass the caller's extra_metadata through verbatim under "extra" — torchwright does not interpret its keys, so domain values like screen/scale ride there rather than in a torchwright- defined schema. Test Suite Status: Unknown Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 200041d commit 8067a01

3 files changed

Lines changed: 165 additions & 25 deletions

File tree

tests/debug/test_onnx_debug_session.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,13 @@ def test_sidecar_carries_annotations(tmp_path):
149149

150150
with open(debug_meta_path_for(onnx_path)) as f:
151151
sidecar = json.load(f)
152-
# The annotated nodes' paths are present; "scene/sum" is deduped, not
153-
# "scene/scene/sum".
154-
labels = set(sidecar["annotations"].values())
152+
# Annotations now ride in the per-node table; "scene/sum" is deduped,
153+
# not "scene/scene/sum".
154+
labels = {
155+
m["annotation"]
156+
for m in sidecar["nodes"].values()
157+
if m["annotation"] is not None
158+
}
155159
assert "scene" in labels
156160
assert "scene/sum" in labels
157161

@@ -162,6 +166,55 @@ def test_sidecar_carries_annotations(tmp_path):
162166
assert sess.annotation(top2) == "scene/sum"
163167

164168

169+
def test_sidecar_nodes_table_schema(tmp_path):
170+
"""The per-node table keys by canonical id (same space as placements /
171+
states) and carries op/width/weights/inputs/layer/sublayer per node."""
172+
out, pos, prod, top = _build_annotated_graph()
173+
onnx_path = str(tmp_path / "nodes.onnx")
174+
compile_headless_to_onnx(
175+
out, pos, onnx_path, d=D, d_head=D_HEAD, max_seq_len=16, verbose=False,
176+
optimize=1, extra_metadata={"screen": {"width": 80, "height": 50}, "scale": 4},
177+
)
178+
with open(debug_meta_path_for(onnx_path)) as f:
179+
sidecar = json.load(f)
180+
181+
# Compile knob is first-class; caller metadata rides through "extra"
182+
# verbatim (torchwright does not interpret its keys).
183+
assert sidecar["optimize"] == 1
184+
assert sidecar["extra"] == {"screen": {"width": 80, "height": 50}, "scale": 4}
185+
186+
nodes = sidecar["nodes"]
187+
assert nodes, "nodes table should be non-empty"
188+
# Every placement / state key is a node in the table.
189+
for key in sidecar["placements"]:
190+
if key.startswith("_"): # reserved op/unreachable buckets
191+
continue
192+
assert key in nodes, f"placement key {key} missing from nodes"
193+
for entry in sidecar["states"]:
194+
for key in entry.get("nodes", {}):
195+
assert key in nodes, f"state node {key} missing from nodes"
196+
197+
# Required per-entry shape.
198+
for cid, m in nodes.items():
199+
assert isinstance(m["op"], str)
200+
assert isinstance(m["width"], int)
201+
assert isinstance(m["weight_params"], int)
202+
assert isinstance(m["weight_shapes"], list)
203+
assert isinstance(m["inputs"], list)
204+
assert all(i in nodes for i in m["inputs"])
205+
assert m["layer"] is None or isinstance(m["layer"], int)
206+
assert m["sublayer"] in (None, "attn", "mlp", "embed")
207+
208+
# The two InputNodes carry weight_params == 0; some weight-bearing node
209+
# (the signed-multiply / add chain compiles to Linears) has > 0.
210+
assert any(m["weight_params"] > 0 for m in nodes.values())
211+
# A computed node lands in a real transformer layer/sublayer.
212+
assert any(
213+
m["layer"] is not None and m["sublayer"] in ("attn", "mlp")
214+
for m in nodes.values()
215+
)
216+
217+
165218
# ---------------------------------------------------------------------------
166219
# Parity with CompiledHeadless
167220
# ---------------------------------------------------------------------------

torchwright/compiler/export.py

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -366,18 +366,25 @@ def _write_debug_sidecar(
366366
cache_stride: int,
367367
cache_window: Optional[int],
368368
verbose: bool,
369+
optimize: int = 0,
370+
extra: Optional[dict] = None,
369371
) -> str:
370372
"""Write ``<stem>.debug.json`` — everything OnnxDebugSession needs.
371373
372374
The sidecar carries the residual assignment keyed by CANONICAL node
373375
id (see :mod:`torchwright.compiler.graph_identity`) per capture
374376
state, a structural fingerprint of the compiled graph for rebuild
375-
validation, the ``annotate()`` label path for every reachable node
376-
that carries one (also keyed by canonical id), and the
377-
Assert/DebugWatch coverage present at compile time (so the loader can
378-
warn when a rebuilt graph carries fewer checks than the compiled one
379-
did — the fingerprint is deliberately wrapper-transparent and cannot
380-
see that).
377+
validation, a per-node metadata table (``nodes``) — also keyed by
378+
canonical id, one entry per reachable node, carrying op type,
379+
annotation path, output width, baked-weight parameter count/shapes,
380+
input ids, and the layer/sublayer the node is scheduled into — and
381+
the Assert/DebugWatch coverage present at compile time (so the loader
382+
can warn when a rebuilt graph carries fewer checks than the compiled
383+
one did — the fingerprint is deliberately wrapper-transparent and
384+
cannot see that). ``optimize`` records the compile-optimization
385+
level the artifact was built at; ``extra`` is the caller's free-form
386+
``extra_metadata`` dict passed straight through (torchwright does not
387+
interpret its keys), mirroring the meta sidecar's ``extra``.
381388
382389
State keys correspond one-to-one with the per-layer residual tensor
383390
names in the emitted ONNX graph: ``"input"`` ↔ ``res_0``,
@@ -410,6 +417,13 @@ def _write_debug_sidecar(
410417

411418
seen_tables: Dict[int, str] = {} # id(mapping dict) -> first state key
412419
state_entries: List[dict] = []
420+
# Earliest state in which each canonical id appears. The state_list
421+
# is ordered input → L0.attn → L0.mlp → L1.attn …, and a node sits in
422+
# the residual stream from the sublayer that computes it until it is
423+
# freed, so its FIRST appearance pins where it was computed. Used as
424+
# the layer/sublayer source for nodes the placement recorder doesn't
425+
# log (literals, concatenations).
426+
first_state: Dict[str, str] = {}
413427
for key, st in state_list:
414428
table = ra.mapping.get(st)
415429
if table is None:
@@ -431,6 +445,7 @@ def _write_debug_sidecar(
431445
# PosEncoding leaf) — cannot be keyed canonically.
432446
continue
433447
nodes.setdefault(str(cid), encode_cols(list(cols)))
448+
first_state.setdefault(str(cid), key)
434449
state_entries.append({"key": key, "nodes": nodes})
435450

436451
assert_targets = sorted(
@@ -440,19 +455,81 @@ def _write_debug_sidecar(
440455
if unwrap_debug(a.inputs[0]).node_id in canon
441456
}
442457
)
443-
# Annotation paths (the "/"-separated label hierarchy set by
444-
# ``annotate()`` / ``@annotated``) keyed by canonical node id, for
445-
# every reachable node that carries one. Wrappers are stepped
446-
# through by ``_canonical_walk``, so this reads the annotation on the
447-
# wrapped node — the same node the residual states key by.
448-
annotations = {
449-
str(cid): node.annotation
450-
for cid, node in nodes_by_canonical_id(out).items()
451-
if node.annotation is not None
452-
}
453458
matrices, placements, n_heads, d_hidden_per_layer = _build_matrix_occupancy(
454459
compiled, canon, d, d_head
455460
)
461+
462+
# Per-node metadata keyed by canonical id — the same key space as
463+
# ``placements`` and the residual ``states``. One entry per node
464+
# reachable from the output.
465+
#
466+
# layer/sublayer: the placement recorder logs the layer + matrix for
467+
# every weight-bearing op (Linear/Attn/ReLU/Add), so it is the
468+
# authoritative source where present; matrix_kind "attn.*"/"mlp.*"
469+
# gives the sublayer. Nodes it doesn't log (literals, concatenations)
470+
# fall back to their first residual-state appearance, and pre-layer
471+
# input nodes (Embedding) report sublayer "embed".
472+
place_loc: Dict[str, tuple] = {}
473+
recorder = getattr(compiled, "placements", None)
474+
if recorder is not None:
475+
for e in recorder.entries:
476+
if e.node is None:
477+
continue
478+
cid = canon.get(unwrap_debug(e.node).node_id)
479+
if cid is None:
480+
continue
481+
cid_s = str(cid)
482+
if cid_s in place_loc:
483+
continue
484+
sub = "attn" if e.matrix_kind.startswith("attn") else "mlp"
485+
place_loc[cid_s] = (int(e.layer), sub)
486+
487+
def _layer_sublayer(cid_s: str, node: Node) -> tuple:
488+
if cid_s in place_loc:
489+
return place_loc[cid_s]
490+
key = first_state.get(cid_s)
491+
if key is not None and key != "input":
492+
lpart, sub = key.split(".") # "L{k}", "attn"|"mlp"
493+
return int(lpart[1:]), sub
494+
if isinstance(node, Embedding):
495+
return None, "embed"
496+
return None, None
497+
498+
# Baked weight tensors probed by attribute name, summed into a
499+
# parameter count and per-tensor shape list. 0 / [] for pure ops.
500+
baked_attrs = ("table", "output_matrix", "matrix", "weight", "value")
501+
nodes_meta: Dict[str, dict] = {}
502+
for cid, node in nodes_by_canonical_id(out).items():
503+
cid_s = str(cid)
504+
weight_params = 0
505+
weight_shapes: List[list] = []
506+
for attr in baked_attrs:
507+
t = getattr(node, attr, None)
508+
shape = getattr(t, "shape", None)
509+
if shape is None:
510+
continue
511+
dims = [int(s) for s in shape]
512+
if not dims: # 0-d scalar — no parameters to attribute
513+
continue
514+
weight_params += int(t.numel()) if hasattr(t, "numel") else 1
515+
weight_shapes.append([attr, dims])
516+
input_cids: List[str] = []
517+
for inp in getattr(node, "inputs", None) or []:
518+
icid = canon.get(unwrap_debug(inp).node_id)
519+
if icid is not None:
520+
input_cids.append(str(icid))
521+
layer, sublayer = _layer_sublayer(cid_s, node)
522+
nodes_meta[cid_s] = {
523+
"op": type(node).__name__,
524+
"annotation": node.annotation,
525+
"width": len(node),
526+
"weight_params": weight_params,
527+
"weight_shapes": weight_shapes,
528+
"inputs": input_cids,
529+
"layer": layer,
530+
"sublayer": sublayer,
531+
"name": getattr(node, "name", None),
532+
}
456533
payload = {
457534
"format": DEBUG_META_FORMAT,
458535
"kind": kind, # "token" | "headless"
@@ -464,7 +541,9 @@ def _write_debug_sidecar(
464541
"n_layers": len(compiled.layers),
465542
"matrices": matrices,
466543
"placements": placements,
467-
"annotations": annotations,
544+
"nodes": nodes_meta,
545+
"optimize": int(optimize),
546+
"extra": dict(extra) if extra else {},
468547
"input_specs": [list(spec) for spec in input_specs],
469548
"cache_stride": int(cache_stride),
470549
"cache_window": int(cache_window) if cache_window is not None else None,
@@ -1434,6 +1513,8 @@ def add(op, ins, outs, **attrs):
14341513
watches=all_watches,
14351514
cache_stride=cache_stride_resolved,
14361515
cache_window=cache_window,
1516+
optimize=optimize,
1517+
extra=extra_metadata,
14371518
verbose=verbose,
14381519
)
14391520

@@ -1779,6 +1860,8 @@ def add(op, ins, outs, **attrs):
17791860
watches=all_watches,
17801861
cache_stride=cache_stride_resolved,
17811862
cache_window=cache_window,
1863+
optimize=optimize,
1864+
extra=extra_metadata,
17821865
verbose=verbose,
17831866
)
17841867

torchwright/debug/onnx_debug.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,12 +221,16 @@ def __init__(
221221
self._ra = ra
222222
self._key_to_state = key_to_state
223223

224-
# --- Annotation paths from the sidecar, keyed by canonical id and
225-
# mapped onto the rebuilt graph's live node objects so callers can
226-
# look one up by node (mirroring node.annotation on the in-process
227-
# backend). Reachable-but-unannotated nodes are simply absent.
224+
# --- Annotation paths from the sidecar's per-node table, keyed by
225+
# canonical id and mapped onto the rebuilt graph's live node
226+
# objects so callers can look one up by node (mirroring
227+
# node.annotation on the in-process backend). Nodes that carried
228+
# no annotation are simply absent.
228229
self._annotation_by_node_id: Dict[int, str] = {}
229-
for cid_str, label in (sidecar.get("annotations") or {}).items():
230+
for cid_str, meta in (sidecar.get("nodes") or {}).items():
231+
label = meta.get("annotation")
232+
if label is None:
233+
continue
230234
node = by_canon.get(int(cid_str))
231235
if node is not None:
232236
self._annotation_by_node_id[node.node_id] = label

0 commit comments

Comments
 (0)