Skip to content

Commit 0e4ac9d

Browse files
physicsrobclaude
andcommitted
feat(compile): materialize constants just-in-time, not as input nodes
LiteralValue was classified as an input node, so every constant was allocated a residual-stream column at layer 0 and held until its last consumer. A constant first needed deep in the network (e.g. select(cond, x, LiteralValue)) needlessly reserved a scarce column across that whole span, inflating residual pressure (and thus d / layer count). Constants are now first-class schedulable nodes, materialized via the (previously dead) compute_literal_value op only when a consumer needs them and freed after use. The heuristic scheduler uses an explicit just-in-time gate -- materialize once a consumer's non-constant inputs have landed, so the consumer is never delayed. CP-SAT gets the same behavior for free: constants become schedulable nodes whose residual interval the solver minimizes (it defers under pressure, and is indifferent otherwise -- harmless, since the column is then uncontended). Removing LiteralValue from is_input_node propagates the change through compile setup and both schedulers with no further CP-SAT edits; the cost/routing/dirty-cancel machinery already generalizes. Adds JIT-specific tests (not-prefilled/materialized-interior/freed, zero-added-latency, I4 liveness, correctness under both schedulers, the unfoldable attention-value path, and an airtight dirty-column-reuse unit test). Design rationale and phased plan in constants_plan.md. Test Suite Status: 726 passed, 1 failed, 3 skipped. The single failure is a pre-existing, environment-specific flake in test_numerical_noise_drift (reciprocal_03_200 p99 at the 30% tolerance boundary on Modal CPUs); it is in the graph-oracle measurement path, has zero LiteralValue nodes, and measures the committed value exactly in isolation -- provably unaffected by this compiler-only change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc75c6f commit 0e4ac9d

6 files changed

Lines changed: 1066 additions & 11 deletions

File tree

constants_plan.md

Lines changed: 541 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
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

Comments
 (0)