Fix QMoE CPU livelock by eliminating nested intra-op parallelism#29081
Conversation
tianleiwu
left a comment
There was a problem hiding this comment.
I found one focused performance edge case in the threading change. The overall direction makes sense and the null-thread-pool fallbacks look safe, but sparse or highly imbalanced routing can still make the outer expert loop look parallel even when only one expert has real work. In that case this patch disables the inner MLAS/GEMM parallelism for the only substantial expert, so capping by active experts should preserve the livelock fix without serializing that workload.
The previous livelock fix gated inner parallelism on the outer expert fan-out (inner_tp = nullptr when num_expert_threads > 1). With routing capped by num_experts (not active experts) and an unconditional work tier cap, sparse/imbalanced routing could either serialize the only busy expert or serialize all active experts, regressing decode badly. Parallelize the expert loop across the *active* experts instead: - Count num_active_experts (experts that actually received tokens) and set num_expert_threads = min(num_active_experts, pool size). For decode (M==1) each per-expert GEMM is a GEMV that MLAS does not thread internally, so spreading active experts over the pool keeps cores busy where the inner op cannot. - Keep parallelism single-level to preserve the no-livelock guarantee: when the expert loop runs multi-threaded the inner ops run serially (inner_tp == nullptr); when a single expert is active the loop is serial and the inner GEMM gets the full pool (inner_tp == tp). This also addresses the review note: a single-active-expert batch now keeps inner MLAS/GEMM parallelism. - Remove the work tier cap that serialized active experts. Validated: 80 parity tests pass, lint clean, 2400-run concurrent livelock stress passes (no hang). Decode speedups 3.3-12x across gpt_oss/qwen3/gemma models; prefill unchanged.
There was a problem hiding this comment.
Pull request overview
This PR updates the QMoE CPU quantized implementation to avoid nested intra-op parallelism (which can livelock ORT’s Eigen-based thread pool) while improving CPU utilization for decode-style workloads by parallelizing across active experts.
Changes:
- Replace the previous “total-work” based expert thread selection with an active-expert count and cap
num_expert_threadsbynum_active_experts. - Introduce
inner_tpso only one level uses the session threadpool: inner ops (GEMM/dequant/copies/activation) run withnullptrwhen the outer expert loop is multi-threaded, and usetponly when the outer loop is effectively serial. - Remove the work-tier cap that previously forced additional serialization of active experts.
Review of PR #29081 — Fix QMoE CPU livelock by eliminating nested intra-op parallelismVerdict: approve, with two design observations and one nit. The diagnosis (nested intra-op parallelism on the shared Eigen pool → livelock) is correct and well-known; the fix is exactly the right shape (single-level parallelism by construction); and the decode-throughput regression that the first round of feedback caught was addressed in commit 20285f3 the way @tianleiwu asked. 86/86 checks green, parity passes, 2400-run stress passes. The patch is also a net -1 LOC, which is a nice side effect. Why the fix is rightThe mechanical claim is: only one tier may use int num_expert_threads = std::max(1, std::min(num_active_experts, max_expert_threads));
concurrency::ThreadPool* inner_tp = (num_expert_threads > 1) ? nullptr : tp;…then mechanically swaps every inner-loop The single-active-expert corner that @tianleiwu raised is now also clean:
The removal of the old tier cap ( Two observations worth tracking (not blockers)1. Highly imbalanced multi-active routing under-uses coresThe fix is binary at the outer level: either the outer loop is multi-threaded (and all inner ops are serial), or it's serial (and inner gets the pool). That's the right invariant for correctness, but it leaves an under-utilization case:
The descending-size bin-packing at I don't think the PR should try to fix this — any "use inner threads if the outer is winding down" heuristic re-opens the door to the original race. But it's worth a follow-up TODO: classify "substantial" experts (e.g. token count above some fraction of the mean) and either cap 2. Per-thread workspace amplification
For gpt_oss_20b sizes (4096 × 11008 fp32) NitThe new comment block at Things that look right
Bottom lineApprove. The fix kills a long-standing intermittent hang in a way that is provably single-level by construction, while also delivering the decode throughput numbers shown in the benchmark table. The two observations above are follow-up perf nits, not regressions on the workloads this PR targets, and the nit is a comment-style preference. Land it. |
|
Findings (ordered by severity)
|
This cherry-picks the following commits for the release: | Commit ID | PR Number | Commit Title | |-----------|-----------|-------------| | 56f6fee | #29038 | [CUDA] QMoE GEMV fast path for batch-1 decode | | a2c7c3b | #29081 | Fix QMoE CPU livelock by eliminating nested intra-op parallelism | | bd0cb9a | #28571 | [MLAS] KleidiAI fix igemm regression | | 5eb4aee | #29574 | Fix CustomOp forward compatibility: cap version instead of rejecting | | 5f49a37 | #29274 | fix(ci): incorrect identity for azcopy | | bb9ba7e | #29468 | Upgrade to Xcode 26 | | 36c6b7e | #29450 | Fix brew install applesimutils failure by trusting wix/brew tap | | a491809 | #29575 | Don't echo command when setting VSO variable in mac-cpu-packing-jobs.yml. | | a06675e | #29609 | Fix web e2e (npm/vite) and Python DML CI pipelines | Also fixed version missed by version update script. --------- Signed-off-by: Qxiang Xu <Qixiang.Xu@arm.com> Signed-off-by: Jonathan Clohessy <Jonathan.Clohessy@arm.com> Signed-off-by: Martin Klacer <martin.klacer@arm.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: tlwu <tlwu@example.com> Co-authored-by: Martin Klacer <martin.klacer@arm.com> Co-authored-by: Jonathan Clohessy <Jonathan.Clohessy@arm.com> Co-authored-by: Damien Dooley <damien.dooley@arm.com> Co-authored-by: Chi Lo <54722500+chilo-ms@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sanaa Hamel <sanaahamel@microsoft.com> Co-authored-by: Edward Chen <18449977+edgchen1@users.noreply.github.com>
Description
QMoECPU::ComputeCommonruns an expert loop over the routed experts. Two issues hurtmulti-threaded execution:
over the shared session pool (
tp), the per-expert body issued further work on thesame pool (token copy, dequant blocks, activation,
MlasGemm/DirectQ4Gemm/TryRunLutGemm/DequantizeBlock). This nested parallelism on ORT's Eigen poolintermittently livelocked (workers spinning at 100% CPU, never completing).
num_experts(not active experts), and an unconditional work tier cap serialized theactive experts. For decode each per-expert GEMM is effectively a GEMV (
M == 1) thatMLAS does not thread internally, so the cores were left idle.
This change makes the expert loop parallelize across the active experts and keeps
parallelism single-level so the livelock fix is preserved:
Changes:
num_expert_threadsbynum_active_experts.inner_tpfrom the active fan-out, so a single-active-expert batch keeps fullinner MLAS/GEMM parallelism (addresses review feedback).
This keeps parallelism single-level (no nested pool use), so the original livelock fix is
intact while decode and imbalanced routing run on all available cores.
Motivation and Context
A random
Run()on a quantized MoE model (com.microsoft.QMoE) intermittently pinned onecore at 100% inside
QMoECPU::Computeand never returned under multi-threaded intra-opexecution;
intra_op_num_threads = 1avoided it at ~3× latency. The signature —non-deterministic, multi-threaded-only, identical stacks (main thread in the QMoE expert
lambda, pool threads in
WorkerLoop) — points to nested parallelism on the shared threadpool. Restricting QMoE to single-level parallelism removes the hang. Capping by active
experts (rather than
num_experts) and dropping the tier cap then restores — andsubstantially improves — throughput for decode and imbalanced routing.
Experiment Results
Latency in ms/inference (fp32, 4-bit, block size 32, default intra-op threads, AMD EPYC
7763 32 logical / 16 physical cores).
Decode (batch = seq = 1)
Mid-range sequence lengths (gpt_oss)
Prefill (neutral)
Validation
test_qmoe_cpu.py— 80 passed, 1 skipped.lintrunner).no errors.