Skip to content

Commit 3b77c08

Browse files
authored
qwen3_5_moe: add CUDA Engine/Session execution path (pytorch#20288)
Add a Qwen3.5-MoE execution adapter on top of the new LLMEngine/LLMSession and CUDA mutable-state foundations. The engine loads one physical model, registers exported mutable-buffer metadata, and creates isolated sessions that rebind their own KV/conv/recurrent state before execution while sharing model weights. Keep the existing CLI behavior by making main.cpp a thin wrapper over the engine/session path. The export now records the model-specific mutable-buffer FQNs, and the CUDA build includes a no-bleed integration proof that interleaves two sessions on one loaded model and checks state isolation, memory growth, and capacity enforcement. This intentionally leaves OpenAI serving, worker loops, warm resume, and per-model serving wrappers for later PRs. pytorch#20001 Will do Gemma4 31B in later PRs CI already exercises the main.cpp, which wraps around QwenEngine and QwenSession
1 parent ab409a3 commit 3b77c08

10 files changed

Lines changed: 1151 additions & 315 deletions

File tree

.ci/scripts/export_model_artifact.sh

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,9 @@ if [ "$MODEL_NAME" = "qwen3_5_moe" ]; then
406406

407407
# Download prequantized model outside OUTPUT_DIR to avoid uploading on failure
408408
LOCAL_MODEL_DIR=$(mktemp -d)
409-
INDUCTOR_CACHE=$(mktemp -d)
410-
trap 'rm -rf "$LOCAL_MODEL_DIR" "$INDUCTOR_CACHE"' EXIT
409+
INDUCTOR_CACHE=$(mktemp -d "${RUNNER_TEMP:-/tmp}/inductor_cache_XXXXXX")
410+
INDUCTOR_TMPDIR=$(mktemp -d "${RUNNER_TEMP:-/tmp}/tmpdir_XXXXXX")
411+
trap 'rm -rf "$LOCAL_MODEL_DIR" "$INDUCTOR_CACHE" "$INDUCTOR_TMPDIR"' EXIT
411412

412413
python -c "from huggingface_hub import snapshot_download; snapshot_download('${HF_MODEL}', local_dir='${LOCAL_MODEL_DIR}')"
413414

@@ -427,6 +428,7 @@ if [ "$MODEL_NAME" = "qwen3_5_moe" ]; then
427428
# Export to .pte/.ptd (short cache dir avoids objcopy symbol length issues)
428429
echo "::group::Export"
429430
EXPORT_LOG=$(mktemp)
431+
TMPDIR="$INDUCTOR_TMPDIR" \
430432
TORCHINDUCTOR_CACHE_DIR="$INDUCTOR_CACHE" \
431433
python -m executorch.examples.models.qwen3_5_moe.export \
432434
--prequantized "$LOCAL_MODEL_DIR" \
@@ -473,8 +475,9 @@ if [ "$MODEL_NAME" = "gemma4_31b" ]; then
473475

474476
# Download prequantized model outside OUTPUT_DIR to avoid uploading on failure
475477
LOCAL_MODEL_DIR=$(mktemp -d)
476-
INDUCTOR_CACHE=$(mktemp -d)
477-
trap 'rm -rf "$LOCAL_MODEL_DIR" "$INDUCTOR_CACHE"' EXIT
478+
INDUCTOR_CACHE=$(mktemp -d "${RUNNER_TEMP:-/tmp}/inductor_cache_XXXXXX")
479+
INDUCTOR_TMPDIR=$(mktemp -d "${RUNNER_TEMP:-/tmp}/tmpdir_XXXXXX")
480+
trap 'rm -rf "$LOCAL_MODEL_DIR" "$INDUCTOR_CACHE" "$INDUCTOR_TMPDIR"' EXIT
478481

479482
python -c "from huggingface_hub import snapshot_download; snapshot_download('${HF_MODEL}', local_dir='${LOCAL_MODEL_DIR}')"
480483

@@ -498,6 +501,7 @@ if [ "$MODEL_NAME" = "gemma4_31b" ]; then
498501

499502
# Export to .pte/.ptd (short cache dir avoids objcopy symbol length issues)
500503
echo "::group::Export"
504+
TMPDIR="$INDUCTOR_TMPDIR" \
501505
TORCHINDUCTOR_CACHE_DIR="$INDUCTOR_CACHE" \
502506
python -m executorch.examples.models.gemma4_31b.export \
503507
--prequantized "$LOCAL_MODEL_DIR" \

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,11 +433,12 @@ voxtral_tts-cuda:
433433
qwen3_5_moe-cuda:
434434
@echo "==> Building and installing ExecuTorch with CUDA..."
435435
cmake --workflow --preset llm-release-cuda
436-
@echo "==> Building Qwen3.5 MoE runner with CUDA..."
436+
@echo "==> Building Qwen3.5 MoE runner and no-bleed test with CUDA..."
437437
cd examples/models/qwen3_5_moe && cmake --workflow --preset qwen3-5-moe-cuda
438438
@echo ""
439439
@echo "✓ Build complete!"
440440
@echo " Binary: cmake-out/examples/models/qwen3_5_moe/qwen3_5_moe_runner"
441+
@echo " Test: cmake-out/examples/models/qwen3_5_moe/test_qwen35_moe_nobleed"
441442

442443
gemma4_31b-cuda:
443444
@echo "==> Building and installing ExecuTorch with CUDA..."

examples/models/qwen3_5_moe/CMakeLists.txt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ set(EXECUTORCH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
1515
include(${EXECUTORCH_ROOT}/tools/cmake/Utils.cmake)
1616

1717
set(_common_include_directories ${EXECUTORCH_ROOT}/..)
18+
set(_json_include
19+
${EXECUTORCH_ROOT}/extension/llm/tokenizers/third-party/json/single_include
20+
)
1821

1922
# gflags
2023
set(gflags_DIR ${CMAKE_CURRENT_BINARY_DIR}/../../../third-party/gflags)
@@ -60,13 +63,26 @@ endif()
6063
# Tokenizer
6164
list(APPEND link_libraries tokenizers::tokenizers)
6265

63-
add_executable(qwen3_5_moe_runner main.cpp)
66+
add_executable(qwen3_5_moe_runner main.cpp qwen35_moe_engine.cpp)
6467
target_include_directories(
65-
qwen3_5_moe_runner PUBLIC ${_common_include_directories}
68+
qwen3_5_moe_runner PUBLIC ${_common_include_directories} ${_json_include}
6669
)
6770
target_link_libraries(qwen3_5_moe_runner PUBLIC ${link_libraries})
6871

6972
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
7073
target_link_options_gc_sections(qwen3_5_moe_runner)
7174
target_link_options(qwen3_5_moe_runner PRIVATE "LINKER:-s")
7275
endif()
76+
77+
if(EXECUTORCH_BUILD_CUDA)
78+
enable_testing()
79+
add_executable(
80+
test_qwen35_moe_nobleed test_qwen35_moe_nobleed.cpp qwen35_moe_engine.cpp
81+
)
82+
target_include_directories(
83+
test_qwen35_moe_nobleed PUBLIC ${_common_include_directories}
84+
${_json_include}
85+
)
86+
target_link_libraries(test_qwen35_moe_nobleed PUBLIC ${link_libraries})
87+
add_test(NAME qwen_nobleed COMMAND test_qwen35_moe_nobleed)
88+
endif()

examples/models/qwen3_5_moe/CMakePresets.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@
4141
"buildPresets": [
4242
{
4343
"name": "qwen3-5-moe-cuda",
44-
"displayName": "Build Qwen3.5 MoE runner (CUDA)",
44+
"displayName": "Build Qwen3.5 MoE runner + no-bleed test (CUDA)",
4545
"configurePreset": "qwen3-5-moe-cuda",
46-
"targets": ["qwen3_5_moe_runner"]
46+
"targets": ["qwen3_5_moe_runner", "test_qwen35_moe_nobleed"]
4747
},
4848
{
4949
"name": "qwen3-5-moe-metal",

examples/models/qwen3_5_moe/README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ It can be uploaded to HuggingFace Hub for easy sharing.
100100

101101
ExecuTorch must be installed from source first (see
102102
[Prerequisites](#prerequisites)). The `make` target handles building
103-
core libraries and the runner binary.
103+
core libraries, the runner binary, and the CUDA no-bleed test binary.
104104

105105
```bash
106106
make qwen3_5_moe-cuda
@@ -109,6 +109,10 @@ make qwen3_5_moe-cuda
109109
This builds ExecuTorch with CUDA backend support, then the runner binary
110110
at `cmake-out/examples/models/qwen3_5_moe/qwen3_5_moe_runner`.
111111

112+
The runner is a thin CLI over `Qwen35MoEEngine` and `Qwen35MoESession`.
113+
On CUDA, the engine loads the model weights once and can create multiple
114+
isolated sessions by rebinding the model's mutable buffers before execution.
115+
112116
## Run
113117

114118
The runner requires:
@@ -133,8 +137,28 @@ cmake-out/examples/models/qwen3_5_moe/qwen3_5_moe_runner \
133137
| `--data_path` | (none) | Path to `.ptd` delegate data file (required for CUDA) |
134138
| `--tokenizer_path` | (required) | Path to HuggingFace `tokenizer.json` |
135139
| `--prompt` | `"Hello"` | Input prompt text |
140+
| `--prompt_file` | (none) | Path to a prompt file (overrides `--prompt`) |
136141
| `--temperature` | `0.8` | Sampling temperature (0 = greedy) |
137142
| `--max_new_tokens` | `128` | Maximum tokens to generate |
143+
| `--warmup` | `0` | Warmup iterations to discard before timing |
144+
| `--num_iters` | `1` | Timed iterations to average after warmup |
145+
| `--cuda_graph` | `false` | CUDA-only decode graph capture for single-session runner use |
146+
147+
`--cuda_graph` is intentionally single-session only. CUDA graph replay captures
148+
device pointers, so it is not combined with per-session mutable-state rebinding.
149+
150+
### CUDA no-bleed test
151+
152+
The CUDA build also produces `test_qwen35_moe_nobleed`, which validates that two
153+
sessions can interleave prefill/decode on one loaded model without sharing
154+
mutable state:
155+
156+
```bash
157+
QWEN_MODEL_PATH=qwen35_moe_exports/model.pte \
158+
QWEN_DATA_PATH=qwen35_moe_exports/aoti_cuda_blob.ptd \
159+
QWEN_TOKENIZER_PATH=~/models/Qwen3.5-35B-A3B/tokenizer.json \
160+
cmake-out/examples/models/qwen3_5_moe/test_qwen35_moe_nobleed
161+
```
138162

139163
## Troubleshooting
140164

examples/models/qwen3_5_moe/export.py

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -624,9 +624,8 @@ def _materialize_buffers(model, config):
624624
Replaces meta buffers with real tensors on CPU, recomputes RoPE
625625
inv_freq and causal masks. State buffers (KV cache, conv/recurrent
626626
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.
627+
they are lifted into the delegate as named constants; per-session sharing
628+
and isolation are handled by runtime rebinding.
630629
"""
631630
# Masks stay bool, inv_freq stays float32.
632631
for fqn, buf in list(model.named_buffers()):
@@ -915,6 +914,47 @@ def _export_metal(model, config, args):
915914
print("Done!")
916915

917916

917+
def _qwen_mutable_buffer_fqns(model):
918+
from executorch.examples.models.qwen3_5_moe.model import GatedDeltaNet, KVCache
919+
920+
fqns = []
921+
for prefix, module in model.named_modules():
922+
if module.__class__.__name__ == "TurboQuantKVCache":
923+
fqns += [
924+
f"{prefix}.k_packed",
925+
f"{prefix}.k_norms",
926+
f"{prefix}.v_packed",
927+
f"{prefix}.v_norms",
928+
]
929+
elif isinstance(module, KVCache):
930+
fqns += [f"{prefix}.k_cache", f"{prefix}.v_cache"]
931+
elif isinstance(module, GatedDeltaNet):
932+
fqns += [f"{prefix}.conv_state", f"{prefix}.recurrent_state"]
933+
934+
named = dict(model.named_buffers())
935+
missing = [f for f in fqns if f not in named]
936+
if missing:
937+
raise RuntimeError(
938+
f"Qwen mutable-buffer contract references missing buffers: {missing}"
939+
)
940+
if not fqns:
941+
raise RuntimeError("Qwen mutable-buffer contract is empty")
942+
return sorted(fqns)
943+
944+
945+
def _mutable_buffer_metadata_json(model):
946+
import json
947+
948+
fqns = _qwen_mutable_buffer_fqns(model)
949+
named = dict(model.named_buffers())
950+
total = sum(named[f].numel() * named[f].element_size() for f in fqns)
951+
print(
952+
f" Recorded {len(fqns)} mutable buffers "
953+
f"({total} B / {total / 1024:.1f} KiB per session)"
954+
)
955+
return json.dumps({"version": 1, "mutable_buffers": fqns})
956+
957+
918958
def _export_cuda(model, config, args):
919959
"""Export model to .pte via torch.export + CUDA backend.
920960
@@ -923,13 +963,9 @@ def _export_cuda(model, config, args):
923963
- "prefill": prefill path (T>=2), batched tensor-core MoE kernel
924964
via fused_moe_batched_gemm, with dynamic sequence length.
925965
926-
Both methods share mutable state buffers (KV cache, conv_state,
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.
966+
The model uses registered buffers with in-place updates for KV,
967+
conv_state, and recurrent_state. The export records which named buffers
968+
are per-session mutable state.
933969
"""
934970
import torch._inductor.config as inductor_config
935971

@@ -1006,6 +1042,7 @@ def _export_cuda(model, config, args):
10061042
"use_kv_cache": True,
10071043
"use_sdpa_with_kv_cache": False,
10081044
"enable_dynamic_shape": True,
1045+
"get_mutable_buffer_metadata": _mutable_buffer_metadata_json(model),
10091046
}
10101047
et_prog = to_edge_transform_and_lower(
10111048
{"decode": decode_ep, "prefill": prefill_ep},
@@ -1037,7 +1074,9 @@ def _export_cuda(model, config, args):
10371074
config=ExecutorchBackendConfig(
10381075
extract_delegate_segments=True,
10391076
do_quant_fusion_and_const_prop=True,
1040-
memory_planning_pass=MemoryPlanningPass(alloc_graph_input=False),
1077+
memory_planning_pass=MemoryPlanningPass(
1078+
alloc_graph_input=False,
1079+
),
10411080
emit_mutable_buffer_names=True,
10421081
),
10431082
)

0 commit comments

Comments
 (0)