Skip to content

Commit 1a2d5f1

Browse files
physicsrobclaude
andcommitted
schedule cache: canonical node ids — survive warm-process rebuilds
Validation caught the fingerprint keying on RAW node ids: the global node-id counter is process-cumulative, so a warm Modal container that compiles twice in one process shifts every id and silently misses the cache (observed live: two identical-topology d8192 compiles stored under different fingerprints). Fix: ids are canonicalized by preorder DFS from the output over each node's ordered inputs — dependent only on topology. Fingerprints, stored assignments, and loads all use canonical ids, remapped onto the live graph at load time; an unmappable entry is treated as a miss, never replayed wrong. Verified stable across in-process rebuilds of the 13,199-node DOOM graph; the round-trip test now rebuilds the graph between compiles to pin the regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 63cdc7e commit 1a2d5f1

3 files changed

Lines changed: 94 additions & 32 deletions

File tree

tests/compile/forward/test_cpsat_knobs.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,18 +171,23 @@ def test_schedule_cache_round_trip(tmp_path, monkeypatch):
171171
verbose=False,
172172
optimize=2,
173173
)
174-
out = _repro_graph()
175-
net1 = forward_compile(output_node=out, **kw)
174+
out1 = _repro_graph()
175+
net1 = forward_compile(output_node=out1, **kw)
176176
assert net1.cpsat_solve_stats.status_name in ("OPTIMAL", "FEASIBLE")
177177
assert len(list(tmp_path.glob("*.json"))) == 1
178178

179-
net2 = forward_compile(output_node=out, **kw)
179+
# Rebuild the graph from scratch: fresh node objects with SHIFTED raw
180+
# node ids (the global counter keeps counting), exactly like a warm
181+
# Modal container compiling twice in one process. The canonical-id
182+
# fingerprint must still hit.
183+
out2 = _repro_graph()
184+
net2 = forward_compile(output_node=out2, **kw)
180185
assert net2.cpsat_solve_stats.status_name == "CACHED"
181186
assert len(net2.layers) == len(net1.layers)
182187

183188
inputs = {"x": torch.randn(3, 8)}
184189
torch.testing.assert_close(
185-
net1.compute(3, inputs)[out], net2.compute(3, inputs)[out]
190+
net1.compute(3, inputs)[out1], net2.compute(3, inputs)[out2]
186191
)
187192

188193

@@ -198,9 +203,12 @@ def test_schedule_cache_disabled_without_env(monkeypatch):
198203
)
199204

200205
monkeypatch.delenv("TW_SCHEDULE_CACHE_DIR", raising=False)
206+
dummy = create_input("dummy", 1)
201207
assert cache_dir() is None
202-
assert load_assignment("deadbeef") is None
203-
assert not store_assignment("deadbeef", ScheduleAssignment({}, {}, {}, 1), {})
208+
assert load_assignment("deadbeef", dummy) is None
209+
assert not store_assignment(
210+
"deadbeef", ScheduleAssignment({}, {}, {}, 1), {}, dummy
211+
)
204212

205213

206214
def test_floor_probe_infeasible_falls_back_to_descent():

torchwright/compiler/forward/compile.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ def forward_compile(
689689
cancel_slack=2,
690690
policy=policy,
691691
)
692-
cached = load_assignment(schedule_fp)
692+
cached = load_assignment(schedule_fp, output_node)
693693
if cached is not None:
694694
assignment, _cached_meta = cached
695695
if verbose:
@@ -862,6 +862,7 @@ def forward_compile(
862862
"d_head": d_head,
863863
"d_hidden": d_hidden if d_hidden else d,
864864
},
865+
output_node=output_node,
865866
)
866867
and verbose
867868
):

torchwright/compiler/forward/schedule_cache.py

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,28 @@ def cache_dir() -> Optional[Path]:
4141
return Path(value) if value else None
4242

4343

44+
def _canonical_ids(output_node: Node) -> Dict[int, int]:
45+
"""Map current ``node_id`` -> canonical id, independent of creation order.
46+
47+
Preorder DFS from the output following each node's ORDERED ``inputs``
48+
list; first visit assigns the next canonical number. The raw global
49+
node-id counter is process-cumulative: a warm process that builds the
50+
graph twice (observed: Modal containers reuse one process across
51+
compiles) shifts every id, which would change the fingerprint and
52+
silently miss the cache. Canonical ids depend only on the topology.
53+
"""
54+
canon: Dict[int, int] = {}
55+
stack = [output_node]
56+
while stack:
57+
n = stack.pop()
58+
if n.node_id in canon:
59+
continue
60+
canon[n.node_id] = len(canon)
61+
# Reversed keeps the first input on top of the stack (preorder).
62+
stack.extend(reversed(getattr(n, "inputs", None) or []))
63+
return canon
64+
65+
4466
def graph_fingerprint(
4567
output_node: Node,
4668
pos_encoding: PosEncoding,
@@ -53,21 +75,26 @@ def graph_fingerprint(
5375
cancel_slack: Optional[int],
5476
policy: Optional[SchedulingPolicy],
5577
) -> str:
56-
"""Topology + geometry hash.
57-
58-
Includes the node ids themselves: a cached assignment is keyed by
59-
``node_id``, which is only meaningful if graph construction replays
60-
identically — any change to construction order changes the ids, the
61-
fingerprint, and therefore misses (correct by construction).
62-
"""
78+
"""Topology + geometry hash over CANONICAL node ids (see
79+
``_canonical_ids``) — stable across processes and warm containers; any
80+
change to graph construction still changes the fingerprint and misses
81+
(correct by construction)."""
82+
canon = _canonical_ids(output_node)
6383
graph = GraphAnalyzer(output_node)
64-
nodes = sorted(graph.get_all_nodes(), key=lambda n: n.node_id)
84+
nodes = sorted(
85+
(n for n in graph.get_all_nodes() if n.node_id in canon),
86+
key=lambda n: canon[n.node_id],
87+
)
6588
topo = [
6689
(
67-
n.node_id,
90+
canon[n.node_id],
6891
type(n).__name__,
6992
len(n),
70-
tuple(inp.node_id for inp in (getattr(n, "inputs", None) or [])),
93+
tuple(
94+
canon[inp.node_id]
95+
for inp in (getattr(n, "inputs", None) or [])
96+
if inp.node_id in canon
97+
),
7198
)
7299
for n in nodes
73100
]
@@ -88,30 +115,44 @@ def graph_fingerprint(
88115

89116
def load_assignment(
90117
fingerprint: str,
118+
output_node: Node,
91119
) -> Optional[Tuple[ScheduleAssignment, Dict[str, Any]]]:
92-
"""Return (assignment, meta) for a cached fingerprint, or None."""
120+
"""Return (assignment, meta) for a cached fingerprint, or None.
121+
122+
Stored keys are canonical ids; they are remapped onto the CURRENT
123+
graph's node ids via the same deterministic traversal."""
93124
base = cache_dir()
94125
if base is None:
95126
return None
96127
path = base / f"{fingerprint}.json"
97128
if not path.exists():
98129
return None
99130
data = json.loads(path.read_text(encoding="utf-8"))
100-
assignment = ScheduleAssignment(
101-
node_to_layer={int(k): v for k, v in data["node_to_layer"].items()},
102-
node_to_cancel_layer={
103-
int(k): v for k, v in data["node_to_cancel_layer"].items()
104-
},
105-
node_to_routing={int(k): v for k, v in data["node_to_routing"].items()},
106-
n_layers=data["n_layers"],
107-
)
131+
current_by_canon = {c: nid for nid, c in _canonical_ids(output_node).items()}
132+
133+
def _remap(table: Dict[str, Any]) -> Dict[int, Any]:
134+
return {current_by_canon[int(k)]: v for k, v in table.items()}
135+
136+
try:
137+
assignment = ScheduleAssignment(
138+
node_to_layer=_remap(data["node_to_layer"]),
139+
node_to_cancel_layer=_remap(data["node_to_cancel_layer"]),
140+
node_to_routing=_remap(data["node_to_routing"]),
141+
n_layers=data["n_layers"],
142+
)
143+
except KeyError:
144+
# Canonical id absent from the current graph: the entry predates a
145+
# construction change the fingerprint failed to capture. Treat as
146+
# a miss rather than replaying a wrong schedule.
147+
return None
108148
return assignment, data.get("meta", {})
109149

110150

111151
def store_assignment(
112152
fingerprint: str,
113153
assignment: ScheduleAssignment,
114154
meta: Dict[str, Any],
155+
output_node: Node,
115156
) -> bool:
116157
"""Persist ``assignment`` unless an equal-or-better entry exists.
117158
@@ -120,14 +161,26 @@ def store_assignment(
120161
base = cache_dir()
121162
if base is None:
122163
return False
123-
existing = load_assignment(fingerprint)
124-
if existing is not None and existing[0].n_layers <= assignment.n_layers:
125-
return False
164+
prior_path = base / f"{fingerprint}.json"
165+
if prior_path.exists():
166+
prior = json.loads(prior_path.read_text(encoding="utf-8"))
167+
if prior.get("n_layers", 1 << 30) <= assignment.n_layers:
168+
return False
126169
base.mkdir(parents=True, exist_ok=True)
170+
canon = _canonical_ids(output_node)
171+
if not set(assignment.node_to_layer) <= set(canon):
172+
# A scheduled node is unreachable from the output via inputs — it
173+
# cannot be keyed canonically; skip caching rather than store a
174+
# partial schedule.
175+
return False
127176
payload = {
128-
"node_to_layer": assignment.node_to_layer,
129-
"node_to_cancel_layer": assignment.node_to_cancel_layer,
130-
"node_to_routing": assignment.node_to_routing,
177+
"node_to_layer": {canon[k]: v for k, v in assignment.node_to_layer.items()},
178+
"node_to_cancel_layer": {
179+
canon[k]: v
180+
for k, v in assignment.node_to_cancel_layer.items()
181+
if k in canon
182+
},
183+
"node_to_routing": {canon[k]: v for k, v in assignment.node_to_routing.items()},
131184
"n_layers": assignment.n_layers,
132185
"meta": meta,
133186
}

0 commit comments

Comments
 (0)