Skip to content

Commit f80b74e

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 6aacee5 commit f80b74e

5 files changed

Lines changed: 171 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 the method
160+
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.
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/BUCK‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ fbcode_target(_kind = runtime.python_library,
523523
"propagate_device_pass.py",
524524
],
525525
deps = [
526+
":convert_constant_dim_order_pass",
526527
":device_copy_ops_registry",
527528
":propagate_device_config",
528529
"//caffe2:torch",

‎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
@@ -18,6 +18,7 @@
1818
import torch.utils._pytree as pytree
1919
from executorch.exir.delegate import executorch_call_delegate
2020
from executorch.exir.lowered_backend_module import LoweredBackendModule
21+
from executorch.exir.passes.convert_constant_dim_order_pass import _should_transform
2122

2223
# Re-exported for backward compatibility; the dataclass lives in a lightweight
2324
# module so that ExecutorchBackendConfig can reference it without importing the
@@ -135,14 +136,19 @@ def _tag_specs_with_device(
135136
return False
136137

137138

138-
def _clone_spec_with_device(
139+
def _copy_output_spec(
139140
spec: TensorSpec,
140141
device_type: schema.DeviceType,
141142
device_index: int = 0,
142143
) -> TensorSpec:
143-
"""Create a copy of a TensorSpec with a different device."""
144+
"""Create the spec of an _h2d_copy or _d2h_copy output from the spec it copies.
145+
146+
The output lives on ``device_type``. The copy writes it at run time, so it is
147+
never a constant, even when the copied tensor is one.
148+
"""
144149
new_spec = copy.copy(spec)
145150
new_spec.init_mem_planning_fields()
151+
new_spec.const = False
146152
_set_device_on_spec(new_spec, device_type, device_index)
147153
return new_spec
148154

@@ -179,7 +185,9 @@ class PropagateDevicePass(PassBase):
179185
Skip-copy optimizations:
180186
- skip_h2d_for_method_inputs: If the input is a graph-level placeholder
181187
feeding directly to a delegate, don't insert H2D — tag the placeholder
182-
as device instead (user provides GPU tensor at runtime).
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.
183191
- skip_d2h_for_method_outputs: The graph output reads a delegate output
184192
on device, not its D2H copy; the copy stays only for other readers.
185193
"""
@@ -227,7 +235,11 @@ def _insert_h2d_copies(
227235
if not isinstance(arg_spec, TensorSpec):
228236
continue
229237

230-
if self.skip_h2d_for_method_inputs and self._is_placeholder(arg):
238+
if (
239+
self.skip_h2d_for_method_inputs
240+
and self._is_placeholder(arg)
241+
and not arg_spec.const
242+
):
231243
# TODO(gasoonjia): support skip_h2d_for_method_inputs for
232244
# multiple-user placeholder inputs.
233245
if len(arg.users) != 1:
@@ -244,14 +256,27 @@ def _insert_h2d_copies(
244256
changed = True
245257
continue
246258

259+
val = arg.meta.get("val")
260+
if (
261+
arg_spec.const
262+
and isinstance(val, torch.Tensor)
263+
and _should_transform(val)
264+
):
265+
raise RuntimeError(
266+
f"Constant '{arg.name}' feeds a delegate on "
267+
f"{target_device_type.name.lower()}:{device_index}, but it is "
268+
f"neither contiguous nor channels-last. The program stores "
269+
f"such a constant contiguous, so its device copy would not "
270+
f"match the stored data. Make the constant contiguous where "
271+
f"it is created, for example in the pass that adds it."
272+
)
273+
247274
with graph_module.graph.inserting_before(node):
248275
h2d_node = graph_module.graph.call_function(
249276
torch.ops.et_copy._h2d_copy.default,
250277
(arg,),
251278
)
252-
h2d_spec = _clone_spec_with_device(
253-
arg_spec, target_device_type, device_index
254-
)
279+
h2d_spec = _copy_output_spec(arg_spec, target_device_type, device_index)
255280
h2d_node.meta["spec"] = h2d_spec
256281
h2d_node.meta["val"] = arg.meta.get("val")
257282
if "tensor_meta" in arg.meta:
@@ -308,7 +333,7 @@ def _insert_d2h_after(
308333
torch.ops.et_copy._d2h_copy.default,
309334
(node,),
310335
)
311-
d2h_spec = _clone_spec_with_device(spec, schema.DeviceType.CPU, 0)
336+
d2h_spec = _copy_output_spec(spec, schema.DeviceType.CPU, 0)
312337
d2h_node.meta["spec"] = d2h_spec
313338
d2h_node.meta["val"] = node.meta.get("val")
314339
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,
@@ -1478,6 +1479,134 @@ def forward(self, a, b):
14781479
"CPU placeholder",
14791480
)
14801481

1482+
def test_constant_feeding_device_delegate_is_copied(self):
1483+
"""A constant feeding a device delegate gets an _h2d_copy into planned device
1484+
memory, with or without skip_h2d_for_method_inputs: its data lives in the
1485+
program on the host, so it is not a method input the caller can place."""
1486+
1487+
class Model(torch.nn.Module):
1488+
def __init__(self):
1489+
super().__init__()
1490+
self.register_buffer("offset", torch.randn(2, 2))
1491+
1492+
def forward(self, a):
1493+
return torch.add(a, self.offset)
1494+
1495+
model = Model()
1496+
inputs = (torch.randn(2, 2),)
1497+
for skip in (False, True):
1498+
et_config = ExecutorchBackendConfig(
1499+
emit_stacktrace=False,
1500+
propagate_device_config=PropagateDeviceConfig(
1501+
skip_h2d_for_method_inputs=skip
1502+
),
1503+
enable_non_cpu_memory_planning=True,
1504+
)
1505+
for pipeline, program, gm in self._get_executorch_program(
1506+
model, inputs, DeviceAwarePartitioner("cuda:0"), et_config
1507+
):
1508+
with self.subTest(pipeline=pipeline, skip=skip):
1509+
nodes = _collect_device_copy_nodes(gm)
1510+
# Input a is copied only when the caller cannot place it.
1511+
self.assertEqual(len(nodes.h2d_nodes), 1 if skip else 2)
1512+
cuda_ph, _ = self._collect_placeholders_by_device(gm)
1513+
self.assertEqual(len(cuda_ph), int(skip))
1514+
self.assertTrue(all(not n.meta["spec"].const for n in cuda_ph))
1515+
copies = [n.meta["spec"] for n in nodes.h2d_nodes]
1516+
self.assertTrue(all(not spec.const for spec in copies))
1517+
self._assert_nodes_buffer_device(
1518+
nodes.h2d_nodes, program, DeviceType.CUDA, pipeline, "H2D copy"
1519+
)
1520+
1521+
def test_constant_copy_keeps_stored_layout(self):
1522+
"""The copy of a constant moves its bytes as they are, so it must use the
1523+
layout the constant is stored in, for a transposed parameter as well as
1524+
a channels-last one."""
1525+
1526+
class Model(torch.nn.Module):
1527+
def __init__(self, weight):
1528+
super().__init__()
1529+
self.weight = torch.nn.Parameter(weight)
1530+
1531+
def forward(self, a):
1532+
return torch.add(a, self.weight)
1533+
1534+
weights = {
1535+
"transposed": torch.randn(3, 2).t(),
1536+
"channels_last": torch.randn(1, 2, 3, 3).to(
1537+
memory_format=torch.channels_last
1538+
),
1539+
}
1540+
for name, weight in weights.items():
1541+
et_config = ExecutorchBackendConfig(
1542+
emit_stacktrace=False,
1543+
propagate_device_config=PropagateDeviceConfig(
1544+
skip_h2d_for_method_inputs=True
1545+
),
1546+
enable_non_cpu_memory_planning=True,
1547+
)
1548+
for pipeline, _program, gm in self._get_executorch_program(
1549+
Model(weight),
1550+
(torch.randn(weight.shape),),
1551+
DeviceAwarePartitioner("cuda:0"),
1552+
et_config,
1553+
):
1554+
with self.subTest(pipeline=pipeline, weight=name):
1555+
nodes = _collect_device_copy_nodes(gm)
1556+
self.assertEqual(len(nodes.h2d_nodes), 1)
1557+
h2d = nodes.h2d_nodes[0]
1558+
# The emitter rewrites the constant's spec to the stored layout.
1559+
stored = h2d.args[0].meta["spec"]
1560+
self.assertEqual(h2d.meta["spec"].dim_order, stored.dim_order)
1561+
self.assertEqual(
1562+
tuple(h2d.meta["spec"].stride), tuple(stored.stride)
1563+
)
1564+
1565+
def test_constant_with_unstored_layout_is_refused(self):
1566+
"""A constant that is neither contiguous nor channels-last is stored
1567+
contiguous, so its copy would not match the stored data. A pass that runs
1568+
after to_edge normalized the constants can create one, and lowering then
1569+
fails instead of producing wrong results."""
1570+
1571+
class StoreWeightTransposed(ExportedProgramPassBase):
1572+
def call(self, ep):
1573+
ep.state_dict["w"] = torch.nn.Parameter(
1574+
ep.state_dict["w"].t().contiguous().t()
1575+
)
1576+
for node in ep.graph.nodes:
1577+
if node.name == "p_w":
1578+
node.meta["val"] = node.meta["val"].t().contiguous().t()
1579+
return ExportedProgramPassResult(ep, True)
1580+
1581+
class Model(torch.nn.Module):
1582+
def __init__(self):
1583+
super().__init__()
1584+
self.w = torch.nn.Parameter(torch.randn(2, 3))
1585+
1586+
def forward(self, a):
1587+
return torch.add(a, self.w)
1588+
1589+
for skip in (False, True):
1590+
edge = to_edge_transform_and_lower(
1591+
export(Model(), (torch.randn(2, 3),)),
1592+
transform_passes=[StoreWeightTransposed()],
1593+
partitioner=[DeviceAwarePartitioner("cuda:0")],
1594+
)
1595+
et_config = ExecutorchBackendConfig(
1596+
emit_stacktrace=False,
1597+
propagate_device_config=PropagateDeviceConfig(
1598+
skip_h2d_for_method_inputs=skip
1599+
),
1600+
enable_non_cpu_memory_planning=True,
1601+
)
1602+
with self.subTest(skip=skip):
1603+
with self.assertRaisesRegex(
1604+
RuntimeError,
1605+
"Constant 'p_w' feeds a delegate on cuda:0, but it is neither "
1606+
"contiguous nor channels-last",
1607+
):
1608+
edge.to_executorch(et_config)
1609+
14811610
def test_tensorspec_repr_includes_device(self):
14821611
spec = TensorSpec(dtype=torch.float32, shape=torch.Size([2, 3]))
14831612
repr_str = repr(spec)

0 commit comments

Comments
 (0)