Skip to content

fix(cute_dsl/moe): unbias autotuner profiling for tile_size enumeration - #3252

Merged
nv-yunzheq merged 6 commits into
flashinfer-ai:mainfrom
leejnau:cute-dsl-moe-wrapper-prealloc-bias-fix
May 11, 2026
Merged

nv-yunzheq merged 6 commits into
flashinfer-ai:mainfrom
leejnau:cute-dsl-moe-wrapper-prealloc-bias-fix

Conversation

@leejnau

@leejnau leejnau commented May 6, 2026

Copy link
Copy Markdown
Contributor

📌 Description

CuteDslMoEWrapper.__init__ pre-allocates _gemm1_output, _gemm1_output_scale, and _moe_sort_buffers sized for self.tile_size only. The use_prealloc gate in _forward_with_tactic (fused_moe.py) is tile_size == self.tile_size and self.use_cuda_graph and num_tokens <= self.max_num_tokens, so during autotune profiling the mismatched-tile_size tactics fall through to dynamic torch.empty() allocation while matching ones run on the prealloc. The autotuner sees asymmetric allocation overhead between tactic groups and consistently picks the matching tile_size even when intrinsic kernel performance favors the other — at EP=8/16 N=16384, fi locks to tile_size=128 in 14/14 cache entries while TRT-LLM picks tile_size=256 more often.

The fix includes three coordinated changes: (1) tuner.py lifts the hardcoded [128, 256] to a module-level VALID_TILE_SIZES tuple — single source of truth for tactic enumeration AND prealloc sizing; (2) fused_moe.py:_allocate_buffers sizes buffers to fit any tile_size in VALID_TILE_SIZES (max_num_permuted_tokens increases with tile_size → use max(VALID_TILE_SIZES); max_num_tiles decreases → use min(VALID_TILE_SIZES)); (3) the prealloc gate becomes tile_size in VALID_TILE_SIZES. Both tactic groups now reuse the prealloc; profiling is unbiased.

🔍 Related Issues

#3216
#3171

🚀 Pull Request Checklist

Thank you for contributing to FlashInfer! Before we review your pull request, please make sure the following items are complete.

✅ Pre-commit Checks

  • I have installed pre-commit by running pip install pre-commit (or used your preferred method).
  • I have installed the hooks with pre-commit install.
  • I have run the hooks manually with pre-commit run --all-files and fixed any reported issues.

If you are unsure about how to set up pre-commit, see the pre-commit documentation.

🧪 Tests

  • Tests have been added or updated as needed.
  • All tests are passing (unittest, etc.).

Reviewer Notes

Summary by CodeRabbit

  • Bug Fixes

    • Improved CUDA preallocation gating so preallocated buffers are reused safely when CUDA graphs are enabled, avoiding allocation mismatches across supported tile sizes and improving memory efficiency.
  • New Features

    • Autotuner now scopes per-tactic timing so preallocation is skipped during measurement windows but used outside them; buffer sizing now supports all valid tile sizes.
  • Tests

    • Added CPU/GPU tests validating preallocation capacity and correct gating behavior during tuning and inference.

`CuteDslMoEWrapper.__init__` pre-allocates `_gemm1_output`,
`_gemm1_output_scale`, and `_moe_sort_buffers` sized for
`self.tile_size` only. The `use_prealloc` gate in
`_forward_with_tactic` honors prealloc only when the probed tactic's
`tile_size` matches `self.tile_size`:

    use_prealloc = (
        self.use_cuda_graph
        and tile_size == self.tile_size
        and num_tokens <= self.max_num_tokens
    )

During autotune profiling, mismatched tactics fall through to dynamic
`torch.empty()` per-call allocation. The autotuner is then comparing
tactic latencies that include asymmetric allocation overhead — tactics
matching `self.tile_size` run on the prealloc, others pay the alloc
cost — so it consistently picks the matching `tile_size` even when
intrinsic kernel performance favors the other.

Empirical signature pre-fix at EP=8/16, N=16384: fi locks to
`tile_size=128` in 14 of 14 autotune cache entries. TRT-LLM at the
same shapes picks `tile_size=256` more often, producing a +5-9%
headline gap from the tactic mismatch.

Fix — three coordinated changes:

1. `tuner.py`: lift the hardcoded `[128, 256]` tile_size list to a
   module-level `VALID_TILE_SIZES` tuple. Single source of truth for
   tactic enumeration AND prealloc sizing. Adding a new tile_size
   here automatically widens the prealloc.

2. `fused_moe.py:_allocate_buffers`: size buffers to fit any
   `tile_size in VALID_TILE_SIZES`. `max_num_permuted_tokens` is
   monotonically increasing in `tile_size` (use
   `max(VALID_TILE_SIZES)`); `max_num_tiles` is monotonically
   decreasing (use `min(VALID_TILE_SIZES)`). Override
   `out_permuted_idx_to_expanded_idx` independently to fit the
   largest tile's `max_num_permuted_tokens`.

3. `fused_moe.py:_forward_with_tactic`: change the prealloc gate from
   `tile_size == self.tile_size` to `tile_size in VALID_TILE_SIZES`.
   Both tactic groups now reuse the prealloc; profiling is unbiased.

Net: post-fix, the autotuner picks the higher-throughput tactic at
each shape on its merits, matching TRT-LLM's choice at large N.

## Tests

Adds two test classes in `tests/moe/test_cute_dsl_fused_moe.py`:

- `TestPreallocStaticInvariants` (1 test, no-GPU): pins
  `VALID_TILE_SIZES` to enumerate more than one tile_size. Catches
  the orthogonal failure mode where a future refactor reduces the
  constant to a single entry — in that case the GPU integration
  tests below would pass trivially (no max/min divergence, only one
  tile_size to gate-check) and the bias-prevention silently
  disappears.

- `TestPreallocBuffersIntegration` (2 tests, GPU/SM100 required):
  constructs a real `CuteDslMoEWrapper(use_cuda_graph=True)`. The
  first test verifies the prealloc'd buffer shapes fit the workload
  at every `tile_size in VALID_TILE_SIZES` — directly empirically
  pinning the buffer-sizing contract. The second test
  monkey-patches the module-level `_moe_core_impl` to capture the
  buffer-passing decision and verifies the `use_prealloc` gate
  honors every `tile_size in VALID_TILE_SIZES`, not just
  `self.tile_size` — directly pinning the load-bearing property of
  the fix.

## Pairs with PR flashinfer-ai#3216

Pairs with PR flashinfer-ai#3216 (autotuner bucket-cap fix, merged 2026-05-06).
Both required to fully close the EP>1 perf gap empirically;
validated at `--num-iters 100` on B200 across EP=8/16, N=16384.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a per-thread per-tactic profiling scope (is_in_profile_measurement), makes VALID_TILE_SIZES canonical, sizes CuteDslMoEWrapper preallocs to cover all valid tile sizes, gates CUDA-graph preallocation on the profiling flag and use_cuda_graph, and adds tests validating sizing and gating.

Changes

Preallocation Gating and Sizing

Layer / File(s) Summary
Autotuner scope & helper
flashinfer/autotuner.py
Add thread-local _profile_measurement_thread_local, _profile_measurement_scope() context manager, and public is_in_profile_measurement(); run warmup+measurement inside the scope.
Tuner: VALID_TILE_SIZES
flashinfer/fused_moe/cute_dsl/tuner.py
Add VALID_TILE_SIZES constant and use it to enumerate MoE tactics in get_moe_valid_tactics.
Allocate buffers for all tile sizes
flashinfer/fused_moe/cute_dsl/fused_moe.py
CuteDslMoEWrapper._allocate_buffers() sizes CUDA-graph preallocs to be valid for any tile_size in VALID_TILE_SIZES (min/max used depending on buffer indexing); docstring updated.
Core Behavior / Gate
flashinfer/fused_moe/cute_dsl/fused_moe.py
_forward_with_tactic() sets use_prealloc when self.use_cuda_graph, not is_in_profile_measurement(), and tile_size in VALID_TILE_SIZES; imports and comment updated.
Tests / Validation
tests/moe/test_cute_dsl_fused_moe.py
Add structural test ensuring VALID_TILE_SIZES has multiple entries, GPU integration test that verifies preallocated buffer capacities for every valid tile size, and TestPreallocGateUnderTuning asserting prealloc is skipped during per-tactic measurement and used otherwise.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

cute-dsl

Suggested reviewers

  • sricketts
  • aleozlx
  • yzh119
  • samuellees
  • jiahanc

Poem

🐰 I hopped through threads and scopes so neat,
A tiny flag to make timing discreet.
During profiling I skip the prealloc song,
Outside the tune, buffers dance along.
Reuse returns when the measurement's gone.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(cute_dsl/moe): unbias autotuner profiling for tile_size enumeration' accurately and concisely describes the main change: removing allocation bias in autotuner profiling by enabling tile_size-agnostic prealloc reuse.
Description check ✅ Passed The description comprehensively covers the problem (asymmetric allocation overhead during profiling), the three coordinated fixes (VALID_TILE_SIZES constant, expanded buffer sizing, updated prealloc gate), expected outcome (unbiased profiling), and test completion with related PR links.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request modifies the CuteDslMoEWrapper to size pre-allocated buffers for all valid tile sizes, preventing profiling bias in the autotuner by ensuring all tactics can reuse pre-allocated memory. The implementation updates buffer allocation logic to account for the range of VALID_TILE_SIZES and adjusts the use_prealloc gate in the forward pass. Comprehensive tests were added to validate buffer sizing and gating behavior. I have no feedback to provide.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
flashinfer/fused_moe/cute_dsl/tuner.py (1)

155-161: ⚡ Quick win

Derive DEFAULT_MOE_TACTIC from VALID_TILE_SIZES to avoid config drift.

Now that VALID_TILE_SIZES is the canonical source, keeping DEFAULT_MOE_TACTIC hardcoded to 128 creates a second policy source. If VALID_TILE_SIZES changes later, fallback can silently become inconsistent.

♻️ Suggested change
+DEFAULT_TILE_SIZE = VALID_TILE_SIZES[0]
+
 DEFAULT_MOE_TACTIC = (
-    128,  # tile_size
-    ((128, 128), (1, 1), False),  # gemm1_tactic
-    ((128, 128), (1, 1), False),  # gemm2_tactic
+    DEFAULT_TILE_SIZE,
+    ((DEFAULT_TILE_SIZE, 128), (DEFAULT_TILE_SIZE // 128, 1), False),
+    ((DEFAULT_TILE_SIZE, 128), (DEFAULT_TILE_SIZE // 128, 1), False),
 )

Also applies to: 200-205

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@flashinfer/fused_moe/cute_dsl/tuner.py` around lines 155 - 161,
DEFAULT_MOE_TACTIC is hardcoded to 128 while VALID_TILE_SIZES is the canonical
source; change DEFAULT_MOE_TACTIC (and any other hardcoded defaults in the same
file, e.g., the ones around the 200-205 block) to be derived from
VALID_TILE_SIZES (for example DEFAULT_MOE_TACTIC = VALID_TILE_SIZES[0] or
another clearly chosen element of VALID_TILE_SIZES) so the default always
reflects the canonical list and avoids config drift; update references to
DEFAULT_MOE_TACTIC accordingly.
tests/moe/test_cute_dsl_fused_moe.py (1)

1833-1835: ⚡ Quick win

Use flashinfer.utils architecture checks for new GPU-gated tests.

These new tests are added under @sm100_required, but repository guidance asks test gating via flashinfer.utils check helpers (is_sm100a_supported() / get_compute_capability() style) instead of custom CUDA-property predicates.

As per coding guidelines: “tests/**/*.py: Skip tests on unsupported GPU architectures using flashinfer.utils check functions like is_sm90a_supported(), is_sm100a_supported(), and get_compute_capability()”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/moe/test_cute_dsl_fused_moe.py` around lines 1833 - 1835, The test
class TestPreallocBuffersIntegration is gated with a custom `@sm100_required`
decorator; replace that with the repository-standard GPU check by importing
flashinfer.utils.is_sm100a_supported and applying pytest.mark.skipif(not
is_sm100a_supported(), reason="SM100a not supported") (keeping the existing
`@cute_dsl_available` decorator). Update the decorators on the class to use
pytest.mark.skipif(...) referencing is_sm100a_supported() so the test is skipped
on unsupported GPUs per the project guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/moe/test_cute_dsl_fused_moe.py`:
- Around line 1890-1933: The test checks capacities for _gemm1_output and
several moe sort buffers but omits asserting the capacity of
wrapper._gemm1_output_scale; add an assertion inside the for loop (where
VALID_TILE_SIZES is iterated and required_permuted is computed via
get_max_num_permuted_tokens) that wrapper._gemm1_output_scale.shape[0] >=
required_permuted, and raise a clear failure message referencing
_gemm1_output_scale, its capacity and required_permuted (matching the style of
the existing assertions).

---

Nitpick comments:
In `@flashinfer/fused_moe/cute_dsl/tuner.py`:
- Around line 155-161: DEFAULT_MOE_TACTIC is hardcoded to 128 while
VALID_TILE_SIZES is the canonical source; change DEFAULT_MOE_TACTIC (and any
other hardcoded defaults in the same file, e.g., the ones around the 200-205
block) to be derived from VALID_TILE_SIZES (for example DEFAULT_MOE_TACTIC =
VALID_TILE_SIZES[0] or another clearly chosen element of VALID_TILE_SIZES) so
the default always reflects the canonical list and avoids config drift; update
references to DEFAULT_MOE_TACTIC accordingly.

In `@tests/moe/test_cute_dsl_fused_moe.py`:
- Around line 1833-1835: The test class TestPreallocBuffersIntegration is gated
with a custom `@sm100_required` decorator; replace that with the
repository-standard GPU check by importing flashinfer.utils.is_sm100a_supported
and applying pytest.mark.skipif(not is_sm100a_supported(), reason="SM100a not
supported") (keeping the existing `@cute_dsl_available` decorator). Update the
decorators on the class to use pytest.mark.skipif(...) referencing
is_sm100a_supported() so the test is skipped on unsupported GPUs per the project
guidelines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43381aba-3f1a-4096-94e2-d5d04899b11d

📥 Commits

Reviewing files that changed from the base of the PR and between e6ac7cc and c9e4d2d.

📒 Files selected for processing (3)
  • flashinfer/fused_moe/cute_dsl/fused_moe.py
  • flashinfer/fused_moe/cute_dsl/tuner.py
  • tests/moe/test_cute_dsl_fused_moe.py

Comment thread tests/moe/test_cute_dsl_fused_moe.py
Address CodeRabbit nit on PR flashinfer-ai#3252: `test_prealloc_buffers_fit_all_valid_tile_sizes`
asserted shapes for `_gemm1_output` and the moe_sort buffers but
omitted `_gemm1_output_scale`. Adds the missing assertion so a
future regression in scale-buffer sizing is caught alongside the
existing buffer-shape contract.

The scale buffer is sized in scale-factor elements (one per
(permuted_token, scale_vec_group) pair), not in permuted tokens
directly. Required capacity is therefore
`max_num_permuted_tokens(..., tile_size) * (intermediate_size //
sf_vec_size)`. Updated the test docstring to match this distinction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nv-yunzheq

Copy link
Copy Markdown
Collaborator

/bot run

@flashinfer-bot

Copy link
Copy Markdown
Collaborator

GitLab MR !642 has been created, and the CI pipeline #50488690 is currently running. I'll report back once the pipeline job completes.

Extend the `use_prealloc` gate in `_forward_with_tactic` with `not
AutoTuner.get().is_tuning_mode` so the wrapper bypasses its
preallocated buffers during autotune profiling. All tactics then
see the same per-call `torch.empty()` allocation overhead and the
autotuner's tactic comparison is unbiased; outside the
`autotune(True)` context the gate behaves as before — prealloc
when `tile_size == self.tile_size`, fall through otherwise.

This replaces an earlier approach in this branch that widened the
preallocated buffers to fit every valid tile_size; the new
approach decouples `self.tile_size` from autotune-time allocation
without expanding the prealloc layout, so `tuner.py` is left
untouched and `_allocate_buffers` reverts to its pre-PR shape.

Pairs with PR flashinfer-ai#3216 (autotuner bucket-cap fix). Both required to
fully close the EP>1 perf gap empirically; validated at
`--num-iters 100` on B200 across EP=8/16, N=16384.

## Tests

Replaces the prior buffer-shape and structural-invariant tests
with a single GPU/SM100 test (`TestPreallocGateUnderTuning`) that
constructs a real `CuteDslMoEWrapper(use_cuda_graph=True)`,
monkey-patches the module-level `_moe_core_impl` to capture the
`moe_sort_buffers` argument across {inside `autotune(True)`,
outside} × {`tile_size == self.tile_size`, mismatch}, and asserts:

- Inside `autotune(True)`: gate skips prealloc for every tactic —
  pinning the unbias property.
- Outside `autotune(True)`: gate uses prealloc when the picked
  tactic's `tile_size` matches `self.tile_size`; skips it
  otherwise (the prealloc layout would be wrong for the other
  tile_size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nv-yunzheq

Copy link
Copy Markdown
Collaborator

/bot stop

@nv-yunzheq

Copy link
Copy Markdown
Collaborator

/bot run

@flashinfer-bot

Copy link
Copy Markdown
Collaborator

The GitLab CI pipeline #50488690 has been cancelled.

@flashinfer-bot

Copy link
Copy Markdown
Collaborator

GitLab MR !642 has been updated with latest changes, and the CI pipeline #50492584 is currently running. I'll report back once the pipeline job completes.

@nv-yunzheq
nv-yunzheq enabled auto-merge (squash) May 7, 2026 16:59
@nv-yunzheq
nv-yunzheq disabled auto-merge May 7, 2026 17:12
num_tokens = x.shape[0]
use_prealloc = (
self.use_cuda_graph
and not AutoTuner.get().is_tuning_mode

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two qq:
AutoTuner.get() is called on every forward invocation in the inference path. should double checked locking singleton so the overhead is negligible, one is None check + attribute return, but worth confirming this meets latency requirements?

another is is_tuning_mode is read without holding _lock. Under CPython this is fine,d boolean reads are atomic due to the GIL, but it's worth noting as a potential concern if the runtime ever changes?

Just reference. could you consider and answer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in f460328

Replaces the wrapper's ``not AutoTuner.get().is_tuning_mode`` gate
clause with ``not is_in_profile_measurement()``.  The new signal is
strictly narrower than ``is_tuning_mode``: it is True only on the
calling thread, and only inside the autotuner's per-tactic measurement
window (warmup + timed run inside ``_profile_single_kernel``).  It is
False during cache lookups, ``do_preparation`` calls, the runner
invocation immediately after ``choose_one`` returns, and other
threads' inference -- all of which the broader ``is_tuning_mode`` flag
swept up incorrectly.

## Why narrower

``AutoTuner.is_tuning_mode`` is True for the whole ``autotune(True)``
context, regardless of whether the autotuner is actively timing a
specific tactic.  Reading it from the wrapper meant that:

1. Cache hits for ops already tuned (where no measurement happens)
   bypassed prealloc anyway.
2. The runner invocation that uses the chosen tactic immediately after
   ``choose_one`` returns -- still inside the ``with autotune(True):``
   block -- bypassed prealloc.
3. Concurrent threads doing inference while another thread held the
   tuning context bypassed prealloc.
4. CUDA-graph capture happening inside an ``autotune(True)`` block
   would record per-call ``torch.empty()`` calls instead of preallocs.

None of these are situations where unbiased measurement matters; they
all benefit from prealloc.  ``is_in_profile_measurement()`` excludes
them while still serving the original intent: during the actual
measurement window, every tactic sees the same per-call allocation
overhead and the autotuner's tactic comparison is unbiased.

## What changes in autotuner.py

- New module-level ``_profile_measurement_thread_local`` (a
  ``threading.local``).
- New ``_profile_measurement_scope`` context manager (private; sets
  the thread-local on entry, restores prior value on exit, supports
  nesting).
- New ``is_in_profile_measurement()`` accessor (public; reads the
  thread-local; returns False on threads that never entered the scope).
- ``AutoTuner._profile_single_kernel`` wraps its warmup + timed run
  with ``_profile_measurement_scope()`` so every runner invocation
  inside the measurement function sees the flag True; runner
  invocations elsewhere in ``choose_one`` (cache search,
  ``do_preparation``, the post-loop ``search_cache`` call) see it
  False.

The change is purely additive: the new helpers don't alter
``AutoTuner``'s class state, no other autotune callers are affected.

## Tests

Replaces the prior ``TestPreallocGateUnderTuning`` test with a
broader contract check that exercises three contexts:

1. Inside ``autotune(True)`` AND inside ``_profile_measurement_scope()``
   (simulating a tactic measurement) -- gate must skip prealloc for
   every tactic, regardless of tile_size match.
2. Inside ``autotune(True)`` but OUTSIDE the measurement scope
   (simulating a cache hit, the do_preparation call, or the
   post-``choose_one`` runner invocation) -- gate must use prealloc
   when ``tile_size == self.tile_size``, skip otherwise.  This is the
   property that distinguishes the narrow signal from the broad one.
3. Outside any tuning context (plain inference) -- same as case 2.

Pairs with PR flashinfer-ai#3216 (autotuner bucket-cap fix).  Both required to
fully close the EP>1 perf gap empirically; validated at
``--num-iters 100`` on B200 across EP=8/16, N=16384.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the narrow ``is_in_profile_measurement()`` gate from the
prior commit by also expanding ``CuteDslMoEWrapper._allocate_buffers``
to size kernel-output buffers for *any* ``tile_size in
VALID_TILE_SIZES``, not just the constructor-time ``self.tile_size``.

## Why

Now that the autotuner profiles tactics unbiasedly (the prior commit's
narrower gate), it can fairly pick a tactic with ``tile_size != self.tile_size``
when that's the higher-throughput choice — at large N this is the
common case (``tile_size=256`` typically wins).  But with the prior
commit alone, the wrapper's prealloc was still sized for
``self.tile_size`` and the gate fell through to per-call
``torch.empty()`` whenever the picked tactic mismatched.  Two real
problems:

1. **CUDA-graph contract**: the wrapper's ``run()`` is documented as
   graph-safe with ``use_cuda_graph=True``, but per-call ``torch.empty``
   means captured graphs record the alloc inside the graph instead of
   binding to the prealloc.  PyTorch's graph private memory pool
   accommodates this since 1.10, but it's not what the contract
   promises.
2. **Buffer-overflow correctness**: ``max_num_permuted_tokens`` is
   monotonically increasing in ``tile_size``, so a tile_size=128-sized
   buffer is too small for a tile_size=256 tactic.  The fall-through to
   per-call alloc isn't a perf-hygiene choice — it's required for
   correctness given the smaller sizing.

## Fix — three coordinated changes

1. ``tuner.py``: lift the hardcoded ``[128, 256]`` tile_size list to
   a module-level ``VALID_TILE_SIZES`` tuple.  Single source of truth
   for tactic enumeration AND prealloc sizing.

2. ``fused_moe.py:_allocate_buffers``: size buffers to fit any
   ``tile_size in VALID_TILE_SIZES``.  ``max_num_permuted_tokens`` is
   monotonically increasing in ``tile_size`` (use
   ``max(VALID_TILE_SIZES)``); ``max_num_tiles`` is monotonically
   decreasing (use ``min(VALID_TILE_SIZES)``).  Override
   ``out_permuted_idx_to_expanded_idx`` independently to fit the
   largest tile's ``max_num_permuted_tokens``.

3. ``fused_moe.py:_forward_with_tactic``: drop the
   ``tile_size == self.tile_size`` check from the gate.  Replace with
   ``tile_size in VALID_TILE_SIZES`` (defensive; should never fail for
   tactics drawn from ``ALL_MOE_TACTICS``).  The narrow
   ``is_in_profile_measurement()`` check from the prior commit is
   retained.

## Resulting gate semantics

``use_prealloc = use_cuda_graph
                 AND not is_in_profile_measurement()
                 AND tile_size in VALID_TILE_SIZES
                 AND num_tokens <= max_num_tokens``

- During autotune profiling: ``is_in_profile_measurement`` is True →
  prealloc bypassed for every tactic → unbiased measurement.
- During cache-lookup / do-preparation / post-``choose_one`` /
  concurrent-thread inference: prealloc used regardless of tile_size,
  preserving the wrapper's CUDA-graph contract.
- During plain inference: same — prealloc used for whichever tactic
  the autotuner picked, including ``tile_size != self.tile_size``.

## Tests

Three test classes in ``tests/moe/test_cute_dsl_fused_moe.py``:

- ``TestPreallocStaticInvariants`` (1 no-GPU test): pin
  ``VALID_TILE_SIZES`` non-trivial.  Catches accidental reduction to
  a single entry which would defeat the whole purpose.

- ``TestPreallocBuffersIntegration`` (1 GPU/SM100 test): construct a
  real ``CuteDslMoEWrapper(use_cuda_graph=True)``, verify
  ``_gemm1_output``, ``_gemm1_output_scale``, and the moe_sort buffers
  fit the workload at every ``tile_size in VALID_TILE_SIZES``.  Pins
  the buffer-sizing contract empirically.

- ``TestPreallocGateUnderTuning`` (1 GPU/SM100 test, updated):
  monkey-patches ``_moe_core_impl`` and exercises three contexts × two
  tile_sizes:
    - measurement scope (inside ``_profile_measurement_scope``):
      gate must skip prealloc for every tactic.
    - inside ``autotune(True)`` but outside the measurement scope
      (cached call, post-choose_one): gate must use prealloc for
      every valid tile_size.
    - outside any tuning context (plain inference): gate must use
      prealloc for every valid tile_size.

  The latter two assertions are what's strengthened in this commit:
  the gate no longer requires ``tile_size == self.tile_size``.

Pairs with PR flashinfer-ai#3216 (autotuner bucket-cap fix).  Both required to
fully close the EP>1 perf gap empirically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/moe/test_cute_dsl_fused_moe.py (1)

2045-2047: ⚡ Quick win

Avoid hardcoding the “other” tile size in gate tests.

This reintroduces a second source of truth; deriving other from VALID_TILE_SIZES will keep the test valid if tile-size enumeration changes.

♻️ Suggested update
-        matching = wrapper.tile_size  # the tile_size the prealloc was sized for
-        other = 256 if matching == 128 else 128
+        from flashinfer.fused_moe.cute_dsl.tuner import VALID_TILE_SIZES
+        matching = wrapper.tile_size  # the tile_size the prealloc was sized for
+        others = [t for t in VALID_TILE_SIZES if t != matching]
+        assert others, "Test requires at least two distinct VALID_TILE_SIZES entries"
+        other = others[0]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/moe/test_cute_dsl_fused_moe.py` around lines 2045 - 2047, The test
hardcodes the alternate tile size using a conditional on wrapper.tile_size;
instead derive `other` from the `VALID_TILE_SIZES` enum to avoid a second source
of truth. Replace the hardcoded logic that sets `other = 256 if matching == 128
else 128` by selecting any tile size from `VALID_TILE_SIZES` that is not equal
to `matching` (e.g., iterate or filter `VALID_TILE_SIZES` and pick the first
non-matching value) so `matching`, `other`, and `wrapper.tile_size` remain
consistent if the enum changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/moe/test_cute_dsl_fused_moe.py`:
- Around line 1834-1836: Replace the local GPU gate decorators (e.g., the
sm100_required marker applied to TestPreallocBuffersIntegration and the similar
marker at the other location) with repository-standard flashinfer.utils
capability checks: import the appropriate functions from flashinfer.utils (such
as is_sm100a_supported() or get_compute_capability()) and use pytest.skip or
pytest.mark.skipif to skip when the target SM is unsupported (e.g., skip if not
is_sm100a_supported()). Keep the existing cute_dsl_available decorator
unchanged; update both TestPreallocBuffersIntegration and the other GPU-gated
test class at the referenced location to use the flashinfer.utils checks for
consistency.

---

Nitpick comments:
In `@tests/moe/test_cute_dsl_fused_moe.py`:
- Around line 2045-2047: The test hardcodes the alternate tile size using a
conditional on wrapper.tile_size; instead derive `other` from the
`VALID_TILE_SIZES` enum to avoid a second source of truth. Replace the hardcoded
logic that sets `other = 256 if matching == 128 else 128` by selecting any tile
size from `VALID_TILE_SIZES` that is not equal to `matching` (e.g., iterate or
filter `VALID_TILE_SIZES` and pick the first non-matching value) so `matching`,
`other`, and `wrapper.tile_size` remain consistent if the enum changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb5e20af-489c-42f0-aa99-444987ba8e33

📥 Commits

Reviewing files that changed from the base of the PR and between 90d78e8 and f460328.

📒 Files selected for processing (3)
  • flashinfer/fused_moe/cute_dsl/fused_moe.py
  • flashinfer/fused_moe/cute_dsl/tuner.py
  • tests/moe/test_cute_dsl_fused_moe.py

Comment on lines +1834 to +1836
@cute_dsl_available
@sm100_required
class TestPreallocBuffersIntegration:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use flashinfer.utils-based architecture checks for these new GPU tests.

These new classes are GPU-gated through the local sm100_required marker path; please switch these skip conditions to the repository-standard flashinfer.utils capability checks for consistency and future arch handling.

As per coding guidelines, tests/**/*.py: “Skip tests on unsupported GPU architectures using flashinfer.utils check functions like is_sm90a_supported(), is_sm100a_supported(), and get_compute_capability()”.

Also applies to: 1936-1938

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/moe/test_cute_dsl_fused_moe.py` around lines 1834 - 1836, Replace the
local GPU gate decorators (e.g., the sm100_required marker applied to
TestPreallocBuffersIntegration and the similar marker at the other location)
with repository-standard flashinfer.utils capability checks: import the
appropriate functions from flashinfer.utils (such as is_sm100a_supported() or
get_compute_capability()) and use pytest.skip or pytest.mark.skipif to skip when
the target SM is unsupported (e.g., skip if not is_sm100a_supported()). Keep the
existing cute_dsl_available decorator unchanged; update both
TestPreallocBuffersIntegration and the other GPU-gated test class at the
referenced location to use the flashinfer.utils checks for consistency.

@qiching qiching left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using threading.local() is correct!
but i have another concerns that if Autotuner profiling might execute the runner in a subthread. If the runner() call during warmup or just measure is executed in another thread, the subthread will not see active=True, and the gate will fail.

could we consider this make sense?

Comment thread flashinfer/autotuner.py
# threads inclusive); ``is_in_profile_measurement`` is True only on the
# specific thread that is actively timing a tactic, and only during the
# warmup + measurement window inside ``_profile_single_kernel``.
_profile_measurement_thread_local = threading.local()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using threading.local() is correct!
but i have another concerns that if Autotuner profiling might execute the runner in a subthread. If the runner() call during warmup or just measure is executed in another thread, the subthread will not see active=True, and the gate will fail.

could we consider this make sense?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current autotuner implementation, _profile_single_kernel() invokes runner(...) synchronously on the same Python thread for both warmup and timed measurement, so the threading.local() flag is visible to the wrapper when _forward_with_tactic() runs.

The CUDA kernels themselves launch asynchronously, but the Python-side allocation/prealloc gate is evaluated before launch, on that same caller thread. So the thread-local scope covers the decision we need here.

If we later refactor autotuner profiling to dispatch runner(...) from a Python worker thread, then we should either propagate the profiling context into that worker or replace this with an explicit per-call kwarg/context token. For today’s code path, threading.local() is probably the correct narrower signal: it avoids leaking profiling behavior into concurrent inference threads while still covering the actual measured runner calls.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now approach is correct for today's synchronous profiling path. Left a note for future: if _profile_single_kernel ever dispatches runner() calls from a worker thread, the threading.local() flag won't propagate.

Replace the hardcoded "other = 256 if matching == 128 else 128" in
TestPreallocGateUnderTuning with iteration over every tile_size in
VALID_TILE_SIZES, so adding a new entry doesn't silently leave the
gate untested for that tile in any of the three contexts (measurement
window / in-tuning-context-outside-measurement / plain inference).

Addresses CodeRabbit nit on PR flashinfer-ai#3252 plus codex's follow-up that
strengthens "first non-matching tile" to "every non-matching tile".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nv-yunzheq nv-yunzheq added run-ci and removed run-ci labels May 7, 2026
@nv-yunzheq

Copy link
Copy Markdown
Collaborator

/bot run

@flashinfer-bot

Copy link
Copy Markdown
Collaborator

GitLab MR !642 has been updated with latest changes, and the CI pipeline #50608050 is currently running. I'll report back once the pipeline job completes.

@nv-yunzheq
nv-yunzheq merged commit e744910 into flashinfer-ai:main May 11, 2026
46 of 72 checks passed
@leejnau
leejnau deleted the cute-dsl-moe-wrapper-prealloc-bias-fix branch May 11, 2026 16:34
nv-yunzheq added a commit that referenced this pull request May 11, 2026
<!-- .github/pull_request_template.md -->

## 📌 Description

This refreshes `benchmarks/bench_moe_deepseek.py` so it runs cleanly on
current main. It rebases Yunzhe Qiu's bench rewrite (`5677a080` from
#2886, which restructures the bench so autotune runs inside the
`bench_gpu_time` measurement region) onto post-#3252 main, plus a small
follow-up that fixes a stale `RoutingMethodType` import.

Two commits from the original #2886 are intentionally dropped because
their fixes have since landed independently. `c0b80b64`'s `num_tokens <=
max_num_tokens` prealloc guard is now subsumed by #3252 — the
`use_prealloc` predicate in `cute_dsl/fused_moe.py` already includes
that check. And `f3beb602`'s `_force_autotune_off()` bench-side
workaround for CUPTI measurement pollution is no longer needed: #3126
moved the cache lookup ahead of `_prepare_input_tensors` synthesis in
the autotuner's tuning-mode loop, eliminating the pollution at the
source. The only remaining mismatch with current main was
`RoutingMethodType`, which moved from `flashinfer.fused_moe.core` to
`flashinfer.tllm_enums` (and is re-exported via `flashinfer.fused_moe`)
— fixed in the second commit here.

Verified on B200 inside `nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc14`:
DeepSeek-V3 at bs=128 ep=8 measures CuteDSL=0.147 ms / TRTLLM=0.144 ms —
in the clean band that matches prior post-pollution-fix measurements
(~0.157 / ~0.142). An 18-cell matrix (N=1, 8, 128, 512, 2048, 16384 ×
EP=1, 8, 16) and an 8-cell gen-phase decode sweep also ran without
errors. Closes #2886.

## 🔍 Related Issues

#2886
#3126
#3252

## 🚀 Pull Request Checklist

Thank you for contributing to FlashInfer! Before we review your pull
request, please make sure the following items are complete.

### ✅ Pre-commit Checks

- [x] I have installed `pre-commit` by running `pip install pre-commit`
(or used your preferred method).
- [x] I have installed the hooks with `pre-commit install`.
- [x] I have run the hooks manually with `pre-commit run --all-files`
and fixed any reported issues.

> If you are unsure about how to set up `pre-commit`, see [the
pre-commit documentation](https://pre-commit.com/).

## 🧪 Tests

- [x] Tests have been added or updated as needed.
- [x] All tests are passing (`unittest`, etc.).

## Reviewer Notes

<!-- Optional: anything you'd like reviewers to focus on, concerns, etc.
-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Refactor**
* Improved MoE throughput benchmarking methodology with enhanced
pre-warm invocation and synchronization for accurate timing capture
* Refactored autotuning strategy to occur inline during benchmark warmup
phase
  * Reorganized benchmark output display for clearer result presentation

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/flashinfer-ai/flashinfer/pull/3292)

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yunzhe Qiu <yunzheq@nvidia.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
leejnau added a commit to leejnau/flashinfer that referenced this pull request May 12, 2026
Adds a --nvtx-profile-region flag that brackets each backend's
bench_gpu_time measurement with cudaProfilerStart/Stop (in try/finally)
and an outer NVTX range `bench_<backend>_n<N>`.  Pair with
`nsys profile --capture-range=cudaProfilerApi
--capture-range-end=repeat-shutdown:3` to restrict capture to the
measurement iters for all three backends in a single nsys run
(pre-warm and autotune are outside the capture window).

Also emits fine-grained NVTX ranges inside each backend's run()
closure, unconditionally (NVTX push/pop is sub-microsecond):

  iter_<backend>           per bench iteration
    topk_<backend>         the fused_topk_deepseek routing call
                           (CuteDSL and CUTLASS only; TRT-LLM's routing
                           is internal to the kernel)
    moe_<backend>          the MoE call itself

These nest within the outer `bench_<backend>_n<N>` range when the
`--nvtx-profile-region` flag is on, giving the parser a clean
hierarchy for attribution.

After each `--nvtx-profile-region` capture, prints a short
`[nvtx] bench_<backend>_n<N>: captured iters=..., median=... ms` line
so the user can cross-check the captured duration against the printed
bench table.

This is a rebased + cleaned-up version of the earlier nvtx_microbench
work.  The _force_autotune_off() bench-side workaround that the
earlier version relied on is no longer needed: PR flashinfer-ai#3126 moved the
autotuner's cache lookup ahead of profile-input synthesis, so cache
hits in tuning mode no longer launch the synthetic torch.rand /
torch.randint kernels that f3beb602 was working around.  The
prealloc-fix (flashinfer-ai#3252) and bench-tool refresh (flashinfer-ai#3292) are also in
main, so this commit applies cleanly as a single change on top of
the merged bench refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
leejnau added a commit to leejnau/flashinfer that referenced this pull request May 15, 2026
Adds a no-GPU pytest class TestInputsHelperContract that exercises
two cross-file contracts of the new inputs_pre_hook helper:

  1. Layout contract: ``CuteDslMoEInputsHelper.inputs_pre_hook`` must
     replace ``inputs[2]`` (token_selected_experts) and pass through
     every other input unchanged (object identity preserved). Pins the
     contract with ``CuteDslMoEWrapper.run``'s 12-element inputs-list
     layout — if someone reorders that list without updating the
     helper's unpacking pattern, the autotune profile silently
     corrupts a different tensor.

  2. Determinism contract: two helper instances with the same seed
     must produce tensor-equal replacement ``token_selected_experts``
     for the same input. Exercises the seeded
     ``torch.random.fork_rng + manual_seed`` pattern in
     ``generate_token_selected_experts``, which is the load-bearing
     determinism property that eliminates cross-process autotune-pick
     variance.

Pure-Python, runs in <1s on CPU. Mirrors the style of
``TestTacticEnumeration`` (no-GPU contract tests added in PR flashinfer-ai#3252).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nv-yunzheq pushed a commit that referenced this pull request May 21, 2026
<!-- .github/pull_request_template.md -->

## 📌 Description

`CuteDslMoEWrapper` currently passes `self._forward_with_tactic` as a
bound method into `CuteDslFusedMoENvfp4Runner`, creating a strong
reference cycle: `wrapper -> runner -> bound method -> wrapper`. When
the wrapper is used with `use_cuda_graph=True`, this can keep
wrapper-owned CUDA graph resources alive after user code has dropped the
wrapper, until Python cyclic GC eventually runs.

This PR replaces that bound-method callback with a weakref trampoline.
The runner can still call into a live wrapper, but it no longer owns the
wrapper lifetime. This prevents stale wrapper CUDA resources from
surviving across same-process tests or later autotune runs.

## 🔍 Related Issues

#3286
#3301
#3252

## 🚀 Pull Request Checklist

Thank you for contributing to FlashInfer! Before we review your pull
request, please make sure the following items are complete.

### ✅ Pre-commit Checks

- [x] I have installed `pre-commit` by running `pip install pre-commit`
(or used your preferred method).
- [x] I have installed the hooks with `pre-commit install`.
- [x] I have run the hooks manually with `pre-commit run --all-files`
and fixed any reported issues.

> If you are unsure about how to set up `pre-commit`, see [the
pre-commit documentation](https://pre-commit.com/).

## 🧪 Tests

Adds a focused regression test that warms a CUDA-graph wrapper, verifies
it is finalized before cyclic GC, and then runs a subsequent autotuned
wrapper call to ensure the output remains NaN-free.

- [x] Tests have been added or updated as needed.
- [x] All tests are passing (`unittest`, etc.).

## Reviewer Notes

<!-- Optional: anything you'd like reviewers to focus on, concerns, etc.
-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling and cleanup of CUDA-graph wrappers to prevent
resource leaks and provide a clear error when a wrapper is no longer
available.

* **Tests**
* Added lifetime tests covering CUDA-graph wrappers before and after
autotune; verify stable, non-NaN outputs during autotune.

* **Documentation**
* Updated comment about cold-L2 cache behavior and noted follow-up to
re-enable it once a related issue is addressed.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/flashinfer-ai/flashinfer/pull/3340?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants