Skip to content

Commit 4353ec4

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. The CUDA docs now list these rules.
1 parent f80b74e commit 4353ec4

3 files changed

Lines changed: 208 additions & 32 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ out works, and the snippet above sets it explicitly only to make the requirement
174174
it to `False` while asking for either skip raises a `ValueError`, because copy insertion happens
175175
during device-aware memory planning.
176176

177+
**With `skip_h2d_for_method_inputs`, only delegates on that device may read an input.** If an
178+
operator outside the delegate also reads it, or the method changes it, export raises an error
179+
instead of adding a copy. Turn the flag off, or have the partitioner take those operators. For a
180+
mutable buffer, `tag_mutated_buffer` does that, and the CUDA partitioner already calls it.
181+
177182
**Inputs must be unplanned**, via `MemoryPlanningPass(alloc_graph_input=False)`. Without it the
178183
program reserves its own input buffer and the runtime copies the caller's memory into it, so the
179184
copy comes back at run time. On device memory that copy is a host memcpy into a device pointer,

‎exir/passes/propagate_device_pass.py‎

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,11 @@ class PropagateDevicePass(PassBase):
184184
185185
Skip-copy optimizations:
186186
- skip_h2d_for_method_inputs: If the input is a graph-level placeholder
187-
feeding directly to a delegate, don't insert H2D — tag the placeholder
188-
as device instead (user provides GPU tensor at runtime). Constants are
189-
placeholders too, but their data is stored on the host, so they still
190-
get an H2D copy.
187+
feeding a delegate, don't insert H2D; tag the placeholder as device
188+
instead (user provides GPU tensor at runtime). Every reader of the
189+
placeholder must then be a delegate on that device, or the pass raises.
190+
Constants are placeholders too, but their data is stored on the host,
191+
so they still get an H2D copy.
191192
- skip_d2h_for_method_outputs: The graph output reads a delegate output
192193
on device, not its D2H copy; the copy stays only for other readers.
193194
"""
@@ -218,6 +219,17 @@ def _is_placeholder(self, node: torch.fx.Node) -> bool:
218219
"""Check if a node is a graph-level input (placeholder)."""
219220
return node.op == "placeholder"
220221

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

‎exir/tests/test_propagate_device_pass.py‎

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

550551
inputs = (torch.randn(2, 2), torch.randn(2, 2))
551552
lowered = _lower_add_to_device(inputs, "cuda:0")
@@ -558,26 +559,35 @@ def __init__(self):
558559
def forward(self, x, y):
559560
return self.lowered(self.lowered(x, y), y)
560561

561-
for skip_d2h in (False, True):
562+
configs = (
563+
PropagateDeviceConfig(),
564+
PropagateDeviceConfig(skip_h2d_for_method_inputs=True),
565+
PropagateDeviceConfig(skip_d2h_for_method_outputs=True),
566+
)
567+
for device_config in configs:
568+
skip_h2d = device_config.skip_h2d_for_method_inputs
569+
skip_d2h = device_config.skip_d2h_for_method_outputs
562570
et = to_edge(export(Model(), inputs)).to_executorch(
563571
ExecutorchBackendConfig(
564572
emit_stacktrace=False,
565-
propagate_device_config=PropagateDeviceConfig(
566-
skip_d2h_for_method_outputs=skip_d2h
567-
),
573+
propagate_device_config=device_config,
568574
enable_non_cpu_memory_planning=True,
569575
)
570576
)
571577
program = et._emitter_output.program
572578
gm = et.exported_program().graph_module
573-
with self.subTest(skip_d2h=skip_d2h):
579+
case = f"skip_h2d={skip_h2d}, skip_d2h={skip_d2h}"
580+
with self.subTest(case=case):
574581
nodes = _collect_device_copy_nodes(gm)
575582
self.assertEqual(len(nodes.delegate_nodes), 2)
576-
# Only method inputs are copied to the device: x and y, then y again.
577-
self.assertEqual(len(nodes.h2d_nodes), 3)
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)
578586
self.assertTrue(
579587
all(n.args[0].op == "placeholder" for n in nodes.h2d_nodes)
580588
)
589+
cuda_ph, _ = self._collect_placeholders_by_device(gm)
590+
self.assertEqual(len(cuda_ph), 2 if skip_h2d else 0)
581591
# Only the method output can be copied back to the host.
582592
self.assertEqual(len(nodes.d2h_nodes), 0 if skip_d2h else 1)
583593
(output,) = gm.graph.output_node().args[0]
@@ -590,18 +600,10 @@ def forward(self, x, y):
590600
),
591601
)
592602
self._assert_nodes_buffer_device(
593-
nodes.delegate_nodes,
594-
program,
595-
DeviceType.CUDA,
596-
f"skip_d2h={skip_d2h}",
597-
"Delegate",
603+
nodes.delegate_nodes, program, DeviceType.CUDA, case, "Delegate"
598604
)
599605
self._assert_nodes_buffer_device(
600-
nodes.d2h_nodes,
601-
program,
602-
DeviceType.CPU,
603-
f"skip_d2h={skip_d2h}",
604-
"D2H copy",
606+
nodes.d2h_nodes, program, DeviceType.CPU, case, "D2H copy"
605607
)
606608

607609
def test_skip_d2h_keeps_output_also_read_on_device(self):
@@ -1607,6 +1609,142 @@ def forward(self, a):
16071609
):
16081610
edge.to_executorch(et_config)
16091611

1612+
def test_skip_h2d_for_input_shared_by_device_delegates(self):
1613+
"""With skip_h2d_for_method_inputs=True, a method input read by two
1614+
delegates on the same device is placed on that device once, with no
1615+
_h2d_copy."""
1616+
1617+
class Model(torch.nn.Module):
1618+
def forward(self, a, b):
1619+
c = torch.sin(torch.add(a, b))
1620+
return torch.add(a, c)
1621+
1622+
model = Model()
1623+
inputs = (torch.randn(2, 2), torch.randn(2, 2))
1624+
et_config = ExecutorchBackendConfig(
1625+
emit_stacktrace=False,
1626+
propagate_device_config=PropagateDeviceConfig(
1627+
skip_h2d_for_method_inputs=True
1628+
),
1629+
enable_non_cpu_memory_planning=True,
1630+
)
1631+
for pipeline, program, gm in self._get_executorch_program(
1632+
model, inputs, DeviceAwarePartitioner("cuda:0"), et_config
1633+
):
1634+
with self.subTest(pipeline=pipeline):
1635+
nodes = _collect_device_copy_nodes(gm)
1636+
self.assertEqual(len(nodes.delegate_nodes), 2)
1637+
# Only sin's output, computed on the host, is copied.
1638+
self.assertEqual(len(nodes.h2d_nodes), 1)
1639+
cuda_ph, _ = self._collect_placeholders_by_device(gm)
1640+
self.assertEqual(sorted(n.name for n in cuda_ph), ["a", "b"])
1641+
self._assert_nodes_buffer_device(
1642+
cuda_ph, program, DeviceType.CUDA, pipeline, "Placeholder"
1643+
)
1644+
1645+
def test_skip_h2d_refuses_input_read_off_device(self):
1646+
"""A method input cannot move to a device when a host op, or a delegate
1647+
on another device, also reads it."""
1648+
1649+
class HostOp(torch.nn.Module):
1650+
def forward(self, a, b):
1651+
return torch.add(a, b), torch.sin(a)
1652+
1653+
class TwoDelegates(torch.nn.Module):
1654+
def forward(self, a, b, c):
1655+
return torch.add(a, b), torch.add(a, c)
1656+
1657+
et_config = ExecutorchBackendConfig(
1658+
emit_stacktrace=False,
1659+
propagate_device_config=PropagateDeviceConfig(
1660+
skip_h2d_for_method_inputs=True
1661+
),
1662+
enable_non_cpu_memory_planning=True,
1663+
)
1664+
cases = {
1665+
"host op": (
1666+
HostOp(),
1667+
2,
1668+
DeviceAwarePartitioner("cuda:0"),
1669+
"aten_sin_default",
1670+
),
1671+
"other device index": (
1672+
TwoDelegates(),
1673+
3,
1674+
PerAddDeviceDelegatePartitioner(["cuda:0", "cuda:1"]),
1675+
"executorch_call_delegate_1",
1676+
),
1677+
"other device type": (
1678+
TwoDelegates(),
1679+
3,
1680+
PerAddDeviceDelegatePartitioner(["cuda:0", "cpu"]),
1681+
"executorch_call_delegate_1",
1682+
),
1683+
}
1684+
for case, (model, num_inputs, partitioner, reader) in cases.items():
1685+
ep = export(model, tuple(torch.randn(2, 2) for _ in range(num_inputs)))
1686+
# Each pipeline gets its own check, so the first refusal cannot hide
1687+
# the second pipeline.
1688+
for pipeline in ("to_edge+to_backend", "to_edge_transform_and_lower"):
1689+
if pipeline == "to_edge+to_backend":
1690+
edge = to_edge(
1691+
deepcopy(ep),
1692+
compile_config=EdgeCompileConfig(_check_ir_validity=False),
1693+
).to_backend(partitioner)
1694+
else:
1695+
edge = to_edge_transform_and_lower(
1696+
deepcopy(ep), partitioner=[partitioner]
1697+
)
1698+
with self.subTest(case=case, pipeline=pipeline):
1699+
with self.assertRaisesRegex(
1700+
RuntimeError, f"placeholder 'a' on cuda:0, because '{reader}'"
1701+
):
1702+
edge.to_executorch(deepcopy(et_config))
1703+
1704+
def test_skip_h2d_refuses_placeholder_written_on_host(self):
1705+
"""A mutated input or mutable buffer that only a device delegate reads is
1706+
still written back on the host, so the refusal says so."""
1707+
1708+
class MutableBuffer(torch.nn.Module):
1709+
def __init__(self):
1710+
super().__init__()
1711+
self.register_buffer("state", torch.zeros(2, 2))
1712+
1713+
def forward(self, a):
1714+
out = torch.add(a, self.state)
1715+
self.state.add_(a)
1716+
return out
1717+
1718+
class MutatedInput(torch.nn.Module):
1719+
def forward(self, a, x):
1720+
out = torch.add(a, x)
1721+
x.add_(a)
1722+
return out
1723+
1724+
et_config = ExecutorchBackendConfig(
1725+
emit_stacktrace=False,
1726+
propagate_device_config=PropagateDeviceConfig(
1727+
skip_h2d_for_method_inputs=True
1728+
),
1729+
enable_non_cpu_memory_planning=True,
1730+
)
1731+
cases = {
1732+
"mutable buffer": (MutableBuffer(), 1, "b_state"),
1733+
"mutated input": (MutatedInput(), 2, "x"),
1734+
}
1735+
for case, (model, num_inputs, name) in cases.items():
1736+
inputs = tuple(torch.randn(2, 2) for _ in range(num_inputs))
1737+
edge = to_edge(export(model, inputs)).to_backend(
1738+
DeviceAwarePartitioner("cuda:0")
1739+
)
1740+
with self.subTest(case=case):
1741+
with self.assertRaisesRegex(
1742+
RuntimeError,
1743+
f"placeholder '{name}' on cuda:0, because 'copy_' writes it "
1744+
r"back on the host \(the method mutates it\)",
1745+
):
1746+
edge.to_executorch(deepcopy(et_config))
1747+
16101748
def test_tensorspec_repr_includes_device(self):
16111749
spec = TensorSpec(dtype=torch.float32, shape=torch.Size([2, 3]))
16121750
repr_str = repr(spec)

0 commit comments

Comments
 (0)