Skip to content

Commit 63cdc7e

Browse files
physicsrobclaude
andcommitted
optimize>=2: floor-probe ladder + durable schedule cache
Replaces the warm-start descent lottery (measured 50-64 layers across identical 180s runs at d=8192; variance is thread-timing, not seed) with a regime-aware ladder: 1. Schedule cache (opt-in via TW_SCHEDULE_CACHE_DIR): solved assignments keyed by topology+geometry fingerprint replay directly — the solver runs at most once per graph shape. Hits surface loudly as status_name=CACHED, never silently. 2. Floor probe: COLD solve at critical_path+1 with tightened domains. At slack width this finds and PROVES the optimum (~2 min at d=8192: {50,50,50,51} across draws); exactly the floor leaves no construction slack, so the +1 is load-bearing. When width binds (d=4096) the probe is proven INFEASIBLE in ~4s — a free regime classifier. 3. Fallback: warm-start descent exactly as before, now with tighten_domains (hint is feasible, hence always inside the bounds). Tests cover knob soundness, lexicographic secondaries, both ladder regimes, and the cache round trip (CACHED status + identical outputs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4876043 commit 63cdc7e

3 files changed

Lines changed: 585 additions & 65 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Tests for the CP-SAT solver knobs added for the scheduling campaign
2+
(2026-06): domain tightening, solver parameter overrides, incumbent trace
3+
capture, and lexicographic secondary objectives.
4+
5+
All tests run on the small post-fusion repro graph from
6+
``test_cpsat_chain_overlap`` — known OPTIMAL at 3 layers — so each solve
7+
is sub-second.
8+
"""
9+
10+
import pytest
11+
import torch
12+
13+
from torchwright.compiler.forward.cpsat_scheduler import (
14+
Costs,
15+
_compute_layer_bounds,
16+
build_graph_model,
17+
solve_schedule,
18+
)
19+
from torchwright.compiler.forward.scheduling_policy import SchedulingPolicy
20+
from torchwright.graph import Linear
21+
from torchwright.graph.optimize import fuse_consecutive_linears
22+
from torchwright.graph.pos_encoding import PosEncoding
23+
from torchwright.graph.relu import ReLU
24+
from torchwright.ops.inout_nodes import create_input
25+
26+
27+
def _repro_graph():
28+
"""x -> L_pre -> R -> fused(L_a,L_b) -> R' -> L_c (post-fusion)."""
29+
torch.manual_seed(0)
30+
x = create_input("x", 8)
31+
L_pre = Linear(x, torch.randn(8, 16), torch.zeros(16), name="L_pre")
32+
R = ReLU(L_pre, name="R")
33+
L_a = Linear(R, torch.randn(16, 12), torch.zeros(12), name="L_a")
34+
L_b = Linear(L_a, torch.randn(12, 16), torch.zeros(16), name="L_b")
35+
R_prime = ReLU(L_b, name="R_prime")
36+
L_c = Linear(R_prime, torch.randn(16, 4), torch.zeros(4), name="L_c")
37+
fuse_consecutive_linears({L_c})
38+
return L_c
39+
40+
41+
_SOLVE_KW = dict(d=64, d_head=8, d_hidden=128, time_budget_s=10.0, max_layers=20)
42+
43+
44+
def test_tighten_domains_preserves_optimum():
45+
out = _repro_graph()
46+
plain, _ = solve_schedule(out, PosEncoding(9), **_SOLVE_KW)
47+
tight, tight_stats = solve_schedule(
48+
out, PosEncoding(9), tighten_domains=True, **_SOLVE_KW
49+
)
50+
assert plain is not None and tight is not None
51+
assert tight_stats.status_name == "OPTIMAL"
52+
assert tight.n_layers == plain.n_layers
53+
54+
55+
def test_layer_bounds_sound_against_solved_schedule():
56+
"""Every layer in an actual feasible schedule satisfies [es, ls]."""
57+
out = _repro_graph()
58+
pos = PosEncoding(9)
59+
assignment, _ = solve_schedule(out, pos, **_SOLVE_KW)
60+
assert assignment is not None
61+
gm = build_graph_model(out, pos)
62+
es, ls = _compute_layer_bounds(
63+
gm, SchedulingPolicy(), True, _SOLVE_KW["max_layers"]
64+
)
65+
for nid, layer in assignment.node_to_layer.items():
66+
assert (
67+
es[nid] <= layer <= ls[nid]
68+
), f"node {nid} scheduled at {layer} outside [{es[nid]}, {ls[nid]}]"
69+
70+
71+
def test_solver_params_applied_and_solve_unchanged():
72+
out = _repro_graph()
73+
assignment, stats = solve_schedule(
74+
out,
75+
PosEncoding(9),
76+
solver_params={
77+
"random_seed": 7,
78+
"shared_tree_num_workers": 0,
79+
"ignore_subsolvers": ["objective_lb_search"],
80+
},
81+
**_SOLVE_KW,
82+
)
83+
assert assignment is not None
84+
assert stats.status_name == "OPTIMAL"
85+
86+
87+
def test_solution_trace_captures_incumbents():
88+
out = _repro_graph()
89+
trace: list = []
90+
assignment, _ = solve_schedule(
91+
out, PosEncoding(9), solution_trace=trace, **_SOLVE_KW
92+
)
93+
assert assignment is not None
94+
assert len(trace) >= 1
95+
last = trace[-1]
96+
assert last["n_layers"] == assignment.n_layers
97+
# Snapshots carry the full assignment.
98+
assert set(last["layers"]) == set(assignment.node_to_layer)
99+
assert set(last["cancels"]) == set(assignment.node_to_cancel_layer) - set(
100+
last["input_cancels"]
101+
)
102+
103+
104+
@pytest.mark.parametrize(
105+
"costs",
106+
[
107+
Costs(alpha=1, earliness=1),
108+
Costs(alpha=1, waste=1),
109+
Costs(alpha=1, earliness=1, waste=1),
110+
],
111+
ids=["earliness", "waste", "both"],
112+
)
113+
def test_secondary_objectives_are_lexicographic(costs):
114+
"""Secondaries must never trade a layer: primary optimum is preserved
115+
and ``objective_value // objective_scale`` recovers it exactly."""
116+
out = _repro_graph()
117+
plain, _ = solve_schedule(out, PosEncoding(9), **_SOLVE_KW)
118+
sec, stats = solve_schedule(out, PosEncoding(9), costs=costs, **_SOLVE_KW)
119+
assert plain is not None and sec is not None
120+
assert sec.n_layers == plain.n_layers
121+
assert stats.objective_scale > 1
122+
assert stats.objective_value // stats.objective_scale == plain.n_layers
123+
124+
125+
def test_plain_costs_keep_scale_one():
126+
out = _repro_graph()
127+
_, stats = solve_schedule(out, PosEncoding(9), **_SOLVE_KW)
128+
assert stats.objective_scale == 1
129+
130+
131+
# ---------------------------------------------------------------------------
132+
# Floor-probe ladder (optimize >= 2 in forward_compile)
133+
# ---------------------------------------------------------------------------
134+
135+
136+
def test_floor_probe_succeeds_at_slack_width():
137+
"""With width slack, optimize=2 cold-probes at critical_path+1 and the
138+
compile lands at (or below) the heuristic's depth with a real solve."""
139+
from torchwright.compiler.forward.compile import forward_compile
140+
from torchwright.compiler.forward.cpsat_scheduler import (
141+
critical_path_layers,
142+
)
143+
144+
out = _repro_graph()
145+
cp = critical_path_layers(out, PosEncoding(9))
146+
net = forward_compile(
147+
d=64,
148+
d_head=8,
149+
output_node=out,
150+
pos_encoding=PosEncoding(9),
151+
device="cpu",
152+
verbose=False,
153+
optimize=2,
154+
)
155+
assert net.cpsat_solve_stats is not None
156+
assert net.cpsat_solve_stats.status_name in ("OPTIMAL", "FEASIBLE")
157+
assert len(net.layers) <= cp + 1
158+
159+
160+
def test_schedule_cache_round_trip(tmp_path, monkeypatch):
161+
"""Second compile of the same topology+geometry replays the cached
162+
schedule (status CACHED), skips the solver, and computes identically."""
163+
from torchwright.compiler.forward.compile import forward_compile
164+
165+
monkeypatch.setenv("TW_SCHEDULE_CACHE_DIR", str(tmp_path))
166+
kw = dict(
167+
d=64,
168+
d_head=8,
169+
pos_encoding=PosEncoding(9),
170+
device="cpu",
171+
verbose=False,
172+
optimize=2,
173+
)
174+
out = _repro_graph()
175+
net1 = forward_compile(output_node=out, **kw)
176+
assert net1.cpsat_solve_stats.status_name in ("OPTIMAL", "FEASIBLE")
177+
assert len(list(tmp_path.glob("*.json"))) == 1
178+
179+
net2 = forward_compile(output_node=out, **kw)
180+
assert net2.cpsat_solve_stats.status_name == "CACHED"
181+
assert len(net2.layers) == len(net1.layers)
182+
183+
inputs = {"x": torch.randn(3, 8)}
184+
torch.testing.assert_close(
185+
net1.compute(3, inputs)[out], net2.compute(3, inputs)[out]
186+
)
187+
188+
189+
def test_schedule_cache_disabled_without_env(monkeypatch):
190+
"""Without TW_SCHEDULE_CACHE_DIR nothing is read or written."""
191+
from torchwright.compiler.forward.cpsat_scheduler import (
192+
ScheduleAssignment,
193+
)
194+
from torchwright.compiler.forward.schedule_cache import (
195+
cache_dir,
196+
load_assignment,
197+
store_assignment,
198+
)
199+
200+
monkeypatch.delenv("TW_SCHEDULE_CACHE_DIR", raising=False)
201+
assert cache_dir() is None
202+
assert load_assignment("deadbeef") is None
203+
assert not store_assignment("deadbeef", ScheduleAssignment({}, {}, {}, 1), {})
204+
205+
206+
def test_floor_probe_infeasible_falls_back_to_descent():
207+
"""Width-starved graph: the floor horizon cannot fit (parallel wide
208+
chains must serialize), so optimize=2 must fall through the probe and
209+
still produce a valid compile via the descent/heuristic path."""
210+
from torchwright.compiler.forward.compile import forward_compile
211+
from torchwright.compiler.forward.cpsat_scheduler import (
212+
critical_path_layers,
213+
)
214+
from torchwright.graph import Concatenate
215+
216+
torch.manual_seed(0)
217+
x = create_input("x", 4)
218+
# 8 independent chains x -> Li(12 cols) -> Mi(2 cols). The critical
219+
# path is short, but at d=48 the Li's cannot coexist, so any schedule
220+
# is far deeper than critical_path + 1 — the probe horizon is
221+
# infeasible by construction.
222+
mids = []
223+
for i in range(8):
224+
li = Linear(x, torch.randn(4, 12), torch.zeros(12), name=f"L{i}")
225+
mids.append(Linear(li, torch.randn(12, 2), torch.zeros(2), name=f"M{i}"))
226+
out = Linear(Concatenate(mids), torch.randn(16, 4), torch.zeros(4), name="out")
227+
cp = critical_path_layers(out, PosEncoding(9))
228+
net = forward_compile(
229+
d=48,
230+
d_head=8,
231+
output_node=out,
232+
pos_encoding=PosEncoding(9),
233+
device="cpu",
234+
verbose=False,
235+
optimize=2,
236+
)
237+
assert net.cpsat_solve_stats is not None
238+
# The compile is valid and necessarily deeper than the probe horizon.
239+
assert len(net.layers) > cp + 1

0 commit comments

Comments
 (0)