|
| 1 | +"""Just-in-time materialization of ``LiteralValue`` constants. |
| 2 | +
|
| 3 | +Before this change a constant was an *input node*: it was allocated a |
| 4 | +residual-stream column at layer 0 and held across the whole network, even |
| 5 | +when consumed only deep in the graph (the ``select(cond, x, literal)`` |
| 6 | +case). Now a constant is a first-class *schedulable* node, materialized via |
| 7 | +``compute_literal_value`` only in the layer(s) around its consumer and freed |
| 8 | +after use. See ``constants_plan.md``. |
| 9 | +
|
| 10 | +The end-to-end *correctness* of literal-bearing graphs is covered by |
| 11 | +``test_forward_compile.py`` (``test_compile_constant``, |
| 12 | +``test_compile_select``, ``test_compile_multi_switch_shared_constants``, |
| 13 | +…). The tests here assert the JIT-specific properties those don't: |
| 14 | +
|
| 15 | +- a deep-consumed constant is **not** prefilled at layer 0; |
| 16 | +- it is materialized in the interior and **freed** after its consumer; |
| 17 | +- compiled values still match the oracle under both the heuristic |
| 18 | + (``optimize=0``) and CP-SAT (``optimize=1``) schedulers; |
| 19 | +- pure-constant subgraphs and shared constants compile correctly. |
| 20 | +""" |
| 21 | + |
| 22 | +import pytest |
| 23 | +import torch |
| 24 | + |
| 25 | +from torchwright.compiler.forward.compile import forward_compile |
| 26 | +from torchwright.compiler.forward.graph_analysis import GraphAnalyzer |
| 27 | +from torchwright.graph import Linear |
| 28 | +from torchwright.ops.arithmetic_ops import add, concat |
| 29 | +from torchwright.ops.inout_nodes import ( |
| 30 | + create_input, |
| 31 | + create_literal_value, |
| 32 | + create_pos_encoding, |
| 33 | +) |
| 34 | +from torchwright.ops.map_select import select |
| 35 | + |
| 36 | +D = 256 |
| 37 | +D_HEAD = 16 |
| 38 | + |
| 39 | + |
| 40 | +def _idchain(x, depth, name): |
| 41 | + """A chain of ``depth`` identity Linears — forces ``depth`` layers of |
| 42 | + dependency without changing the value (each ``Linear(h, I, 0) == h``).""" |
| 43 | + h = x |
| 44 | + for i in range(depth): |
| 45 | + h = Linear( |
| 46 | + h, torch.eye(len(x)), torch.zeros(len(x)), name=f"{name}{i}" |
| 47 | + ) |
| 48 | + return h |
| 49 | + |
| 50 | + |
| 51 | +def _live_layers(net, node): |
| 52 | + """Layer indices whose post-MLP residual snapshot holds ``node``.""" |
| 53 | + ra = net.residual_assignment |
| 54 | + return [ |
| 55 | + i for i, layer in enumerate(net.layers) |
| 56 | + if ra.has_node(layer.mlp.out_state, node) |
| 57 | + ] |
| 58 | + |
| 59 | + |
| 60 | +def _first_layer(net, node): |
| 61 | + """First post-MLP layer that materializes ``node`` (None if never).""" |
| 62 | + live = _live_layers(net, node) |
| 63 | + return live[0] if live else None |
| 64 | + |
| 65 | + |
| 66 | +# --------------------------------------------------------------------------- |
| 67 | +# is_input_node no longer claims constants |
| 68 | +# --------------------------------------------------------------------------- |
| 69 | + |
| 70 | + |
| 71 | +def test_literal_is_not_an_input_node(): |
| 72 | + lit = create_literal_value(torch.tensor([1.0, 2.0])) |
| 73 | + assert not GraphAnalyzer(lit).is_input_node(lit) |
| 74 | + |
| 75 | + |
| 76 | +# --------------------------------------------------------------------------- |
| 77 | +# Deep-consumed constant: not prefilled, materialized in the interior, freed |
| 78 | +# --------------------------------------------------------------------------- |
| 79 | + |
| 80 | + |
| 81 | +def test_deep_literal_not_prefilled_and_materialized_in_interior(): |
| 82 | + pos = create_pos_encoding() |
| 83 | + x = create_input("x", 2, value_range=(-5.0, 5.0)) |
| 84 | + h = _idchain(x, 3, "pre") # consumer lands several layers in |
| 85 | + lit = create_literal_value(torch.tensor([7.0, -3.0])) |
| 86 | + consumed = add(h, lit) # the constant's only consumer — deep |
| 87 | + out = _idchain(consumed, 3, "post") # downstream work after the consumer |
| 88 | + |
| 89 | + net = forward_compile( |
| 90 | + d=D, d_head=D_HEAD, output_node=out, pos_encoding=pos, verbose=False |
| 91 | + ) |
| 92 | + ra = net.residual_assignment |
| 93 | + |
| 94 | + # (1) The constant is not baked into the layer-0 input residual stream. |
| 95 | + in0 = ra.get_nodes(net.layers[0].attn.in_state) |
| 96 | + assert lit not in in0, "constant was prefilled at layer 0 (still an input node)" |
| 97 | + |
| 98 | + # (2) It is materialized strictly in the interior: born after layer 0 |
| 99 | + # (just-in-time, not eagerly) and freed before the final layer (its |
| 100 | + # consumer is mid-network, downstream layers don't need it). |
| 101 | + live = _live_layers(net, lit) |
| 102 | + assert live, "constant was never materialized" |
| 103 | + assert min(live) > 0, f"constant materialized at layer 0, not JIT: live={live}" |
| 104 | + assert max(live) < len(net.layers) - 1, ( |
| 105 | + f"constant held to the final layer instead of freed after its " |
| 106 | + f"consumer: live={live}, n_layers={len(net.layers)}" |
| 107 | + ) |
| 108 | + |
| 109 | + # (3) Output is still correct. |
| 110 | + xv = torch.randn(4, 2) |
| 111 | + result = net.compute(4, {"x": xv}) |
| 112 | + assert torch.allclose( |
| 113 | + result[out].cpu(), out.compute(4, {"x": xv}), atol=1e-3 |
| 114 | + ) |
| 115 | + |
| 116 | + |
| 117 | +# --------------------------------------------------------------------------- |
| 118 | +# Oracle agreement under both schedulers (the motivating select case) |
| 119 | +# --------------------------------------------------------------------------- |
| 120 | + |
| 121 | + |
| 122 | +@pytest.mark.parametrize("optimize", [0, 1]) |
| 123 | +def test_deep_select_literal_matches_oracle(optimize): |
| 124 | + """``select(cond, <deep>, literal)`` — the constant is the false branch, |
| 125 | + consumed deep in the network. Compiled output must match exact math |
| 126 | + under the heuristic (optimize=0) and CP-SAT (optimize=1) schedulers, and |
| 127 | + the false-branch positions must equal the constant.""" |
| 128 | + pos = create_pos_encoding() |
| 129 | + cond = create_input("cond", 1, value_range=(-1.0, 1.0)) |
| 130 | + base = create_input("t", 2, value_range=(-4.0, 4.0)) |
| 131 | + deep_true = _idchain(base, 3, "t") |
| 132 | + lit = create_literal_value(torch.tensor([2.0, -1.0])) |
| 133 | + out = select(cond, deep_true, lit) |
| 134 | + |
| 135 | + net = forward_compile( |
| 136 | + d=D, |
| 137 | + d_head=D_HEAD, |
| 138 | + output_node=out, |
| 139 | + pos_encoding=pos, |
| 140 | + verbose=False, |
| 141 | + optimize=optimize, |
| 142 | + ) |
| 143 | + |
| 144 | + n_pos = 3 |
| 145 | + inputs = { |
| 146 | + "cond": torch.tensor([[1.0], [-1.0], [1.0]]), # position 1 picks false |
| 147 | + "t": torch.randn(n_pos, 2), |
| 148 | + } |
| 149 | + actual = net.compute(n_pos, inputs)[out].cpu() |
| 150 | + expected = out.compute(n_pos, inputs) |
| 151 | + assert torch.allclose(actual, expected, atol=1e-3), ( |
| 152 | + f"optimize={optimize} max diff {(actual - expected).abs().max():.5f}" |
| 153 | + ) |
| 154 | + # The false-branch row must equal the just-in-time-materialized constant. |
| 155 | + assert torch.allclose(actual[1], lit.value, atol=1e-2) |
| 156 | + |
| 157 | + |
| 158 | +# --------------------------------------------------------------------------- |
| 159 | +# No deadlock: a pure-constant computation has nothing to wait for |
| 160 | +# --------------------------------------------------------------------------- |
| 161 | + |
| 162 | + |
| 163 | +def test_pure_constant_subgraph_compiles(): |
| 164 | + a = create_literal_value(torch.tensor([1.0, 2.0])) |
| 165 | + b = create_literal_value(torch.tensor([3.0, -4.0])) |
| 166 | + out = add(a, b) # Add(literal, literal) — eligible immediately, no stall |
| 167 | + |
| 168 | + net = forward_compile(d=D, d_head=D_HEAD, output_node=out, verbose=False) |
| 169 | + result = net.compute(2, {}) |
| 170 | + assert torch.allclose(result[out].cpu(), out.compute(2, {}), atol=1e-4) |
| 171 | + |
| 172 | + |
| 173 | +# --------------------------------------------------------------------------- |
| 174 | +# Shared constant: two consumers at different depths, still correct |
| 175 | +# --------------------------------------------------------------------------- |
| 176 | + |
| 177 | + |
| 178 | +def test_shared_literal_two_consumers_correct(): |
| 179 | + pos = create_pos_encoding() |
| 180 | + x = create_input("x", 2, value_range=(-5.0, 5.0)) |
| 181 | + lit = create_literal_value(torch.tensor([1.5, -2.5])) |
| 182 | + shallow = add(x, lit) # needs the constant near layer 0 |
| 183 | + deep = add(_idchain(x, 3, "d"), lit) # needs it deep |
| 184 | + # Wrap the join in an identity Linear so the output is a single |
| 185 | + # materialized node (a bare Concatenate isn't keyed in compute()'s result). |
| 186 | + out = Linear(concat([shallow, deep]), torch.eye(4), torch.zeros(4), name="out") |
| 187 | + |
| 188 | + net = forward_compile( |
| 189 | + d=D, d_head=D_HEAD, output_node=out, pos_encoding=pos, verbose=False |
| 190 | + ) |
| 191 | + xv = torch.randn(3, 2) |
| 192 | + result = net.compute(3, {"x": xv}) |
| 193 | + assert torch.allclose( |
| 194 | + result[out].cpu(), out.compute(3, {"x": xv}), atol=1e-3 |
| 195 | + ) |
| 196 | + # The constant is materialized at least once and lives long enough to |
| 197 | + # serve both consumers. |
| 198 | + assert _live_layers(net, lit), "shared constant was never materialized" |
| 199 | + |
| 200 | + |
| 201 | +# --------------------------------------------------------------------------- |
| 202 | +# Non-foldable path: a constant feeding an attention value (Attn has no bias) |
| 203 | +# --------------------------------------------------------------------------- |
| 204 | + |
| 205 | + |
| 206 | +def test_deep_attention_fed_constant_matches_oracle(): |
| 207 | + """A constant that feeds an attention *value* path must be materialized |
| 208 | + just-in-time and produce correct output. ``Attn`` has no bias, so this |
| 209 | + constant could never be folded — JIT is the only mechanism that handles |
| 210 | + it. The constant is part of the attention value via a Concatenate, needed |
| 211 | + only when the (deep) attention op runs.""" |
| 212 | + pos = create_pos_encoding() |
| 213 | + x = create_input("x", 2, value_range=(-5.0, 5.0)) |
| 214 | + h = _idchain(x, 3, "d") |
| 215 | + lit = create_literal_value(torch.tensor([4.0, -2.0])) |
| 216 | + v = concat([h, lit]) # the constant is part of the attention value |
| 217 | + out = pos.attend_to_offset(v, delta_pos=-1) |
| 218 | + |
| 219 | + net = forward_compile( |
| 220 | + d=D, d_head=D_HEAD, output_node=out, pos_encoding=pos, verbose=False |
| 221 | + ) |
| 222 | + # Not prefilled — materialized as a schedulable node. |
| 223 | + assert lit not in net.residual_assignment.get_nodes( |
| 224 | + net.layers[0].attn.in_state |
| 225 | + ) |
| 226 | + n_pos = 5 |
| 227 | + xv = torch.randn(n_pos, 2) |
| 228 | + actual = net.compute(n_pos, {"x": xv})[out].cpu() |
| 229 | + expected = out.compute(n_pos, {"x": xv}) |
| 230 | + assert torch.allclose(actual, expected, atol=1e-2), ( |
| 231 | + f"max diff {(actual - expected).abs().max():.5f}" |
| 232 | + ) |
| 233 | + |
| 234 | + |
| 235 | +# --------------------------------------------------------------------------- |
| 236 | +# Dirty-column reuse: a late constant lands on a recycled column |
| 237 | +# --------------------------------------------------------------------------- |
| 238 | + |
| 239 | + |
| 240 | +def test_literal_into_recycled_column_is_clean(): |
| 241 | + """A constant materialized deep in the network, under residual pressure, |
| 242 | + lands on a column recycled from an earlier dead node. The birth |
| 243 | + dirty-cancel must zero that column before the bias write — otherwise the |
| 244 | + dead node's stale value contaminates the constant. The investigation |
| 245 | + flagged this recycled-column path as previously untested, since the old |
| 246 | + code prefilled every constant into its own layer-0 column. |
| 247 | +
|
| 248 | + A small ``d`` forces recycling; correctness of the output (the constant |
| 249 | + is a saturated value the stale data would visibly corrupt) validates the |
| 250 | + cancel.""" |
| 251 | + pos = create_pos_encoding() |
| 252 | + x = create_input("x", 8, value_range=(-3.0, 3.0)) |
| 253 | + # ``early`` is consumed immediately and then dies, freeing its (now |
| 254 | + # dirty) columns back into the pool. |
| 255 | + early = _idchain(x, 1, "early") |
| 256 | + sink = add(x, early) # consumes ``early``; it dies here |
| 257 | + deep = _idchain(sink, 4, "deep") # push the constant's consumer late |
| 258 | + lit = create_literal_value(torch.full((8,), 5.0)) |
| 259 | + out = add(deep, lit) |
| 260 | + |
| 261 | + net = forward_compile( |
| 262 | + d=64, d_head=16, output_node=out, pos_encoding=pos, verbose=False |
| 263 | + ) |
| 264 | + xv = torch.randn(5, 8) |
| 265 | + result = net.compute(5, {"x": xv}) |
| 266 | + assert torch.allclose( |
| 267 | + result[out].cpu(), out.compute(5, {"x": xv}), atol=1e-3 |
| 268 | + ) |
| 269 | + |
| 270 | + |
| 271 | +# --------------------------------------------------------------------------- |
| 272 | +# Zero added latency: the JIT gate must not delay the consumer |
| 273 | +# --------------------------------------------------------------------------- |
| 274 | + |
| 275 | + |
| 276 | +def _consumer_layer(other_kind): |
| 277 | + """Compile ``Add(deep_chain(x), other)`` and return the layer the Add is |
| 278 | + materialized at. ``other`` is either a constant (JIT-materialized) or a |
| 279 | + pre-seeded input (available from layer 0). Structurally identical |
| 280 | + otherwise, so the Add's layer reveals whether the JIT gate delayed it.""" |
| 281 | + pos = create_pos_encoding() |
| 282 | + x = create_input("x", 2, value_range=(-5.0, 5.0)) |
| 283 | + deep = _idchain(x, 3, "d") |
| 284 | + if other_kind == "literal": |
| 285 | + other = create_literal_value(torch.tensor([1.0, 2.0])) |
| 286 | + else: |
| 287 | + other = create_input("c", 2, value_range=(-5.0, 5.0)) |
| 288 | + out = add(deep, other) |
| 289 | + net = forward_compile( |
| 290 | + d=D, d_head=D_HEAD, output_node=out, pos_encoding=pos, verbose=False |
| 291 | + ) |
| 292 | + return _first_layer(net, out) |
| 293 | + |
| 294 | + |
| 295 | +def test_jit_constant_adds_no_latency_to_consumer(): |
| 296 | + """The consumer of a just-in-time constant is scheduled at the same layer |
| 297 | + as it would be if that constant were a pre-seeded input (available from |
| 298 | + layer 0). Materializing the constant alongside the consumer's last |
| 299 | + non-constant input — rather than eagerly at layer 0 — costs the consumer |
| 300 | + nothing. This is the load-bearing scheduling claim from the design.""" |
| 301 | + with_literal = _consumer_layer("literal") |
| 302 | + with_input = _consumer_layer("input") |
| 303 | + assert with_literal is not None and with_input is not None |
| 304 | + assert with_literal <= with_input, ( |
| 305 | + f"JIT gate delayed the consumer: Add at layer {with_literal} with a " |
| 306 | + f"constant vs {with_input} with a pre-seeded input" |
| 307 | + ) |
| 308 | + |
| 309 | + |
| 310 | +# --------------------------------------------------------------------------- |
| 311 | +# I4 (column liveness): a constant must not be freed before its consumer |
| 312 | +# --------------------------------------------------------------------------- |
| 313 | + |
| 314 | + |
| 315 | +def test_jit_graph_passes_end_of_layer_liveness(monkeypatch): |
| 316 | + """Compile a deep-constant graph with the gated end-of-layer liveness |
| 317 | + walk on (``TW_COMPILER_VERIFY=1``). It raises if any node — here a |
| 318 | + just-in-time constant — is freed while an effective consumer is still |
| 319 | + uncomputed. Exercising it directly validates the new free-after-use |
| 320 | + behavior for constants.""" |
| 321 | + monkeypatch.setenv("TW_COMPILER_VERIFY", "1") |
| 322 | + pos = create_pos_encoding() |
| 323 | + x = create_input("x", 2, value_range=(-5.0, 5.0)) |
| 324 | + deep = _idchain(x, 3, "d") |
| 325 | + lit = create_literal_value(torch.tensor([7.0, -3.0])) |
| 326 | + out = add(deep, lit) |
| 327 | + |
| 328 | + net = forward_compile( |
| 329 | + d=D, d_head=D_HEAD, output_node=out, pos_encoding=pos, verbose=False |
| 330 | + ) |
| 331 | + xv = torch.randn(3, 2) |
| 332 | + assert torch.allclose( |
| 333 | + net.compute(3, {"x": xv})[out].cpu(), out.compute(3, {"x": xv}), atol=1e-3 |
| 334 | + ) |
| 335 | + |
| 336 | + |
| 337 | +# --------------------------------------------------------------------------- |
| 338 | +# CP-SAT discovers just-in-time placement as the residual-pressure optimum |
| 339 | +# --------------------------------------------------------------------------- |
| 340 | + |
| 341 | + |
| 342 | +def test_cpsat_treats_constant_as_schedulable_not_prefilled(): |
| 343 | + """Phase 2: under CP-SAT (optimize=2) the constant is a *schedulable* node |
| 344 | + materialized via ``compute_literal_value`` — not a residual column |
| 345 | + prefilled at layer 0 — and the output is correct. |
| 346 | +
|
| 347 | + Note on placement: CP-SAT defers a constant (just-in-time) only when |
| 348 | + residual pressure makes a later birth strictly better. In an |
| 349 | + unconstrained graph like this one (large ``d``, few nodes) the solver is |
| 350 | + *indifferent* to the constant's birth layer and may place it early — which |
| 351 | + is harmless, since the column is uncontended and the layer count is |
| 352 | + unchanged. The pressure-bound "places late" behavior is exercised on a |
| 353 | + real, residual-bound graph (the DOOM graph), not here.""" |
| 354 | + pos = create_pos_encoding() |
| 355 | + x = create_input("x", 2, value_range=(-5.0, 5.0)) |
| 356 | + deep = _idchain(x, 4, "d") |
| 357 | + lit = create_literal_value(torch.tensor([1.0, 2.0])) |
| 358 | + out = add(deep, lit) |
| 359 | + |
| 360 | + net = forward_compile( |
| 361 | + d=D, |
| 362 | + d_head=D_HEAD, |
| 363 | + output_node=out, |
| 364 | + pos_encoding=pos, |
| 365 | + verbose=False, |
| 366 | + optimize=2, |
| 367 | + ) |
| 368 | + ra = net.residual_assignment |
| 369 | + # Schedulable, not a prefilled source. |
| 370 | + assert lit not in ra.get_nodes(net.layers[0].attn.in_state), ( |
| 371 | + "CP-SAT prefilled the constant into layer-0 in_state (still a source)" |
| 372 | + ) |
| 373 | + assert _live_layers(net, lit), "CP-SAT never materialized the constant" |
| 374 | + # Correct. |
| 375 | + xv = torch.randn(3, 2) |
| 376 | + assert torch.allclose( |
| 377 | + net.compute(3, {"x": xv})[out].cpu(), out.compute(3, {"x": xv}), atol=1e-3 |
| 378 | + ) |
0 commit comments