Repository navigation
[CUDA] Add MoEPacked CPU/GPU hybrid offload and consolidate QMoE/GGUF offload optimizations - #33973
Conversation
…ffload optimizations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…zations - Replace MoEPackedParallelPool with qmoe::SpinPoolRegistry and pin doorbell thread to CpuForWorker(0), eliminating epoch/remaining counter race and scheduler barrier stalls - Add register-accumulator AVX2+FMA+F16C cold-expert kernels for IQ3_XXS, IQ4_XS, IQ2_S, IQ2_XXS, IQ2_XS, IQ4_NL, Q2_0, Q5_0, and mixed gate/up types with /arch:AVX2 on MSVC - Extend Int2Group8Avx512 to RT=1..4 and factor block scales into single FMA - Calibrate 16GB and 32GB WDDM post-session reserves for MoEPacked and QMoE, and filter PrefetchVirtualMemory by stream_skip Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use checked workspace sizing for 8192-row offload prefills and allocate offload outputs before dispatch. Preserve shared-provider include ordering and enable the required AVX2, FMA, and F16C flags for Linux builds. Initialize pinned decode state correctly and preserve prefill staging type linkage under warnings-as-errors. Extend offload parity coverage to empty input and prefills beyond 2048 rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
200420e to
0482c13
Compare
Merge pinned main 3bde254 (23 commits ahead of the previous merge base 548473f, the #33953 merge). Conflict resolution: js/react_native/e2e/test/OnnxruntimeModuleExample.test.js took main's version. Main already selects the EPContext button on iOS via testID/by.id (e2a328f). The branch's by.label selector matches two nested views on iOS ("Multiple elements found"), so the branch no longer touches the React Native e2e test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical defects can overflow scratch and stack buffers, trigger invalid CUDA accesses, and silently produce incorrect expert outputs.
16 open issues
AVX2/FMA/F16C compilation can crash on unsupported x86 CPUs · New BF16 conversion mishandles NaN payloads as negative zero · New Staging singleton reuses CUDA resources across devices · New Registration can race with worker shutdown and resource teardown · New CUDA Graph replay uses a stale warmup latch for live-session filtering · New Backend ignores host-cache limits and reads complete initializer banks · New Unbounded inter_size can overflow the fallback dequantizer's stack buffer · New Prefill allocations exceed device and pinned-memory budgets · New Small-route layout exceeds the workspace sizing helper's allocation · New Finalizers read uninitialized contributions from pruned routes · New Cold-input copy uses uint4 without checking source alignment · New Offload decode finalizer misaligns element-aligned output stores · New Dispatchers ignore BF16 tensor-core support and launch empty MMA kernels · New FP16 offload always stages in shared memory, exceeding device limits · New Prefill finalizer needs an alignment check before uint4 stores · New Offload test lacks an SM80 capability guard · New
What changed in this PR
This PR extends ONNX Runtime’s existing QMoE offload infrastructure to MoEPacked, enabling CPU/GPU hybrid execution of GGUF-packed experts, and consolidates related offload optimizations.
Changes:
- Adds CPU placement, offload admission, hybrid decode, streamed prefill, and adaptive expert promotion for
MoEPacked. - Optimizes GGUF CPU kernels and QMoE INT2 execution, and adjusts memory reserves and popularity-profile handling.
- Adds offload parity coverage and expands INT2 overflow regression coverage.
| File | Description |
|---|---|
onnxruntime/test/python/transformers/test_gguf_packed_ops.py |
Adds MoEPacked offload parity tests across row counts and activation types. |
onnxruntime/test/contrib_ops/qmoe_shared_layout_test.cc |
Extends INT2 overflow regression coverage to multiple rows. |
onnxruntime/core/providers/cuda/qmoe_session_controller.h |
Adjusts memory reserves, profile parsing, admission ordering, and host-page eviction. |
onnxruntime/core/framework/session_state.cc |
Validates MoEPacked offload eligibility and places expert initializers on CPU. |
onnxruntime/contrib_ops/cuda/moe/qmoe_offload.cc |
Optimizes compact CPU output writes, INT2 prefill dispatch, and Windows prefetching. |
onnxruntime/contrib_ops/cuda/moe/moe_packed.h |
Declares offload state, interfaces, limits, and CUDA launch APIs. |
onnxruntime/contrib_ops/cuda/moe/moe_packed.cu |
Adds hybrid decode classification, synchronization, grouped execution, and output combination. |
onnxruntime/contrib_ops/cuda/moe/moe_packed.cc |
Implements CPU expert kernels, offload lifecycle, streamed prefill, and promotion. |
onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemv.cu |
Handles the sorted-expert-map marker before fallback dispatch. |
onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemv_tc.cuh |
Enables INT2 Tensor Core dispatch for larger sorted prefill waves. |
onnxruntime/contrib_ops/cpu/moe/qmoe_shared_layout_int2_avx512.cc |
Extends the eight-column INT2 kernel to four-row tiles. |
cmake/onnxruntime_providers_cuda.cmake |
Adds AVX2/FMA/F16C compilation flags for MoEPacked host code. |
cmake/onnxruntime_providers_cuda_plugin.cmake |
Applies corresponding compilation flags to the CUDA plugin build. |
Review effort: Balanced
Address review findings in the MoEPacked CPU/GPU hybrid offload backend. - Move the AVX2/FMA/F16C GGUF cold-expert kernels into moe_packed_cpu_avx2.cc, which enables the ISA per function and exports only its entry points, and dispatch to them after qmoe_shared::CpuHasAvx2FmaF16c(). moe_packed.cc is baseline code again. - Keep NaN as NaN when rounding to BF16, and give the scalar dot product K-sized scratch so inter_size > 4096 cannot overrun the stack. - Serialize doorbell Register/Unregister with the whole stop, join and release transition; key prefill staging by CUDA device; reset freed pinned pointers; order reuse of the shared pinned buffers across streams with an event; compare doorbell sequences wrap-safely. - Read the run-scoped warm-up control when the doorbell is served instead of a ComputeInternal latch that CUDA graph replay never updates. - Use the session policy for tail substitution and minimum expert weight; it already applies the ORT_QMOE_* fallbacks with config precedence. - Stop advertising a bounded host cache, honor prefill_wave_experts and explicit device_working_bytes / pinned_bytes, and allocate staging banks only when a layer streams cold experts. - Reserve the same tile bound as GetMoEPackedWorkspaceBytes, skip zero-scale routes in the prefill finalizer, and drop the routes of cold experts that warm-up does not execute. - Honor bf16_tc_supported, the 48 KiB staging limit and element-aligned I/O-bound buffers in the offload decode and grouped prefill paths. - Start the unreachable >kMaxGroupSlots QMoE compact copy at the group's first compact row. - Skip offload tests below SM80 and cover unaligned I/O binding, small-route prefill, FP16 hidden_size=4096, inter_size > 4096 on the CPU path, and CUDA graph replay after warm-up capture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…_SM_120 link in moe_gemm_kernels_fp4_fp4.cu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The hybrid decode rounded the CPU cold-route partial sum to BF16/FP16 before the GPU added it to the resident routes, so outputs were rounded twice. Keep the partial sum in FP32 so the combine rounds once, as the GPU-only finalize does. The FP16 CPU path also skipped the FP16 rounding that the CUDA kernels apply to the gate/up dot products, the SwiGLU output, and each route's FC2 output. Mirror it, as the BF16 path already did. Resolve the CPU pool size once per node instead of on every ParallelFor: std::thread::hardware_concurrency() reads sysfs on Linux, and the decode path that the GPU waits for calls ParallelFor twice per layer. Add tests that run every CPU kernel family through the decode path and check rounding parity against a reference and against the GPU-only output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The CUDA EP ends the QMoE controller run before the run's kernel streams are synchronized. EndRun() clears the warm-up flag and releases the overlap guard, but MoEPacked registered no invocation, so the controller had nothing to wait on. With device-resident outputs, the doorbell thread then read InWarmup() == false for most warm-up decode steps and ran the cold experts on the CPU (296 of 300 warm-up runs in an all-cold session). Register a per-node invocation with the kernel stream, and with the replay stream through the graph-replay hook, so OnRunEnd synchronizes it first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Baiju Meswani (baijumeswani)
left a comment
There was a problem hiding this comment.
This PR lets block-packed GGUF MoE models keep cold experts on the CPU and run resident experts on the GPU, with streamed prefill and CUDA-graph decode. It also brings in QMoE INT2 and offload improvements.
The CPU fallback and ISA isolation, rounding fixes, FP32 cold-output accumulation, replay lifetime handling, and regression tests are good improvements. Independent CPU arithmetic and CUDA kernel reviews found no new actionable numerical or kernel-memory issues.
Local validation passed: native/plugin host compilation, CUDA compilation, 53 policy/admission/lifetime tests, 11 INT2 tests, 143,262 sanitized CPU comparisons, and 155 A100 reference cases. CUDA memcheck, racecheck, synccheck, and initcheck were clean. These were targeted fresh-source checks, not a complete CUDA wheel or end-to-end operator-suite rebuild; Windows/WDDM and AVX512 execution were not validated.
The four inline comments cover remaining integration issues: pinned-memory accounting, automatic GPU workspace limits, CPU worker-count settings, and the decode token threshold. These prevent the shared session policy from reliably controlling resource use and dispatch. Since the PR is already merged, please address them with focused regression tests in a follow-up.
| ORT_RETURN_IF_ERROR(CUDA_CALL(cudaHostAlloc( | ||
| reinterpret_cast<void**>(&st->h_decode_pinned), sizeof(MoEPackedDecodePinned), cudaHostAllocMapped))); |
There was a problem hiding this comment.
Please count this allocation against the session's pinned-memory limit. Admission accepts session.moe.offload.pinned_bytes=0, but this block still allocates 396,352 pinned bytes for every admitted node, including fully GPU-resident and CPU-disabled plans. The prefill routing buffers and shared staging banks also allocate outside the pinned-memory ledger, and larger banks remain allocated after a smaller request.
This means the configured limit does not bound actual pinned memory, and other allocations using the ledger cannot see this usage. Please account for all live decode and prefill pinned allocations through the controller's ledger, including retained shared capacity, and shrink, fall back, or return an error when the complete allocation cannot fit. Add tests for zero/small limits and for a smaller session following a larger one.
| for (;;) { | ||
| max_params.num_rows = static_cast<int>(max_chunk); | ||
| ORT_RETURN_IF_ERROR(GetMoEPackedWorkspaceBytes(max_params, sizeof(T), ws_bytes)); | ||
| if (!policy.device_working_bytes_explicit || ws_bytes + gpu_bank_bytes <= policy.device_working_bytes) { |
There was a problem hiding this comment.
Please enforce the resolved device working budget even when the caller did not set it explicitly. This condition skips the size check for automatic budgets, although admission still reserves a working budget when deciding how many experts fit on the GPU.
Calling the current workspace-size function with 8,192 rows, hidden size 4,096, intermediate size 1,536, k=8, 128 experts, and two-byte activations requires 704.9 MiB of scratch before either staging bank. That exceeds both the usual 256 MiB budget and the 560 MiB large-model allowance, but this branch accepts it. A model can therefore initialize successfully and run out of GPU memory during prefill.
Please fit the scratch and live staging capacity within every resolved budget, reducing the chunk/wave size or adjusting admission as needed. Add a regression using an automatic budget, not only an explicit one.
| const bool det_replay = !det_env.empty() && det_env != "0"; | ||
| st->is_adaptive = !det_replay && (policy.placement == onnxruntime::moe_offload::PlacementPolicy::kAdaptive); | ||
| st->cpu_execution_enabled = (policy.cpu_execution != onnxruntime::moe_offload::CpuExecutionMode::kDisabled); | ||
| st->cpu_threads = MoEPackedParallelPool::ResolveThreads(); |
There was a problem hiding this comment.
Please use the admitted policy.cpu_threads value here when it is positive. ResolveThreads() only reads the hardware thread count and ORT_QMOE_SHARED_CPU_THREADS, so session.moe.offload.cpu_threads=1 is ignored. The resolver selects 24 workers on the test host, or 7 when the environment variable is 7, despite the session setting of 1.
This prevents callers from limiting CPU worker usage and gives the environment variable precedence over the explicit session setting. Use the positive policy value first, and only use the automatic/environment path when it is zero, as the QMoE backend does. Add a test with an explicit session value and a different environment value.
| if (st.cpu_execution_enabled && | ||
| num_rows <= kMoEPackedMaxDecodeRows && num_rows * k_ <= kMoEPackedMaxDecodeRoutes && | ||
| hidden_size <= kMoEPackedMaxHiddenSize) { |
There was a problem hiding this comment.
Please include offload_policy_.async_max_tokens in this decode decision. The condition only checks the fixed 16-row limit, so session.moe.offload.async_max_tokens=0 still selects CPU doorbell decode for one row, and the default value of 8 still selects it for 9–16 rows.
Callers therefore cannot use the shared session setting to select GPU streaming for those batches, unlike the QMoE path. Require the row count to fit the configured threshold as well as the existing backend shape limits. Add tests for zero and for row counts just below, at, and above a configured threshold.

Summary
This PR consolidates and optimizes CPU/GPU hybrid expert offload for
com.microsoft::MoEPacked(GGUF sub-byte block-packed experts) andcom.microsoft::QMoE(RC5 mixed-precision INT4/INT3/INT2 experts) across 16 GB (RTX 5060 Ti), 24 GB (RTX 4090), 32 GB (RTX 5090), and H200 GPUs.Stacked On
[CUDA] Improve QMoE offload decode throughput via AVX2 spill elimination and working-set retention)[CUDA] Tail expert substitution for QMoE offload decode (stacked on #33935))Support direct external initializer buffers in AddExternalInitializers)[CUDA] Add GatherPacked, MatMulPacked, and MoEPacked contrib ops for block-packed weights)Key Changes
com.microsoft::MoEPacked(session_state.cc,moe_packed.h,moe_packed.cu,moe_packed.cc):MoEPackedexpert weight initializers (gate_weights,up_weights,down_weights) on CPU (zero-copy file mapping) and implementsIMoeOffloadNodeadmission withQMoESessionController.MoEPackedOffloadClassifyAndBuildTilesKernel,MoEPackedDoorbellWaitKernel,MoEPackedOffloadFinalizeVec8Kernel, andLaunchQMoETailSubstitutionintegration) and 8,192-row double-buffered 32-expert wave-streaming prefill with D2D adaptive hot-expert promotion.qmoe::SpinPool& Register-Accumulator AVX2+FMA+F16C GGUF Cold-Expert Kernels (moe_packed_cpu_avx2.cc,moe_packed.cc,cmake/onnxruntime_providers_cuda*.cmake):MoEPackedParallelPoolwithqmoe::SpinPoolRegistry(qmoe_spin_pool.h) and pinsMoEPackedDoorbellServicetoCpuForWorker(0).moe_packed_cpu_avx2.cc(ISA enabled per function;/arch:AVX2for that file on MSVC x64) and are dispatched afterqmoe_shared::CpuHasAvx2FmaF16c(), with a scalar fallback.GgufDualDot2ChAvx2Tpl<RT>) and 4-column (IQ4_NL/Q2_0) AVX2+FMA+F16C kernels coveringIQ2_S,IQ2_XXS,IQ2_XS,IQ3_XXS,IQ4_XS,IQ4_NL,Q2_0,Q5_0, and mixedgate_type != up_typelayers, cuttingiq3_xxsCPU cold-expert latency 4.8x (245.5 us/exp -> 49.2 us/exp) and boostingiq3_xxsdecode throughput 4.1x (16.2–20.8 tok/s -> 67.2–81.7 tok/slossless,179.96 tok/swith tail substitution) andiq1_sdecode throughput up to142.91 tok/slossless (261.65 tok/swith tail substitution) on RTX 5090.QMoEINT2 Prefill & AVX-512 Cold-Expert Optimizations + 16 GB / 24 GB / 32 GB WDDM Reserve Calibration (qmoe_offload.cc,moe_gemv_tc.cuh,qmoe_shared_layout_int2_avx512.cc,qmoe_session_controller.h):MoeGemvTcInt2Kernelon sortedexpert_mapprefill waves (+24%to+30%faster8K/32Kprefill on2b375, reaching2,560 tok/s).Int2Group8Avx512toRT = 1..4with factored scale FMA.QMoEandMoEPackedand auto-detects 8-byte vs. 4-byteSTRPv1 popularity profiles.MoEPackedHybrid Decode (moe_packed.cc,moe_packed.cu,moe_packed.h,test_gguf_packed_ops.py):MoEPackedDecodePinned::host_cold_output), so the hybrid combine rounds to BF16/FP16 once, as the GPU-only finalize does. Rounding the partial sum to the activation type first rounded the output twice.QMoEInvocationRecordwith the kernel stream, and with the replay stream through the graph-replay hook, so the controller synchronizes it beforeEndRun()clears the warm-up flag. Previously, with device-resident outputs, 296 of 300 warm-up decode runs in an all-cold session computed the cold experts on the CPU; now none do.std::thread::hardware_concurrency()(a sysfs read, about 2.6 us on Linux) on every decodeParallelFor.Accuracy & Performance Impact of Tail Expert Substitution (threshold = 0.02, min_rank = 2) on 2b375 (RTX 5090 32 GB)
Evaluated on qwen_flash_next_int4_int8_rc5_contrib_dflash2_qmoe_2b375_x02_y14_z32_dp23_b64_unified (xhigh reasoning, greedy decoding):
CPU vs. CUDA Numerical Parity of
MoEPackedHybrid Decode (H200)Fraction of output elements that differ from the GPU-only
MoEPackedoutput (session.moe.offload.mode=disabled), over eight GGUF format mixes that cover every CPU cold-expert kernel family and decode batches of 1, 3, and 16 rows:With BF16, the CPU path matches a NumPy emulation that keeps dequantized weights in FP32 bit-for-bit, and the CUDA path matches one that rounds them to BF16 bit-for-bit. The remaining difference comes from the CUDA BF16 MMA kernels rounding GGUF-dequantized weights to BF16. The CPU kernels keep the more precise FP32 weights: emulating the rounding would slow the AVX2 loops, and results would still depend on expert placement because the summation order differs.
Testing
python -m pytest onnxruntime/test/python/transformers/test_gguf_packed_ops.py: 39 passed (857 subtests) on H200 (sm90). The offload tests also pass withORT_QMOE_SHARED_CPU_THREADS=1and7.test_moe_packed_cuda_offload_cpu_kernels_match_referenceruns every CPU cold-expert kernel family with both activation types against a reference with FP32 dequantized weights.test_moe_packed_cuda_offload_mixed_placement_matches_gpucompares FP16 hybrid decode with the GPU-only output, using aligned and offset buffers. Both fail before item 4 and pass now.