|
| 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