Skip to content

Commit f4594db

Browse files
Gasoonjiafacebook-github-bot
authored andcommitted
Turn on device memory planing as default (#20060)
Summary: Pull Request resolved: #20060 Differential Revision: D107597774
1 parent d9d3232 commit f4594db

11 files changed

Lines changed: 316 additions & 289 deletions

File tree

backends/cuda/runtime/cuda_backend.cpp

Lines changed: 105 additions & 203 deletions
Large diffs are not rendered by default.

backends/cuda/runtime/utils.h

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,13 @@ inline void _strided_copy(
147147
}
148148

149149
// Copy data from SlimTensor to ETensor, rearranging if strides differ.
150-
// When stream is non-null, GPU copies use that stream (async fast path).
151-
// When stream is null, GPU copies are synchronous.
150+
// dst_device selects the destination memory space (CPU for D2H, a CUDA device
151+
// for D2D). When stream is non-null, GPU copies use that stream (async fast
152+
// path). When stream is null, GPU copies are synchronous.
152153
inline executorch::runtime::Error _copy_slimtensor_to_etensor_impl(
153154
const executorch::backends::aoti::slim::SlimTensor* slim_tensor,
154155
executorch::runtime::etensor::Tensor* etensor,
156+
const executorch::backends::aoti::slim::c10::Device& dst_device,
155157
cudaStream_t stream) {
156158
ET_CHECK_OK_OR_RETURN_ERROR(_check_tensor_metadata(slim_tensor, etensor));
157159

@@ -165,7 +167,7 @@ inline executorch::runtime::Error _copy_slimtensor_to_etensor_impl(
165167

166168
if (_strides_match(slim_tensor, etensor)) {
167169
// Fast path: strides match, raw byte copy
168-
if (slim_tensor->is_cpu()) {
170+
if (slim_tensor->is_cpu() && dst_device.is_cpu()) {
169171
std::memcpy(dst_data, src_data, nbytes);
170172
} else if (stream) {
171173
executorch::backends::aoti::slim::DeviceTraits<
@@ -174,7 +176,7 @@ inline executorch::runtime::Error _copy_slimtensor_to_etensor_impl(
174176
dst_data,
175177
src_data,
176178
nbytes,
177-
executorch::backends::aoti::slim::CPU_DEVICE,
179+
dst_device,
178180
slim_tensor->device(),
179181
stream);
180182
} else {
@@ -184,13 +186,14 @@ inline executorch::runtime::Error _copy_slimtensor_to_etensor_impl(
184186
dst_data,
185187
src_data,
186188
nbytes,
187-
executorch::backends::aoti::slim::CPU_DEVICE,
189+
dst_device,
188190
slim_tensor->device());
189191
}
190192
} else {
191193
// Slow path: strides differ (e.g., AOTI delegate output layout differs
192-
// from .pte's dim_order). Copy to a temp CPU buffer, then rearrange
193-
// element-by-element to match the ETensor's expected layout.
194+
// from .pte's dim_order). Copy to a temp CPU buffer, rearrange
195+
// element-by-element to match the ETensor's expected layout, then move the
196+
// result to the destination (CPU stays in place; GPU gets an H2D copy).
194197
std::vector<char> tmp(nbytes);
195198
if (slim_tensor->is_cpu()) {
196199
std::memcpy(tmp.data(), src_data, nbytes);
@@ -218,13 +221,38 @@ inline executorch::runtime::Error _copy_slimtensor_to_etensor_impl(
218221

219222
size_t elem_size = executorch::backends::aoti::slim::c10::elementSize(
220223
slim_tensor->dtype());
221-
_strided_copy(
222-
dst_data,
223-
tmp.data(),
224-
elem_size,
225-
sizes_vec,
226-
src_strides_vec,
227-
dst_strides_vec);
224+
225+
if (dst_device.is_cpu()) {
226+
_strided_copy(
227+
dst_data,
228+
tmp.data(),
229+
elem_size,
230+
sizes_vec,
231+
src_strides_vec,
232+
dst_strides_vec);
233+
} else {
234+
// Rearrange into a CPU staging buffer, then copy to the GPU destination.
235+
std::vector<char> rearranged(nbytes);
236+
_strided_copy(
237+
rearranged.data(),
238+
tmp.data(),
239+
elem_size,
240+
sizes_vec,
241+
src_strides_vec,
242+
dst_strides_vec);
243+
if (stream) {
244+
ET_CUDA_CHECK_OR_RETURN_ERROR(cudaMemcpyAsync(
245+
dst_data,
246+
rearranged.data(),
247+
nbytes,
248+
cudaMemcpyHostToDevice,
249+
stream));
250+
ET_CUDA_CHECK_OR_RETURN_ERROR(cudaStreamSynchronize(stream));
251+
} else {
252+
ET_CUDA_CHECK_OR_RETURN_ERROR(cudaMemcpy(
253+
dst_data, rearranged.data(), nbytes, cudaMemcpyHostToDevice));
254+
}
255+
}
228256
}
229257

230258
return executorch::runtime::Error::Ok;
@@ -251,7 +279,39 @@ inline executorch::runtime::Error copy_slimtensor_to_etensor_async(
251279
const executorch::backends::aoti::slim::SlimTensor* slim_tensor,
252280
executorch::runtime::etensor::Tensor* etensor,
253281
cudaStream_t stream) {
254-
return _copy_slimtensor_to_etensor_impl(slim_tensor, etensor, stream);
282+
return _copy_slimtensor_to_etensor_impl(
283+
slim_tensor,
284+
etensor,
285+
executorch::backends::aoti::slim::CPU_DEVICE,
286+
stream);
287+
}
288+
289+
/**
290+
* Copies data from a SlimTensor to a GPU-resident ETensor asynchronously
291+
* (device-to-device).
292+
*
293+
* Used when the destination ETensor's storage lives in a planned GPU arena.
294+
* The destination device is taken from the source SlimTensor, so this only
295+
* supports same-device D2D copies (source and destination on the same GPU).
296+
*
297+
* When strides match (common case), performs a fast async D2D copy on the
298+
* provided stream. When strides differ, falls back to a staged copy with
299+
* element-by-element rearrangement on the host.
300+
*
301+
* NOTE: In the fast path the copy is asynchronous. The caller must synchronize
302+
* the stream before consuming the ETensor data.
303+
*
304+
* @param slim_tensor Pointer to the source SlimTensor (must not be null).
305+
* @param etensor Pointer to the destination GPU ETensor (must not be null).
306+
* @param stream The CUDA stream to use for async copy.
307+
* @return Error::Ok on success, or an appropriate error code on failure.
308+
*/
309+
inline executorch::runtime::Error copy_slimtensor_to_device_etensor_async(
310+
const executorch::backends::aoti::slim::SlimTensor* slim_tensor,
311+
executorch::runtime::etensor::Tensor* etensor,
312+
cudaStream_t stream) {
313+
return _copy_slimtensor_to_etensor_impl(
314+
slim_tensor, etensor, slim_tensor->device(), stream);
255315
}
256316

257317
/**
@@ -267,7 +327,11 @@ inline executorch::runtime::Error copy_slimtensor_to_etensor_async(
267327
inline executorch::runtime::Error copy_slimtensor_to_etensor(
268328
const executorch::backends::aoti::slim::SlimTensor* slim_tensor,
269329
executorch::runtime::etensor::Tensor* etensor) {
270-
return _copy_slimtensor_to_etensor_impl(slim_tensor, etensor, nullptr);
330+
return _copy_slimtensor_to_etensor_impl(
331+
slim_tensor,
332+
etensor,
333+
executorch::backends::aoti::slim::CPU_DEVICE,
334+
nullptr);
271335
}
272336

273337
/**

backends/cuda/tests/test_cuda_export.py

Lines changed: 70 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -328,18 +328,23 @@ def test_triton_kernel_mode_off(self):
328328

329329
def test_device_info_propagated_to_cuda_delegate_outputs(self):
330330
"""
331-
Test that device info is correctly propagated from export to serialization
332-
for CUDA delegate outputs.
333-
334-
This verifies the device propagation flow:
335-
1. CudaPartitioner adds target_device="cuda:0" CompileSpec
336-
2. PropagateDevicePass sets TensorSpec.device = CUDA for delegate outputs
337-
3. Emitter serializes device info into ExtraTensorInfo.device_type
338-
4. Serialized tensors have device_type = DeviceType.CUDA
339-
340-
Note: At this stage, the tensor memory is still on CPU. The CUDA backend
341-
will copy data to GPU device at runtime. Device info tagging is the first
342-
step toward full device-aware memory allocation.
331+
Verify that, for a CUDA-delegated graph, every memory-planned tensor's
332+
actual planned memory location matches its device_type tag.
333+
334+
With device memory planning (the default), the flow is:
335+
1. CudaPartitioner adds target_device="cuda:0" CompileSpec.
336+
2. PropagateDevicePass tags delegate IO TensorSpecs as CUDA and inserts
337+
et_copy._h2d_copy / _d2h_copy ops at the delegate boundary, so the
338+
method inputs/outputs stay on CPU while the delegate IO is CUDA.
339+
3. Device-aware memory planning allocates each non-CPU tensor into a CUDA
340+
buffer, recorded in ExecutionPlan.non_const_buffer_device.
341+
4. The emitter serializes device info into ExtraTensorInfo.device_type.
342+
343+
The core check: for each planned tensor, the device of the buffer it is
344+
allocated into (non_const_buffer_device) must agree with the tensor's
345+
own device_type. A CUDA-tagged tensor planned into a CPU buffer (or vice
346+
versa) means planning and device tagging disagree about where the
347+
tensor's real memory lives.
343348
"""
344349

345350
class AddModule(torch.nn.Module):
@@ -354,7 +359,8 @@ def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
354359
edge_program_manager = self._export_to_cuda_with_lower(module, inputs)
355360
self.assertIsNotNone(edge_program_manager, "CUDA export failed")
356361

357-
# Convert to ExecuTorch and access the serialized program
362+
# Convert to ExecuTorch and access the serialized program. The default
363+
# config enables device memory planning, so delegate IO is GPU-resident.
358364
et_prog = edge_program_manager.to_executorch()
359365
program = et_prog._emitter_output.program
360366

@@ -366,32 +372,60 @@ def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
366372
"Expected at least one delegate in the execution plan",
367373
)
368374

369-
# Count tensors by device type
370-
cpu_tensors = []
371-
cuda_tensors = []
372-
375+
# Build buffer_idx -> device map from the per-buffer device mapping.
376+
# Buffers without an entry default to CPU.
377+
buffer_device: dict[int, schema.DeviceType] = {}
378+
for entry in plan.non_const_buffer_device or []:
379+
buffer_device[entry.buffer_idx] = entry.device_type
380+
381+
def tensor_device(t: schema.Tensor) -> schema.DeviceType:
382+
if t.extra_tensor_info is not None:
383+
return t.extra_tensor_info.device_type
384+
return schema.DeviceType.CPU
385+
386+
# Walk every memory-planned tensor in the graph and assert its declared
387+
# device_type matches the device of the buffer it lives in.
388+
cuda_planned = 0
389+
cpu_planned = 0
373390
for value in plan.values:
374-
if isinstance(value.val, schema.Tensor):
375-
tensor = value.val
376-
if (
377-
tensor.extra_tensor_info is not None
378-
and tensor.extra_tensor_info.device_type == schema.DeviceType.CUDA
379-
):
380-
cuda_tensors.append(tensor)
381-
else:
382-
# Either no extra_tensor_info or device_type is CPU (default)
383-
cpu_tensors.append(tensor)
384-
385-
# Both input and output tensors should be on CUDA device for now.
391+
if not isinstance(value.val, schema.Tensor):
392+
continue
393+
tensor = value.val
394+
# Only memory-planned (non-constant) tensors have allocation_info;
395+
# their memory_id indexes into the non_const buffers.
396+
if tensor.allocation_info is None:
397+
continue
398+
399+
declared = tensor_device(tensor)
400+
mem_id = tensor.allocation_info.memory_id
401+
planned = buffer_device.get(mem_id, schema.DeviceType.CPU)
402+
403+
self.assertEqual(
404+
planned,
405+
declared,
406+
f"Tensor planned into buffer {mem_id} has device_type="
407+
f"{declared.name} but the buffer is allocated on "
408+
f"{planned.name}; planned memory location and device tag "
409+
f"must agree.",
410+
)
411+
if declared == schema.DeviceType.CUDA:
412+
cuda_planned += 1
413+
else:
414+
cpu_planned += 1
415+
416+
# AddModule has 2 inputs + 1 output. With device memory planning the
417+
# delegate IO is CUDA-resident (2 h2d copies + 1 delegate output) and
418+
# the host-side method inputs/outputs stay on CPU (2 inputs + 1 d2h
419+
# output), giving exactly 3 CUDA- and 3 CPU-resident planned tensors.
386420
self.assertEqual(
387-
len(cpu_tensors),
388-
0,
389-
f"Expected no CPU tensors: method inputs/outputs should be tagged "
390-
f"CUDA, but found {len(cpu_tensors)}",
421+
cuda_planned,
422+
3,
423+
f"Expected exactly 3 CUDA-resident planned tensors (2 h2d copies + "
424+
f"1 delegate output), but found {cuda_planned}.",
391425
)
392426
self.assertEqual(
393-
len(cuda_tensors),
427+
cpu_planned,
394428
3,
395-
f"Expected 3 CUDA tensors (2 method inputs + 1 method output), "
396-
f"but found {len(cuda_tensors)}",
429+
f"Expected exactly 3 CPU-resident planned tensors (2 method inputs "
430+
f"+ 1 d2h output), but found {cpu_planned}.",
397431
)

examples/models/gemma4_31b/export.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,13 @@ def _export_cuda(model: Gemma4_31B, config: Gemma4_31BConfig, output_dir: str) -
264264
do_quant_fusion_and_const_prop=True,
265265
memory_planning_pass=MemoryPlanningPass(
266266
alloc_graph_input=False,
267-
share_mutable_buffers=True,
267+
# Stateful buffers (KV cache) are lifted into the CUDA/AOTI
268+
# delegate as constants and shared across the decode/prefill
269+
# methods at runtime via the backend's per-FQN buffer cache, so
270+
# there are no top-level mutable buffers for ExecuTorch to share.
271+
# share_mutable_buffers would be a no-op here and is incompatible
272+
# with device-aware memory planning, so leave it off.
273+
share_mutable_buffers=False,
268274
),
269275
emit_mutable_buffer_names=True,
270276
),

examples/models/gemma4_31b/model.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ Decoder norms per layer: `input_layernorm`, `post_attention_layernorm`,
109109
| `decode` | tokens `(1, 1)` + input_pos `(1,)` + temperature `(1,)` | `(1, 1)` float |
110110
| `prefill` | tokens `(1, T)` + input_pos `(T,)` + temperature `(1,)`, T∈[5, min(max_seq_len-1, 2×sliding_window)] | `(1, 1)` float |
111111

112-
Both methods share the same KV-cache buffers via
113-
`MemoryPlanningPass(share_mutable_buffers=True)` and
114-
`emit_mutable_buffer_names=True`. The exported program performs Gumbel-max
115-
sampling on-device and returns a single token ID per call so the C++ runner
116-
only has to feed tokens.
112+
Both methods share the same KV-cache buffers. On the CUDA/AOTI backend the
113+
stateful buffers are lifted into the delegate as constants and shared across
114+
`decode`/`prefill` at runtime via the backend's per-FQN buffer cache, so the
115+
CUDA export leaves `share_mutable_buffers` off (other backends, e.g. MLX, instead
116+
share graph-level buffers via `share_mutable_buffers`). The exported program
117+
performs Gumbel-max sampling on-device and returns a single token ID per call so
118+
the C++ runner only has to feed tokens.
117119

118120
### MLX (`--backend mlx`)
119121

examples/models/gemma4_31b/model.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88
Gemma 4 31B-IT — export-friendly reference implementation for ExecuTorch.
99
1010
Model definition designed for torch.export(strict=True) with the CUDA backend.
11-
All stateful buffers (KV cache, RoPE inv_freq) are registered buffers so they
12-
are captured by share_mutable_buffers across prefill/decode. The numerically
11+
All stateful buffers (KV cache, RoPE inv_freq) are registered buffers with
12+
in-place updates. On the CUDA/AOTI backend they are lifted into the delegate as
13+
constants and shared across prefill/decode at runtime via the backend's per-FQN
14+
buffer cache (so the CUDA export leaves share_mutable_buffers off); backends that
15+
keep these buffers at the graph level (e.g. MLX) instead share them via
16+
share_mutable_buffers. The numerically
1317
sensitive primitives — RMSNorm, GELU-tanh MLP, proportional/full RoPE, and
1418
the BHSD KV cache — are imported from ``examples.models.gemma4.text_decoder``
1519
so the 31B and E2B/E4B paths share them.

examples/models/qwen3_5_moe/export.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -623,8 +623,10 @@ def _materialize_buffers(model, config):
623623
624624
Replaces meta buffers with real tensors on CPU, recomputes RoPE
625625
inv_freq and causal masks. State buffers (KV cache, conv/recurrent
626-
state) are zero-initialized registered buffers that will be shared
627-
across methods via share_mutable_buffers.
626+
state) are zero-initialized registered buffers. On the CUDA/AOTI backend
627+
they are lifted into the delegate as constants and shared across methods at
628+
runtime via the backend's per-FQN buffer cache; backends that keep them at
629+
the graph level instead share them via share_mutable_buffers.
628630
"""
629631
# Masks stay bool, inv_freq stays float32.
630632
for fqn, buf in list(model.named_buffers()):
@@ -922,8 +924,12 @@ def _export_cuda(model, config, args):
922924
via fused_moe_batched_gemm, with dynamic sequence length.
923925
924926
Both methods share mutable state buffers (KV cache, conv_state,
925-
recurrent_state) via share_mutable_buffers=True. The model uses
926-
registered buffers with in-place updates — no state in/out args.
927+
recurrent_state): the model uses registered buffers with in-place
928+
updates (no state in/out args). On the CUDA/AOTI backend these buffers
929+
are lifted into the delegate as constants and shared across the
930+
decode/prefill methods at runtime via the backend's per-FQN buffer cache
931+
(share_mutable_buffers is left off for CUDA); backends that keep them at
932+
the graph level instead share them via share_mutable_buffers.
927933
"""
928934
import torch._inductor.config as inductor_config
929935

@@ -1033,7 +1039,14 @@ def _export_cuda(model, config, args):
10331039
do_quant_fusion_and_const_prop=True,
10341040
memory_planning_pass=MemoryPlanningPass(
10351041
alloc_graph_input=False,
1036-
share_mutable_buffers=True,
1042+
# Stateful buffers (KV cache / recurrent state) are lifted into
1043+
# the CUDA/AOTI delegate as constants and shared across the
1044+
# decode/prefill methods at runtime via the backend's per-FQN
1045+
# buffer cache, so there are no top-level mutable buffers for
1046+
# ExecuTorch to share. share_mutable_buffers would be a no-op
1047+
# here and is incompatible with device-aware memory planning, so
1048+
# leave it off.
1049+
share_mutable_buffers=False,
10371050
),
10381051
emit_mutable_buffer_names=True,
10391052
),

exir/capture/_config.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,12 @@ class ExecutorchBackendConfig:
128128

129129
# When True, memory planning partitions specs by device and runs the
130130
# algorithm independently per device, producing separate buffers for CPU
131-
# vs. accelerator memory. Default False preserves the legacy behavior
132-
# where all tensors are planned into CPU memory regardless of device.
133-
enable_non_cpu_memory_planning: bool = False
131+
# vs. accelerator memory. This is the default: device (e.g. CUDA) delegate
132+
# inputs/outputs are planned into real accelerator memory, and
133+
# PropagateDevicePass inserts explicit h2d/d2h copies at delegate
134+
# boundaries. Set to False to fall back to the legacy behavior where all
135+
# tensors are planned into CPU memory regardless of device.
136+
enable_non_cpu_memory_planning: bool = True
134137

135138
# Add ops to the set of re-inplace ops to be used by the reinplace pass.
136139
# Re-inplace pass checks the eligibility of an op to be re-inplaced and

0 commit comments

Comments
 (0)