Skip to content

Commit 3bd78c9

Browse files
committed
Copy constants that feed a device delegate into device memory
A constant that feeds a delegate on a device, such as a buffer the partitioner leaves outside the delegate, broke device memory planning. - Without skip_h2d_for_method_inputs, its _h2d_copy cloned the constant's spec, constant flag included, so memory planning skipped the copy's output and emission failed. - With it, the constant was treated as a method input: its placeholder was tagged with the device while its data stays in the program on the host, so the delegate got a host pointer labeled as device memory. A copy's output is now never a constant, and skip_h2d_for_method_inputs applies only to placeholders that are not constants. The config comment and the CUDA docs say that this copy runs on every call. The copy moves the constant's bytes as they are stored. The program stores a constant that is neither contiguous nor channels-last as contiguous, so such a constant, which a pass that runs after to_edge can create, is now refused with a clear error instead of getting a copy with the wrong layout.
1 parent 4259fab commit 3bd78c9

4 files changed

Lines changed: 170 additions & 9 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ exec_program = et_program.to_executorch(
156156
outputs = method([torch.randn(4, 64, device="cuda")])
157157
```
158158

159+
Constants that the partitioner leaves outside the delegate, such as weights or buffers, are not
160+
passed by the caller. Their data stays in the program in host memory, so they are still copied to
161+
the device on every call, once for each delegate that reads them. Keep constants inside the
162+
delegate to avoid that cost.
163+
159164
Outputs also stay on the device, which is what you want when the next stage of your pipeline is
160165
also on the GPU.
161166

‎exir/passes/propagate_device_config.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ class PropagateDeviceConfig:
4949
# When True, method-level input tensors that feed directly into a device
5050
# delegate are NOT wrapped with _h2d_copy. The user must provide tensors
5151
# already on the target device. Useful for pipelines where inputs are
52-
# pre-staged on GPU.
52+
# pre-staged on GPU. Constants are not passed by the caller, so they are
53+
# 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.
5355
#
5456
# Pair with MemoryPlanningPass(alloc_graph_input=False), or the runtime reserves its own buffer
5557
# 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: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,19 @@ def _tag_specs_with_device(
135135
return False
136136

137137

138-
def _clone_spec_with_device(
138+
def _copy_output_spec(
139139
spec: TensorSpec,
140140
device_type: schema.DeviceType,
141141
device_index: int = 0,
142142
) -> TensorSpec:
143-
"""Create a copy of a TensorSpec with a different device."""
143+
"""Create the spec of an _h2d_copy or _d2h_copy output from the spec it copies.
144+
145+
The output lives on ``device_type``. The copy writes it at run time, so it is
146+
never a constant, even when the copied tensor is one.
147+
"""
144148
new_spec = copy.copy(spec)
145149
new_spec.init_mem_planning_fields()
150+
new_spec.const = False
146151
_set_device_on_spec(new_spec, device_type, device_index)
147152
return new_spec
148153

@@ -179,7 +184,9 @@ class PropagateDevicePass(PassBase):
179184
Skip-copy optimizations:
180185
- skip_h2d_for_method_inputs: If the input is a graph-level placeholder
181186
feeding directly to a delegate, don't insert H2D — tag the placeholder
182-
as device instead (user provides GPU tensor at runtime).
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.
183190
- skip_d2h_for_method_outputs: If a delegate output feeds directly to
184191
graph output, don't insert D2H; the output stays on device.
185192
"""
@@ -231,7 +238,11 @@ def _insert_h2d_copies(
231238
if not isinstance(arg_spec, TensorSpec):
232239
continue
233240

234-
if self.skip_h2d_for_method_inputs and self._is_placeholder(arg):
241+
if (
242+
self.skip_h2d_for_method_inputs
243+
and self._is_placeholder(arg)
244+
and not arg_spec.const
245+
):
235246
# TODO(gasoonjia): support skip_h2d_for_method_inputs for
236247
# multiple-user placeholder inputs.
237248
if len(arg.users) != 1:
@@ -248,14 +259,28 @@ def _insert_h2d_copies(
248259
changed = True
249260
continue
250261

262+
val = arg.meta.get("val")
263+
if (
264+
arg_spec.const
265+
and isinstance(val, torch.Tensor)
266+
and not val.is_contiguous()
267+
and not val.is_contiguous(memory_format=torch.channels_last)
268+
):
269+
raise RuntimeError(
270+
f"Constant '{arg.name}' feeds a delegate on "
271+
f"{target_device_type.name.lower()}:{device_index}, but it is "
272+
f"neither contiguous nor channels-last. The program stores "
273+
f"such a constant contiguous, so its device copy would not "
274+
f"match the stored data. Make the constant contiguous where "
275+
f"it is created, for example in the pass that adds it."
276+
)
277+
251278
with graph_module.graph.inserting_before(node):
252279
h2d_node = graph_module.graph.call_function(
253280
torch.ops.et_copy._h2d_copy.default,
254281
(arg,),
255282
)
256-
h2d_spec = _clone_spec_with_device(
257-
arg_spec, target_device_type, device_index
258-
)
283+
h2d_spec = _copy_output_spec(arg_spec, target_device_type, device_index)
259284
h2d_node.meta["spec"] = h2d_spec
260285
h2d_node.meta["val"] = arg.meta.get("val")
261286
if "tensor_meta" in arg.meta:
@@ -312,7 +337,7 @@ def _insert_d2h_after(
312337
torch.ops.et_copy._d2h_copy.default,
313338
(node,),
314339
)
315-
d2h_spec = _clone_spec_with_device(spec, schema.DeviceType.CPU, 0)
340+
d2h_spec = _copy_output_spec(spec, schema.DeviceType.CPU, 0)
316341
d2h_node.meta["spec"] = d2h_spec
317342
d2h_node.meta["val"] = node.meta.get("val")
318343
if "tensor_meta" in node.meta:

‎exir/tests/test_propagate_device_pass.py‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from executorch.exir.capture._config import ExecutorchBackendConfig
3131
from executorch.exir.delegate import executorch_call_delegate
3232
from executorch.exir.dialects._ops import ops as exir_ops
33+
from executorch.exir.pass_base import ExportedProgramPassBase, ExportedProgramPassResult
3334
from executorch.exir.passes import MemoryPlanningPass
3435
from executorch.exir.passes.propagate_device_pass import (
3536
_get_target_device_from_compile_specs,
@@ -1392,6 +1393,134 @@ def forward(self, a, b):
13921393
"CPU placeholder",
13931394
)
13941395

1396+
def test_constant_feeding_device_delegate_is_copied(self):
1397+
"""A constant feeding a device delegate gets an _h2d_copy into planned device
1398+
memory, with or without skip_h2d_for_method_inputs: its data lives in the
1399+
program on the host, so it is not a method input the caller can place."""
1400+
1401+
class Model(torch.nn.Module):
1402+
def __init__(self):
1403+
super().__init__()
1404+
self.register_buffer("offset", torch.randn(2, 2))
1405+
1406+
def forward(self, a):
1407+
return torch.add(a, self.offset)
1408+
1409+
model = Model()
1410+
inputs = (torch.randn(2, 2),)
1411+
for skip in (False, True):
1412+
et_config = ExecutorchBackendConfig(
1413+
emit_stacktrace=False,
1414+
propagate_device_config=PropagateDeviceConfig(
1415+
skip_h2d_for_method_inputs=skip
1416+
),
1417+
enable_non_cpu_memory_planning=True,
1418+
)
1419+
for pipeline, program, gm in self._get_executorch_program(
1420+
model, inputs, DeviceAwarePartitioner("cuda:0"), et_config
1421+
):
1422+
with self.subTest(pipeline=pipeline, skip=skip):
1423+
nodes = _collect_device_copy_nodes(gm)
1424+
# Input a is copied only when the caller cannot place it.
1425+
self.assertEqual(len(nodes.h2d_nodes), 1 if skip else 2)
1426+
cuda_ph, _ = self._collect_placeholders_by_device(gm)
1427+
self.assertEqual(len(cuda_ph), int(skip))
1428+
self.assertTrue(all(not n.meta["spec"].const for n in cuda_ph))
1429+
copies = [n.meta["spec"] for n in nodes.h2d_nodes]
1430+
self.assertTrue(all(not spec.const for spec in copies))
1431+
self._assert_nodes_buffer_device(
1432+
nodes.h2d_nodes, program, DeviceType.CUDA, pipeline, "H2D copy"
1433+
)
1434+
1435+
def test_constant_copy_keeps_stored_layout(self):
1436+
"""The copy of a constant moves its bytes as they are, so it must use the
1437+
layout the constant is stored in, for a transposed parameter as well as
1438+
a channels-last one."""
1439+
1440+
class Model(torch.nn.Module):
1441+
def __init__(self, weight):
1442+
super().__init__()
1443+
self.weight = torch.nn.Parameter(weight)
1444+
1445+
def forward(self, a):
1446+
return torch.add(a, self.weight)
1447+
1448+
weights = {
1449+
"transposed": torch.randn(3, 2).t(),
1450+
"channels_last": torch.randn(1, 2, 3, 3).to(
1451+
memory_format=torch.channels_last
1452+
),
1453+
}
1454+
for name, weight in weights.items():
1455+
et_config = ExecutorchBackendConfig(
1456+
emit_stacktrace=False,
1457+
propagate_device_config=PropagateDeviceConfig(
1458+
skip_h2d_for_method_inputs=True
1459+
),
1460+
enable_non_cpu_memory_planning=True,
1461+
)
1462+
for pipeline, _program, gm in self._get_executorch_program(
1463+
Model(weight),
1464+
(torch.randn(weight.shape),),
1465+
DeviceAwarePartitioner("cuda:0"),
1466+
et_config,
1467+
):
1468+
with self.subTest(pipeline=pipeline, weight=name):
1469+
nodes = _collect_device_copy_nodes(gm)
1470+
self.assertEqual(len(nodes.h2d_nodes), 1)
1471+
h2d = nodes.h2d_nodes[0]
1472+
# The emitter rewrites the constant's spec to the stored layout.
1473+
stored = h2d.args[0].meta["spec"]
1474+
self.assertEqual(h2d.meta["spec"].dim_order, stored.dim_order)
1475+
self.assertEqual(
1476+
tuple(h2d.meta["spec"].stride), tuple(stored.stride)
1477+
)
1478+
1479+
def test_constant_with_unstored_layout_is_refused(self):
1480+
"""A constant that is neither contiguous nor channels-last is stored
1481+
contiguous, so its copy would not match the stored data. A pass that runs
1482+
after to_edge normalized the constants can create one, and lowering then
1483+
fails instead of producing wrong results."""
1484+
1485+
class StoreWeightTransposed(ExportedProgramPassBase):
1486+
def call(self, ep):
1487+
ep.state_dict["w"] = torch.nn.Parameter(
1488+
ep.state_dict["w"].t().contiguous().t()
1489+
)
1490+
for node in ep.graph.nodes:
1491+
if node.name == "p_w":
1492+
node.meta["val"] = node.meta["val"].t().contiguous().t()
1493+
return ExportedProgramPassResult(ep, True)
1494+
1495+
class Model(torch.nn.Module):
1496+
def __init__(self):
1497+
super().__init__()
1498+
self.w = torch.nn.Parameter(torch.randn(2, 3))
1499+
1500+
def forward(self, a):
1501+
return torch.add(a, self.w)
1502+
1503+
for skip in (False, True):
1504+
edge = to_edge_transform_and_lower(
1505+
export(Model(), (torch.randn(2, 3),)),
1506+
transform_passes=[StoreWeightTransposed()],
1507+
partitioner=[DeviceAwarePartitioner("cuda:0")],
1508+
)
1509+
et_config = ExecutorchBackendConfig(
1510+
emit_stacktrace=False,
1511+
propagate_device_config=PropagateDeviceConfig(
1512+
skip_h2d_for_method_inputs=skip
1513+
),
1514+
enable_non_cpu_memory_planning=True,
1515+
)
1516+
with self.subTest(skip=skip):
1517+
with self.assertRaisesRegex(
1518+
RuntimeError,
1519+
"Constant 'p_w' feeds a delegate on cuda:0, but it is neither "
1520+
"contiguous nor channels-last",
1521+
):
1522+
edge.to_executorch(et_config)
1523+
13951524
def test_tensorspec_repr_includes_device(self):
13961525
spec = TensorSpec(dtype=torch.float32, shape=torch.Size([2, 3]))
13971526
repr_str = repr(spec)

0 commit comments

Comments
 (0)