Skip to content

Commit bebfbfb

Browse files
committed
Replace cap-pass remediation with topo-span-bounded bucketing
Summary Replaces the `_cap_compute_batch_size` post-hoc remediator with a topo-span close-bucket condition inside the patched FSDP bucketing. Same load-bearing knob (`bucket_cap_mb`) gains a companion (`max_topo_span`) that bounds the dependency-footprint problem the cap was trying to undo. The bucketing-time prevention has shipped at `max_topo_span=1500` on `fmassa/double_recomp` and matches the prior cap-based path on min latency while substantially improving variance. Motivation The prior cherry (`9871a48`) combined two opposing patches: non-adjacent bucketing (which widens bucket dependency footprints to improve NCCL throughput) and `_cap_compute_batch_size` (which reorders compute post-hoc to undo the resulting MM*N batching). The cap relied on pre-bucket reduce_scatter names surviving bucketing — a property that doesn't always hold when bucketing renames RS ops — so the cap silently degrades to chain-deps-only and doesn't bound `max_consec_compute_between_RS`. Bounding bucket topo-span at the source addresses the same MM*N harm without a downstream remediator, surfaces one tunable knob users can reason about, and degenerates to the prior cherry's behavior when `max_topo_span=None`. What changed `autoparallel/graph_passes/auto_bucketing.py`, −56 net LOC: - **Kept** `_patched_identify_fsdp_groups` (primary-group-only filter) and `max_compute_pre_fetch=50`. - **Replaced** the non-adjacent `_patched_greedy_bucket` with a version that adds `close_for_span` as a third close-bucket condition alongside `close_for_bytes`. Snapshots ranks at function entry, closes a bucket when `current_rank - bucket_start_rank > max_topo_span`. - **Added** `aten_autobucketing_config.max_topo_span: int | None = 1500`. Set to `None` to restore the prior bytes-only (cherry) behavior with no codepath difference. - **Added** INFO-level metrics inside `_patched_greedy_bucket`: per-invocation `(num_buckets, max_observed_span, n_close_bytes, n_close_span, max_topo_span)`. The cap's silent-failure mode is no longer possible. - **Added** `_max_consec_compute_between_rs(graph)` helper, logged at INFO after `aten_autobucketing_reordering_pass` as a regression metric matching what the old cap enforced. - **Removed** `_cap_compute_batch_size` (~120 LOC) and the pre/post-bucket name-snapshotting that fed it. Validation `tests/test_auto_bucketing_patches.py` (8 tests, ~3 s, no GPU): - `identify_fsdp_groups`: primary group wins on imbalanced counts; empty graph returns empty; ties pick exactly one. - `greedy_bucket`: merges within caps; splits on bytes; splits on span when bytes are under cap; `max_topo_span=None` disables span; descendant collectives never co-bucket. Mutation-verified — reverting either patch causes the corresponding test to fail with an informative message. Real benchmark results (LLaMA-3 8B, 32 layers, 128 H100s, seqlen=8192, global batch=32) `max_topo_span=1500`, job 6513650, vs the most-stable prior cap-based run (6477238): **Unconstrained** | Run | min ms | avg ms | max ms | alloc GiB | rsrvd GiB | MFU | | -------------------------- | ------- | --------- | --------- | --------- | --------- | --------- | | New (`max_topo_span=1500`) | 379.3 | **389.6** | **436.6** | 7.69 | **8.06** | 492.5 % | | Prior `_cap` (cleanest) | 378.3 | 384.2 | 412.0 | 7.55 | 8.20 | 499.4 % | | Prior `_cap` (typical) | 370–377 | 424–569 | 1.8–6.0 s | 7.55 | 8.20 | 337–452 % | | TorchTitan reference | 366.2 | 414.1 | 1742.8 | 8.62 | 9.43 | 463.4 % | - **Min latency**: 379.3 ms, ~+1–9 ms over best prior runs; within the prior-run min spread. - **Avg latency**: 389.6 ms — second-best across all runs (only the lucky 6477238 beats it at 384.2 ms); typical prior cap-based avg was 424–569 ms. - **Max latency**: 436.6 ms — **best of all benchmarked runs**; every other prior run except 6477238 had >1.8 s tails. - **Variance**: avg − min collapses from 5–200 ms range to 10 ms. The cap's silent-failure failure mode (which produced occasional multi-second tails) appears to be the source of prior variance; bucketing-time prevention is much more deterministic. - **Memory**: +0.14 GiB alloc vs cap-based prior; rsrvd memory is the best of all runs. **Constrained** | Run | min ms | avg ms | max ms | alloc GiB | rsrvd GiB | | -------------------------- | ----------- | ------- | ----------- | --------- | --------- | | New (`max_topo_span=1500`) | 443.6 | 484.3 | 662.9 | 7.11 | **7.51** | | Prior `_cap` (range) | 439.8–445.9 | 447–628 | 0.98–5.58 s | 7.12 | 7.49–7.59 | - Min within prior noise band; avg/max middle of prior variance; **rsrvd memory is the best of all runs**. Telemetry from the shipped run The `max_topo_span=1500` knob is **dormant in practice** at this configuration: - Fwd: 27 buckets, max span 619, closures `(bytes=26, span=0)`, `max_consec_compute_between_rs=8` - Bwd group A: 43 buckets, max span 1325, closures `(bytes=40, span=1)` - Bwd group B: 64 buckets, max span 561, closures `(bytes=62, span=0)` - Bwd `max_consec_compute_between_rs=12` (vs 8 under the old explicit cap — 50% looser) The span gate fires at most once across all bucketing invocations; actual behavior is byte-cap-only plus primary-group filter. The 4-extra MMs concentrated before each RS may explain the ~9 ms unconstrained min-latency gap vs the best prior run — testing `max_topo_span=1000` is in progress to validate whether tightening the gate closes that gap without reintroducing variance. What this gives up vs the cap-based approach - Min latency: within ±9 ms of best prior min, plausibly recoverable by tuning `max_topo_span` downward. - Memory: rsrvd best-of-all-runs in both modes; alloc within 2% of prior best. What this gains - Avg/max latency stability: variance collapses dramatically vs the prior cap-based path (typical prior tails of 1.8–6.0 s disappear in unconstrained; 0.98–5.58 s tails in constrained reduce to 0.66 s). - One declarative knob (`max_topo_span`) replaces two coupled patches plus a downstream remediator with a known name-matching brittleness. - Loud metrics: `n_close_bytes` / `n_close_span` / `max_consec_compute_between_rs` are logged at INFO; the prior cap's silent-failure mode is no longer possible. - ~56 net LOC removed. Authored with Claude.
1 parent 9871a48 commit bebfbfb

2 files changed

Lines changed: 371 additions & 148 deletions

File tree

autoparallel/graph_passes/auto_bucketing.py

Lines changed: 91 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,16 @@ def _patch_fsdp_bucketing():
2323
1. Primary-group-only: only include the group with the most FSDP
2424
all-gathers in fsdp_groups, preventing minority groups (tp, combined)
2525
from limiting dp bucketing aggressiveness.
26-
2. Non-adjacent bucketing: allow collectives to be bucketed even when
27-
interleaved with non-FSDP collectives on other groups. Only
28-
descendant conflicts prevent bucketing, not graph position.
26+
2. Topo-span-bounded bucketing: allow collectives to be bucketed even
27+
when interleaved with non-FSDP collectives on other groups, but
28+
close the bucket once its topo-span (rank of latest member minus
29+
rank of first member) exceeds aten_autobucketing_config.max_topo_span.
30+
31+
Without a span bound, merging collectives that are far apart in the
32+
graph rewires the dependency graph so that stable_topological_sort
33+
can pull compute from late layers forward, batching many MMs before
34+
any RS fires (the MM*N problem). The span bound caps how far compute
35+
can be displaced by any one bucket merge.
2936
"""
3037
import torch._inductor.fx_passes.bucketing as bucketing_mod
3138
import torch._inductor.fx_passes.fsdp as fsdp_mod
@@ -71,6 +78,11 @@ def _patched_greedy_bucket(
7178
filter_wait_node=None,
7279
):
7380
g = gm.graph
81+
# Snapshot ranks before any bucketing mutates the graph. Used to
82+
# bound each bucket's topo-span, which bounds how far compute can
83+
# be displaced by stable_topological_sort after merging.
84+
ranks = {n.name: i for i, n in enumerate(g.nodes)}
85+
7486
groups = defaultdict(list)
7587
for node in g.nodes:
7688
if is_wait_tensor(node) and filter_node(node.args[0]):
@@ -83,12 +95,19 @@ def _patched_greedy_bucket(
8395
return []
8496

8597
node_descendents = collect_node_descendants(g)
98+
max_topo_span = aten_autobucketing_config.max_topo_span
8699

87100
buckets = []
101+
# Metrics aggregated across all groups.
102+
n_close_bytes = 0
103+
n_close_span = 0
104+
max_observed_span = 0
105+
88106
for key, nodes in groups.items():
89107
cur_bucket = []
90108
cur_bucket_descendents = OrderedSet()
91109
cur_bucket_size_bytes = 0
110+
cur_bucket_start_rank = None
92111
cur_bucket_id = 0
93112
bucket_size_bytes = int(
94113
bucket_cap_mb_by_bucket_idx(cur_bucket_id) * 1024 * 1024
@@ -101,24 +120,65 @@ def _patched_greedy_bucket(
101120
n_input_val = node.all_input_nodes[0].meta["val"]
102121
in_size_bytes = n_input_val.numel() * n_input_val.element_size()
103122
size_bytes = max(out_size_bytes, in_size_bytes)
104-
if (
123+
124+
node_rank = ranks.get(node.name, 0)
125+
would_span = (
126+
node_rank - cur_bucket_start_rank
127+
if cur_bucket_start_rank is not None
128+
else 0
129+
)
130+
131+
close_for_bytes = (
105132
cur_bucket_size_bytes + size_bytes > bucket_size_bytes
106133
and cur_bucket
107-
):
134+
)
135+
close_for_span = (
136+
max_topo_span is not None
137+
and would_span > max_topo_span
138+
and cur_bucket
139+
)
140+
141+
if close_for_bytes or close_for_span:
108142
if len(cur_bucket) > 1:
109143
buckets.append(cur_bucket)
144+
if close_for_bytes:
145+
n_close_bytes += 1
146+
if close_for_span:
147+
n_close_span += 1
148+
observed_span = (
149+
ranks.get(cur_bucket[-1].name, 0) - cur_bucket_start_rank
150+
)
151+
max_observed_span = max(max_observed_span, observed_span)
110152
cur_bucket = []
111153
cur_bucket_size_bytes = 0
112154
cur_bucket_id += 1
113155
cur_bucket_descendents = OrderedSet()
156+
cur_bucket_start_rank = None
114157
bucket_size_bytes = int(
115158
bucket_cap_mb_by_bucket_idx(cur_bucket_id) * 1024 * 1024
116159
)
117160
cur_bucket_size_bytes += size_bytes
118161
cur_bucket.append(node)
119162
cur_bucket_descendents |= node_descendents[node]
163+
if cur_bucket_start_rank is None:
164+
cur_bucket_start_rank = node_rank
120165
if len(cur_bucket) > 1:
121166
buckets.append(cur_bucket)
167+
observed_span = (
168+
ranks.get(cur_bucket[-1].name, 0) - cur_bucket_start_rank
169+
)
170+
max_observed_span = max(max_observed_span, observed_span)
171+
172+
if buckets:
173+
logger.info(
174+
"greedy_bucket: %d buckets, max_span=%d, "
175+
"closed (bytes=%d, span=%d, max_topo_span=%s)",
176+
len(buckets),
177+
max_observed_span,
178+
n_close_bytes,
179+
n_close_span,
180+
max_topo_span,
181+
)
122182
return buckets
123183

124184
fsdp_mod.identify_fsdp_groups = _patched_identify_fsdp_groups
@@ -128,130 +188,26 @@ def _patched_greedy_bucket(
128188
_patch_fsdp_bucketing()
129189

130190

131-
def _cap_compute_batch_size(
132-
graph, original_compute_names, original_rs_after_compute, max_consecutive=8
133-
):
134-
"""Break up long compute segments between ReduceScatter operations.
135-
136-
After overlap scheduling + FSDP bucketing, compute nodes may be reordered
137-
so that many layers' matmuls execute before any ReduceScatter fires. This
138-
inflates peak memory because all layers' activations are alive
139-
simultaneously.
140-
141-
Instead of restoring the full original order (which kills comm/compute
142-
overlap), this function only intervenes when the number of compute nodes
143-
between consecutive ReduceScatter ops exceeds max_consecutive. For each
144-
oversized segment, it sorts compute nodes by original order, splits into
145-
chunks, then:
146-
1. Chains consecutive compute nodes within each chunk (so they move
147-
together during topological sort).
148-
2. Adds a dep from the first compute node of each chunk to an RS node
149-
that originally appeared between the previous chunk and this one.
150-
151-
Args:
152-
graph: The post-scheduled FX graph.
153-
original_compute_names: List of compute node names in original order.
154-
original_rs_after_compute: Dict mapping compute node name to the list
155-
of RS node names that appeared between it and the next compute node
156-
in the original (pre-scheduling) graph.
157-
max_consecutive: Maximum compute nodes allowed between RS ops.
191+
def _max_consec_compute_between_rs(graph):
192+
"""Return the maximum consecutive compute nodes between RS ops.
193+
194+
Useful as a regression metric: bucketing followed by topo sort can
195+
pull compute from late layers forward, batching many MMs before any
196+
RS fires. This metric grows linearly with the size of such batches.
158197
"""
159-
from torch._dynamo.graph_deduplication import _stable_topological_sort
160198
from torch._inductor.fx_passes.overlap_scheduling import is_compute_node
161199

162-
def _is_rs(node):
163-
if node.op != "call_function":
164-
return False
165-
name = str(node.target)
166-
return "reduce_scatter" in name and "wait" not in name
167-
168-
original_rank = {name: rank for rank, name in enumerate(original_compute_names)}
169-
node_by_name = {n.name: n for n in graph.nodes}
170-
171-
scheduled_rs_names = {
172-
n.name for n in graph.nodes if n.op == "call_function" and _is_rs(n)
173-
}
174-
175-
# Collect compute nodes between RS ops in the post-scheduled graph.
176-
segments: list[tuple[list[torch.fx.Node], torch.fx.Node | None]] = []
177-
current_compute: list[torch.fx.Node] = []
178-
for node in graph.nodes:
179-
if node.op != "call_function":
180-
continue
181-
if is_compute_node(node):
182-
current_compute.append(node)
183-
elif _is_rs(node):
184-
segments.append((current_compute, node))
185-
current_compute = []
186-
if current_compute:
187-
segments.append((current_compute, None))
188-
189-
additional_deps: dict[torch.fx.Node, OrderedSet] = defaultdict(OrderedSet)
190-
191-
for compute_nodes, _seg_rs_node in segments:
192-
if len(compute_nodes) <= max_consecutive:
200+
max_run = cur = 0
201+
for n in graph.nodes:
202+
if n.op != "call_function":
193203
continue
194-
195-
sorted_nodes = sorted(
196-
compute_nodes,
197-
key=lambda n: original_rank.get(n.name, float("inf")),
198-
)
199-
200-
# Split into chunks of max_consecutive.
201-
chunks = []
202-
for i in range(0, len(sorted_nodes), max_consecutive):
203-
chunks.append(sorted_nodes[i : i + max_consecutive])
204-
205-
# Chain consecutive compute nodes within each chunk.
206-
for chunk in chunks:
207-
for j in range(1, len(chunk)):
208-
additional_deps[chunk[j]].add(chunk[j - 1])
209-
210-
# At each chunk boundary, find an RS that originally appeared between
211-
# the last compute of the previous chunk and the first compute of
212-
# the current chunk, then add dep: first_of_chunk after RS.
213-
for ci in range(1, len(chunks)):
214-
prev_chunk = chunks[ci - 1]
215-
curr_chunk = chunks[ci]
216-
217-
last_in_prev = prev_chunk[-1]
218-
first_in_curr = curr_chunk[0]
219-
last_rank = original_rank.get(last_in_prev.name, -1)
220-
first_rank = original_rank.get(
221-
first_in_curr.name, len(original_compute_names)
222-
)
223-
224-
found_rs_node = None
225-
for r in range(last_rank, first_rank):
226-
cname = (
227-
original_compute_names[r]
228-
if r < len(original_compute_names)
229-
else None
230-
)
231-
if cname is None:
232-
continue
233-
for rs_name in original_rs_after_compute.get(cname, []):
234-
if rs_name in scheduled_rs_names:
235-
rs_obj = node_by_name.get(rs_name)
236-
if rs_obj is not None:
237-
found_rs_node = rs_obj
238-
break
239-
if found_rs_node is not None:
240-
break
241-
242-
if found_rs_node is not None:
243-
additional_deps[first_in_curr].add(found_rs_node)
244-
245-
if not additional_deps:
246-
return
247-
248-
try:
249-
_stable_topological_sort(graph, additional_deps)
250-
except AssertionError:
251-
logger.warning(
252-
"Failed to cap compute batch size (cycle detected), "
253-
"falling back to uncapped ordering"
254-
)
204+
target = str(n.target)
205+
if is_compute_node(n):
206+
cur += 1
207+
max_run = max(max_run, cur)
208+
elif "reduce_scatter" in target and "wait" not in target:
209+
cur = 0
210+
return max_run
255211

256212

257213
class simplefsdp_autobucketing_config:
@@ -341,13 +297,18 @@ class aten_autobucketing_config:
341297
- max_in_flight_gb: maximum GB of concurrent collective data
342298
- compute_overlap_multipler: scale factor for compute time used to hide collectives
343299
- max_coll_distance: maximum node distance for overlap consideration
300+
- max_topo_span: maximum number of graph positions a single bucket may
301+
span. Bounds how far compute can be displaced when bucketing rewires
302+
the dep graph and stable_topological_sort runs afterwards. Set to
303+
None to disable the span bound (only bytes cap applies).
344304
"""
345305

346306
max_in_flight_gb = 2.0
347307
compute_overlap_multipler = 1.0
348308
max_coll_distance = 100
349309
custom_runtime_estimation = None
350310
max_compute_pre_fetch = 50
311+
max_topo_span: int | None = 1500
351312
collective_bucketing = False
352313
save_trace = True
353314
_counter = 0
@@ -358,27 +319,6 @@ def aten_autobucketing_reordering_pass(
358319
) -> torch.fx.GraphModule:
359320
assert gm.owning_module is not None
360321

361-
# Record compute + RS interleaving before bucketing + overlap scheduling.
362-
from torch._inductor.fx_passes.overlap_scheduling import is_compute_node
363-
364-
def _is_rs_node(node):
365-
if node.op != "call_function":
366-
return False
367-
name = str(node.target)
368-
return "reduce_scatter" in name and "wait" not in name
369-
370-
original_compute_names = []
371-
original_rs_after_compute: dict[str, list[str]] = {}
372-
last_compute_name = None
373-
for n in gm.owning_module.graph.nodes:
374-
if n.op != "call_function":
375-
continue
376-
if is_compute_node(n):
377-
original_compute_names.append(n.name)
378-
last_compute_name = n.name
379-
elif _is_rs_node(n) and last_compute_name is not None:
380-
original_rs_after_compute.setdefault(last_compute_name, []).append(n.name)
381-
382322
new_gm = schedule_overlap_bucketing(
383323
gm.owning_module,
384324
collective_bucketing=configs.collective_bucketing,
@@ -389,9 +329,12 @@ def _is_rs_node(node):
389329
max_coll_distance=configs.max_coll_distance,
390330
)
391331

392-
_cap_compute_batch_size(
393-
new_gm.graph, original_compute_names, original_rs_after_compute
332+
logger.info(
333+
"aten_autobucketing_reordering_pass: post-pass "
334+
"max_consec_compute_between_rs=%d",
335+
_max_consec_compute_between_rs(new_gm.graph),
394336
)
337+
395338
new_gm.recompile()
396339

397340
if configs.save_trace:

0 commit comments

Comments
 (0)