Skip to content

Commit 0a113f8

Browse files
authored
Enable Qwen3.5-MoE AOTI export on memory-constrained GPUs (pytorch#19205)
### Goal Make the Qwen3.5-35B-A3B HQQ-INT4 CUDA AOTI export viable on consumer-class 24 GB GPUs (RTX 4090 / 3090), so users without datacenter hardware can run the model end-to-end. ### Why it's needed Out of the box the export OOMs on anything smaller than ~40 GB because the AOTI compile pipeline (a) clones mutated buffers onto the target device on top of the live model, and (b) leaks multi-GB of CUDA tensors between method compiles via Inductor / Triton internal caches. ### What this PR changes All fixes are scoped to `executorch/backends/cuda/cuda_backend.py` — **no PyTorch core patches**, **no impact on Metal or other AOTI backends**: 1. Monkey-patch Inductor's buffer cloning so the AOTI compile-time clones land on **CPU** instead of the target GPU, and patch the C++ wrapper's device codegen so `constants_info_` still points at the real model device for the runtime. 2. Wire those patches into the existing `get_extra_aoti_compile_context_manager()` so they only apply during `aot_compile` and revert cleanly afterwards. 3. Override `preprocess_multimethod` for CUDA only: release cuda memory between method compiles. 4. Add a regression guard: the Qwen3.5 MoE export script prints the peak GPU memory used, and CI fails if it exceeds 20 GB. ### Verified - Export succeeds on an A100 capped to 20 GB; peak ~18 GB. - Export succeeds on real 4090 model, export - Inference perf matches an unconstrained export within noise. - Without the fixes, peak shoots to ~37 GB and the new CI gate fails. ### Future upstream work The fixes here are workarounds for three underlying Inductor / PyTorch issues that deserve proper upstream solutions: 1. A first-class option for `aot_compile` to clone lifted buffers on CPU while still recording the model's target device for the runtime. 2. Inductor / Triton should drain their own caches at the end of each `aot_compile` call (or expose a public reset API). Once those land upstream, the entire workaround block in `cuda_backend.py` can be removed. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell
1 parent b0bcbdc commit 0a113f8

4 files changed

Lines changed: 249 additions & 19 deletions

File tree

.ci/scripts/export_model_artifact.sh

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,14 +415,40 @@ if [ "$MODEL_NAME" = "qwen3_5_moe" ]; then
415415

416416
# Export to .pte/.ptd (short cache dir avoids objcopy symbol length issues)
417417
echo "::group::Export"
418+
EXPORT_LOG=$(mktemp)
418419
TORCHINDUCTOR_CACHE_DIR="$INDUCTOR_CACHE" \
419420
python -m executorch.examples.models.qwen3_5_moe.export \
420421
--prequantized "$LOCAL_MODEL_DIR" \
421422
--output-dir "${OUTPUT_DIR}" \
422423
--dense-prefill dequant \
423-
--moe-activation-dtype int8
424+
--moe-activation-dtype int8 2>&1 | tee "$EXPORT_LOG"
425+
EXPORT_RC=${PIPESTATUS[0]}
424426
echo "::endgroup::"
425427

428+
if [ "$EXPORT_RC" -ne 0 ]; then
429+
echo "ERROR: Qwen3.5 MoE export failed (exit $EXPORT_RC)"
430+
rm -f "$EXPORT_LOG"
431+
exit "$EXPORT_RC"
432+
fi
433+
434+
# Gate peak GPU memory so we keep the export viable on consumer GPUs
435+
# (e.g. RTX 4090 with 24 GB). The export script prints a machine-
436+
# parseable marker line "EXPORT_GPU_PEAK_MEMORY_MB: <float>".
437+
EXPORT_GPU_PEAK_MB_LIMIT="${EXPORT_GPU_PEAK_MB_LIMIT:-20480}"
438+
PEAK_LINE=$(grep -E '^EXPORT_GPU_PEAK_MEMORY_MB:' "$EXPORT_LOG" | tail -1)
439+
rm -f "$EXPORT_LOG"
440+
if [ -z "$PEAK_LINE" ]; then
441+
echo "ERROR: export did not emit EXPORT_GPU_PEAK_MEMORY_MB marker; cannot enforce GPU memory budget"
442+
exit 1
443+
fi
444+
PEAK_MB=$(echo "$PEAK_LINE" | awk '{print $2}')
445+
echo "Export GPU peak memory: ${PEAK_MB} MB (limit ${EXPORT_GPU_PEAK_MB_LIMIT} MB)"
446+
if awk -v p="$PEAK_MB" -v l="$EXPORT_GPU_PEAK_MB_LIMIT" 'BEGIN{exit !(p>l)}'; then
447+
echo "ERROR: export exceeded GPU memory budget (${PEAK_MB} MB > ${EXPORT_GPU_PEAK_MB_LIMIT} MB)"
448+
echo " — this would prevent the model from being exported on a 24 GB consumer GPU."
449+
exit 1
450+
fi
451+
426452
test -f "${OUTPUT_DIR}/model.pte"
427453
test -f "${OUTPUT_DIR}/aoti_cuda_blob.ptd"
428454
ls -al "${OUTPUT_DIR}"

backends/aoti/aoti_backend.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import typing
1010
from abc import ABC, abstractmethod
1111
from enum import Enum
12-
from typing import Any, Dict, List, Set
12+
from typing import Any, Dict, List, Optional, Set
1313

1414
import torch
1515
from executorch.backends.aoti.passes.replace_view_copy_with_view import (
@@ -88,8 +88,14 @@ def save_data_externally(cls) -> bool:
8888
return False
8989

9090
@classmethod
91-
def get_extra_aoti_compile_context_manager(cls):
92-
"""Return extra context manager to apply during aoti_compile stage. By default returns an empty context manager."""
91+
def get_extra_aoti_compile_context_manager(
92+
cls, compile_specs: Optional[List[CompileSpec]] = None
93+
):
94+
"""Return extra context manager to apply during aoti_compile stage. By default returns an empty context manager.
95+
96+
Subclasses may inspect ``compile_specs`` to opt into behaviors that
97+
only apply to specific methods/models (e.g. low-memory export).
98+
"""
9399
return contextlib.nullcontext()
94100

95101
@classmethod
@@ -105,6 +111,24 @@ def codesign_so(cls, so_path: str, compile_specs: List[CompileSpec]) -> None:
105111
"""
106112
return
107113

114+
@classmethod
115+
def release_moved_tensors(
116+
cls,
117+
device_edge_program: ExportedProgram,
118+
compile_specs: List[CompileSpec],
119+
) -> None:
120+
"""Release device memory held by tensors that ``move_to_device_pass``
121+
placed on the target device.
122+
123+
Called at the end of ``preprocess`` so that the next ``preprocess``
124+
call (e.g. for the next method in a multi-method export) can reuse
125+
the freed memory. Override in concrete backends (e.g. ``CudaBackend``)
126+
to actually free device memory.
127+
128+
Default: no-op.
129+
"""
130+
return
131+
108132
@classmethod
109133
@contextlib.contextmanager
110134
def collect_unsupported_fallback_kernels(cls, missing_fallback_kernels: Set[str]):
@@ -208,7 +232,7 @@ def preprocess(
208232
# Compile with fallback kernel collection
209233
with cls.collect_unsupported_fallback_kernels(
210234
missing_fallback_kernels
211-
), torch.no_grad(), cls.get_extra_aoti_compile_context_manager():
235+
), torch.no_grad(), cls.get_extra_aoti_compile_context_manager(compile_specs):
212236
paths = torch._inductor.aot_compile(
213237
edge_program_module, tuple(user_input_placeholders), options=options
214238
)
@@ -269,6 +293,12 @@ def preprocess(
269293
os.remove(so_path)
270294
os.remove(blob_path)
271295

296+
# Release device memory held by tensors that ``move_to_device_pass``
297+
# placed on the target device. Default impl is a no-op; concrete
298+
# backends (e.g. CudaBackend) override this to free GPU memory before
299+
# the next preprocess call (e.g. for the next method).
300+
cls.release_moved_tensors(device_edge_program, compile_specs)
301+
272302
return PreprocessResult(
273303
processed_bytes=b"",
274304
debug_handle_map={},

backends/cuda/cuda_backend.py

Lines changed: 171 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
# LICENSE file in the root directory of this source tree.
66

77

8+
import contextlib
89
import logging
910
import os
1011
import shutil
12+
import threading
1113
import typing
1214
from importlib import resources
1315
from typing import Any, Dict, final, List, Optional
@@ -27,6 +29,83 @@
2729
from torch.nn.attention import SDPBackend
2830

2931

32+
# ---------------------------------------------------------------------------
33+
# AOTI compile-time CPU clones for mutated buffers
34+
# ---------------------------------------------------------------------------
35+
#
36+
# Inductor's `_unlift_graph` clones every mutated buffer that gets lifted into
37+
# the AOTI graph. By default it clones on whatever device the original tensor
38+
# lives on — which after `move_to_device_pass` is CUDA. For Large models like
39+
# Qwen3.5-MoE that means an extra ~18 GB GPU clone during compile, blowing past
40+
# the 24 GB cap we want to honor for consumer GPUs (RTX 4090 and similar).
41+
#
42+
# The patch below side-steps that by:
43+
# 1. Wrapping `torch._inductor.compile_fx.clone_preserve_strides` so every
44+
# clone the AOTI compile pipeline produces lands on CPU.
45+
# 2. Wrapping `CppWrapperCpu.codegen_device` so the C++ wrapper still records
46+
# the model's original target device (e.g. cuda) in `constants_info_`,
47+
# not the now-CPU storage device. Without this the runtime would refuse
48+
# to load the constants because of a mixed-device mismatch.
49+
#
50+
# The wrappers are scoped via a thread-local guard and are only active while
51+
# `_compile_time_cpu_clones(...)` is on the call stack — they are inert
52+
# anywhere else in the process.
53+
54+
_CPU_CLONE_GUARD = threading.local()
55+
56+
57+
def _is_cpu_clone_active() -> bool:
58+
return getattr(_CPU_CLONE_GUARD, "active", False)
59+
60+
61+
@contextlib.contextmanager
62+
def _compile_time_cpu_clones(target_device: torch.device):
63+
"""Force AOTI's mutated-buffer clones onto CPU while preserving the
64+
serialized constants' target device."""
65+
from torch._inductor import compile_fx as _cfx
66+
from torch._inductor.codegen.cpp_wrapper_cpu import CppWrapperCpu as _Cpp
67+
68+
orig_clone = _cfx.clone_preserve_strides
69+
orig_codegen_device = _Cpp.codegen_device
70+
71+
def _cpu_clone_preserve_strides(x: torch.Tensor) -> torch.Tensor:
72+
# `clone_preserve_strides` is shared by `_unlift_graph` (clones
73+
# lifted buffers — can be safely kept on CPU) and by autotuning code
74+
# in `triton_heuristics.py` (clones for benchmark — must stay on
75+
# GPU for Triton). Discriminate by caller frame so we only force
76+
# CPU clones for the buffer-lifting path.
77+
import sys
78+
79+
caller = sys._getframe(1).f_code.co_name
80+
if caller == "_unlift_graph":
81+
return orig_clone(x).cpu()
82+
return orig_clone(x)
83+
84+
def _codegen_device_target_aware(self, device):
85+
# Translate accidental CPU device strings back to the model target
86+
# device only when a constant we forced to CPU is being serialized.
87+
# Other code paths (extern op args etc.) are pass-through.
88+
if (
89+
_is_cpu_clone_active()
90+
and self.device != "cpu"
91+
and isinstance(device, torch.device)
92+
and device.type == "cpu"
93+
):
94+
device = target_device
95+
return orig_codegen_device(self, device)
96+
97+
_cfx.clone_preserve_strides = _cpu_clone_preserve_strides
98+
_Cpp.codegen_device = _codegen_device_target_aware
99+
prev_active = getattr(_CPU_CLONE_GUARD, "active", False)
100+
_CPU_CLONE_GUARD.active = True
101+
try:
102+
yield
103+
finally:
104+
_CPU_CLONE_GUARD.active = prev_active
105+
_cfx.clone_preserve_strides = orig_clone
106+
_Cpp.codegen_device = orig_codegen_device
107+
108+
30109
@final
31110
@experimental(
32111
"This API and all of cuda backend related functionality are experimental."
@@ -253,19 +332,97 @@ def get_aoti_compile_options(
253332
return options
254333

255334
@classmethod
256-
def get_extra_aoti_compile_context_manager(cls):
335+
def get_extra_aoti_compile_context_manager(
336+
cls, compile_specs: Optional[List[CompileSpec]] = None
337+
):
257338
"""
258-
Return SDPA MATH backend context manager for CUDA compilation.
259-
260-
This context manager plays as a fallback solution for any remaining PyTorch SDPA
261-
operations to use the MATH backend (decomposed SDPA) during AOTInductor compilation.
262-
263-
Note:
264-
- If SDPA ops are replaced with Triton kernels by ReplaceEdgeOpWithTritonOpPass,
265-
this context manager will have no effect on those ops (they are no longer
266-
PyTorch SDPA ops).
267-
- If SDPA ops are NOT replaced (e.g., when triton_kernel_mode="OFF"), this
268-
context manager will force them to use the MATH backend, causing them to
269-
be automatically decomposed during compilation.
339+
Combine all extra context managers needed during AOTInductor
340+
compilation for the CUDA backend. Each manager is documented at
341+
its own `enter_context` call site below.
342+
343+
The low-memory export monkey-patch (CPU clones for mutated buffers)
344+
is gated on the ``low_memory_mode`` compile spec — only models that
345+
explicitly opt in (currently Qwen3.5 MoE) get it. Other models go
346+
through the unmodified AOTI codepath, which avoids regressions in
347+
their cuda CI exports.
348+
"""
349+
# Parse compile_specs for low_memory_mode (default OFF). compile_specs
350+
# may be None when called without specs (parity with base default).
351+
low_memory_mode = "OFF"
352+
for spec in compile_specs or []:
353+
if spec.key == "low_memory_mode":
354+
mode = spec.value.decode("utf-8").upper()
355+
if mode not in ["ON", "OFF"]:
356+
raise ValueError(
357+
f"Invalid low_memory_mode: {mode}. Expected 'ON' or 'OFF'."
358+
)
359+
low_memory_mode = mode
360+
361+
@contextlib.contextmanager
362+
def _combined():
363+
with contextlib.ExitStack() as stack:
364+
# Force any remaining PyTorch SDPA ops to use the MATH
365+
# backend during compilation so AOTI can lower / decompose
366+
# them. SDPA ops already replaced by Triton kernels via
367+
# `ReplaceEdgeOpWithTritonOpPass` are unaffected; this is
368+
# only the fallback for the `triton_kernel_mode="OFF"` path.
369+
stack.enter_context(torch.nn.attention.sdpa_kernel([SDPBackend.MATH]))
370+
if low_memory_mode == "ON":
371+
# Force AOTI's mutated-buffer clones onto CPU during
372+
# compile so we stay under tight GPU memory caps (e.g.
373+
# 24 GB on a consumer 4090). See
374+
# `_compile_time_cpu_clones` for details. Only enabled
375+
# for models that explicitly opt in via the
376+
# `low_memory_mode="ON"` compile spec, since the
377+
# monkey-patch can interact poorly with other models'
378+
# AOTI compile pipelines.
379+
stack.enter_context(
380+
_compile_time_cpu_clones(torch.device(cls.get_device_name()))
381+
)
382+
yield
383+
384+
return _combined()
385+
386+
@staticmethod
387+
def _is_low_memory_mode(compile_specs: List[CompileSpec]) -> bool:
388+
"""Return True if any compile spec opts into low-memory export."""
389+
for spec in compile_specs:
390+
if spec.key == "low_memory_mode":
391+
return spec.value.decode("utf-8").upper() == "ON"
392+
return False
393+
394+
@classmethod
395+
def release_moved_tensors(
396+
cls,
397+
device_edge_program,
398+
compile_specs: List[CompileSpec],
399+
) -> None:
400+
"""
401+
Free GPU memory held by tensors that ``move_to_device_pass`` placed
402+
on CUDA (params, buffers, and constants of ``device_edge_program``).
403+
404+
Resizing the underlying storage to 0 returns those bytes to PyTorch's
405+
caching allocator, so the next ``preprocess`` call (e.g. for the
406+
next method in a multi-method export) can reuse them when its own
407+
``move_to_device_pass`` runs.
270408
"""
271-
return torch.nn.attention.sdpa_kernel([SDPBackend.MATH])
409+
if not torch.cuda.is_available():
410+
return
411+
412+
pools = []
413+
state_dict = getattr(device_edge_program, "state_dict", None)
414+
if state_dict:
415+
pools.append(state_dict.values())
416+
constants = getattr(device_edge_program, "constants", None)
417+
if constants:
418+
pools.append(constants.values())
419+
420+
for pool in pools:
421+
for tensor in pool:
422+
if isinstance(tensor, torch.Tensor) and tensor.is_cuda:
423+
try:
424+
tensor.untyped_storage().resize_(0)
425+
except Exception:
426+
# Some storages may be shared / non-resizable; skip
427+
# them rather than failing the export.
428+
pass

examples/models/qwen3_5_moe/export.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,7 @@ def _export_cuda(model, config, args):
934934
ExecutorchBackendConfig,
935935
to_edge_transform_and_lower,
936936
)
937+
from executorch.exir.backend.compile_spec_schema import CompileSpec
937938
from executorch.exir.passes import MemoryPlanningPass
938939
from torch.export import Dim, export
939940

@@ -1007,13 +1008,15 @@ def _export_cuda(model, config, args):
10071008
CudaPartitioner(
10081009
[
10091010
CudaBackend.generate_method_name_compile_spec("decode"),
1011+
CompileSpec("low_memory_mode", b"ON"),
10101012
]
10111013
)
10121014
],
10131015
"prefill": [
10141016
CudaPartitioner(
10151017
[
10161018
CudaBackend.generate_method_name_compile_spec("prefill"),
1019+
CompileSpec("low_memory_mode", b"ON"),
10171020
]
10181021
)
10191022
],
@@ -1166,6 +1169,13 @@ def main(): # noqa: C901
11661169
# Register FLA Triton kernel (CUDA only)
11671170
import executorch.backends.cuda.triton.kernels # noqa: F401
11681171

1172+
# Reset peak GPU memory stats so we can report the actual peak
1173+
# consumed during the export pipeline (load + quantize + lowering)
1174+
# at the very end. This is also gated by CI to make sure low-VRAM
1175+
# GPUs (e.g. RTX 4090, 24 GB) can still complete the export.
1176+
if torch.cuda.is_available():
1177+
torch.cuda.reset_peak_memory_stats(0)
1178+
11691179
if args.backend == "mlx":
11701180
if args.prequantized:
11711181
parser.error("--prequantized is not supported with --backend mlx")
@@ -1207,6 +1217,13 @@ def main(): # noqa: C901
12071217

12081218
export_and_lower(model, config, args)
12091219

1220+
# Report peak GPU memory consumed during the export so CI / users can
1221+
# gate this against a known budget (e.g. 24 GB consumer GPUs).
1222+
if args.backend == "cuda" and torch.cuda.is_available():
1223+
peak_mb = torch.cuda.max_memory_allocated(0) / (1024 * 1024)
1224+
# Stable, machine-parseable marker for CI grep.
1225+
print(f"EXPORT_GPU_PEAK_MEMORY_MB: {peak_mb:.2f}")
1226+
12101227

12111228
if __name__ == "__main__":
12121229
main()

0 commit comments

Comments
 (0)