Skip to content

Commit 0ea4b78

Browse files
[graph_trainer][aot_fx_trace][graph_pp] Add FSDP collective splitting (#3088)
Add GraphPP extraction for explicit FSDP UNSHARD and REDUCE_GRAD graphs. The splitters use shared FSDP trace-pattern helpers to identify validated all-gather/wait reconstruction chains and reduce-scatter epilogues, keep the no-FSDP forward/backward graphs on the GraphPP flat calling convention, and leave fused edge graphs out of the runtime contract. Teach the default SAC policy to force-save the same helper-selected unshard output when FSDP runs with reshard_after_forward=False, matching the AutoParallel force-save behavior instead of saving every all-gather target. Add FSDP split tests for collective extraction, view and split-cat reconstruction, malformed unshard chains, no-op paths, input-source metadata, reduce-grad input naming, and pre-reduce cast placement.
1 parent ae1f88f commit 0ea4b78

8 files changed

Lines changed: 1168 additions & 11 deletions

File tree

torchtitan/experiments/graph_trainer/fsdp_passes.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,76 @@
4848
_is_backward_node,
4949
_MODULE_FQN,
5050
)
51+
from torchtitan.experiments.graph_trainer.fsdp_patterns import find_fsdp_unshard_outputs
5152
from torchtitan.tools.logging import logger
5253

5354

5455
_FSDP_BUCKET_META = "fsdp_bucket"
5556

5657

58+
def _chain_nodes_to_placeholder(
59+
output: fx.Node,
60+
placeholder: fx.Node,
61+
) -> set[fx.Node]:
62+
"""Return graph nodes in one FSDP unshard chain, excluding the placeholder."""
63+
chain_nodes: set[fx.Node] = set()
64+
queue = [output]
65+
while queue:
66+
node = queue.pop()
67+
if node is placeholder or node in chain_nodes:
68+
continue
69+
chain_nodes.add(node)
70+
queue.extend(node.all_input_nodes)
71+
return chain_nodes
72+
73+
74+
def deduplicate_fsdp_unshard_chains_pass(
75+
gm: torch.fx.GraphModule,
76+
example_inputs: tuple | None = None,
77+
) -> torch.fx.GraphModule:
78+
"""Canonicalize duplicate SimpleFSDP unshard chains per flat parameter.
79+
80+
A traced parametrized module can read the same FSDP parameter more than
81+
once. Each read materializes an equivalent all-gather/wait reconstruction
82+
chain from the same flat parameter placeholder. Downstream FSDP passes
83+
assume one unsharded value per flat parameter, so this pass rewrites all
84+
duplicate chains to the first chain and removes the now-dead duplicate
85+
collective infrastructure.
86+
"""
87+
del example_inputs
88+
89+
removable_nodes: set[fx.Node] = set()
90+
num_duplicate_chains = 0
91+
for placeholder in gm.graph.find_nodes(op="placeholder"):
92+
unshard_outputs = find_fsdp_unshard_outputs(placeholder)
93+
if len(unshard_outputs) <= 1:
94+
continue
95+
canonical_output = unshard_outputs[0]
96+
for duplicate_output in unshard_outputs[1:]:
97+
removable_nodes.update(
98+
_chain_nodes_to_placeholder(duplicate_output, placeholder)
99+
)
100+
duplicate_output.replace_all_uses_with(canonical_output)
101+
num_duplicate_chains += 1
102+
103+
if num_duplicate_chains == 0:
104+
return gm
105+
106+
def _is_impure_for_fsdp_dedup(node: fx.Node) -> bool:
107+
if node in removable_nodes:
108+
return False
109+
return node.is_impure()
110+
111+
gm.graph.eliminate_dead_code(is_impure_node=_is_impure_for_fsdp_dedup)
112+
gm.graph.lint()
113+
gm.recompile()
114+
logger.info(
115+
"Canonicalized %d duplicate FSDP unshard chain(s)",
116+
num_duplicate_chains,
117+
)
118+
return gm
119+
120+
57121
def is_wait_tensor_from_fsdp(node: torch.fx.Node) -> bool:
58122
"""
59123
Returns True if the node is a wait_tensor node that is the result of an all_gather
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
"""Pattern helpers for GraphPP's current simple-FSDP collective traces.
7+
8+
The matchers intentionally follow c10d functional traces produced by FSDP2:
9+
10+
param_shard -> all_gather -> wait -> view*/split-cat -> compute
11+
local_grad -> cast/view* -> reduce_scatter -> wait -> param_grad
12+
local_grad -> cast/view* -> all_reduce -> wait -> param_grad
13+
14+
They are structural helpers for today's trace shape. A future upstream FSDP or
15+
torch.pipelining annotation should replace this with explicit collective-region
16+
metadata instead of broader pattern matching.
17+
"""
18+
19+
import operator
20+
from typing import Any
21+
22+
import torch
23+
import torch.fx as fx
24+
25+
26+
def is_wait_tensor(node: fx.Node) -> bool:
27+
return (
28+
node.op == "call_function"
29+
and node.target == torch.ops._c10d_functional.wait_tensor.default
30+
)
31+
32+
33+
def is_all_gather_into_tensor(node: fx.Node) -> bool:
34+
return (
35+
node.op == "call_function"
36+
and node.target == torch.ops._c10d_functional.all_gather_into_tensor.default
37+
)
38+
39+
40+
def is_reduce_scatter_tensor(node: fx.Node) -> bool:
41+
return (
42+
node.op == "call_function"
43+
and node.target is torch.ops._c10d_functional.reduce_scatter_tensor.default
44+
)
45+
46+
47+
def is_all_reduce(node: fx.Node) -> bool:
48+
return (
49+
node.op == "call_function"
50+
and node.target is torch.ops._c10d_functional.all_reduce.default
51+
)
52+
53+
54+
def is_reduce_grad_collective(node: fx.Node) -> bool:
55+
return is_reduce_scatter_tensor(node) or is_all_reduce(node)
56+
57+
58+
def _find_last_all_gather_in_chain(start_node: fx.Node) -> fx.Node | None:
59+
"""Find the final all-gather in a linear FSDP unshard launch chain."""
60+
node = start_node
61+
last_all_gather = None
62+
while True:
63+
if is_all_gather_into_tensor(node):
64+
last_all_gather = node
65+
if len(node.users) != 1:
66+
break
67+
user = next(iter(node.users))
68+
if len(user.all_input_nodes) > 1:
69+
break
70+
node = user
71+
return last_all_gather
72+
73+
74+
def _find_last_user_in_wait_chain(wait_node: fx.Node) -> fx.Node:
75+
"""Find the last FSDP unshard node before the value enters real compute.
76+
77+
The traced FSDP unshard has a mostly linear shape:
78+
79+
flat_param -> ... -> all_gather -> wait -> view* -> compute
80+
81+
Some models reshape the gathered flat buffer through a split/cat fanout:
82+
83+
wait -> split -> getitem_0 --+
84+
-> getitem_1 --+-> cat -> view* -> compute
85+
86+
In both cases the FSDP region ends before the first consumer with multiple
87+
FX inputs. The split/getitem/cat fanout is still part of reconstructing the
88+
unsharded parameter value, so it is included in the chain.
89+
"""
90+
node = wait_node
91+
while True:
92+
if len(node.users) != 1:
93+
if (
94+
node.op == "call_function"
95+
and node.target == torch.ops.aten.split.Tensor
96+
and all(
97+
user.op == "call_function"
98+
and user.target == operator.getitem
99+
and len(user.users) == 1
100+
for user in node.users
101+
)
102+
):
103+
getitem_users = [next(iter(user.users)) for user in node.users]
104+
potential_cat = getitem_users[0]
105+
if all(user == potential_cat for user in getitem_users) and (
106+
potential_cat.op == "call_function"
107+
and potential_cat.target == torch.ops.aten.cat.default
108+
):
109+
node = potential_cat
110+
continue
111+
break
112+
113+
user = next(iter(node.users))
114+
if len(user.all_input_nodes) > 1:
115+
break
116+
node = user
117+
return node
118+
119+
120+
def _find_last_non_view_node_in_chain(node: fx.Node) -> fx.Node:
121+
"""Return the value GraphPP should pass across the unshard boundary."""
122+
result = node
123+
while hasattr(result.target, "is_view") and result.target.is_view:
124+
if len(result.all_input_nodes) != 1:
125+
raise ValueError(f"View node {result.name} should have exactly one input")
126+
result = result.all_input_nodes[0]
127+
return result
128+
129+
130+
def _unshard_output_from_all_gather(last_all_gather: fx.Node) -> fx.Node:
131+
if len(last_all_gather.users) != 1:
132+
raise ValueError(
133+
f"Expected one wait_tensor user for all_gather node {last_all_gather.name}, "
134+
f"got {len(last_all_gather.users)}"
135+
)
136+
wait_node = next(iter(last_all_gather.users))
137+
if not is_wait_tensor(wait_node):
138+
raise ValueError(
139+
f"Expected wait_tensor after all_gather node {last_all_gather.name}, "
140+
f"got {wait_node.name}"
141+
)
142+
143+
wait_chain_user = _find_last_user_in_wait_chain(wait_node)
144+
return _find_last_non_view_node_in_chain(wait_chain_user)
145+
146+
147+
def find_fsdp_unshard_outputs(param_placeholder: fx.Node) -> tuple[fx.Node, ...]:
148+
"""Return all FSDP unshard outputs launched from one flat parameter input.
149+
150+
Most parameters have one linear all-gather chain. Some real traces read the
151+
same parametrized value more than once, which produces multiple equivalent
152+
all-gather/wait chains from the same placeholder.
153+
``deduplicate_fsdp_unshard_chains_pass`` canonicalizes those duplicate
154+
chains before downstream FSDP passes rely on a single unsharded value.
155+
"""
156+
last_all_gather = _find_last_all_gather_in_chain(param_placeholder)
157+
if last_all_gather is not None:
158+
return (_unshard_output_from_all_gather(last_all_gather),)
159+
160+
outputs: list[fx.Node] = []
161+
seen: set[fx.Node] = set()
162+
for user in param_placeholder.users:
163+
if len(user.all_input_nodes) > 1:
164+
continue
165+
last_all_gather = _find_last_all_gather_in_chain(user)
166+
if last_all_gather is None:
167+
continue
168+
output = _unshard_output_from_all_gather(last_all_gather)
169+
if output not in seen:
170+
seen.add(output)
171+
outputs.append(output)
172+
return tuple(outputs)
173+
174+
175+
def find_fsdp_unshard_output(param_placeholder: fx.Node) -> fx.Node | None:
176+
"""Return the extracted unshard output for one flat parameter input.
177+
178+
GraphPP and the SAC force-save policy must agree on this node. It is the
179+
same value AutoParallel saves for ``reshard_after_forward=False``: the last
180+
non-view node in the FSDP all-gather/wait reconstruction chain. Parameters
181+
without an all-gather are replicated or otherwise already local, so callers
182+
should keep the original placeholder as that parameter's unsharded value.
183+
184+
TODO(sanketpurandare): requires upstream change: FSDP trace/passes should
185+
annotate unshard collective regions for downstream graph extraction.
186+
"""
187+
outputs = find_fsdp_unshard_outputs(param_placeholder)
188+
if not outputs:
189+
return None
190+
return outputs[0]
191+
192+
193+
def find_fsdp_unshard_save_node(param_placeholder: fx.Node) -> fx.Node | None:
194+
return find_fsdp_unshard_output(param_placeholder)
195+
196+
197+
def find_fsdp_unshard_save_nodes(param_placeholder: fx.Node) -> tuple[fx.Node, ...]:
198+
"""Return all FSDP unshard values that SAC must save for one parameter."""
199+
return find_fsdp_unshard_outputs(param_placeholder)
200+
201+
202+
def find_fsdp_reduce_grad_input(param_grad_output: Any) -> fx.Node | None:
203+
"""Return the split point before an FSDP reduce-grad epilogue.
204+
205+
The backward FSDP/DDP/HSDP tail is traced as a unary chain ending in the
206+
synced grad output:
207+
208+
local_grad -> cast/view* -> reduce_scatter -> wait -> sharded_grad
209+
local_grad -> cast/view* -> all_reduce -> wait -> replicated_grad
210+
local_grad -> cast/view* -> all_reduce -> wait -> reduce_scatter
211+
-> wait -> grad
212+
213+
GraphPP splits at the input to the earliest grad-sync collective in that
214+
suffix. The cast remains in ``bw_no_fsdp`` so microbatch accumulation
215+
happens in FSDP's reduce dtype, and ``reduce_grad`` contains only the
216+
scheduled collective epilogue. Values that are not FX nodes, such as
217+
``None`` parameter-grad slots, are not collective outputs and are preserved
218+
by the caller.
219+
220+
TODO(sanketpurandare): requires upstream change: FSDP trace/passes should
221+
annotate reduce-grad collective regions for downstream graph extraction.
222+
"""
223+
if not isinstance(param_grad_output, fx.Node):
224+
return None
225+
226+
node = param_grad_output
227+
reduce_grad_input = None
228+
while isinstance(node, fx.Node) and len(node.all_input_nodes) == 1:
229+
input_node = node.all_input_nodes[0]
230+
if len(input_node.users) > 1:
231+
break
232+
previous_node = node
233+
node = input_node
234+
if is_reduce_grad_collective(previous_node):
235+
reduce_grad_input = node
236+
return reduce_grad_input

torchtitan/experiments/graph_trainer/graph_pp/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@
1212
GraphPPDiDwSplit,
1313
split_di_dw_graph,
1414
)
15+
from torchtitan.experiments.graph_trainer.graph_pp.split_fsdp_collectives import (
16+
GraphPPFSDPBackwardSplit,
17+
GraphPPFSDPForwardSplit,
18+
split_backward_fsdp_collectives,
19+
split_forward_fsdp_collectives,
20+
)
1521

1622
__all__ = [
1723
"GraphPPDiDwSplit",
24+
"GraphPPFSDPBackwardSplit",
25+
"GraphPPFSDPForwardSplit",
1826
"GraphMeta",
1927
"partition_joint_graph",
28+
"split_backward_fsdp_collectives",
2029
"split_di_dw_graph",
30+
"split_forward_fsdp_collectives",
2131
]

0 commit comments

Comments
 (0)