Repository navigation
[CUDA] Share MoE GEMM tactic profiling across identical-shape layers - #34169
Conversation
Each QMoE/MoE kernel instance owns a MoeGemmProfiler with a private (GemmId, M bucket) cache, so a model whose MoE layers all have the same shape (e.g. the 48 QMoE nodes of Qwen3-30B-A3B) profiles the identical problem once per layer for every new M bucket. Share successful profiling results process-wide, keyed by everything the profiling run reads: the runner type, runner state that changes the candidate tactics (SM80 FP4 family) or the profiled GEMM2 epilogue (deterministic Hopper reduce), the device, every GemmProfilerBackend::init parameter, the GemmId and the M bucket. This is the in-process part of the QMoE cross-node dedup planned in docs/contrib_ops/cuda/gemm_profiler_cache.md and follows the process-wide profile map MatMulNBits shares through GemmPluginProfilerManager, with two differences: - Coordination is per key. A short map lock hands out the key's entry and only that entry's mutex is held while it is profiled, so concurrent first uses of one key profile it once while other problems, M buckets and devices profile in parallel. - Only successful results are shared, as the design doc requires. A run that returns no config (for example after a transient allocation failure) is kept by that profiler instance, as before, and the next profiler retries instead of reusing the failure. ORT_DISABLE_MOE_SHARED_TACTIC_CACHE=1 restores per-instance profiling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The tests reuse process-wide cache entries across repeated runs, causing assertion failures and a 60-second stall, and lack exception-safe worker cleanup, allowing an allocation failure to abort the test process.
2 open issues
What changed in this PR
This PR shares successful CUDA MoE/QMoE GEMM profiling results across identical problems within a process, reducing repeated profiling across model layers. It implements in-process deduplication without adding disk persistence.
Changes:
- Adds a shared tactic cache keyed by runner type and state, device, problem parameters, and M bucket, with per-key locking and retries after failed profiling.
- Adds tests for reuse, runner-state separation, failure retries, and concurrent profiling.
- Documents the environment variable that restores per-instance profiling.
| File | Description |
|---|---|
onnxruntime/test/contrib_ops/cuda_kernels/moe_gemm_profiler_shared_cache_test.cc |
Tests cache sharing, failure retries, and concurrency. |
onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_kernels.h |
Exposes runner state needed to distinguish cache keys. |
onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_profiler.h |
Declares the profiling-run counter used by tests. |
onnxruntime/contrib_ops/cuda/llm/moe_gemm/moe_gemm_profiler.cc |
Implements shared caching, per-key coordination, and profiling counts. |
docs/contrib_ops/cuda/gemm_profiler_cache.md |
Documents in-process sharing and its opt-out setting. |
Review effort: Balanced
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Nice cold-start improvement (~82.5s → ~5.5s at 32K tokens). The two-level locking design (SharedTacticCache::mutex_ for map lookup plus per-entry SharedTacticEntry::mutex while profiling), fast-path bypass via the per-instance config_cache_ on warm runs, and inclusion of tactic/epilogue-relevant runner state (useSm80Fp4(), use_deterministic_hopper_reduce_) are all well reasoned.
+1 to the two existing inline comments on moe_gemm_profiler_shared_cache_test.cc regarding --gtest_repeat cache isolation and exception-safe held.join() in OtherKeysProfileWhileOneIsInFlight. Left three additional suggestions/nitpicks inline on SharedTacticKey representation, built-in CUDAAllocator OOM exception handling, and the env-var reference table in gemm_profiler_cache.md.
Key the shared cache with a named SharedTacticKey struct ordered through std::tie instead of a 25-element positional tuple, and identify the runner by std::type_index instead of the type_info name string. A transposed field can no longer compile silently, and lookups no longer copy the mangled name. Catch exceptions from the profiling workspace Alloc and return no config. On the built-in CUDA EP an out-of-memory Alloc throws instead of returning nullptr, so it now takes the same tactics[0] fallback and later retry as a nullptr result instead of failing MoE and QMoE Compute. Tests: give every test invocation a fresh expert count so the process-wide cache cannot carry results across --gtest_repeat iterations, join the held worker before rethrowing in OtherKeysProfileWhileOneIsInFlight, and cover a throwing allocator in FailedProfilingIsNotShared. Docs: list ORT_DISABLE_MOE_SHARED_TACTIC_CACHE in the supported environment variables table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Process-wide CUDA tactic reuse changes cross-thread synchronization, but runtime validation was unavailable and GEMM1-only tests leave SM90 GEMM2 epilogues and multi-device cache isolation awaiting GPU validation.
No open issues 🎉
2 resolved since last review
Review effort: Balanced
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Reviewed a5b018a, including the profiler/backend inputs, runner state, MoE/QMoE callers, per-key synchronization, failure retries, and the updated tests. The earlier review feedback is addressed, and I found no new production correctness issue. One test-registration gap is noted inline.
Validation: source review and current-head CI inspection. Linux CUDA CI passed, and all five new tests passed twice in Windows CUDA CI (the dedicated internal-test step and the subsequent provider-test run). I did not run the tests locally because the available build artifacts predate this change.

Description
Each QMoE/MoE kernel instance owns a MoeGemmProfiler with a private (GemmId, M bucket) cache. A model whose MoE layers all have the same shape therefore profiles the identical problem once per layer for every new M bucket. Qwen3-30B-A3B has 48 such QMoE nodes.
This change shares successful profiling results process-wide. The key holds everything a profiling run reads:
This is the in-process part of the QMoE cross-node dedup planned in docs/contrib_ops/cuda/gemm_profiler_cache.md (section 8, phase 2). It follows the process-wide profile map that MatMulNBits shares through GemmPluginProfilerManager (matmul_nbits.cc), with two differences:
The cache is intentionally leaked, like the MatMulNBits tactic cache registry, because a CUDA EP owned by another static can be destroyed after this library's statics. ORT_DISABLE_MOE_SHARED_TACTIC_CACHE=1 restores per-instance profiling and is documented in gemm_profiler_cache.md. MoeGemmProfiler::ProfilingRunCountForTesting() is a read-only counter that only the tests use.
Tests (moe_gemm_profiler_shared_cache_test.cc):
FailedProfilingIsNotShared and OtherKeysProfileWhileOneIsInFlight fail against an earlier, unpublished draft of this change that held one global lock while profiling and cached failed results. With sharing forced off, the four sharing tests fail.
Measured on an A100 80GB, main (6596e0c) vs this change:
Motivation and Context
On Qwen3-30B-A3B-Instruct-2507 at a 32,640-token prompt on an A100, profiling dominated the first request: 82.5 s cold time to first token versus about 3.4 s warm. Most of that was the same two GEMMs being profiled 48 times.
Limits: