Skip to content

Commit ae1f88f

Browse files
[graph_trainer][aot_fx_trace][graph_pp] Add dI/dW backward splitting (#2727)
Add the GraphPP backward split pass for schedules that execute input-gradient and weight-gradient work as separate PP actions. The pass preserves the flat graph calling convention selected by the partitioner and returns explicit activation handoff metadata for the dW graph. Add unit coverage that reconstructs the full backward result from split dI and dW graphs and validates the no-op path when a graph has no input-gradient outputs.
1 parent e8adc79 commit ae1f88f

3 files changed

Lines changed: 341 additions & 1 deletion

File tree

torchtitan/experiments/graph_trainer/graph_pp/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88
GraphMeta,
99
partition_joint_graph,
1010
)
11+
from torchtitan.experiments.graph_trainer.graph_pp.split_di_dw import (
12+
GraphPPDiDwSplit,
13+
split_di_dw_graph,
14+
)
1115

1216
__all__ = [
17+
"GraphPPDiDwSplit",
1318
"GraphMeta",
1419
"partition_joint_graph",
20+
"split_di_dw_graph",
1521
]
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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+
7+
import copy
8+
import operator
9+
from dataclasses import dataclass
10+
11+
import torch.fx as fx
12+
from torch._functorch.partitioners import (
13+
_extract_fwd_bwd_outputs,
14+
_extract_graph_with_inputs_outputs,
15+
is_sym_node,
16+
)
17+
from torch.fx._lazy_graph_module import _make_graph_module
18+
19+
from torchtitan.experiments.graph_trainer.graph_pp.utils import (
20+
is_fake_tensor_node,
21+
output_names,
22+
placeholder_names,
23+
rename_placeholder,
24+
trace_graph_pp_graph,
25+
unique_in_order,
26+
)
27+
28+
29+
@dataclass(frozen=True, slots=True)
30+
class GraphPPDiDwSplit:
31+
"""Backward graph split into dI and dW GraphPP callables.
32+
33+
Attributes:
34+
bw_di_module (fx.GraphModule): Backward-input graph. It computes input
35+
gradients to send to the previous PP stage and live-ins needed by
36+
the backward-weight graph.
37+
bw_dw_module (fx.GraphModule): Backward-weight graph. It consumes the
38+
dW live-ins and computes parameter gradients.
39+
num_input_grads (int): Number of leading ``bw_di_module`` outputs that
40+
are input_grads_to_prev.
41+
bw_di_input_names (tuple[str, ...]): ``bw_di_module`` placeholder
42+
names.
43+
bw_di_output_names (tuple[str, ...]): ``bw_di_module`` output names.
44+
bw_dw_input_names (tuple[str, ...]): ``bw_dw_module`` placeholder
45+
names.
46+
bw_dw_output_names (tuple[str, ...]): ``bw_dw_module`` output names.
47+
"""
48+
49+
bw_di_module: fx.GraphModule
50+
bw_dw_module: fx.GraphModule
51+
num_input_grads: int
52+
bw_di_input_names: tuple[str, ...]
53+
bw_di_output_names: tuple[str, ...]
54+
bw_dw_input_names: tuple[str, ...]
55+
bw_dw_output_names: tuple[str, ...]
56+
57+
58+
def _reorder_backward_outputs_for_di(
59+
gm: fx.GraphModule, *, num_param_grads: int
60+
) -> int:
61+
outputs = gm.graph.find_nodes(op="output")
62+
if len(outputs) != 1:
63+
raise ValueError(f"Expected exactly one output node, found {len(outputs)}")
64+
output = outputs[0]
65+
if not isinstance(output.args[0], tuple):
66+
raise ValueError("Backward graph output must be a tuple")
67+
68+
output_values = output.args[0]
69+
if len(output_values) < num_param_grads:
70+
raise ValueError(
71+
f"Backward graph has {len(output_values)} outputs but "
72+
f"{num_param_grads} parameter grads were requested"
73+
)
74+
75+
# The upstream extractor treats the first outputs as the "forward" side.
76+
# For dI/dW splitting, those early outputs are the input grads that PP
77+
# schedules send to the previous stage before parameter grads are computed.
78+
param_grads = output_values[:num_param_grads]
79+
input_grads = output_values[num_param_grads:]
80+
with gm.graph.inserting_after(output):
81+
new_output = gm.graph.output(tuple(input_grads + param_grads))
82+
new_output.meta.update(output.meta)
83+
gm.graph.erase_node(output)
84+
gm.graph.lint()
85+
gm.recompile()
86+
return len(input_grads)
87+
88+
89+
def _collect_saved_values_for_dw(
90+
bw_gm: fx.GraphModule,
91+
di_graph: fx.Graph,
92+
dw_output_nodes: list[fx.Node] | None = None,
93+
) -> tuple[list[fx.Node], list[fx.Node]]:
94+
di_node_names = {node.name for node in di_graph.nodes if node.op != "output"}
95+
dw_output_set = set(dw_output_nodes or ())
96+
saved_values: list[fx.Node] = []
97+
saved_sym_nodes: list[fx.Node] = []
98+
99+
for node in bw_gm.graph.nodes:
100+
if node.name not in di_node_names:
101+
continue
102+
dw_users = [
103+
user
104+
for user in node.users
105+
if user.name not in di_node_names and user.op != "output"
106+
]
107+
is_dw_output = node in dw_output_set
108+
if not dw_users and not is_dw_output:
109+
continue
110+
if node.op == "get_attr":
111+
# get_attr nodes are graph constants, such as FlexAttention's
112+
# mask/score submodules. The dW graph should retain them as
113+
# get_attr references instead of receiving Python objects as
114+
# runtime live-ins from the dI graph.
115+
continue
116+
if is_sym_node(node):
117+
# Symbolic shape values used by dW must stay as SymInt live-ins,
118+
# not tensor placeholders.
119+
saved_sym_nodes.append(node)
120+
elif (
121+
"tensor_meta" not in node.meta
122+
and node.op == "call_function"
123+
and not is_fake_tensor_node(node)
124+
):
125+
# Non-tensor tuple-like results flow through getitem leaves. Save
126+
# the leaves because the dW graph consumes those concrete values.
127+
users = list(node.users)
128+
if not all(user.target == operator.getitem for user in users):
129+
raise ValueError(
130+
f"Non-tensor multi-output node {node.name} has unexpected users"
131+
)
132+
saved_values.extend(user for user in users if user in dw_users)
133+
else:
134+
if (
135+
not is_dw_output
136+
and "tensor_meta" in node.meta
137+
and all(is_sym_node(user) for user in dw_users)
138+
):
139+
# If dW only needs shape reads from this tensor, pass the
140+
# symbolic reads instead of keeping the tensor live.
141+
saved_sym_nodes.extend(dw_users)
142+
else:
143+
saved_values.append(node)
144+
145+
return (
146+
unique_in_order(saved_values),
147+
unique_in_order(saved_sym_nodes),
148+
)
149+
150+
151+
def split_di_dw_graph(
152+
bw_module: fx.GraphModule,
153+
*,
154+
num_param_grads: int,
155+
) -> GraphPPDiDwSplit | None:
156+
"""Split a backward graph into input-gradient and weight-gradient graphs.
157+
158+
Contract:
159+
Input:
160+
backward(saved_values_for_backward, output_grads_from_next?)
161+
-> param_grads, input_grads_to_prev
162+
163+
Output:
164+
bw_di(saved_values_for_backward, output_grads_from_next?)
165+
-> input_grads_to_prev, dw_live_ins
166+
167+
bw_dw(dw_live_ins)
168+
-> param_grads
169+
170+
Terms:
171+
``num_param_grads`` is the leading output count for parameter gradients.
172+
``input_grads_to_prev`` are remaining backward outputs sent to the
173+
previous PP stage. ``dw_live_ins`` are values computed by bw_di that the
174+
dW graph still needs. ``saved_sym_nodes`` carries symbolic shape live-ins.
175+
176+
If a stage has no input grads, GraphPP skips ``BACKWARD_INPUT`` and keeps
177+
the original full backward graph for ``BACKWARD_WEIGHT``. This pass runs
178+
after AC/remat has already materialized recomputed backward nodes, so those
179+
nodes are treated like ordinary backward graph nodes.
180+
181+
Args:
182+
bw_module (fx.GraphModule): Backward graph whose outputs are ordered as
183+
parameter gradients followed by input gradients.
184+
num_param_grads (int): Number of leading backward outputs that are
185+
parameter-gradient slots.
186+
187+
Returns:
188+
GraphPPDiDwSplit | None: Split dI/dW graph modules and metadata, or
189+
``None`` when the stage has no input gradients and the full backward
190+
graph should be used unchanged.
191+
192+
Raises:
193+
ValueError: If ``num_param_grads`` is invalid or the backward graph
194+
does not match the expected flat output convention.
195+
"""
196+
if num_param_grads < 0:
197+
raise ValueError(f"num_param_grads must be non-negative, got {num_param_grads}")
198+
199+
bw_gm = copy.deepcopy(bw_module)
200+
for placeholder in list(bw_gm.graph.find_nodes(op="placeholder")):
201+
if placeholder.name.startswith("tangent"):
202+
rename_placeholder(
203+
bw_gm,
204+
placeholder,
205+
f"graph_pp_runtime_grad{placeholder.name[len('tangent') :]}",
206+
)
207+
208+
num_input_grads = _reorder_backward_outputs_for_di(
209+
bw_gm, num_param_grads=num_param_grads
210+
)
211+
trace_graph_pp_graph("graph_pp_split_di_dw_input", bw_gm)
212+
if num_input_grads == 0:
213+
return None
214+
215+
placeholders = list(bw_gm.graph.find_nodes(op="placeholder"))
216+
di_outputs, dw_outputs, di_output_descs, dw_output_descs = _extract_fwd_bwd_outputs(
217+
bw_gm, num_fwd_outputs=num_input_grads
218+
)
219+
di_closure_graph = _extract_graph_with_inputs_outputs(
220+
bw_gm.graph,
221+
placeholders,
222+
di_outputs,
223+
di_output_descs,
224+
"forward",
225+
ignore_must_be_in_fw_bw=True,
226+
)
227+
saved_values, saved_sym_nodes = _collect_saved_values_for_dw(
228+
bw_gm,
229+
di_closure_graph,
230+
dw_outputs,
231+
)
232+
di_runtime_outputs = [*di_outputs, *saved_values, *saved_sym_nodes]
233+
di_runtime_output_descs = list(di_output_descs) + [None] * (
234+
len(saved_values) + len(saved_sym_nodes)
235+
)
236+
bw_di_graph = _extract_graph_with_inputs_outputs(
237+
bw_gm.graph,
238+
placeholders,
239+
di_runtime_outputs,
240+
di_runtime_output_descs,
241+
"bw_di",
242+
ignore_must_be_in_fw_bw=True,
243+
)
244+
bw_dw_graph = _extract_graph_with_inputs_outputs(
245+
bw_gm.graph,
246+
[*saved_values, *saved_sym_nodes],
247+
dw_outputs,
248+
dw_output_descs,
249+
"bw_dw",
250+
ignore_must_be_in_fw_bw=True,
251+
)
252+
bw_di_module = _make_graph_module(bw_gm, bw_di_graph)
253+
bw_dw_module = _make_graph_module(bw_gm, bw_dw_graph)
254+
bw_di_module.graph.lint()
255+
bw_dw_module.graph.lint()
256+
bw_di_module.recompile()
257+
bw_dw_module.recompile()
258+
trace_graph_pp_graph("graph_pp_bw_di", bw_di_module)
259+
trace_graph_pp_graph("graph_pp_bw_dw", bw_dw_module)
260+
261+
return GraphPPDiDwSplit(
262+
bw_di_module=bw_di_module,
263+
bw_dw_module=bw_dw_module,
264+
num_input_grads=num_input_grads,
265+
bw_di_input_names=placeholder_names(bw_di_module),
266+
bw_di_output_names=output_names(bw_di_module),
267+
bw_dw_input_names=placeholder_names(bw_dw_module),
268+
bw_dw_output_names=output_names(bw_dw_module),
269+
)

torchtitan/experiments/graph_trainer/tests/test_graph_pp_passes.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
from torchtitan.experiments.graph_trainer.deepseek_v3 import (
2626
model_registry as dsv3_model_registry,
2727
)
28-
from torchtitan.experiments.graph_trainer.graph_pp import partition_joint_graph
28+
from torchtitan.experiments.graph_trainer.graph_pp import (
29+
partition_joint_graph,
30+
split_di_dw_graph,
31+
)
2932
from torchtitan.experiments.graph_trainer.graph_pp.partition import GraphMeta
3033
from torchtitan.experiments.graph_trainer.graph_pp.utils import flatten_graph_values
3134
from torchtitan.experiments.graph_trainer.make_fx_tracer import (
@@ -446,5 +449,67 @@ def test_real_dsv3_moe_block_fsdp_partition_matches_joint_graph(self) -> None:
446449
_assert_tensor_sequence_equal(self, bw_outputs, joint_outputs[1:])
447450

448451

452+
class GraphPPSplitDiDwTest(unittest.TestCase):
453+
def test_real_dsv3_moe_block_split_reconstructs_backward(self) -> None:
454+
traced_block = _trace_dsv3_moe_block_stage()
455+
fw_module, bw_module, meta = partition_joint_graph(
456+
traced_block.traced,
457+
num_fwd_outputs=1,
458+
backward_only_input_indices=(len(traced_block.traced.example_inputs) - 1,),
459+
)
460+
split = split_di_dw_graph(
461+
bw_module,
462+
num_param_grads=traced_block.num_param_grad_values,
463+
)
464+
465+
self.assertIsNotNone(split)
466+
if split is None:
467+
self.fail("Expected dI/dW split for decoder block with input grad")
468+
self.assertEqual(split.num_input_grads, 1)
469+
self.assertGreater(len(split.bw_dw_input_names), 0)
470+
471+
fw_args = [
472+
traced_block.flat_inputs[index] for index in meta.fwd_flat_input_indices
473+
]
474+
fw_outputs = _boxed_run(fw_module, list(fw_args))
475+
bw_args = _backward_args_from_partition(
476+
meta,
477+
fw_outputs,
478+
(traced_block.output_grad,),
479+
)
480+
full_bw_outputs = _boxed_run(bw_module, list(bw_args))
481+
482+
di_outputs = _boxed_run(split.bw_di_module, list(bw_args))
483+
input_grads_to_prev = di_outputs[: split.num_input_grads]
484+
dw_live_ins = di_outputs[split.num_input_grads :]
485+
dw_outputs = _boxed_run(split.bw_dw_module, list(dw_live_ins))
486+
487+
_assert_tensor_sequence_equal(
488+
self,
489+
input_grads_to_prev,
490+
full_bw_outputs[traced_block.num_param_grad_values :],
491+
)
492+
_assert_tensor_sequence_equal(
493+
self,
494+
dw_outputs,
495+
full_bw_outputs[: traced_block.num_param_grad_values],
496+
)
497+
498+
def test_real_dsv3_moe_block_without_input_grad_skips_split(self) -> None:
499+
traced_block = _trace_dsv3_moe_block_stage(include_input_grad=False)
500+
_, bw_module, _ = partition_joint_graph(
501+
traced_block.traced,
502+
num_fwd_outputs=1,
503+
backward_only_input_indices=(len(traced_block.traced.example_inputs) - 1,),
504+
)
505+
506+
split = split_di_dw_graph(
507+
bw_module,
508+
num_param_grads=traced_block.num_param_grad_values,
509+
)
510+
511+
self.assertIsNone(split)
512+
513+
449514
if __name__ == "__main__":
450515
unittest.main()

0 commit comments

Comments
 (0)