Skip to content

Commit e1ed5ce

Browse files
committed
Let a method input feed several delegates on one device without a copy
With skip_h2d_for_method_inputs, a method input had to have exactly one user, because its spec is changed in place to the delegate's device. A program that calls one lowered module more than once, or passes one input to two delegates, could not keep its inputs on the device. Changing the spec is safe when every user is a delegate on that same device, so that case is now allowed. An input that a host op or a delegate on another device also reads is still refused, and the error names that reader and the device the input was going to. When the reader is the write-back of a mutated input or mutable buffer, the error says so, and for a mutable buffer it points to tag_mutated_buffer, which lets the partitioner keep the buffer inside the delegate.
1 parent 3bd78c9 commit e1ed5ce

2 files changed

Lines changed: 202 additions & 32 deletions

File tree

‎exir/passes/propagate_device_pass.py‎

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,10 @@ class PropagateDevicePass(PassBase):
183183
184184
Skip-copy optimizations:
185185
- skip_h2d_for_method_inputs: If the input is a graph-level placeholder
186-
feeding directly to a delegate, don't insert H2D — tag the placeholder
187-
as device instead (user provides GPU tensor at runtime). Constants are
188-
placeholders too, but their data is stored on the host, so they still
189-
get an H2D copy.
186+
read only by delegates on one device, don't insert H2D; tag the
187+
placeholder as device instead (user provides GPU tensor at runtime).
188+
Constants are placeholders too, but their data is stored on the host,
189+
so they still get an H2D copy.
190190
- skip_d2h_for_method_outputs: If a delegate output feeds directly to
191191
graph output, don't insert D2H; the output stays on device.
192192
"""
@@ -217,6 +217,17 @@ def _is_placeholder(self, node: torch.fx.Node) -> bool:
217217
"""Check if a node is a graph-level input (placeholder)."""
218218
return node.op == "placeholder"
219219

220+
def _delegate_device(
221+
self, graph_module: torch.fx.GraphModule, node: torch.fx.Node
222+
) -> Optional[tuple[schema.DeviceType, int]]:
223+
"""The target device of a delegate call node, or None when there is none."""
224+
if node.op != "call_function" or node.target != executorch_call_delegate:
225+
return None
226+
lowered_module = _get_lowered_module(graph_module, node)
227+
if lowered_module is None:
228+
return None
229+
return _get_target_device_from_compile_specs(lowered_module)
230+
220231
def _feeds_directly_to_output(self, node: torch.fx.Node) -> bool:
221232
"""Check if all users of a node are output nodes."""
222233
return all(user.op == "output" for user in node.users)
@@ -243,17 +254,38 @@ def _insert_h2d_copies(
243254
and self._is_placeholder(arg)
244255
and not arg_spec.const
245256
):
246-
# TODO(gasoonjia): support skip_h2d_for_method_inputs for
247-
# multiple-user placeholder inputs.
248-
if len(arg.users) != 1:
257+
# The spec is shared by every reader, so all of them must be
258+
# delegates on this same device.
259+
other_reader = next(
260+
(
261+
user
262+
for user in arg.users
263+
if self._delegate_device(graph_module, user)
264+
!= (target_device_type, device_index)
265+
),
266+
None,
267+
)
268+
if other_reader is not None:
269+
refusal = (
270+
f"skip_h2d_for_method_inputs=True cannot place placeholder "
271+
f"'{arg.name}' on {target_device_type.name.lower()}:"
272+
f"{device_index}, because '{other_reader.name}'"
273+
)
274+
if (
275+
other_reader.target == torch.ops.aten.copy_.default
276+
and other_reader.args[0] is arg
277+
):
278+
raise RuntimeError(
279+
f"{refusal} writes it back on the host (the method "
280+
f"mutates it). Either disable "
281+
f"skip_h2d_for_method_inputs, or, for a mutable buffer, "
282+
f"have the partitioner delegate it, for example with "
283+
f"tag_mutated_buffer."
284+
)
249285
raise RuntimeError(
250-
f"skip_h2d_for_method_inputs=True requires placeholder "
251-
f"'{arg.name}' to have exactly one user, but it has "
252-
f"{len(arg.users)} users. The placeholder is shared by "
253-
f"multiple consumers, so its TensorSpec cannot be safely "
254-
f"mutated in-place to the delegate's device. Either disable "
255-
f"skip_h2d_for_method_inputs, or ensure the placeholder is "
256-
f"used exclusively by this delegate."
286+
f"{refusal} also reads it and is not a delegate on that "
287+
f"device. Either disable skip_h2d_for_method_inputs, or use "
288+
f"the placeholder only from delegates on one device."
257289
)
258290
_set_device_on_spec(arg_spec, target_device_type, device_index)
259291
changed = True

‎exir/tests/test_propagate_device_pass.py‎

Lines changed: 156 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,8 @@ def forward(self, a, b, c):
515515
def test_single_tensor_delegate_output_copied_to_host(self):
516516
"""A lowered module that returns one tensor, called from another module,
517517
has no getitem after its call. Its device output still gets a _d2h_copy,
518-
and the next call on the same device reads it without a round-trip."""
518+
and the next call on the same device reads it without a round-trip. With
519+
skip_h2d_for_method_inputs, y, read by both calls, is placed once."""
519520

520521
inputs = (torch.randn(2, 2), torch.randn(2, 2))
521522
lowered = _lower_add_to_device(inputs, "cuda:0")
@@ -528,26 +529,35 @@ def __init__(self):
528529
def forward(self, x, y):
529530
return self.lowered(self.lowered(x, y), y)
530531

531-
for skip_d2h in (False, True):
532+
configs = (
533+
PropagateDeviceConfig(),
534+
PropagateDeviceConfig(skip_h2d_for_method_inputs=True),
535+
PropagateDeviceConfig(skip_d2h_for_method_outputs=True),
536+
)
537+
for device_config in configs:
538+
skip_h2d = device_config.skip_h2d_for_method_inputs
539+
skip_d2h = device_config.skip_d2h_for_method_outputs
532540
et = to_edge(export(Model(), inputs)).to_executorch(
533541
ExecutorchBackendConfig(
534542
emit_stacktrace=False,
535-
propagate_device_config=PropagateDeviceConfig(
536-
skip_d2h_for_method_outputs=skip_d2h
537-
),
543+
propagate_device_config=device_config,
538544
enable_non_cpu_memory_planning=True,
539545
)
540546
)
541547
program = et._emitter_output.program
542548
gm = et.exported_program().graph_module
543-
with self.subTest(skip_d2h=skip_d2h):
549+
case = f"skip_h2d={skip_h2d}, skip_d2h={skip_d2h}"
550+
with self.subTest(case=case):
544551
nodes = _collect_device_copy_nodes(gm)
545552
self.assertEqual(len(nodes.delegate_nodes), 2)
546-
# Only method inputs are copied to the device: x and y, then y again.
547-
self.assertEqual(len(nodes.h2d_nodes), 3)
553+
# Only method inputs are copied to the device: x and y, then y
554+
# again, unless the caller places them.
555+
self.assertEqual(len(nodes.h2d_nodes), 0 if skip_h2d else 3)
548556
self.assertTrue(
549557
all(n.args[0].op == "placeholder" for n in nodes.h2d_nodes)
550558
)
559+
cuda_ph, _ = self._collect_placeholders_by_device(gm)
560+
self.assertEqual(len(cuda_ph), 2 if skip_h2d else 0)
551561
# Only the method output can be copied back to the host.
552562
self.assertEqual(len(nodes.d2h_nodes), 0 if skip_d2h else 1)
553563
(output,) = gm.graph.output_node().args[0]
@@ -560,18 +570,10 @@ def forward(self, x, y):
560570
),
561571
)
562572
self._assert_nodes_buffer_device(
563-
nodes.delegate_nodes,
564-
program,
565-
DeviceType.CUDA,
566-
f"skip_d2h={skip_d2h}",
567-
"Delegate",
573+
nodes.delegate_nodes, program, DeviceType.CUDA, case, "Delegate"
568574
)
569575
self._assert_nodes_buffer_device(
570-
nodes.d2h_nodes,
571-
program,
572-
DeviceType.CPU,
573-
f"skip_d2h={skip_d2h}",
574-
"D2H copy",
576+
nodes.d2h_nodes, program, DeviceType.CPU, case, "D2H copy"
575577
)
576578

577579
def test_skip_d2h_keeps_output_also_read_on_device(self):
@@ -1521,6 +1523,142 @@ def forward(self, a):
15211523
):
15221524
edge.to_executorch(et_config)
15231525

1526+
def test_skip_h2d_for_input_shared_by_device_delegates(self):
1527+
"""With skip_h2d_for_method_inputs=True, a method input read by two
1528+
delegates on the same device is placed on that device once, with no
1529+
_h2d_copy."""
1530+
1531+
class Model(torch.nn.Module):
1532+
def forward(self, a, b):
1533+
c = torch.sin(torch.add(a, b))
1534+
return torch.add(a, c)
1535+
1536+
model = Model()
1537+
inputs = (torch.randn(2, 2), torch.randn(2, 2))
1538+
et_config = ExecutorchBackendConfig(
1539+
emit_stacktrace=False,
1540+
propagate_device_config=PropagateDeviceConfig(
1541+
skip_h2d_for_method_inputs=True
1542+
),
1543+
enable_non_cpu_memory_planning=True,
1544+
)
1545+
for pipeline, program, gm in self._get_executorch_program(
1546+
model, inputs, DeviceAwarePartitioner("cuda:0"), et_config
1547+
):
1548+
with self.subTest(pipeline=pipeline):
1549+
nodes = _collect_device_copy_nodes(gm)
1550+
self.assertEqual(len(nodes.delegate_nodes), 2)
1551+
# Only sin's output, computed on the host, is copied.
1552+
self.assertEqual(len(nodes.h2d_nodes), 1)
1553+
cuda_ph, _ = self._collect_placeholders_by_device(gm)
1554+
self.assertEqual(sorted(n.name for n in cuda_ph), ["a", "b"])
1555+
self._assert_nodes_buffer_device(
1556+
cuda_ph, program, DeviceType.CUDA, pipeline, "Placeholder"
1557+
)
1558+
1559+
def test_skip_h2d_refuses_input_read_off_device(self):
1560+
"""A method input cannot move to a device when a host op, or a delegate
1561+
on another device, also reads it."""
1562+
1563+
class HostOp(torch.nn.Module):
1564+
def forward(self, a, b):
1565+
return torch.add(a, b), torch.sin(a)
1566+
1567+
class TwoDelegates(torch.nn.Module):
1568+
def forward(self, a, b, c):
1569+
return torch.add(a, b), torch.add(a, c)
1570+
1571+
et_config = ExecutorchBackendConfig(
1572+
emit_stacktrace=False,
1573+
propagate_device_config=PropagateDeviceConfig(
1574+
skip_h2d_for_method_inputs=True
1575+
),
1576+
enable_non_cpu_memory_planning=True,
1577+
)
1578+
cases = {
1579+
"host op": (
1580+
HostOp(),
1581+
2,
1582+
DeviceAwarePartitioner("cuda:0"),
1583+
"aten_sin_default",
1584+
),
1585+
"other device index": (
1586+
TwoDelegates(),
1587+
3,
1588+
PerAddDeviceDelegatePartitioner(["cuda:0", "cuda:1"]),
1589+
"executorch_call_delegate_1",
1590+
),
1591+
"other device type": (
1592+
TwoDelegates(),
1593+
3,
1594+
PerAddDeviceDelegatePartitioner(["cuda:0", "cpu"]),
1595+
"executorch_call_delegate_1",
1596+
),
1597+
}
1598+
for case, (model, num_inputs, partitioner, reader) in cases.items():
1599+
ep = export(model, tuple(torch.randn(2, 2) for _ in range(num_inputs)))
1600+
# Each pipeline gets its own check, so the first refusal cannot hide
1601+
# the second pipeline.
1602+
for pipeline in ("to_edge+to_backend", "to_edge_transform_and_lower"):
1603+
if pipeline == "to_edge+to_backend":
1604+
edge = to_edge(
1605+
deepcopy(ep),
1606+
compile_config=EdgeCompileConfig(_check_ir_validity=False),
1607+
).to_backend(partitioner)
1608+
else:
1609+
edge = to_edge_transform_and_lower(
1610+
deepcopy(ep), partitioner=[partitioner]
1611+
)
1612+
with self.subTest(case=case, pipeline=pipeline):
1613+
with self.assertRaisesRegex(
1614+
RuntimeError, f"placeholder 'a' on cuda:0, because '{reader}'"
1615+
):
1616+
edge.to_executorch(deepcopy(et_config))
1617+
1618+
def test_skip_h2d_refuses_placeholder_written_on_host(self):
1619+
"""A mutated input or mutable buffer that only a device delegate reads is
1620+
still written back on the host, so the refusal says so."""
1621+
1622+
class MutableBuffer(torch.nn.Module):
1623+
def __init__(self):
1624+
super().__init__()
1625+
self.register_buffer("state", torch.zeros(2, 2))
1626+
1627+
def forward(self, a):
1628+
out = torch.add(a, self.state)
1629+
self.state.add_(a)
1630+
return out
1631+
1632+
class MutatedInput(torch.nn.Module):
1633+
def forward(self, a, x):
1634+
out = torch.add(a, x)
1635+
x.add_(a)
1636+
return out
1637+
1638+
et_config = ExecutorchBackendConfig(
1639+
emit_stacktrace=False,
1640+
propagate_device_config=PropagateDeviceConfig(
1641+
skip_h2d_for_method_inputs=True
1642+
),
1643+
enable_non_cpu_memory_planning=True,
1644+
)
1645+
cases = {
1646+
"mutable buffer": (MutableBuffer(), 1, "b_state"),
1647+
"mutated input": (MutatedInput(), 2, "x"),
1648+
}
1649+
for case, (model, num_inputs, name) in cases.items():
1650+
inputs = tuple(torch.randn(2, 2) for _ in range(num_inputs))
1651+
edge = to_edge(export(model, inputs)).to_backend(
1652+
DeviceAwarePartitioner("cuda:0")
1653+
)
1654+
with self.subTest(case=case):
1655+
with self.assertRaisesRegex(
1656+
RuntimeError,
1657+
f"placeholder '{name}' on cuda:0, because 'copy_' writes it "
1658+
r"back on the host \(the method mutates it\)",
1659+
):
1660+
edge.to_executorch(deepcopy(et_config))
1661+
15241662
def test_tensorspec_repr_includes_device(self):
15251663
spec = TensorSpec(dtype=torch.float32, shape=torch.Size([2, 3]))
15261664
repr_str = repr(spec)

0 commit comments

Comments
 (0)