Skip to content

Commit 635d39e

Browse files
committed
Copy a tensor to a device once when several delegates there read it
When several delegates on one device read the same tensor, the pass made a separate host-to-device copy for each of them. A constant read by three delegates on cuda:0, such as a lookup table, was copied three times on every call. Method inputs were copied the same way when skip_h2d_for_method_inputs is off. The pass visits delegates in graph order, so a copy made for an earlier delegate on the same device already comes before the later ones. It now reuses that copy. A tensor read on two devices still gets one copy for each device. For example, a constant read by delegates on cuda:0, cuda:1 and cuda:0 now gets two copies instead of three.
1 parent 4353ec4 commit 635d39e

4 files changed

Lines changed: 89 additions & 25 deletions

File tree

‎docs/source/backends/cuda/cuda-overview.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ outputs = method([torch.randn(4, 64, device="cuda")])
158158

159159
Constants that the partitioner leaves outside the delegate, such as weights or buffers the method
160160
does not change, are not passed by the caller. Their data stays in the program in host memory, so
161-
they are still copied to the device on every call, once for each delegate that reads them. Keep
162-
constants inside the delegate to avoid that cost.
161+
they are still copied to the device on every call. Keep constants inside the delegate to avoid
162+
that cost.
163163

164164
Outputs also stay on the device, which is what you want when the next stage of your pipeline is
165165
also on the GPU.

‎exir/passes/propagate_device_config.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class PropagateDeviceConfig:
5151
# already on the target device. Useful for pipelines where inputs are
5252
# pre-staged on GPU. Constants are not passed by the caller, so they are
5353
# still copied: their data stays in the program on the host. That copy runs
54-
# on every call, once for each delegate that reads the constant.
54+
# on every call, once for each device that reads the constant.
5555
#
5656
# Pair with MemoryPlanningPass(alloc_graph_input=False), or the runtime reserves its own buffer
5757
# for the input and copies the caller's memory into it, which is what this exists to avoid.

‎exir/passes/propagate_device_pass.py‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ class PropagateDevicePass(PassBase):
175175
boundaries based on partitioner-assigned device info.
176176
177177
When a delegate has a target_device CompileSpec (e.g., "cuda:0"):
178-
- For each delegate input: insert et_copy._h2d_copy before the delegate call.
178+
- For each delegate input: insert et_copy._h2d_copy before the delegate call,
179+
or reuse the one an earlier delegate on the same device reads.
179180
The original input node stays CPU; the h2d_copy output is tagged as device.
180181
- For each delegate output: insert et_copy._d2h_copy after each getitem,
181182
or after the call itself when it returns a single tensor.
@@ -304,18 +305,32 @@ def _insert_h2d_copies(
304305
f"it is created, for example in the pass that adds it."
305306
)
306307

307-
with graph_module.graph.inserting_before(node):
308-
h2d_node = graph_module.graph.call_function(
309-
torch.ops.et_copy._h2d_copy.default,
310-
(arg,),
311-
)
312-
h2d_spec = _copy_output_spec(arg_spec, target_device_type, device_index)
313-
h2d_node.meta["spec"] = h2d_spec
314-
h2d_node.meta["val"] = arg.meta.get("val")
315-
if "tensor_meta" in arg.meta:
316-
h2d_node.meta["tensor_meta"] = arg.meta["tensor_meta"]
317-
new_args[i] = h2d_node
318-
changed = True
308+
# Delegates are visited in graph order, so a copy made for an earlier
309+
# delegate on this device already comes before this one.
310+
h2d_node = next(
311+
(
312+
user
313+
for user in arg.users
314+
if user.target == torch.ops.et_copy._h2d_copy.default
315+
and user.meta["spec"].device == target_device_type
316+
and user.meta["spec"].device_index == device_index
317+
),
318+
None,
319+
)
320+
if h2d_node is None:
321+
with graph_module.graph.inserting_before(node):
322+
h2d_node = graph_module.graph.call_function(
323+
torch.ops.et_copy._h2d_copy.default,
324+
(arg,),
325+
)
326+
h2d_node.meta["spec"] = _copy_output_spec(
327+
arg_spec, target_device_type, device_index
328+
)
329+
h2d_node.meta["val"] = arg.meta.get("val")
330+
if "tensor_meta" in arg.meta:
331+
h2d_node.meta["tensor_meta"] = arg.meta["tensor_meta"]
332+
new_args[i] = h2d_node
333+
changed = True
319334

320335
node.args = tuple(new_args)
321336
return changed

‎exir/tests/test_propagate_device_pass.py‎

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -580,9 +580,9 @@ def forward(self, x, y):
580580
with self.subTest(case=case):
581581
nodes = _collect_device_copy_nodes(gm)
582582
self.assertEqual(len(nodes.delegate_nodes), 2)
583-
# Only method inputs are copied to the device: x and y, then y
584-
# again, unless the caller places them.
585-
self.assertEqual(len(nodes.h2d_nodes), 0 if skip_h2d else 3)
583+
# Only method inputs are copied to the device, x and y once
584+
# each, unless the caller places them.
585+
self.assertEqual(len(nodes.h2d_nodes), 0 if skip_h2d else 2)
586586
self.assertTrue(
587587
all(n.args[0].op == "placeholder" for n in nodes.h2d_nodes)
588588
)
@@ -779,14 +779,14 @@ def forward(self, a, b):
779779
4,
780780
f"[{pipeline}] expected four delegates",
781781
)
782-
# a -> {D0, D1} and b -> {D0, D2} give 4 graph-input H2D copies;
783-
# the single graph output gives 1 D2H. All inter-delegate
784-
# round-trips (including delegate 0's output feeding D1 and D2)
785-
# are elided.
782+
# a -> {D0, D1} and b -> {D0, D2} give one graph-input H2D copy
783+
# each, shared by the delegates that read it; the single graph
784+
# output gives 1 D2H. All inter-delegate round-trips (including
785+
# delegate 0's output feeding D1 and D2) are elided.
786786
self.assertEqual(
787787
len(nodes.h2d_nodes),
788-
4,
789-
f"[{pipeline}] expected 4 boundary H2D copies, got "
788+
2,
789+
f"[{pipeline}] expected 2 boundary H2D copies, got "
790790
f"{len(nodes.h2d_nodes)}",
791791
)
792792
self.assertEqual(
@@ -1609,6 +1609,55 @@ def forward(self, a):
16091609
):
16101610
edge.to_executorch(et_config)
16111611

1612+
def test_constant_read_by_several_delegates_is_copied_once_per_device(self):
1613+
"""A constant read by several delegates gets one _h2d_copy for each
1614+
device, not one for each delegate."""
1615+
1616+
inputs = (torch.randn(2, 2), torch.randn(2, 2))
1617+
lowered = {
1618+
device: _lower_add_to_device(inputs, device)
1619+
for device in ("cuda:0", "cuda:1")
1620+
}
1621+
1622+
class Model(torch.nn.Module):
1623+
def __init__(self, devices):
1624+
super().__init__()
1625+
self.register_buffer("offset", torch.randn(2, 2))
1626+
self.first, self.second, self.third = (lowered[d] for d in devices)
1627+
1628+
def forward(self, x):
1629+
return (
1630+
self.first(x, self.offset),
1631+
self.second(x, self.offset),
1632+
self.third(x, self.offset),
1633+
)
1634+
1635+
et_config = ExecutorchBackendConfig(
1636+
emit_stacktrace=False, enable_non_cpu_memory_planning=True
1637+
)
1638+
cases = {
1639+
"one device": (("cuda:0", "cuda:0", "cuda:0"), [0]),
1640+
"two devices": (("cuda:0", "cuda:1", "cuda:0"), [0, 1]),
1641+
}
1642+
for case, (devices, copy_indices) in cases.items():
1643+
et = to_edge(export(Model(devices), inputs[:1])).to_executorch(
1644+
deepcopy(et_config)
1645+
)
1646+
gm = et.exported_program().graph_module
1647+
with self.subTest(case=case):
1648+
nodes = _collect_device_copy_nodes(gm)
1649+
offset_copies = [
1650+
n for n in nodes.h2d_nodes if n.args[0].name == "b_offset"
1651+
]
1652+
self.assertEqual(
1653+
sorted(n.meta["spec"].device_index for n in offset_copies),
1654+
copy_indices,
1655+
)
1656+
for delegate, device in zip(nodes.delegate_nodes, devices):
1657+
offset = delegate.args[2]
1658+
self.assertIn(offset, offset_copies)
1659+
self.assertEqual(f"cuda:{offset.meta['spec'].device_index}", device)
1660+
16121661
def test_skip_h2d_for_input_shared_by_device_delegates(self):
16131662
"""With skip_h2d_for_method_inputs=True, a method input read by two
16141663
delegates on the same device is placed on that device once, with no

0 commit comments

Comments
 (0)