Skip to content

Commit e0ca246

Browse files
physicsrobclaude
andcommitted
Fix CP-SAT INFEASIBLE-under-width-pressure (silent heuristic fallback)
On the DOOM forward, CP-SAT returned INFEASIBLE and forward_compile silently fell back to the heuristic at every width where residual width was binding (d <= 6400), so optimize>0 was secretly optimize=0. The model proved infeasible on schedules the heuristic compiles fine, because it over-counted two resources versus the heuristic it must replay: 1. BIRTH-dirty over-count (attention-head cumulative). The model charged a full-width dirty cancel to EVERY fresh allocation; the heuristic only clears the initial-pool dirty subset (recycled columns are already clean). Under width pressure columns recycle constantly, so the model charged hundreds of columns of phantom cancels and the pooled attention cumulative overflowed. Fix: compile_to_onnx / compile_headless_to_onnx default assume_zero_init=True -- the ONNX runtime always builds the residual stream from zeros, so the dirty cancels are unnecessary and model and replay both skip them. 2. Input-pinning over-reservation (residual cumulative). The model reserved every input column forever (available_residual = d - input_residual), but the heuristic frees consumed inputs and recycles their columns (the 600+-wide token Embedding is freed early to carry the geometry-stage intermediates). Fix: every input except pos_encoding is now modelled as a freeable residual interval [0, cancel); the cancel layers are written into the assignment and DirectedLayerScheduler frees the inputs at replay. Result on the DOOM forward: d=6400 solves FEASIBLE at 32 layers (heuristic 40), d=11200 at 28 (heuristic 34). The residual floor (d <= 3040, where the heuristic runs at ~100% residual occupancy) still falls back -- the model cannot represent the heuristic's within-layer eager freeing -- but the fallback is now loud, not silent. Supporting changes: - Extract build_cpsat_model from solve_schedule (behavior-preserving) so the model has one definition, plus a diagnostic constraint-family toggle. - forward_compile(require_solver=True) raises instead of silently falling back; the default path warns (RuntimeWarning) and surfaces SolveStats on net.cpsat_solve_stats. - Tests: input-freeing regression + require_solver guard. - docs/cpsat_scheduler.md updated for the residual budget, the BIRTH-dirty conservatism, and the eager-free limitation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0e4ac9d commit e0ca246

5 files changed

Lines changed: 556 additions & 123 deletions

File tree

docs/cpsat_scheduler.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,24 @@ The DEATH and BIRTH terms only run for residual-using nodes —
289289
chain-internal exclusive `L1` and chain-internal `ReLU` live in MLP
290290
hidden slots, not residual, so they have no columns to cancel.
291291

292+
**BIRTH-dirty is over-conservative under `assume_zero_init=False`.**
293+
The model charges a full-width BIRTH-dirty cancel to *every* fresh
294+
allocation, but the heuristic only clears the *dirty subset* of the
295+
allocated columns — columns recycled from a previously-cancelled node
296+
are already clean (`ResidualStreamMap.dirty_subset`). Under width
297+
pressure columns recycle constantly, so the model charges hundreds of
298+
columns of phantom cancels the replay never pays, and the pooled
299+
attention cumulative rejects schedules the heuristic compiles. The
300+
exact dirty cost is allocation-order-dependent (a property of replay,
301+
not of the layer assignment), so a sound tight per-layer bound is not
302+
easily expressible. The practical resolution: `compile_to_onnx`
303+
defaults `assume_zero_init=True`, because the ONNX runtime always
304+
builds the residual stream from zeros (`get_input_res_stream`), making
305+
every BIRTH-dirty cancel unnecessary — both the model and the replay
306+
then skip them and agree. `assume_zero_init=False` stays the
307+
conservative default of `forward_compile` for callers that may pass a
308+
non-zero residual stream to `forward()` directly.
309+
292310
**MLP slot budget.** Capacity `d_hidden` per layer.
293311

294312
- For each MLP-routed standalone `Linear` `n`: an optional unit-width
@@ -305,18 +323,49 @@ hidden slots, not residual, so they have no columns to cancel.
305323
`layer_var[chain.relu]`, demand `len(chain.relu)` (the chain's
306324
hidden width).
307325

308-
**Residual column budget.** Capacity `d − input_residual_cols`,
309-
where `input_residual_cols` is the sum of widths of pre-allocated
310-
input nodes plus `pos_encoding`.
326+
**Residual column budget.** Capacity `d − len(pos_encoding)`. Only
327+
`pos_encoding` is reserved for the whole schedule (the attention
328+
sublayer reads it at nearly every layer); every other input node is
329+
*freeable*.
311330

312-
- For each residual-using node `n`: a regular interval
331+
- For each residual-using scheduled node `n`: a regular interval
313332
`[layer_var[n], cancel_layer[n])`, demand `len(n)`.
333+
- For each freeable input node `n` (every input except `pos_encoding`):
334+
a regular interval `[0, input_cancel_layer[n])`, demand `len(n)`.
335+
Inputs are pre-allocated at layer 0, so their birth is fixed at 0;
336+
`input_cancel_layer[n]` obeys the same consumer lower bound and
337+
cancel-slack window as a scheduled node's `cancel_layer`, and is kept
338+
at `max_layers` for an input feeding a terminal `Concatenate`
339+
(output cone). `solve_schedule` writes these cancels into
340+
`ScheduleAssignment.node_to_cancel_layer`, and
341+
`DirectedLayerScheduler._find_dead_nodes` frees the input at replay —
342+
matching the heuristic, which frees consumed inputs and recycles their
343+
columns (the wide token `Embedding` is freed early and its 600+
344+
columns carry the geometry-stage intermediates). Reserving every
345+
input forever (the pre-fix model) starved intermediates under width
346+
pressure and made the residual cumulative reject schedules the
347+
heuristic compiles fine. Each freeable input also contributes a
348+
DEATH-layer cancel interval to the attention-head cumulative (its
349+
columns must be zeroed before reuse).
314350

315351
`uses_residual(n)` is `False` for chain-internal `ReLU` (lives in
316352
MLP hidden slots, not residual) and exclusive chain L1 (computed
317353
inline inside `linear1` from its input's residual columns, never
318354
written back to the stream). True for everything else.
319355

356+
**Limit — within-layer (eager) freeing is not modelled.** The
357+
cumulative is layer-granular, so a node read by a consumer at layer
358+
`K` is held through `K` (`cancel ≥ K+1`). The heuristic frees such a
359+
node *within* layer `K` (after the consuming attention op) and reuses
360+
its columns in the same layer's MLP sublayer — a density the model
361+
cannot represent without a sublayer-resolution residual axis. The
362+
consequence shows only at the residual floor: where the heuristic runs
363+
at ~100% residual occupancy (e.g. the DOOM forward at `d≈2560`), the
364+
model needs strictly more layers than the heuristic and CP-SAT cannot
365+
find an incumbent in budget, so it falls back. With any residual
366+
slack (the DOOM forward at `d ≥ 6400`) CP-SAT solves and beats the
367+
heuristic (e.g. 32 vs 40 layers).
368+
320369
### Objective
321370

322371
```

tests/compile/forward/test_cpsat_integration.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,13 +416,115 @@ def fake_solve(*args, **kwargs):
416416
monkeypatch.setattr(compile_mod, "solve_schedule", fake_solve)
417417

418418
out, inputs = _build_branchy()
419+
# The fallback now warns loudly (RuntimeWarning) so it can't masquerade
420+
# as a solve; the compile is still valid.
421+
with pytest.warns(RuntimeWarning, match="UNOPTIMIZED|fall(ing|s)? back"):
422+
net = forward_compile(
423+
d=D,
424+
d_head=D_HEAD,
425+
output_node=out,
426+
verbose=False,
427+
optimize=1,
428+
)
429+
actual = net.compute(2, inputs)[out].cpu()
430+
expected = out.compute(2, inputs)
431+
torch.testing.assert_close(actual, expected, atol=1e-4, rtol=1e-4)
432+
433+
434+
def test_cpsat_frees_wide_input_for_intermediate():
435+
"""A wide input consumed early must be freed so its columns carry a
436+
wide downstream intermediate.
437+
438+
Regression for the input-freeing fix. The token-`Embedding`-shaped
439+
case in miniature: a 24-wide input ``x`` feeds one narrow ``Linear``
440+
and is then dead, after which a 40-wide standalone ``Linear`` ``b``
441+
must materialise. At ``d=64`` with a 9-wide ``pos_encoding``:
442+
443+
* Pinning ``x`` forever (the pre-fix model) leaves only
444+
``64 - 24 - 9 = 31`` residual columns for intermediates — less
445+
than ``b``'s 40, so the residual cumulative is INFEASIBLE and
446+
CP-SAT falls back. ``require_solver=True`` turns that silent
447+
regression into a hard error.
448+
* Freeing ``x`` once its consumer runs (the fix) frees its 24
449+
columns, leaving ``64 - 9 = 55`` for ``b`` (40) plus the narrow
450+
``a`` (4) — a comfortable fit, so CP-SAT solves.
451+
452+
The compiled output must match the graph oracle: the directed replay
453+
has to actually reclaim ``x``'s columns and reuse them for ``b``.
454+
"""
455+
from torchwright.graph.pos_encoding import PosEncoding
456+
457+
torch.manual_seed(0)
458+
x = create_input("x", 24)
459+
a = Linear(x, torch.randn(24, 4), torch.zeros(4), name="a")
460+
b = Linear(a, torch.randn(4, 40), torch.zeros(40), name="b")
461+
out = Linear(b, torch.randn(40, 4), torch.zeros(4), name="out")
462+
419463
net = forward_compile(
420-
d=D,
421-
d_head=D_HEAD,
464+
d=64,
465+
d_head=8,
422466
output_node=out,
467+
pos_encoding=PosEncoding(9),
423468
verbose=False,
424469
optimize=1,
470+
require_solver=True,
471+
)
472+
# require_solver=True would have raised on a fallback, so reaching here
473+
# means CP-SAT produced a real assignment.
474+
assert net.cpsat_solve_stats is not None
475+
assert net.cpsat_solve_stats.status_name in ("OPTIMAL", "FEASIBLE")
476+
477+
inputs = {"x": torch.randn(2, 24)}
478+
actual = net.compute(2, inputs)[out].cpu()
479+
expected = out.compute(2, inputs)
480+
torch.testing.assert_close(actual, expected, atol=1e-4, rtol=1e-4)
481+
482+
483+
def test_require_solver_raises_on_fallback(monkeypatch):
484+
"""``require_solver=True`` converts a silent CP-SAT fallback into a hard
485+
error; the default (``require_solver=False``) warns loudly instead of
486+
failing silently. Regression guard for the silent-fallback footgun.
487+
"""
488+
from torchwright.compiler.forward import compile as compile_mod
489+
from torchwright.compiler.forward.cpsat_scheduler import SolveStats
490+
491+
fake_stats = SolveStats(
492+
status_name="INFEASIBLE",
493+
objective_value=-1,
494+
best_objective_bound=0.0,
495+
wall_time_s=0.0,
496+
solver_log="",
497+
total_attn_heads=-1,
498+
total_mlp_bypass_slots=-1,
499+
is_optimal=False,
500+
)
501+
monkeypatch.setattr(
502+
compile_mod, "solve_schedule", lambda *a, **k: (None, fake_stats)
425503
)
504+
505+
out, inputs = _build_branchy()
506+
507+
# require_solver=True -> raise rather than silently fall back.
508+
with pytest.raises(RuntimeError, match="no usable assignment|require_solver"):
509+
forward_compile(
510+
d=D,
511+
d_head=D_HEAD,
512+
output_node=out,
513+
verbose=False,
514+
optimize=1,
515+
require_solver=True,
516+
)
517+
518+
# require_solver=False (default) -> warn, fall back, still produce a
519+
# correct compile.
520+
with pytest.warns(RuntimeWarning, match="UNOPTIMIZED|fall(ing|s)? back"):
521+
net = forward_compile(
522+
d=D,
523+
d_head=D_HEAD,
524+
output_node=out,
525+
verbose=False,
526+
optimize=1,
527+
)
426528
actual = net.compute(2, inputs)[out].cpu()
427529
expected = out.compute(2, inputs)
428530
torch.testing.assert_close(actual, expected, atol=1e-4, rtol=1e-4)

torchwright/compiler/export.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ def compile_headless_to_onnx(
562562
extra_metadata: Optional[dict] = None,
563563
d_hidden: Optional[int] = None,
564564
trim_heads: bool = True,
565+
assume_zero_init: bool = True,
565566
) -> None:
566567
"""Compile a float-I/O graph to a KV-cached ONNX model.
567568
@@ -582,6 +583,12 @@ def compile_headless_to_onnx(
582583
``d_hidden`` is the per-layer MLP hidden width. Defaults to ``d``
583584
when omitted; pass an explicit value to decouple the MLP intermediate
584585
width from the residual stream width.
586+
587+
``assume_zero_init`` defaults to ``True`` (see :func:`compile_to_onnx`):
588+
the ONNX runtime always builds the residual stream from zeros plus the
589+
input projections, so BIRTH-layer dirty-column cancels are unnecessary and
590+
skipping them keeps the CP-SAT attention-head cumulative from being
591+
over-tight under width pressure.
585592
"""
586593
dense_inits: list = []
587594
sparse_inits: list = []
@@ -608,6 +615,7 @@ def compile_headless_to_onnx(
608615
on_layer_compiled=on_layer_compiled,
609616
d_hidden=d_hidden,
610617
trim_heads=trim_heads,
618+
assume_zero_init=assume_zero_init,
611619
)
612620
t_compile = time.perf_counter() - t0
613621

@@ -759,6 +767,7 @@ def compile_to_onnx(
759767
verbose: bool = True,
760768
trim_heads: bool = True,
761769
optimize: int = 0,
770+
assume_zero_init: bool = True,
762771
) -> None:
763772
"""Compile a token-I/O graph to a KV-cached ONNX model.
764773
@@ -775,6 +784,20 @@ def compile_to_onnx(
775784
776785
Prefill = empty past + past_len=0; decode = feed back the new_K_i /
777786
new_V_i from a prior run with the accumulated past_len.
787+
788+
``assume_zero_init`` defaults to ``True`` here (unlike ``forward_compile``,
789+
which defaults ``False``): the ONNX runtime always constructs the residual
790+
stream from zeros plus the input projections (see
791+
``HeadlessTransformer.get_input_res_stream`` /
792+
``_OnnxRuntime._build_res_stream``), so the initially-free residual columns
793+
are guaranteed clean on entry. The compiler can therefore skip the
794+
BIRTH-layer dirty-column cancels that defend against a non-zero residual
795+
stream — those cancels are pure overhead the ONNX runtime never needs, and
796+
modelling them per-allocation makes the CP-SAT attention-head cumulative
797+
over-tight under width pressure (it charges every fresh allocation a
798+
full-width dirty cancel, while the replay only clears the genuinely-dirty
799+
initial-pool subset). No ONNX caller can supply a non-zero stream, so
800+
``True`` is always sound for this entry point.
778801
"""
779802
dense_inits: list = []
780803
sparse_inits: list = []
@@ -801,6 +824,7 @@ def compile_to_onnx(
801824
on_layer_compiled=on_layer_compiled,
802825
trim_heads=trim_heads,
803826
optimize=optimize,
827+
assume_zero_init=assume_zero_init,
804828
)
805829
t_compile = time.perf_counter() - t0
806830

torchwright/compiler/forward/compile.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import copy
99
import os
1010
import time
11+
import warnings
1112
from typing import Callable, Optional, Set
1213

1314
import torch
@@ -404,6 +405,7 @@ def forward_compile(
404405
cpsat_costs: Costs = Costs(),
405406
cpsat_flex_routing: bool = True,
406407
assume_zero_init: bool = False,
408+
require_solver: bool = False,
407409
) -> HeadlessTransformer:
408410
"""Compile a computation graph into a HeadlessTransformer.
409411
@@ -463,6 +465,15 @@ def forward_compile(
463465
directly. Defaults to False — the conservative behaviour
464466
that runs BIRTH-layer dirty cancels on every fresh
465467
allocation regardless of the runtime contract.
468+
require_solver: When True and ``optimize>0``, raise
469+
``RuntimeError`` if CP-SAT returns no usable assignment
470+
instead of silently falling back to the heuristic. Use it
471+
in tests and measurement harnesses that must distinguish a
472+
real optimizer solve from a fallback (a fallback returns a
473+
valid-but-unoptimized compile with no error). When False
474+
(the default) the compile still falls back, but now emits a
475+
``warnings.warn`` so the fallback is never silent. Ignored
476+
when ``optimize=0``.
466477
467478
Returns:
468479
A HeadlessTransformer whose compute() method reproduces
@@ -509,6 +520,9 @@ def forward_compile(
509520

510521
# 2. Initialize
511522
net = HeadlessTransformer(d, d_head, pos_encoding, d_hidden=d_hidden)
523+
# Solver provenance, populated only when CP-SAT runs (optimize>0); stays
524+
# None for the heuristic path so callers can always query it.
525+
net.cpsat_solve_stats = None
512526
residual_map = ResidualStreamMap(d)
513527
residual_map.allocate(pos_encoding)
514528
# pos_encoding + input_nodes are populated by get_input_res_stream at
@@ -675,22 +689,45 @@ def forward_compile(
675689
hint_cancel=hint_cancel if hint_cancel else None,
676690
log_search_progress=verbose,
677691
)
692+
# Surface the solver provenance on the returned net so callers can
693+
# distinguish a real solve from a fallback without re-deriving it
694+
# (``stats.status_name``, ``is_optimal``, the LB/objective gap).
695+
net.cpsat_solve_stats = _stats
678696
if assignment is None:
679697
# CP-SAT found no feasible incumbent within budget — fall
680698
# back to the heuristic schedule. The warm-start was a
681699
# sunk cost; we already know the heuristic produces a
682700
# valid schedule.
701+
#
702+
# A fallback returns a valid-but-unoptimized compile with no
703+
# error, so a layer-count-only measurement cannot tell it from
704+
# a real solve. Surface it loudly (and optionally fatally via
705+
# ``require_solver``) so it can never masquerade as an
706+
# optimizer result. ``INFEASIBLE`` here is almost always a
707+
# model bug (the heuristic found a feasible schedule yet
708+
# CP-SAT's model proved none exists) rather than a hard
709+
# instance — see ``docs/cpsat_scheduler.md``.
710+
fallback_msg = (
711+
f"CP-SAT returned no usable assignment "
712+
f"(status={_stats.status_name}, "
713+
f"{cpsat_time_budget_s:.0f}s budget); falling back to the "
714+
f"heuristic schedule ({hint_n_layers} layers). The compile "
715+
f"is valid but UNOPTIMIZED — optimize>0 did not take "
716+
f"effect. status=INFEASIBLE on a graph the heuristic "
717+
f"schedules indicates a CP-SAT model bug, not a hard "
718+
f"instance."
719+
)
720+
if require_solver:
721+
raise RuntimeError(
722+
fallback_msg
723+
+ " (require_solver=True: refusing the silent fallback)"
724+
)
725+
warnings.warn(fallback_msg, RuntimeWarning, stacklevel=2)
683726
if verbose and _stats.solver_log:
684727
print("--- CP-SAT solver log (last 40 lines) ---")
685728
for line in _stats.solver_log.splitlines()[-40:]:
686729
print(f" {line}")
687730
print("--- end CP-SAT solver log ---")
688-
if verbose:
689-
print(
690-
f" CP-SAT found no feasible incumbent within "
691-
f"{cpsat_time_budget_s:.0f}s budget — falling back to "
692-
f"heuristic schedule ({hint_n_layers} layers)"
693-
)
694731
scheduler = LayerScheduler(
695732
graph,
696733
d,

0 commit comments

Comments
 (0)