Skip to content

Commit 87a4a87

Browse files
physicsrobclaude
andcommitted
Trim ONNX attention heads + MLP slots per layer (not just sparsify)
The streaming ONNX exporter gated both head trimming (compile.py:1034) and MLP-slot trimming (1079) behind `on_layer_compiled is None`, but the ONNX path always installs a streaming callback — so every ONNX compile skipped both trims and emitted full-width (128-head) layers, merely zeroing the unused heads/slots. The KV cache and MatMuls stayed full. Fix: trim each layer inside `_make_stream_layer_weights_cb` — the one chokepoint all 3 callsites flow through — before reading `attn.n_heads` and before the tensors are nulled. `used_heads` and the per-slot weights are already populated by write_attn_sublayer/write_mlp_sublayer when the callback fires, so both trims see final counts. Gated by a new `trim_heads` param on the factory, threaded through both exporters; `trim_heads=False` keeps today's full-width sparsified model. Downstream is already per-layer-ready (per_layer_n_heads sizes every emitted tensor + ValueInfo; OnnxTokenRuntime discovers heads from past_K shapes), so no runtime change. Post-loop in-process trims and their guards are unchanged (corrected the false comments that claimed the streaming sparse export already dropped zeros). Added a verbose "Head pruning (ONNX)" summary to both exporters. Tests: rewrote test_streaming_trim docstring (the ordering regression stays); added 4 tests to test_headless_onnx — KV cache shrinks per-layer + non-uniform, full width preserved under trim_heads=False, MLP W1 slots shrink, and a trim-vs-no-trim numerical no-op (only all-zero heads/slots removed). Full torchwright + torchwright_doom suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 865ddc5 commit 87a4a87

4 files changed

Lines changed: 197 additions & 13 deletions

File tree

tests/compile/forward/test_headless_onnx.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def _build_sample_graph():
5353
return out, create_pos_encoding()
5454

5555

56-
def _export(output_node, pos_encoding, tmpdir, name="model.onnx"):
56+
def _export(output_node, pos_encoding, tmpdir, name="model.onnx", trim_heads=True):
5757
onnx_path = os.path.join(tmpdir, name)
5858
compile_headless_to_onnx(
5959
output_node,
@@ -63,10 +63,39 @@ def _export(output_node, pos_encoding, tmpdir, name="model.onnx"):
6363
d_head=D_HEAD,
6464
max_seq_len=32,
6565
verbose=False,
66+
trim_heads=trim_heads,
6667
)
6768
return onnx_path
6869

6970

71+
def _l_w1_d_hidden(onnx_path):
72+
"""Per-layer MLP hidden widths read straight off the ONNX initializers.
73+
74+
``l{i}_W1`` is the first MLP weight, emitted with shape ``(d, d_hidden_i)``
75+
(it is the LHS of ``MatMul(res, W1)`` where ``res`` is ``(t, d)``), so its
76+
second dim is that layer's hidden width after trimming. A mostly-zero W1 is
77+
emitted as a SparseTensorProto, so look in both the dense and sparse init
78+
lists (the sparse proto's ``dims`` still carries the full dense shape and its
79+
name lives on ``values.name``).
80+
"""
81+
import onnx
82+
83+
model = onnx.load(onnx_path)
84+
dims_by_name = {init.name: list(init.dims) for init in model.graph.initializer}
85+
for sp in model.graph.sparse_initializer:
86+
dims_by_name[sp.values.name] = list(sp.dims)
87+
88+
widths = []
89+
i = 0
90+
while f"l{i}_W1" in dims_by_name:
91+
dims = dims_by_name[f"l{i}_W1"]
92+
assert dims[0] == D, f"l{i}_W1 first dim {dims[0]} != d {D}"
93+
widths.append(int(dims[1]))
94+
i += 1
95+
assert widths, "no l{i}_W1 initializers found"
96+
return widths
97+
98+
7099
# ---------------------------------------------------------------------------
71100
# Test 1: Prefill on full sequence matches compute() reference
72101
# ---------------------------------------------------------------------------
@@ -401,3 +430,107 @@ def test_compiled_headless_step_prefill_decode_matches_full():
401430
# past_K should have grown to n_total = 5
402431
past_K, _ = past
403432
assert past_K[0].shape[1] == 5
433+
434+
435+
# ---------------------------------------------------------------------------
436+
# Test 7: trim_heads actually trims the exported ONNX (heads + MLP slots)
437+
# ---------------------------------------------------------------------------
438+
#
439+
# The streaming exporter used to leave every layer full-width (d/d_head heads,
440+
# full d_hidden MLP), merely sparsifying the unused heads/slots to zero. These
441+
# tests pin that ``trim_heads=True`` genuinely shrinks the per-layer KV cache
442+
# (past_K_i widths) and MLP MatMuls (l{i}_W1 widths), that ``trim_heads=False``
443+
# preserves the full width, and that trimming is a numerical no-op (only all-zero
444+
# heads/slots are removed).
445+
446+
447+
def test_headless_onnx_trim_heads_shrinks_kv_cache():
448+
"""trim_heads=True: per-layer past_K widths are below the full head count."""
449+
out, pos = _build_sample_graph()
450+
max_heads = D // D_HEAD # 16
451+
with tempfile.TemporaryDirectory() as tmpdir:
452+
onnx_path = _export(out, pos, tmpdir, trim_heads=True)
453+
session = onnxruntime.InferenceSession(onnx_path)
454+
_, per_layer_n_heads, _ = _discover_meta(session)
455+
456+
assert per_layer_n_heads, "no layers discovered"
457+
assert all(1 <= nh <= max_heads for nh in per_layer_n_heads)
458+
assert min(per_layer_n_heads) < max_heads, (
459+
f"no layer trimmed below full width {max_heads}: {per_layer_n_heads}"
460+
)
461+
# A real per-layer trim leaves the layers non-uniform (different layers use
462+
# different head counts); a coincidental uniform value would still satisfy
463+
# the bound above, so assert the per-layer variation explicitly.
464+
assert len(set(per_layer_n_heads)) > 1, (
465+
f"head counts are uniform across layers: {per_layer_n_heads}"
466+
)
467+
468+
469+
def test_headless_onnx_no_trim_preserves_full_width():
470+
"""trim_heads=False: every layer keeps the full d/d_head head count."""
471+
out, pos = _build_sample_graph()
472+
max_heads = D // D_HEAD # 16
473+
with tempfile.TemporaryDirectory() as tmpdir:
474+
onnx_path = _export(out, pos, tmpdir, trim_heads=False)
475+
session = onnxruntime.InferenceSession(onnx_path)
476+
_, per_layer_n_heads, _ = _discover_meta(session)
477+
478+
assert per_layer_n_heads, "no layers discovered"
479+
assert all(nh == max_heads for nh in per_layer_n_heads), (
480+
f"expected all layers at full width {max_heads}: {per_layer_n_heads}"
481+
)
482+
483+
484+
def test_headless_onnx_trim_shrinks_mlp_slots():
485+
"""trim_heads=True shrinks some l{i}_W1 below full d_hidden; False keeps all."""
486+
out, pos = _build_sample_graph()
487+
full_d_hidden = D # d_hidden defaults to d when omitted
488+
with tempfile.TemporaryDirectory() as tmpdir:
489+
trim_path = _export(out, pos, tmpdir, name="trim.onnx", trim_heads=True)
490+
notrim_path = _export(out, pos, tmpdir, name="notrim.onnx", trim_heads=False)
491+
trim_widths = _l_w1_d_hidden(trim_path)
492+
notrim_widths = _l_w1_d_hidden(notrim_path)
493+
494+
assert all(1 <= w <= full_d_hidden for w in trim_widths)
495+
assert min(trim_widths) < full_d_hidden, (
496+
f"no layer's MLP trimmed below full d_hidden {full_d_hidden}: {trim_widths}"
497+
)
498+
assert all(w == full_d_hidden for w in notrim_widths), (
499+
f"expected all layers at full d_hidden {full_d_hidden}: {notrim_widths}"
500+
)
501+
502+
503+
def test_headless_onnx_trim_is_numerical_noop():
504+
"""Trimmed and full-width ONNX produce identical output on the same prefill.
505+
506+
Trimming removes only all-zero heads/slots, so prefilling the same rows
507+
through both sessions must agree — any divergence means a non-zero head or
508+
slot was trimmed (a real bug).
509+
"""
510+
out, pos = _build_sample_graph()
511+
a_vals = torch.tensor([[3.0], [5.0], [-2.0], [0.0], [4.0]])
512+
b_vals = torch.tensor([[4.0], [-1.0], [3.0], [7.0], [2.0]])
513+
inputs_np = torch.cat([a_vals, b_vals], dim=1).numpy().astype(np.float32)
514+
515+
with tempfile.TemporaryDirectory() as tmpdir:
516+
trim_path = _export(out, pos, tmpdir, name="trim.onnx", trim_heads=True)
517+
notrim_path = _export(out, pos, tmpdir, name="notrim.onnx", trim_heads=False)
518+
519+
sess_trim = onnxruntime.InferenceSession(trim_path)
520+
sess_notrim = onnxruntime.InferenceSession(notrim_path)
521+
522+
_, heads_trim, d_head = _discover_meta(sess_trim)
523+
_, heads_notrim, _ = _discover_meta(sess_notrim)
524+
525+
feeds_trim = {"inputs": inputs_np}
526+
feeds_trim.update(_empty_past_feeds(heads_trim, d_head))
527+
out_trim = sess_trim.run(["outputs"], feeds_trim)[0]
528+
529+
feeds_notrim = {"inputs": inputs_np}
530+
feeds_notrim.update(_empty_past_feeds(heads_notrim, d_head))
531+
out_notrim = sess_notrim.run(["outputs"], feeds_notrim)[0]
532+
533+
assert np.allclose(out_trim, out_notrim, atol=1e-4), (
534+
f"trim changed the output: max diff "
535+
f"{np.abs(out_trim - out_notrim).max():.6e}"
536+
)

tests/compile/forward/test_streaming_trim.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@
1010
1111
The onnxruntime-based ``compile_to_onnx`` tests are skipped when onnxruntime is
1212
absent, so this no-onnxruntime test pins the trim/null ordering directly: with a
13-
streaming callback present, ``forward_compile`` must complete (trims skipped).
14-
The streamed sparse export already drops the zero heads/slots, so the in-memory
15-
trim is both impossible and redundant in that mode.
13+
streaming callback present, ``forward_compile`` must complete (post-loop trims
14+
skipped because the callback already nulled the weights).
15+
16+
NOTE: the *real* per-layer head/slot trim for the ONNX export now happens
17+
*inside* ``_make_stream_layer_weights_cb`` (it calls ``trim_unused_heads`` /
18+
``trim_unused_slots`` on each layer before reading and nulling its tensors), so
19+
the exported ONNX genuinely shrinks the KV cache and MatMuls. That behaviour is
20+
covered by ``test_headless_onnx.py`` (per-layer ``past_K_i`` widths, ``l{i}_W1``
21+
shapes, and a trim-vs-no-trim numerical no-op). The stub callback below does
22+
NOT trim — it only nulls — so this test remains a pure regression for the
23+
ordering guard: the post-loop trim must not slice already-nulled weights.
1624
"""
1725

1826
import torch

torchwright/compiler/export.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,14 +258,27 @@ def _make_stream_layer_weights_cb(
258258
dense_inits: list,
259259
sparse_inits: list,
260260
per_layer_n_heads: list,
261+
trim_heads: bool = True,
261262
) -> Callable[[int, object], None]:
262263
"""Factory for the forward_compile on_layer_compiled callback.
263264
264265
Emits each freshly-compiled layer's dense weights into the
265266
dense/sparse init lists (sparsified on the fly when mostly zero) and
266267
nulls out the layer's tensor attributes so GC can reclaim them.
267268
268-
After head trimming, each layer's attention matrices have shape
269+
When ``trim_heads`` is set (the default), each layer is trimmed *in
270+
place here* — before its tensors are read — so the unused (all-zero)
271+
attention heads and MLP hidden slots are physically removed: the
272+
emitted WQ/WK/WV/WO matrices, the per-layer reshape constants, and
273+
the ``past_K_i``/``past_V_i`` ValueInfo all shrink to the used count,
274+
cutting both the KV cache and the inference MatMuls. This is the
275+
*only* place the ONNX export trims; the post-loop trims in
276+
``forward_compile`` are skipped whenever a streaming callback is
277+
installed (their tensors are already nulled here). With
278+
``trim_heads=False`` the full-width (sparsified-but-not-trimmed)
279+
model is emitted instead.
280+
281+
After trimming, each layer's attention matrices have shape
269282
``(nh, d, d_head)`` where ``nh <= d // d_head``. Weight matrices
270283
are emitted at the trimmed size and per-layer reshape constants
271284
are added so the ONNX graph can reshape to the correct head count.
@@ -277,6 +290,14 @@ def on_layer_compiled(i: int, layer) -> None:
277290
attn = layer.attn.attn
278291
mlp = layer.mlp
279292

293+
# Trim each layer here, before its tensors are read and nulled
294+
# below. ``used_heads`` (attn) and the per-slot weight columns
295+
# (mlp) were populated by write_attn_sublayer/write_mlp_sublayer
296+
# during this layer's compile, so both trims see final counts.
297+
if trim_heads:
298+
attn.trim_unused_heads() # n_heads -> used heads (KV cache + attn MatMuls)
299+
mlp.trim_unused_slots() # d_hidden -> used slots (MLP MatMuls)
300+
280301
nh = attn.n_heads # post-trim head count
281302
hd = nh * d_head
282303
per_layer_n_heads.append(nh)
@@ -621,6 +642,7 @@ def compile_headless_to_onnx(
621642
dense_inits,
622643
sparse_inits,
623644
per_layer_n_heads,
645+
trim_heads=trim_heads,
624646
)
625647

626648
# --- Phase 1: streaming compile ---------------------------------------
@@ -639,6 +661,15 @@ def compile_headless_to_onnx(
639661
assume_zero_init=assume_zero_init,
640662
)
641663
t_compile = time.perf_counter() - t0
664+
if verbose and per_layer_n_heads:
665+
_max_heads = d // d_head
666+
_total = _max_heads * len(per_layer_n_heads)
667+
_kept = sum(per_layer_n_heads)
668+
print(
669+
f" Head pruning (ONNX): {_total - _kept}/{_total} heads pruned; "
670+
f"per-layer heads range [{min(per_layer_n_heads)}, "
671+
f"{max(per_layer_n_heads)}] of {_max_heads}"
672+
)
642673

643674
# --- Phase 2: metadata + graph assembly -------------------------------
644675
assert compiled.residual_assignment is not None
@@ -833,6 +864,7 @@ def compile_to_onnx(
833864
dense_inits,
834865
sparse_inits,
835866
per_layer_n_heads,
867+
trim_heads=trim_heads,
836868
)
837869

838870
# --- Phase 1: streaming compile ---------------------------------------
@@ -852,6 +884,15 @@ def compile_to_onnx(
852884
d_hidden=d_hidden,
853885
)
854886
t_compile = time.perf_counter() - t0
887+
if verbose and per_layer_n_heads:
888+
_max_heads = d // d_head
889+
_total = _max_heads * len(per_layer_n_heads)
890+
_kept = sum(per_layer_n_heads)
891+
print(
892+
f" Head pruning (ONNX): {_total - _kept}/{_total} heads pruned; "
893+
f"per-layer heads range [{min(per_layer_n_heads)}, "
894+
f"{max(per_layer_n_heads)}] of {_max_heads}"
895+
)
855896

856897
# --- Phase 2: metadata + graph assembly -------------------------------
857898
assert compiled.residual_assignment is not None

torchwright/compiler/forward/compile.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,11 +1026,12 @@ def forward_compile(
10261026
net.residual_assignment = ra
10271027
net.assert_aliases = graph.get_assert_aliases()
10281028

1029-
# Skip in-place weight trimming when streaming: ``on_layer_compiled`` (the
1030-
# ONNX streaming path) extracts each layer's weights and NULLs the tensors
1031-
# per layer, so the post-loop trims here would slice ``None``. The streamed
1032-
# sparse export already drops the unused (zero) heads/slots, making the
1033-
# in-memory trim both impossible and redundant in that mode.
1029+
# This post-loop trim is the in-process (no-callback) path's trim. The ONNX
1030+
# streaming path trims each layer *inside* ``on_layer_compiled`` (see
1031+
# ``_make_stream_layer_weights_cb``) before reading and NULLing its tensors,
1032+
# so by the time we get here those tensors are already trimmed and nulled —
1033+
# re-trimming would slice ``None``. Hence the ``on_layer_compiled is None``
1034+
# guard: only the in-process return value still carries live weights to trim.
10341035
if trim_heads and on_layer_compiled is None:
10351036
max_heads = d // d_head
10361037
for layer in net.layers:
@@ -1073,9 +1074,10 @@ def forward_compile(
10731074
f"{kv_depth:>10} {cp:>10} {sa:>10}"
10741075
)
10751076

1076-
# Trim trailing unused MLP slots (same idea as head trimming). Skipped when
1077-
# streaming for the same reason as head trimming above (weights already
1078-
# extracted + nulled per layer by ``on_layer_compiled``).
1077+
# Trim trailing unused MLP slots (same idea as head trimming, and the
1078+
# in-process counterpart to the per-layer ``mlp.trim_unused_slots()`` the
1079+
# streaming callback runs). Skipped when streaming because the callback
1080+
# already trimmed and nulled each layer's MLP weights above.
10791081
if on_layer_compiled is None:
10801082
for layer in net.layers:
10811083
layer.mlp.trim_unused_slots()

0 commit comments

Comments
 (0)