Skip to content

Commit 8c50411

Browse files
committed
Merge torchwright:main from /data/torchdoom2 (CP-SAT feasible warm-start b017bbd)
Brings the CP-SAT no-eager feasible warm-start hint (post-J d=4096) from the parallel torchdoom2 checkout into main alongside the per-column gate-offset M fix. Disjoint file sets (compiler/scheduler vs ops/), no conflicts.
2 parents c5856df + b017bbd commit 8c50411

7 files changed

Lines changed: 293 additions & 30 deletions

File tree

docs/cpsat_scheduler.md

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,36 @@ heuristic's layer count also tightens the search horizon
553553
(`max_layers = min(user_max, hint_n_layers + 1)`), which shrinks
554554
each `layer_var`'s domain.
555555

556+
**The warm-start runs with eager freeing disabled (`eager_free=False`).**
557+
The default heuristic frees a node's columns *within* its consumer's
558+
layer and reuses them the same layer (within-layer reuse). The
559+
layer-granular CP-SAT model — where a node occupies the residual
560+
stream for the half-open layer interval `[birth, cancel)` — cannot
561+
express that density, so the *eager* schedule is an **infeasible**
562+
hint: CP-SAT discards it, cold-searches, and times out into the
563+
silent heuristic fallback (the post-J DOOM symptom at d=4096/d_head=32:
564+
`optimize=2` returned 85 via fallback). The no-eager schedule is one
565+
or two layers deeper (87 vs 85 at d=4096) but model-representable, so
566+
CP-SAT gets a *real feasible incumbent* to verify and improve — it
567+
walks 87 → 86 → … → 81–82 within the `optimize=2` budget (the exact
568+
floor varies run-to-run with CP-SAT's parallel workers). The heuristic
569+
**fallback** (used only when CP-SAT finds nothing) keeps the default
570+
eager behavior, so a timeout never regresses below the eager depth.
571+
572+
**Rollback-cancel correction.** `LayerScheduler` speculatively
573+
allocates a node, then rolls the allocation back with `free(node)`
574+
when the op can't be committed under the dirty-cancel / head budget.
575+
That rollback is not a real death — the node is re-allocated (reborn)
576+
in a later layer, and may then die by `reassign` (free-add, never
577+
`free`). Recording the rollback layer as the cancel produced hints
578+
with `cancel < birth` (e.g. freed at layer 6, actually born at 7),
579+
which is hard-infeasible in the model — exactly the 3 records that kept
580+
the otherwise-feasible no-eager hint from being accepted as a complete
581+
incumbent. `_TrackingResidualStreamMap` therefore clears a node's
582+
stale cancel whenever it is re-allocated or reborn via `reassign`, so
583+
the emitted `hint_cancel` is internally consistent (`cancel ≥ birth+1`,
584+
or omitted when the node dies by reassign).
585+
556586
### Cancel-domain restriction
557587

558588
The cancel decision space is the dominant LB-search cost when the
@@ -610,18 +640,25 @@ feasible but possibly non-optimal. `forward_compile` accepts it —
610640
### Geometry sensitivity
611641

612642
The win-size from CP-SAT versus heuristic depends on residual-stream
613-
slack. Measured on the headless DOOM graph (~4.4K nodes); each row
614-
records the ``optimize`` level (and corresponding budget) used:
615-
616-
| geometry | -O / budget | heuristic | CP-SAT | Δ | first incumbent | OPTIMAL at |
617-
|---------------------|-------------|----------:|-------:|-----:|----------------:|-----------:|
618-
| d=2048, d_h=8192 | -O 1 (60s) | 61 | (none) | n/a | not in 60s | not in 60s |
619-
| d=3072, d_h=8192 | -O 2 (180s) | 58 | 46 | -21% | ~27s | ~80s |
620-
| d=4096, d_h=4096 | -O 1 (60s) | 59 | 46 | -22% | ~17s | ~31s |
643+
slack. The first three rows were measured on an earlier headless DOOM
644+
graph (~4.4K nodes); the last row is the post-J graph (~13K nodes,
645+
the flat pass roughly doubled depth). Each row records the
646+
``optimize`` level (and corresponding budget) used:
647+
648+
| geometry | -O / budget | heuristic | CP-SAT | Δ | first incumbent | OPTIMAL at |
649+
|--------------------------------|-------------|----------:|-------:|-----:|----------------:|-----------:|
650+
| d=2048, d_h=8192 | -O 1 (60s) | 61 | (none) | n/a | not in 60s | not in 60s |
651+
| d=3072, d_h=8192 | -O 2 (180s) | 58 | 46 | -21% | ~27s | ~80s |
652+
| d=4096, d_h=4096 | -O 1 (60s) | 59 | 46 | -22% | ~17s | ~31s |
653+
| post-J d=4096, d_h=4096, dh=32 | -O 2 (180s) | 85 | 81–82 | -4% | ~70–80s | never (LB 50) |
621654

622655
At d=2048 the residual cumulative is the binding constraint and
623656
CP-SAT struggles to close the LB gap within budget. At d=3072+
624-
CP-SAT converges optimally inside the budget. The heuristic-
657+
CP-SAT converges optimally inside the budget. On the much larger
658+
post-J graph CP-SAT no longer proves optimality (the lower bound
659+
sticks at 50) but still beats the heuristic — and only once the
660+
warm-start feeds a *feasible* (no-eager) hint; with the eager hint it
661+
finds no incumbent and falls back. The heuristic-
625662
fallback behavior (when CP-SAT can't find an incumbent) is the
626663
right answer for d=2048 — users always get a schedule, just not
627664
the CP-SAT one.
@@ -643,7 +680,23 @@ the CP-SAT one.
643680
solution. CP-SAT's first feasible reproduces the heuristic
644681
warm-start; stopping there returned no improvement over
645682
`optimize=0` while paying the model-build cost. Removed.
646-
- **`repair_hint=True`** would have let CP-SAT actively complete
647-
the partial hint into a feasible solution, but it conflicts
648-
with `AddDecisionStrategy` (CP-SAT crashes with
649-
"fixed_search != nullptr"). Not pursued.
683+
- **`repair_hint=True`** would have let CP-SAT actively complete the
684+
partial hint into a feasible solution, but it is **unusable in this
685+
OR-Tools version** (v9.15): it aborts the process with
686+
`Check failed: heuristics.fixed_search != nullptr`*with and
687+
without* `AddDecisionStrategy`, and even with
688+
`search_branching=FIXED_SEARCH` (where the crash just moves from
689+
setup into a parallel worker mid-search). (An earlier note here
690+
claimed it merely "conflicts with `AddDecisionStrategy`"; that was
691+
wrong — removing the strategy does not avoid the crash.) And it
692+
would not have helped regardless: measured on the post-J DOOM graph,
693+
the ~70 s to first incumbent is **genuine model-size search**
694+
(≈23 s presolve + ≈45 s LB/feasibility search on a ~80 k-variable
695+
model with three coupled `AddCumulative` constraints), not a blocked
696+
seed. The raw hint (with the 3 infeasible `cancel < birth` records)
697+
and the clean hint reach their first incumbent at the *same* time —
698+
the soft `AddHint` silently drops the infeasible values either way —
699+
so the cancel artifacts were a correctness curiosity, not the
700+
performance lever. The lever is feeding a *feasible* hint at all
701+
(the no-eager schedule, see *Warm-start hints*) plus an `optimize≥2`
702+
budget; CP-SAT then improves 87 → 81.

tests/compile/forward/test_residual_map.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,86 @@ def test_resolve_indices_concatenate():
140140
# Concatenate is NOT allocated — resolve_indices resolves through it
141141
result = rmap.resolve_indices(cat)
142142
assert result == idx_a + idx_b
143+
144+
145+
def test_tracking_map_clears_rollback_cancel_on_reallocate():
146+
"""A node freed by a *rolled-back* speculative allocation, then re-allocated
147+
(reborn) at a later layer, must not keep the rollback layer as its cancel.
148+
149+
Regression for the CP-SAT warm-start hint producing ``cancel < birth``:
150+
``LayerScheduler`` speculatively allocates a node, fails its dirty-cancel/
151+
head budget, and rolls the allocation back with ``free(node)`` at layer 6 —
152+
but the node is really born at layer 7 and dies by ``reassign`` (free-add,
153+
never ``free``). The recorded ``cancel = 6 < birth = 7`` was hard-infeasible
154+
in the CP-SAT model. The tracking map clears the stale cancel on
155+
(re)allocate / reassign so the emitted hint is internally consistent.
156+
"""
157+
from torchwright.compiler.forward.compile import _TrackingResidualStreamMap
158+
159+
base = ResidualStreamMap(64)
160+
tmap = _TrackingResidualStreamMap(base)
161+
x = InputNode("x", 1, value_range=(-100.0, 100.0))
162+
163+
# Layer 6: speculative allocate then rollback free -> records cancel=6.
164+
tmap.current_layer = 6
165+
tmap.allocate(x)
166+
tmap.free(x)
167+
assert tmap.cancel_layer[x.node_id] == 6
168+
169+
# Layer 7: the node is really born here -> the stale cancel must be cleared.
170+
tmap.current_layer = 7
171+
tmap.allocate(x)
172+
assert x.node_id not in tmap.cancel_layer
173+
174+
# A genuine later free re-records the real cancel.
175+
tmap.current_layer = 9
176+
tmap.free(x)
177+
assert tmap.cancel_layer[x.node_id] == 9
178+
179+
180+
def test_node_deepcopy_preserves_identity():
181+
"""`copy.deepcopy` of a container that *references* nodes must keep node
182+
identity (nodes are graph singletons keyed on node_id), while still
183+
deep-copying the surrounding container.
184+
185+
Regression for the warm-start: `_run_heuristic_warm_start` does
186+
`copy.deepcopy(residual_map)` to isolate its scheduler mutations. Without
187+
`Node.__deepcopy__` returning self, that cloned the `pos` node, breaking the
188+
`n is self.pos_encoding` identity check so the warm-start freed `pos` early
189+
and produced a hint the CP-SAT model (which reserves pos) rejected by exactly
190+
len(pos) columns.
191+
"""
192+
import copy
193+
194+
n = InputNode("x", 4, value_range=(-100.0, 100.0))
195+
container = {"node": n, "list": [n]}
196+
dup = copy.deepcopy(container)
197+
198+
assert dup["node"] is n # node identity preserved (not cloned)
199+
assert dup["list"][0] is n
200+
assert dup is not container # the container itself is still deep-copied
201+
assert dup["list"] is not container["list"]
202+
203+
204+
def test_tracking_map_clears_stale_cancel_on_reassign():
205+
"""A node reborn via ``reassign`` (the free-add path) must not keep a stale
206+
cancel from an earlier rolled-back allocation."""
207+
from torchwright.compiler.forward.compile import _TrackingResidualStreamMap
208+
209+
base = ResidualStreamMap(64)
210+
tmap = _TrackingResidualStreamMap(base)
211+
y = InputNode("y", 1, value_range=(-100.0, 100.0))
212+
w = InputNode("w", 1, value_range=(-100.0, 100.0))
213+
214+
# Earlier rolled-back allocation of y records a stale cancel.
215+
tmap.current_layer = 6
216+
tmap.allocate(y)
217+
tmap.free(y)
218+
assert tmap.cancel_layer[y.node_id] == 6
219+
220+
# Later, y is reborn by reassigning w's columns to it (free-add reuse).
221+
tmap.current_layer = 8
222+
tmap.allocate(w)
223+
tmap.reassign(w, y)
224+
assert y.node_id not in tmap.cancel_layer
225+
assert tmap.is_allocated(y)

tests/compile/forward/test_scheduler.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,3 +970,27 @@ def test_output_already_computed():
970970
scheduler = LayerScheduler(graph, D, D_HEAD, pos)
971971
# Should not raise — nothing to do
972972
attn_ops, mlp_ops, _biased = scheduler.schedule_layer(rmap, computed)
973+
974+
975+
def test_eager_free_flag_gates_within_layer_freeing():
976+
"""``eager_free=False`` makes ``_freshly_dead_inputs`` a no-op, so the
977+
CP-SAT warm-start schedule never frees+reuses a column within a consumer's
978+
layer — keeping it representable by the layer-granular CP-SAT model. The
979+
default ``eager_free=True`` (used by the heuristic fallback) surfaces the
980+
freshly-dead intermediate so its column can be reclaimed in the same layer.
981+
"""
982+
pos = _make_pos_encoding()
983+
x = InputNode("x", D, value_range=(-100.0, 100.0))
984+
a = _make_linear(x, D, "a") # intermediate: a's only consumer is b
985+
b = _make_linear(a, D, "b")
986+
graph = GraphAnalyzer(b)
987+
988+
rmap = ResidualStreamMap(D * 4)
989+
rmap.allocate(a) # a is live; b has just been placed -> a is now dead
990+
991+
computed = {x, a, b}
992+
eager = LayerScheduler(graph, D, D_HEAD, pos, eager_free=True)
993+
noeager = LayerScheduler(graph, D, D_HEAD, pos, eager_free=False)
994+
995+
assert a in eager._freshly_dead_inputs(b, computed, rmap)
996+
assert noeager._freshly_dead_inputs(b, computed, rmap) == []

torchwright/compiler/forward/compile.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ class _TrackingResidualStreamMap(ResidualStreamMap):
5252
records that value for every node that gets freed. Nodes
5353
consumed via ``reassign`` (free-add path) don't go through
5454
``free`` and are correctly omitted from the cancel hint.
55+
56+
**Rollback-free correction.** ``LayerScheduler`` speculatively
57+
allocates a node, then rolls the allocation back with ``free(node)``
58+
when the op can't be committed under the dirty-cancel / head budget
59+
(``scheduler.py`` ``residual_map.free(node); continue``). That
60+
rollback is not a real death — the node is re-allocated (reborn) in
61+
a later layer. Recording the rollback layer as the node's cancel
62+
produced a hint with ``cancel < birth`` (e.g. freed at layer 6,
63+
actually born at layer 7), which is hard-infeasible in the CP-SAT
64+
model. To keep the emitted ``hint_cancel`` internally consistent,
65+
a node's stale cancel record is cleared whenever it is re-allocated
66+
or reborn via ``reassign``: a node is only ever (re)allocated after
67+
a premature rollback free, so any prior cancel for it must be
68+
discarded and re-recorded by its genuine later free (or omitted
69+
entirely if it dies by reassign, per the free-add design above).
5570
"""
5671

5772
def __init__(self, base: ResidualStreamMap) -> None:
@@ -64,10 +79,24 @@ def __init__(self, base: ResidualStreamMap) -> None:
6479
self.current_layer: int = 0
6580
self.cancel_layer: dict[int, int] = {}
6681

82+
def allocate(self, node: Node): # type: ignore[override]
83+
# A node reaching allocate after a recorded free was rolled back
84+
# (its earlier free was premature); drop the stale cancel so the
85+
# genuine death — a later free, or omission if it dies by
86+
# reassign — is what the hint reflects.
87+
self.cancel_layer.pop(node.node_id, None)
88+
return super().allocate(node)
89+
6790
def free(self, node: Node) -> None: # type: ignore[override]
6891
self.cancel_layer[node.node_id] = self.current_layer
6992
super().free(node)
7093

94+
def reassign(self, old_node: Node, new_node: Node) -> None: # type: ignore[override]
95+
# The new node is born here (free-add reuse), so any stale cancel
96+
# recorded for it by an earlier rolled-back allocation is wrong.
97+
self.cancel_layer.pop(new_node.node_id, None)
98+
super().reassign(old_node, new_node)
99+
71100

72101
def _run_heuristic_warm_start(
73102
graph: GraphAnalyzer,
@@ -105,6 +134,15 @@ def _run_heuristic_warm_start(
105134
admission_budget_fraction=admission_budget_fraction,
106135
policy=policy,
107136
pinned_nodes=overlay_pinned_inputs,
137+
# The warm-start must hand CP-SAT a *model-representable* schedule.
138+
# Eager (within-layer) freeing produces a shallower schedule that frees
139+
# and reuses a column inside a consumer's layer — a density the
140+
# layer-granular CP-SAT model cannot express, so the eager schedule is
141+
# an infeasible hint and CP-SAT cold-searches and times out. The
142+
# no-eager schedule is deeper but feasible, giving the solver a real
143+
# incumbent to improve from (d=4096/d_head=32: 87 -> ~81 vs the eager
144+
# heuristic's 85). The heuristic *fallback* below stays eager.
145+
eager_free=False,
108146
)
109147
hint_layers: dict = {}
110148
hint_routing: dict = {}
@@ -703,19 +741,22 @@ def forward_compile(
703741
# error, so a layer-count-only measurement cannot tell it from
704742
# a real solve. Surface it loudly (and optionally fatally via
705743
# ``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``.
744+
# optimizer result. ``UNKNOWN`` means CP-SAT found no incumbent
745+
# within the budget (expected at small budgets / hard geometries);
746+
# ``INFEASIBLE`` on a graph the heuristic schedules would indicate
747+
# a CP-SAT model bug — see ``docs/cpsat_scheduler.md``. The
748+
# fallback uses the EAGER heuristic (below), which is typically
749+
# shallower than the no-eager warm-start hint (``hint_n_layers``).
710750
fallback_msg = (
711751
f"CP-SAT returned no usable assignment "
712752
f"(status={_stats.status_name}, "
713753
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."
754+
f"eager heuristic schedule (the no-eager warm-start hint was "
755+
f"{hint_n_layers} layers; the eager fallback is typically "
756+
f"shallower). The compile is valid but UNOPTIMIZED — "
757+
f"optimize>0 did not take effect. status=INFEASIBLE (vs "
758+
f"UNKNOWN/timeout) on a graph the heuristic schedules would "
759+
f"indicate a CP-SAT model bug, not a hard instance."
719760
)
720761
if require_solver:
721762
raise RuntimeError(
@@ -728,6 +769,9 @@ def forward_compile(
728769
for line in _stats.solver_log.splitlines()[-40:]:
729770
print(f" {line}")
730771
print("--- end CP-SAT solver log ---")
772+
# Eager heuristic fallback (default eager_free=True): a timeout
773+
# never regresses below the eager heuristic's depth, even though
774+
# the CP-SAT hint was the deeper no-eager schedule.
731775
scheduler = LayerScheduler(
732776
graph,
733777
d,

torchwright/compiler/forward/cpsat_scheduler.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -984,14 +984,39 @@ def build_cpsat_model(
984984
resid_intervals: List = []
985985
resid_demands: List[int] = []
986986
for n in residual_nodes:
987-
size = model.NewIntVar(1, max_layers + 1, f"rsz_n{n.node_id}")
988-
model.Add(size == cancel_layer[n.node_id] - layer_var[n.node_id])
989-
iv = model.NewIntervalVar(
990-
layer_var[n.node_id],
991-
size,
992-
cancel_layer[n.node_id],
993-
f"riv_n{n.node_id}",
994-
)
987+
if isinstance(n, Add) and n.node_id in is_free:
988+
# Free-add reuses a dead addend's already-allocated residual
989+
# columns (`reassign` in both `LayerScheduler._schedule_attn_
990+
# sublayer` and the directed replay), so it adds NO fresh
991+
# residual column at its birth layer. The reused addend's own
992+
# interval already covers that layer — `cancel_consumer_lb`
993+
# forces the addend's cancel >= layer[A] + 1 because A is one of
994+
# its consumers — so giving the Add a full interval starting at
995+
# layer[A] double-counts the shared column for exactly one layer.
996+
# Shift the Add's residual start by `is_free[A]`: a free-add
997+
# (is_free=1) starts one layer later (the addend covers layer[A]),
998+
# a compute-add (is_free=0) allocates fresh columns and starts at
999+
# layer[A]. This is the residual-cumulative analogue of the
1000+
# BIRTH-dirty / attention-head free-add gating above, and removes
1001+
# the residual over-count that rejected schedules the heuristic
1002+
# compiles (the dead addend and its Add never occupy two distinct
1003+
# columns at the add layer).
1004+
start = model.NewIntVar(0, max_layers, f"rstart_n{n.node_id}")
1005+
model.Add(start == layer_var[n.node_id] + is_free[n.node_id])
1006+
size = model.NewIntVar(0, max_layers + 1, f"rsz_n{n.node_id}")
1007+
model.Add(size == cancel_layer[n.node_id] - start)
1008+
iv = model.NewIntervalVar(
1009+
start, size, cancel_layer[n.node_id], f"riv_n{n.node_id}"
1010+
)
1011+
else:
1012+
size = model.NewIntVar(1, max_layers + 1, f"rsz_n{n.node_id}")
1013+
model.Add(size == cancel_layer[n.node_id] - layer_var[n.node_id])
1014+
iv = model.NewIntervalVar(
1015+
layer_var[n.node_id],
1016+
size,
1017+
cancel_layer[n.node_id],
1018+
f"riv_n{n.node_id}",
1019+
)
9951020
resid_intervals.append(iv)
9961021
resid_demands.append(len(n))
9971022
# Freeable inputs occupy residual columns from layer 0 until their cancel

0 commit comments

Comments
 (0)