Skip to content

Commit 29ef909

Browse files
Gasoonjiafacebook-github-bot
authored andcommitted
make device support config method-based
Summary: Device memory planning and H2D/D2H copy configuration were previously global flags on ExecutorchBackendConfig, applied uniformly across all methods in a multi-method program. This made it impossible to configure different behaviors per method — e.g., skipping H2D copies for one method while keeping them for another. This diff introduces PropagateDeviceConfig dataclass that groups `skip_h2d_for_method_inputs` and `skip_d2h_for_method_outputs`, with each field accepting either a single bool or a Dict[str, bool] for per-method overrides. A new `propagate_device_config` field on ExecutorchBackendConfig similarly accepts either a single config or Dict[str, PropagateDeviceConfig]. Reviewed By: JacobSzwejbka Differential Revision: D101243687
1 parent d98aa22 commit 29ef909

8 files changed

Lines changed: 120 additions & 33 deletions

File tree

docs/source/compiler-memory-planning.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ Users attempting to write a custom memory planning algorithm should start by loo
9494

9595
## Device-Aware Memory Planning
9696

97-
When `enable_non_cpu_memory_planning=True` is set on `ExecutorchBackendConfig`,
97+
When `enable_non_cpu_memory_planning=True` is set on `MemoryPlanningPass`,
9898
the memory planning pass partitions tensor specs by their device type and runs
9999
the planning algorithm independently for each device. This produces separate
100100
memory buffers for each device (e.g. CPU vs. CUDA), ensuring that device memory
@@ -103,7 +103,7 @@ and host memory are never mixed.
103103
```python
104104
program = edge_program.to_executorch(
105105
exir.ExecutorchBackendConfig(
106-
enable_non_cpu_memory_planning=True,
106+
memory_planning_pass=exir.passes.MemoryPlanningPass(enable_non_cpu_memory_planning=True),
107107
)
108108
)
109109
```

exir/capture/_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from executorch.exir.dynamic_shape import DynamicMemoryPlanningMode
1414
from executorch.exir.pass_manager import PassType
1515
from executorch.exir.passes import MemoryPlanningPass, ToOutVarPass
16+
from executorch.exir.passes.propagate_device_pass import PropagateDeviceConfig
1617
from executorch.exir.passes.sym_shape_eval_pass import ConstraintBasedSymShapeEvalPass
1718
from executorch.exir.tracer import ExirDynamoConfig
1819
from torch.fx._compatibility import compatibility
@@ -60,6 +61,13 @@ class ExecutorchBackendConfig:
6061
# A single memory planning pass can be defined for all the programs in the
6162
# EdgeProgramManager or can be defined per program.
6263
memory_planning_pass: Union[PassType, Dict[str, PassType]] = MemoryPlanningPass()
64+
65+
# A single propagate device config can be defined for all the programs in the
66+
# EdgeProgramManager or can be defined per program.
67+
propagate_device_config: Union[
68+
PropagateDeviceConfig, Dict[str, PropagateDeviceConfig]
69+
] = field(default_factory=PropagateDeviceConfig)
70+
6371
to_out_var_pass: PassType = ToOutVarPass(ignore_to_out_var_failure=False)
6472
dynamic_memory_planning_mode: DynamicMemoryPlanningMode = (
6573
DynamicMemoryPlanningMode.UPPER_BOUND

exir/emit/test/test_emit.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2185,9 +2185,10 @@ def forward(self, x):
21852185
ExecutorBackendPartitioner()
21862186
).to_executorch()
21872187

2188-
# Check that there is only one delegate because two methods are exactly the same
2188+
# Check that there are two delegates now because the
2189+
# passes might apply differently due to per-method config support.
21892190
self.assertEqual(
2190-
len(edge_program_manager.executorch_program.backend_delegate_data), 1
2191+
len(edge_program_manager.executorch_program.backend_delegate_data), 2
21912192
)
21922193

21932194
def test_delegate_deduplicate_with_different_compile_specs(self) -> None:

exir/passes/memory_planning_pass.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,6 @@ def __init__(
153153
alloc_mutable_buffers: bool = True,
154154
share_mutable_buffers: bool = False,
155155
alignment: int = ALIGNMENT,
156-
enable_non_cpu_memory_planning: bool = False,
157156
) -> None:
158157
r"""
159158
alloc_graph_input/alloc_graph_output will have 4 different combinations
@@ -174,8 +173,11 @@ def __init__(
174173
self.alloc_mutable_buffers = alloc_mutable_buffers
175174
self.share_mutable_buffers = share_mutable_buffers
176175
self.alignment = alignment
177-
self.enable_non_cpu_memory_planning = enable_non_cpu_memory_planning
178176
self.state = _MemoryPlanningState()
177+
# Set by EdgeProgramManager.to_executorch() from the top-level
178+
# ExecutorchBackendConfig. When True, apply_algo partitions specs by
179+
# device so non-CPU buffers get their own memory arenas.
180+
self.enable_non_cpu_memory_planning: bool = False
179181

180182
def _set_alloc_node_spec(self, graph_module: torch.fx.GraphModule) -> None:
181183
"""
@@ -237,11 +239,14 @@ def run(
237239
self,
238240
graph_module: torch.fx.GraphModule,
239241
graph_signature: Optional[ExportGraphSignature] = None,
242+
enable_non_cpu_memory_planning: Optional[bool] = None,
240243
) -> PassResult:
241244
"""
242245
A pass for memory planning. The actual algorithm used will be picked by
243246
memory_planning_algo
244247
"""
248+
if enable_non_cpu_memory_planning is None:
249+
enable_non_cpu_memory_planning = self.enable_non_cpu_memory_planning
245250
self._set_alloc_node_spec(graph_module)
246251
# TODO(shunting) if people have concern of adding a field to GraphModule
247252
# directly, we should define a GraphModule subclass that we can add our
@@ -259,7 +264,7 @@ def run(
259264
# If mutable buffers are shared, then do not allocate them in the
260265
# main memory planning algo; they are allocated in run_multimethod.
261266
self.alloc_mutable_buffers and not self.share_mutable_buffers,
262-
self.enable_non_cpu_memory_planning,
267+
enable_non_cpu_memory_planning,
263268
)
264269

265270
if self.share_mutable_buffers and graph_signature is not None:

exir/passes/propagate_device_pass.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import copy
1010
import logging
1111
import operator
12-
from typing import Optional
12+
from dataclasses import dataclass
13+
from typing import Dict, Optional, Union
1314

1415
# Import to register the et_copy ops so torch.ops.et_copy is available.
1516
import executorch.exir.passes._device_copy_ops_registry # noqa: F401
@@ -20,6 +21,7 @@
2021
from executorch.exir.delegate import executorch_call_delegate
2122
from executorch.exir.lowered_backend_module import LoweredBackendModule
2223
from executorch.exir.tensor import TensorSpec
24+
from torch.fx._compatibility import compatibility
2325
from torch.fx.passes.infra.pass_base import PassBase, PassResult
2426

2527
logger: logging.Logger = logging.getLogger(__name__)
@@ -30,6 +32,40 @@
3032
TARGET_DEVICE_COMPILE_SPEC_KEY = "target_device"
3133

3234

35+
@compatibility(is_backward_compatible=False)
36+
@dataclass
37+
class PropagateDeviceConfig:
38+
# When True, method-level input tensors that feed directly into a device
39+
# delegate are NOT wrapped with _h2d_copy. The user must provide tensors
40+
# already on the target device. Useful for pipelines where inputs are
41+
# pre-staged on GPU.
42+
# A dict can be used to set per-method values, keyed by method name.
43+
skip_h2d_for_method_inputs: Union[bool, Dict[str, bool]] = False
44+
45+
# When True, device delegate outputs that are directly method outputs
46+
# are NOT wrapped with _d2h_copy. The method outputs stay on device.
47+
# Useful for cross-method GPU pipelines where the next method consumes
48+
# GPU tensors directly.
49+
# A dict can be used to set per-method values, keyed by method name.
50+
skip_d2h_for_method_outputs: Union[bool, Dict[str, bool]] = False
51+
52+
def __hash__(self):
53+
return hash(
54+
(
55+
str(self.skip_h2d_for_method_inputs),
56+
str(self.skip_d2h_for_method_outputs),
57+
)
58+
)
59+
60+
def __eq__(self, other: object) -> bool:
61+
if not isinstance(other, PropagateDeviceConfig):
62+
return False
63+
return (
64+
self.skip_h2d_for_method_inputs == other.skip_h2d_for_method_inputs
65+
and self.skip_d2h_for_method_outputs == other.skip_d2h_for_method_outputs
66+
)
67+
68+
3369
def _parse_device_spec_value(value: bytes) -> tuple[schema.DeviceType, int]:
3470
"""
3571
Parse a target_device CompileSpec value (e.g., b"cuda:0") into

exir/program/_program.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@
5959
from executorch.exir.passes.normalize_view_copy_base_pass import (
6060
NormalizeViewCopyBasePass,
6161
)
62-
from executorch.exir.passes.propagate_device_pass import PropagateDevicePass
62+
from executorch.exir.passes.propagate_device_pass import (
63+
PropagateDeviceConfig,
64+
PropagateDevicePass,
65+
)
6366
from executorch.exir.passes.quant_fusion_pass import quant_fusion_and_const_prop_pass
6467
from executorch.exir.passes.reinplace import DEFAULT_INPLACEABLE_OPS, reinplace_pass
6568
from executorch.exir.passes.remove_graph_asserts_pass import (
@@ -758,15 +761,22 @@ def edge_to_executorch_passes(
758761
Returns a list of passes to lower from edge to executorch.
759762
Get the pre memory planning passes based on the method name, if the pass is not in the dict, use the default pass.
760763
"""
764+
# Handle propagate device config
765+
propagate_device_config = config.propagate_device_config
766+
if isinstance(propagate_device_config, dict):
767+
device_cfg = propagate_device_config.get(name, PropagateDeviceConfig())
768+
else:
769+
device_cfg = propagate_device_config
770+
761771
passes: List[PassType] = [
762772
# ExecuTorch backend ops are unable to handle unbacked symints. So after
763773
# this pass, passes cannot be Interpreter-based, because it will fail if
764774
# there exists an unbacked symint operation.
765775
*config.passes,
766776
SpecPropPass(),
767777
PropagateDevicePass(
768-
skip_h2d_for_method_inputs=config.skip_h2d_for_method_inputs,
769-
skip_d2h_for_method_outputs=config.skip_d2h_for_method_outputs,
778+
skip_h2d_for_method_inputs=device_cfg.skip_h2d_for_method_inputs,
779+
skip_d2h_for_method_outputs=device_cfg.skip_d2h_for_method_outputs,
770780
enable_non_cpu_memory_planning=config.enable_non_cpu_memory_planning,
771781
),
772782
EdgeToBackendOpsPass(),

exir/tests/test_propagate_device_pass.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@
2929
from executorch.exir.capture._config import ExecutorchBackendConfig
3030
from executorch.exir.delegate import executorch_call_delegate
3131
from executorch.exir.dialects._ops import ops as exir_ops
32+
from executorch.exir.passes.memory_planning_pass import MemoryPlanningPass
3233
from executorch.exir.passes.propagate_device_pass import (
3334
_get_target_device_from_compile_specs,
3435
_parse_device_spec_value,
36+
PropagateDeviceConfig,
3537
TARGET_DEVICE_COMPILE_SPEC_KEY,
3638
)
3739
from executorch.exir.schema import DeviceType
@@ -766,8 +768,12 @@ def forward(self, a, b):
766768
inputs = (torch.randn(2, 2), torch.randn(2, 2))
767769
et_config = ExecutorchBackendConfig(
768770
emit_stacktrace=False,
769-
skip_h2d_for_method_inputs=True,
770-
enable_non_cpu_memory_planning=True,
771+
propagate_device_config=PropagateDeviceConfig(
772+
skip_h2d_for_method_inputs=True
773+
),
774+
memory_planning_pass=MemoryPlanningPass(
775+
enable_non_cpu_memory_planning=True
776+
),
771777
)
772778

773779
for pipeline, program, gm in self._get_executorch_program(
@@ -822,8 +828,12 @@ def forward(self, a, b):
822828
inputs = (torch.randn(2, 2), torch.randn(2, 2))
823829
et_config = ExecutorchBackendConfig(
824830
emit_stacktrace=False,
825-
skip_d2h_for_method_outputs=True,
826-
enable_non_cpu_memory_planning=True,
831+
propagate_device_config=PropagateDeviceConfig(
832+
skip_d2h_for_method_outputs=True
833+
),
834+
memory_planning_pass=MemoryPlanningPass(
835+
enable_non_cpu_memory_planning=True
836+
),
827837
)
828838

829839
for pipeline, program, gm in self._get_executorch_program(
@@ -876,9 +886,13 @@ def forward(self, a, b):
876886
inputs = (torch.randn(2, 2), torch.randn(2, 2))
877887
et_config = ExecutorchBackendConfig(
878888
emit_stacktrace=False,
879-
skip_h2d_for_method_inputs=True,
880-
skip_d2h_for_method_outputs=True,
881-
enable_non_cpu_memory_planning=True,
889+
propagate_device_config=PropagateDeviceConfig(
890+
skip_h2d_for_method_inputs=True,
891+
skip_d2h_for_method_outputs=True,
892+
),
893+
memory_planning_pass=MemoryPlanningPass(
894+
enable_non_cpu_memory_planning=True
895+
),
882896
)
883897

884898
for pipeline, program, gm in self._get_executorch_program(
@@ -952,8 +966,12 @@ def forward(self, a, b):
952966
inputs = (torch.randn(2, 2), torch.randn(2, 2))
953967
et_config = ExecutorchBackendConfig(
954968
emit_stacktrace=False,
955-
skip_h2d_for_method_inputs=True,
956-
enable_non_cpu_memory_planning=True,
969+
propagate_device_config=PropagateDeviceConfig(
970+
skip_h2d_for_method_inputs=True
971+
),
972+
memory_planning_pass=MemoryPlanningPass(
973+
enable_non_cpu_memory_planning=True
974+
),
957975
)
958976

959977
for pipeline, program, gm in self._get_executorch_program(

runtime/executor/test/method_meta_test.cpp

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -248,21 +248,30 @@ TEST_F(MethodMetaTest, MethodMetaBufferDeviceReturnsCudaForDeviceBuffer) {
248248
ASSERT_EQ(method_meta.error(), Error::Ok);
249249

250250
// ModuleAddWithDevice exports with enable_non_cpu_memory_planning=True.
251-
// The model delegates add(a,b) to CUDA, producing:
252-
// non_const_buffer_sizes: [0, 48] (index 0 reserved)
253-
// non_const_buffer_device: [{buffer_idx=1, device_type=CUDA,
254-
// device_index=0}]
255-
// So there is exactly 1 planned buffer (user-facing index 0), on CUDA.
256-
ASSERT_EQ(method_meta->num_memory_planned_buffers(), 1);
257-
258-
// Buffer 0 should be CUDA device.
259-
auto device = method_meta->memory_planned_buffer_device(0);
260-
ASSERT_TRUE(device.ok());
261-
EXPECT_EQ(device->type(), executorch::runtime::etensor::DeviceType::CUDA);
262-
EXPECT_EQ(device->index(), 0);
251+
// The model delegates add(a,b) to CUDA with H2D/D2H copies:
252+
// - non_const_buffer_sizes: [0, 32, 48]
253+
// (index 0 reserved, buffer 0 = 32 bytes CPU for inputs,
254+
// buffer 1 = 48 bytes CUDA for delegate output)
255+
// - non_const_buffer_device: [{buffer_idx=2, device_type=CUDA,
256+
// device_index=0}]
257+
// So there are 2 planned buffers: user-facing index 0 (CPU) and index 1
258+
// (CUDA).
259+
ASSERT_EQ(method_meta->num_memory_planned_buffers(), 2);
260+
261+
// Buffer 0 should be CPU device (method inputs).
262+
auto device0 = method_meta->memory_planned_buffer_device(0);
263+
ASSERT_TRUE(device0.ok());
264+
EXPECT_EQ(device0->type(), executorch::runtime::etensor::DeviceType::CPU);
265+
EXPECT_EQ(device0->index(), 0);
266+
267+
// Buffer 1 should be CUDA device (delegate output).
268+
auto device1 = method_meta->memory_planned_buffer_device(1);
269+
ASSERT_TRUE(device1.ok());
270+
EXPECT_EQ(device1->type(), executorch::runtime::etensor::DeviceType::CUDA);
271+
EXPECT_EQ(device1->index(), 0);
263272

264273
// Out of range should return error.
265274
EXPECT_EQ(
266-
method_meta->memory_planned_buffer_device(1).error(),
275+
method_meta->memory_planned_buffer_device(2).error(),
267276
Error::InvalidArgument);
268277
}

0 commit comments

Comments
 (0)