Skip to content

Commit 54d0455

Browse files
jon-chuangpytorchmergebot
authored andcommitted
[fx, DDP] fx.split_module will setup/unwind autocast & grad_mode (#113374)
--- Replaces: #112231 Fixes: #111794 DDPOptimizer splits modules. We need to setup/unwind global states (autocast, grad_enabled) for each split, as this affects downstream compilation. --- See before and after this PR for the split fx modules here (for autocast mode): #112231 (comment) --- ### Discussion We don't actually have to do this for grad mode: #112231 (comment). It's not wrong to do it anyway, but maybe unnecessary? But may still be better to keep this PR's changes so we're sure what the grad mode state ought to be for each subgraph. It may come in handy in the future. Pull Request resolved: #113374 Approved by: https://github.com/wconstab
1 parent 6ff7260 commit 54d0455

2 files changed

Lines changed: 187 additions & 16 deletions

File tree

test/distributed/test_dynamo_distributed.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ def init_weights(m):
4747
m.bias.data.fill_(0.01)
4848

4949
class ToyModel(nn.Module):
50-
def __init__(self, in_feat=10, hidden_feat=5000, out_feat=5):
50+
def __init__(self, in_feat=10, hidden_feat=5000, out_feat=5, ctx_manager=None):
5151
super().__init__()
52+
self.ctx_manager = ctx_manager
5253
self.net = nn.Sequential(
5354
*[nn.Linear(in_feat, hidden_feat), nn.ReLU()]
5455
+ [nn.Linear(hidden_feat, hidden_feat), nn.ReLU()]
@@ -57,10 +58,14 @@ def __init__(self, in_feat=10, hidden_feat=5000, out_feat=5):
5758
)
5859

5960
def forward(self, inputs):
60-
return self.net(inputs)
61-
62-
def get_model(device, bsz=20, in_feat=10, hidden_feat=5000, out_feat=5):
63-
m = ToyModel(in_feat=in_feat, hidden_feat=hidden_feat, out_feat=out_feat).to(device)
61+
if self.ctx_manager is not None:
62+
with self.ctx_manager():
63+
return self.net(inputs)
64+
else:
65+
return self.net(inputs)
66+
67+
def get_model(device, bsz=20, in_feat=10, hidden_feat=5000, out_feat=5, ctx_manager=None):
68+
m = ToyModel(in_feat=in_feat, hidden_feat=hidden_feat, out_feat=out_feat, ctx_manager=ctx_manager).to(device)
6469
m.apply(init_weights)
6570
inputs = torch.rand(bsz, in_feat).to(device)
6671
outputs = m(inputs)
@@ -508,8 +513,8 @@ class TestSingleProc(DynamoDistributedSingleProcTestCase):
508513
Use TestMultiProc for things that really need to run on multiple nodes
509514
"""
510515

511-
def get_model(self, bsz=20, in_feat=10, hidden_feat=5000, out_feat=5):
512-
m = ToyModel(in_feat=in_feat, hidden_feat=hidden_feat, out_feat=out_feat).to(self.device)
516+
def get_model(self, bsz=20, in_feat=10, hidden_feat=5000, out_feat=5, ctx_manager=None):
517+
m = ToyModel(in_feat=in_feat, hidden_feat=hidden_feat, out_feat=out_feat, ctx_manager=ctx_manager).to(self.device)
513518
m.apply(init_weights)
514519
inputs = torch.rand(bsz, in_feat).to(self.device)
515520
outputs = m(inputs)
@@ -565,6 +570,57 @@ def opt_fn(inputs):
565570
self.assertEqual(len(break_reasons), 3)
566571
self.assertTrue(all("DDPOptimizer" in r.reason for r in break_reasons))
567572

573+
@patch.object(config, "optimize_ddp", True)
574+
def test_graph_split_ctx_manager(self):
575+
"""
576+
Ensures that we get the right number of splits and that the respective
577+
context managers' effects are applied to the computation.
578+
"""
579+
580+
for get_compiler in [
581+
lambda: CheckSplitsCompiler(),
582+
lambda: None,
583+
]:
584+
for ctx_manager, output_test in [
585+
(
586+
lambda: torch.autocast(torch.device(self.device).type, torch.float16),
587+
lambda out: self.assertEqual(out.dtype, torch.float16),
588+
),
589+
(
590+
torch.enable_grad,
591+
lambda out: self.assertTrue(out.requires_grad)
592+
),
593+
(
594+
torch.no_grad,
595+
lambda out: self.assertTrue(not out.requires_grad)
596+
),
597+
]:
598+
m, inputs, correct_outputs = self.get_model(out_feat=1000, hidden_feat=1000, in_feat=1000, ctx_manager=ctx_manager)
599+
# inp - 1000 * 1000 matrix of float32 (4 bytes) = 4MB
600+
# hidden - 1000 * 1000 matrix of float32 (4 bytes) = 4MB
601+
bucket_cap_mb = 3.5 # 4MB
602+
ddp_m = DDP(m, device_ids=self.device_ids, bucket_cap_mb=bucket_cap_mb)
603+
604+
compiler = get_compiler()
605+
606+
@torch._dynamo.optimize(compiler.compile_fn if compiler else "aot_eager")
607+
def opt_fn(inputs):
608+
return ddp_m(inputs)
609+
610+
opt_outputs = opt_fn(inputs)
611+
self.assertTrue(same(correct_outputs, opt_outputs))
612+
if compiler:
613+
self.assertEqual(compiler.compiler_called, 4)
614+
615+
output_test(opt_outputs)
616+
617+
# ensure compatibility with dynamo explain
618+
619+
explain_out = torch._dynamo.explain(ddp_m)(inputs)
620+
break_reasons = explain_out.break_reasons
621+
self.assertEqual(len(break_reasons), 4)
622+
self.assertTrue(all("DDPOptimizer" in r.reason for r in break_reasons))
623+
568624
@patch.object(config, "optimize_ddp", True)
569625
@unittest.skipIf(not has_triton(), "Inductor+gpu needs triton and recent GPU arch")
570626
def test_graph_split_inductor(self):

torch/fx/passes/split_module.py

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import inspect
2-
from typing import Any, Callable, Dict, List, Optional
2+
from typing import Any, Callable, Dict, List, Optional, Set
3+
from collections import OrderedDict
4+
import logging
35

46
import torch
57
from torch.fx._compatibility import compatibility
68
from torch.fx.graph_module import GraphModule
79
from torch.fx.node import Node
810

911
__all__ = ["Partition", "split_module"]
10-
12+
_LOGGER = logging.getLogger(__name__)
1113

1214
@compatibility(is_backward_compatible=True)
1315
class Partition:
@@ -194,6 +196,76 @@ def instantiate_node_partition_mapping(node):
194196
partition.node_names.append(node.name)
195197
node._fx_partition = partition_name
196198

199+
# Global State Nodes are nodes which by their global state effects,
200+
# "taint" all downstream nodes while they are active.
201+
GLOBAL_STATE_NODES = [
202+
torch.amp._enter_autocast,
203+
torch.amp._exit_autocast,
204+
torch._C._set_grad_enabled
205+
]
206+
207+
# For grad regions:
208+
# ------------------------
209+
# 1. first region: we do nothing
210+
# 2. subsequent regions: we insert the set_grad at the beginning
211+
grad_regions: OrderedDict[Node, Set[int]] = OrderedDict()
212+
213+
# For autocast regions:
214+
# ------------------------
215+
# 1. first region: we will only insert the _exit at the end
216+
# 2. intermediate regions: we will insert both the
217+
# _enter at the beginning and _exit at the end
218+
# 3. last region: we will only insert _enter at the beginning
219+
# We will do so in the order in which the autocasts were instantiated.
220+
autocast_regions: OrderedDict[Node, Set[int]] = OrderedDict()
221+
autocast_exits: Dict[Node, Optional[Node]] = {}
222+
223+
active_grad = None
224+
active_autocasts = set()
225+
226+
for node in m.graph.nodes:
227+
if node.op in ["placeholder", "get_attr", "output"]:
228+
continue
229+
230+
instantiate_node_partition_mapping(node)
231+
232+
if node.op == "call_function" and node.target in GLOBAL_STATE_NODES:
233+
if node.target == torch._C._set_grad_enabled:
234+
assert len(node.args) == 1
235+
assert isinstance(node.args[0], bool)
236+
active_grad = node
237+
grad_regions[active_grad] = set({split_callback(node)})
238+
elif node.target == torch.amp._enter_autocast:
239+
# Should all be python constants
240+
assert all(not isinstance(arg, Node) for arg in node.args)
241+
active_autocasts.add(node)
242+
autocast_regions[node] = set({split_callback(node)})
243+
autocast_exits[node] = None
244+
elif node.target == torch.amp._exit_autocast:
245+
assert len(node.args) == 1
246+
autocast_regions[node.args[0]].add(split_callback(node))
247+
active_autocasts.remove(node.args[0])
248+
autocast_exits[node.args[0]] = node
249+
250+
if active_grad is not None:
251+
grad_regions[active_grad].add(split_callback(node))
252+
253+
for a in active_autocasts:
254+
autocast_regions[a].add(split_callback(node))
255+
256+
assert all(v is not None for v in autocast_exits.values()), "autocast must exit"
257+
258+
autocast_regions = {k: sorted(v) for k, v in autocast_regions.items()}
259+
grad_regions = {k: sorted(v) for k, v in grad_regions.items()}
260+
261+
if _LOGGER.isEnabledFor(logging.DEBUG):
262+
_LOGGER.debug("autocast_regions: %s", autocast_regions)
263+
_LOGGER.debug("grad_regions: %s", grad_regions)
264+
265+
assert_monotonically_increasing = bool(autocast_regions) or bool(grad_regions)
266+
267+
# split nodes into partitions
268+
highest_partition = -1
197269
for node in m.graph.nodes:
198270
orig_nodes[node.name] = node
199271

@@ -207,14 +279,22 @@ def instantiate_node_partition_mapping(node):
207279
)
208280
continue
209281

210-
instantiate_node_partition_mapping(node)
282+
if assert_monotonically_increasing:
283+
pid = split_callback(node)
284+
assert highest_partition <= pid,\
285+
("autocast or set_grad_enabled require monotonically increasing partitions:"
286+
f"highest: {highest_partition}, this node's: {pid}")
287+
highest_partition = pid
211288

212-
torch.fx.graph.map_arg(
213-
node.args, lambda def_node: record_cross_partition_use(def_node, node)
214-
)
215-
torch.fx.graph.map_arg(
216-
node.kwargs, lambda def_node: record_cross_partition_use(def_node, node)
217-
) # noqa: B950
289+
# do not capture cross-partition dependencies for global state nodes as they will be
290+
# self-contained - their setup and unwind will be isolated to each partition submodule.
291+
if node.target not in GLOBAL_STATE_NODES:
292+
torch.fx.graph.map_arg(
293+
node.args, lambda def_node: record_cross_partition_use(def_node, node)
294+
)
295+
torch.fx.graph.map_arg(
296+
node.kwargs, lambda def_node: record_cross_partition_use(def_node, node)
297+
) # noqa: B950
218298

219299
original_partition_order = list(partitions.keys())
220300
# find partitions with no dependencies
@@ -235,6 +315,23 @@ def instantiate_node_partition_mapping(node):
235315
if len(sorted_partitions) != len(partitions):
236316
raise RuntimeError("cycle exists between partitions!")
237317

318+
# Enter prelude
319+
for regions_mapping in [autocast_regions, grad_regions]:
320+
for node, regions in regions_mapping.items():
321+
assert len(regions) > 0
322+
partitions[str(regions[0])].environment[node] = node
323+
for r in regions[1:]:
324+
partition = partitions[str(r)]
325+
new_node = partition.graph.create_node(
326+
op=node.op,
327+
target=node.target,
328+
args=tuple(arg for arg in node.args),
329+
kwargs={},
330+
type_expr=node.type,
331+
)
332+
new_node.meta = node.meta.copy() # is it really a good idea to copy this?
333+
partition.environment[node] = new_node
334+
238335
# add placeholders to partition inputs
239336
for partition_name in sorted_partitions:
240337
partition = partitions[partition_name]
@@ -289,6 +386,24 @@ def instantiate_node_partition_mapping(node):
289386
new_node.meta = node.meta.copy()
290387
partition.environment[node] = new_node
291388

389+
# Exit epilogue
390+
for regions_mapping in [autocast_regions]:
391+
for node in reversed(regions_mapping):
392+
regions = regions_mapping[node]
393+
assert len(regions) > 0
394+
for r in regions[:-1]:
395+
partition = partitions[str(r)]
396+
exit_node = autocast_exits[node]
397+
assert exit_node is not None, "Missing exit node"
398+
new_node = partition.graph.create_node(
399+
op=exit_node.op,
400+
target=exit_node.target,
401+
args=(partition.environment[node],),
402+
kwargs={},
403+
type_expr=exit_node.type,
404+
)
405+
new_node.meta = exit_node.meta.copy() # is it really a good idea to copy this?
406+
292407
# original module environment dict mapping node names to nodes
293408
orig_mod_env: Dict[str, Node] = {}
294409
# Set up values to construct base module

0 commit comments

Comments
 (0)