Skip to content

[https://nvbugs/6550749][fix] Size the DeepGEMM warmup bucket stride to the 16-token config quantum - #17242

Closed
chenfeiz0326 wants to merge 4 commits into
NVIDIA:mainfrom
chenfeiz0326:fix/deep-gemm-warmup-bucket-stride-6550749
Closed

[https://nvbugs/6550749][fix] Size the DeepGEMM warmup bucket stride to the 16-token config quantum#17242
chenfeiz0326 wants to merge 4 commits into
NVIDIA:mainfrom
chenfeiz0326:fix/deep-gemm-warmup-bucket-stride-6550749

Conversation

@chenfeiz0326

@chenfeiz0326 chenfeiz0326 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

[https://nvbugs/6550749][fix] Size the DeepGEMM warmup bucket stride to the 16-token config quantum

Summary

deep_gemm_gen_tuning_buckets exists to make DeepGEMM JIT-compile every kernel
config a workload will need during autotuning at startup, so no live
iteration pays an nvcc stall. It walked M with range(128, x, 128).

DeepGEMM's SM100 config selection changes every 16 tokens, so a stride-128
grid sampled 1 band in 8 and left the other 7 unwarmed. Whichever live iteration
first landed in a cold band paid ~2.2 s of nvcc inside the measured window.

On minimax_m2.5_fp8-bench-pytorch-float8-maxbs:512-maxnt:2048-input_output_len:128,128-gpus:4
(GB300) that was +55.9 % end-to-end, and it was filed as a perf instability
bug because whether a run pays it depends on request packing: iterations 132–139
pack the full 2048-token cap, iteration 140 gets the ragged 540-token leftover
(ctx 416 + gen 124), whose config was never warmed.

Two changes, both to the same one-line expression:

  1. Stride 128 → 16, matching the config quantum.
  2. Include the top of the range, rounded up to a whole quantum. The
    half-open range(128, x, 16) never warmed the band containing
    max_num_tokens itself — the M every full batch runs at. This is a
    second, pre-existing hole (128 wide before this change); it is why
    Fp8BlockScalingGemmRunner, which pins tune_max_num_tokens=4096, had the
    band [3969, 4096] cold.

Why stride 16 is correct, not just empirically sufficient

csrc/jit_kernels/heuristics/sm100.hpp has exactly three M-dependent sites, and
every breakpoint sits at M % 16 == 1:

  • :62-63 — the non-swap_ab BLOCK_M pick (m<=32 → 32; m<=64 → 64; else 128), stepping at M=33 and M=65.
  • :230 — the only use of M in config scoring, ceil_div(get_expected_m(), layout.block_m), stepping at M = k*block_m + 1. Every candidate block_m is a multiple of 16 (swap_ab uses lcm(16, get_block_m_multiple_of()); non-swap_ab uses {32, 64, 128}).
  • expected_m equals m on this path (sm100_fp8_fp4_gemm_1d1d.hpp:188, config.hpp:27).

num_stages is a closed form over block_m/block_n/smem (:176-217), the
tmem check reads block_m/block_n only (:123-128), and num_sms is
constant — none adds a breakpoint.

Every band is therefore an aligned [16k+1, 16k+16] cell, and each such cell
contains exactly one multiple of 16. A stride-16 grid provably touches every
band for any (N, K), not merely the shapes measured here. Correspondingly, a
stride of 32 must miss any band that is exactly 16 wide.

Measurements (GB300, aws-cmh)

One node, byte-identical dataset, sole variable the JIT cache state:

Total Latency iter 140 host_step_time cubins compiled
before, cold cache 6061.27 ms 2316.62 ms
before, warm cache 3888.29 ms 114.08 ms
after, cold cache #1 3978.59 ms 114.16 ms 37
after, cold cache #2 3929.05 ms 118.88 ms 37
after, warm cache 3989.01 ms 113.68 ms 0

Iteration 140 alone accounted for 101.4 % of the end-to-end gap (all 136 other
iterations combined were −19.4 ms). After the change a cold cache costs the same
as a warm one on that iteration, to within 0.08 ms.

End to end, cold and warm are now indistinguishable — both cold reps came in
marginally faster than the warm one, so the residual ±1.5 % is run-to-run noise
with no cache-state signal left in it. Iteration 140 does not appear in any rep's
worst-iteration list; the slowest non-startup iteration is 131 in all three, and
it is highest in the warm rep (220.5 ms vs 206.5 / 201.3), which is what noise
looks like. The warm rep compiling zero cubins while holding 37 also settles
completeness independently: the stride-16 warmup set left nothing for a later
process to discover.

The compiles moving to startup is also visible in the cache directory alone,
independent of any timing:

last kernel.cubin written first benchmark iteration
before 19:40:53 19:40:33 — 20 s of nvcc inside the window
after 22:32:20 22:32:38 — all compiles done 18 s early

Both cold reps compiled an identical 37 cubins, so the warmup set is
deterministic.

A stride-1 residual sweep, one fresh cache and one process per candidate:

stride buckets nvcc compiles nvcc time bands left unwarmed
128 (before) 46 28 64 s 7
32 76 33 88 s 1 (M=385, a band exactly 16 wide)
16 136 34 81 s 0
8 256 34 111 s 0 (no gain over 16)

Cost

Tripling the bucket count costs +4 compiles, not +200: the number of distinct
configs is bounded by the BLOCK_M ladder (~17 per shape), not by how densely M
is sampled. In this workload the DeepGEMM cache goes from 33 cubins to 37. The
extra buckets are warm GEMM calls at ~4.3 ms each, they run during autotuning at
startup, and they cannot change which kernel steady-state inference selects —
selection is a function of M either way.

Stride 8 was measured and rejected: identical compiles and identical coverage for
+120 buckets and +30 s of nvcc. Stride 16 is chosen because 16 is the band
quantum, not because a sweep happened to find no holes there.

Notes

  • The max(x, 4096) floor is deliberately left alone, and a comment now records why it is load-bearing: fp8SwapABGemmRunner leaves tune_max_num_tokens unset, so autotuner.py passes the current input size rather than a maximum. Dropping the floor would make a small first call (say M=64) warm nothing above 120.
  • Alternatives rejected: padding M up to the nearest warm bucket (540→640 is a +18.5 % permanent steady-state tax versus a one-time JIT); map_to_tuning_buckets round-up (only rewrites the autotuner cache key, leaving raw M and the JIT untouched); set_block_size_multiple_of(128) (process-global, changes steady-state kernel choice for every model).

Test coverage

deep_gemm_gen_tuning_buckets had no test coverage, which is how a stride
this coarse survived. Adds tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py
(25 tests, no GPU required), asserting the property that matters — for every
reachable M, some bucket selects the same config — exhaustively at stride 1 over
every BLOCK_M DeepGEMM can choose. It is auto-collected by CI, since
unittest/_torch/misc is listed as a directory in l0_h100.yml (pre_merge) and
l0_b200.yml (post_merge).

The top-band hole was found by these tests, not by a benchmark: the bug's own
case is maxnt:2048 and never reaches that band.

One assertion form is deliberately avoided as vacuous: ceil_div(512, 144) == ceil_div(540, 144), so "some bucket shares M=540's band index" passes on the
pre-fix buckets
even though M=540 demonstrably compiled a fresh kernel — the
heuristic picks a different BLOCK_M at 512 than at 540. The tests assert a
bucket lands physically inside the 16-wide band instead.

Dev Engineer Review

  • Updated deep_gemm_gen_tuning_buckets to use 16-token strides above 128.
  • Rounded the upper bound up to include the final DeepGEMM configuration band.
  • Preserved the max(x, 4096) floor for callers without a declared maximum.
  • Added x_is_declared_max support for callers with a maximum token count.
  • Reduced CuTe DSL MoE buckets from 264 to 40 without reducing reachable coverage.
  • Warmed all reachable SM100 configuration bands during startup autotuning.
  • Reduced cold-cache GB300 latency from 6061.27 ms to approximately 3930–3979 ms.
  • No configuration or test-list files changed.

QA Engineer Review

  • Added CPU-only tests for band coverage, stride alignment, bounds, sorting, uniqueness, declared maximums, and preserved low-input behavior.
  • Added tests:
    • test_every_reachable_m_shares_a_band_with_some_bucket
    • test_stride_matches_the_block_m_quantum
    • test_lower_clamp_survives_a_small_first_call
    • test_the_band_containing_max_num_tokens_is_warmed
    • test_low_buckets_are_unchanged
    • test_buckets_are_sorted_and_unique
    • test_measured_cold_band_now_has_a_bucket_inside_it
    • test_declared_max_is_not_raised_to_the_floor
    • test_declared_max_still_covers_every_reachable_m
    • test_declared_max_thins_the_multi_tactic_moe_case
    • test_below_128_is_unchanged_from_main
    • test_the_floor_still_fires_from_128_up
  • The tests are not listed in tests/integration/test_lists/ under test-db/ or qa/.
  • Verdict: needs follow-up.

…token config quantum

deep_gemm_gen_tuning_buckets exists so DeepGEMM JIT-compiles every kernel
config a workload needs during autotuning at startup, keeping nvcc out of
the measured window. It walked M with range(128, x, 128).

DeepGEMM's SM100 config selection changes every 16 tokens, so a stride-128
grid sampled 1 band in 8 and left the other 7 cold. Whichever live iteration
first landed in a cold band paid ~2.2s of nvcc mid-inference.

Why 16 is correct rather than merely sufficient: sm100.hpp has exactly three
M-dependent sites, and every breakpoint sits at M % 16 == 1.

  * :62-63  the non-swap_ab BLOCK_M pick (m<=32 -> 32; m<=64 -> 64; else 128),
            stepping at M = 33 and M = 65;
  * :230    the only use of M in config scoring, ceil_div(expected_m, block_m),
            stepping at M = k*block_m + 1 -- and every candidate block_m is a
            multiple of 16 (swap_ab uses lcm(16, block_m_multiple_of),
            non-swap_ab uses {32, 64, 128});
  * expected_m equals m on this path.

num_stages is a closed form over block_m/block_n/smem and num_sms is constant,
so neither adds a breakpoint. Every band is therefore an aligned
[16k+1, 16k+16] cell containing exactly one multiple of 16, so a stride-16 grid
provably touches every band for any (N, K). A stride-1 residual sweep confirmed
it: stride 128 left 7 bands cold, stride 32 left 1 (M=385, a band exactly 16
wide), stride 16 left 0.

Second fix, same expression: round the top up to a whole quantum and include
it. The only multiple of 16 inside a band is its top, so half-open
range(128, x, 16) never warmed the band containing max_num_tokens itself -- the
M every full batch runs at. That hole predates this change (it was 128 wide) and
is why Fp8BlockScalingGemmRunner, which pins tune_max_num_tokens=4096, had
[3969, 4096] cold.

Measured on minimax_m2.5_fp8-bench-pytorch-float8-maxbs:512-maxnt:2048-
input_output_len:128,128-gpus:4, GB300, one node, byte-identical dataset, sole
variable the JIT cache state:

                         Total Latency   iter 140 host_step_time   cubins built
  before, cold cache        6061.27 ms              2316.62 ms               -
  before, warm cache        3888.29 ms               114.08 ms               -
  after,  cold cache NVIDIA#1     3978.59 ms               114.16 ms              37
  after,  cold cache NVIDIA#2     3929.05 ms               118.88 ms              37
  after,  warm cache        3989.01 ms               113.68 ms               0

Iteration 140 alone was 101.4% of the end-to-end gap: iters 132-139 pack the
2048-token cap and iter 140 gets the ragged 540-token leftover (ctx 416 + gen
124), whose config (BLOCK_M=144, num_stages=8) was never warmed. This is why
the bug was filed as instability -- whether a run pays it depends on request
packing, not on the commit.

After the change cold and warm are indistinguishable; both cold reps came in
marginally faster than warm, so no cache-state signal remains. The warm rep
compiling 0 cubins while holding 37 settles completeness independently of any
timing. The compiles moving to startup is visible in the cache dir alone: the
last kernel.cubin was written 20s after the first benchmark iteration before,
and 18s before it after.

Cost is +4 compiles (33 -> 37 cubins) for 46 -> 264 buckets: the number of
distinct configs is bounded by the BLOCK_M ladder, not by how densely M is
sampled. The extra buckets are warm GEMM calls at ~4.3ms each during startup
autotuning, and they cannot change which kernel steady-state inference picks --
selection is a function of M either way.

The max(x, 4096) floor is left alone and now carries a comment recording why it
is load-bearing: fp8SwapABGemmRunner leaves tune_max_num_tokens unset, so
autotuner.py passes the current input size rather than a maximum.

Adds tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py (25 tests, no
GPU). The function had no coverage before, which is how a stride this coarse
survived; the tests assert the property that matters -- for every reachable M
some bucket selects the same config -- exhaustively at stride 1 over every
BLOCK_M DeepGEMM can choose. They found the top-band hole, which no benchmark
here could: this bug's case is maxnt:2048 and never reaches that band.

Signed-off-by: chenfeiz0326 <203214996+chenfeiz0326@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

DeepGEMM tuning buckets now support declared maximums and use a 16-token quantum above 128. FC1, FC2, and FP8 tuning callbacks use declared maximums. CPU-only tests cover bounds, coverage, spacing, legacy behavior, and measured cold bands.

Changes

DeepGEMM tuning bucket generation

Layer / File(s) Summary
Declared-maximum bucket generation
tensorrt_llm/_torch/utils.py
Adds the public 16-token quantum and return annotation. Declared maximums are no longer raised to 4096. High buckets use 16-token increments through the rounded-up upper bound.
Declared maximum integration
tensorrt_llm/_torch/custom_ops/cute_dsl_custom_ops.py, tensorrt_llm/_torch/custom_ops/torch_custom_ops.py
Dense SwiGLU MoE FC1, FC2, and FP8 block-scaling GEMM tuning use configured bucket callbacks with x_is_declared_max=True.
Bucket coverage validation
tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py
Adds CPU-only tests for reachable-band coverage, spacing, bounds, legacy behavior, ordering, uniqueness, measured cold bands, and declared maximums.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TuningConfig
  participant BucketGenerator
  participant DeepGEMMAutotuner
  TuningConfig->>BucketGenerator: configure x_is_declared_max=True
  BucketGenerator->>BucketGenerator: generate bounded 16-token tuning buckets
  BucketGenerator-->>DeepGEMMAutotuner: return tuning bucket tuple
  DeepGEMMAutotuner->>DeepGEMMAutotuner: profile configured buckets
Loading

Possibly related PRs

Suggested reviewers: bowenfu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the DeepGEMM warmup stride fix and matches the main change.
Description check ✅ Passed The description explains the issue, solution, measurements, cost, alternatives, and test coverage, but omits the template checklist.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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)
tensorrt_llm/_torch/utils.py (1)

365-385: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the public return type and API docstring.

Add -> tuple[int, ...] to deep_gemm_gen_tuning_buckets. Add a Google-style docstring that documents x and the returned bucket sequence. As per coding guidelines, “Annotate every function” and “Prefer docstrings for external interfaces.”

🤖 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 `@tensorrt_llm/_torch/utils.py` around lines 365 - 385, Add the public return
annotation tuple[int, ...] to deep_gemm_gen_tuning_buckets and add a concise
Google-style docstring documenting the x parameter and returned tuning-bucket
sequence. Keep the existing bucket-generation logic unchanged.

Source: Coding 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/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py`:
- Line 1: Add the standard required NVIDIA copyright header at the beginning of
test_deep_gemm_tuning_buckets.py, placing it before the module docstring.
Preserve the existing deep_gemm_gen_tuning_buckets coverage test content
unchanged.

---

Nitpick comments:
In `@tensorrt_llm/_torch/utils.py`:
- Around line 365-385: Add the public return annotation tuple[int, ...] to
deep_gemm_gen_tuning_buckets and add a concise Google-style docstring
documenting the x parameter and returned tuning-bucket sequence. Keep the
existing bucket-generation logic unchanged.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 537aa206-1877-4541-bb41-dcb3535e4662

📥 Commits

Reviewing files that changed from the base of the PR and between 6af4e0b and 570c57e.

📒 Files selected for processing (2)
  • tensorrt_llm/_torch/utils.py
  • tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py

Comment thread tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py
@chenfeiz0326
chenfeiz0326 requested a review from hyukn August 4, 2026 06:20
# max_num_tokens cold, which is the single most likely M of all (every
# full batch hits it). Pre-fix the same hole was 128 wide.
top = -(-x // DEEP_GEMM_BLOCK_M_QUANTUM) * DEEP_GEMM_BLOCK_M_QUANTUM
buckets += tuple(range(128, top + 1, DEEP_GEMM_BLOCK_M_QUANTUM))

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.

This will increase the tuning numbers a lot. Is it expected?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, expected — but you're right that it's more than it needs to be, and I'd like to fix half of it.

Measured cost. 46 -> 264 buckets, and the autotune window (FMHA JIT warmup marker -> "Run warmup") goes 107.6s -> 110.3s, so +2.7s (+2.5%) at ~12.2ms per extra bucket. Compiles only go 33 -> 37 cubins: the distinct-config count is bounded by the BLOCK_M ladder (~17/shape), not by bucket density, so the extra buckets are warm GEMM calls, not nvcc. They also cannot change steady-state kernel choice — selection is a function of M either way.

Where the waste actually is. You're right that the grid is mostly redundant: 264 buckets collapse to 37 distinct configs (86% duplicates). But the stride isn't the culprit — a greedy minimum cover over the conservative BLOCK_M superset needs 263 buckets, versus my 264. Stride 16 is essentially minimal for a guaranteed cover, because a band can be exactly 16 wide (measured: stride 32 leaves M=385 cold).

The real waste is the pre-existing max(x, 4096) floor. This workload is maxnt:2048, so:

buckets reachable unreachable
maxnt:1024 264 72 192
maxnt:2048 264 136 128 (~1.6s of the +2.7s)
maxnt:4096 264 264 0

So ~48% of the buckets on this case tune M values the workload can never reach. My standalone coverage probe measured exactly that reachable-clamped stride-16 set — 136 buckets — and it came back with residual 0 on a stride-1 sweep, i.e. provably complete for both live shapes. Same coverage, half the buckets, ~1.6s cheaper.

Why I didn't just drop the floor here. fp8SwapABGemmRunner leaves tune_max_num_tokens=None, so autotuner.py:1579-1582 hands this function the current input size, not a maximum. Remove the floor and a small first call (say M=64) warms nothing above 120 — reintroducing the same class of hole. The floor is a workaround for the runner not declaring its max.

The clean fix, if you're happy with it: plumb a real max through, exactly as the neighbouring runners already do — Fp8BlockScalingGemmRunner pins tune_max_num_tokens=4096 and MoERunner 8192 (torch_custom_ops.py:86, 233, 1957, 2552). fp8_swap_ab_gemm gets a tune_max_num_tokens arg, linear.py:1152 passes the model's real max_num_tokens, and the floor goes away. Then the count is 136 at maxnt:2048 and 264 only when the workload genuinely reaches 4096 — strictly fewer buckets than today in every case, and it removes the guessing.

That's a slightly wider change than a warmup-stride bugfix, so I didn't fold it in unasked. Happy to do it in this PR, or land the stride fix (which is what removes the 2.2s mid-inference nvcc stall and the +55.9% end-to-end) and follow up with the plumbing — your call.

One note on the diff you're looking at: the second half of the change is also a bugfix, not just a stride change. range(128, x, 16) is half-open, so the band containing max_num_tokens itself was never warmed — the M every full batch runs at. That hole was 128 wide before this change and it's why Fp8BlockScalingGemmRunner had [3969, 4096] cold.

…ocstring

Addresses review feedback: NVIDIA copyright header on the new test file,
and a Tuple[int, ...] return annotation plus a Google-style docstring on
deep_gemm_gen_tuning_buckets (Tuple was not previously imported).

Signed-off-by: chenfeiz0326 <203214996+chenfeiz0326@users.noreply.github.com>
@BowenFu

BowenFu commented Aug 4, 2026

Copy link
Copy Markdown

Seconding @hyukn's question with numbers — the tuning cost is very uneven across callers, and the two that pay most are not the ones this PR is about.

deep_gemm_gen_tuning_buckets has four callers.

The two this PR targets are cheap. fp8SwapABGemmRunner (torch_custom_ops.py:1861) and Fp8BlockScalingGemmRunner (:1960) both return [0] from get_valid_tactics, so they hit the single-pair shortcut at autotuner.py:1229-1249 — one forward per bucket, no timed loop. +218 buckets ≈ +218 forwards, which matches the ~4.3 ms/bucket in the description.

The other two are not. The CuTe DSL NVFP4 runners (cute_dsl_custom_ops.py:4383 FC1, :4781 FC2) have real tactic searches — up to 12 and 48 tactics — with use_cold_l2_cache=True and distributed_tuning_strategy=PARALLEL, so the shortcut never applies. Every pair runs the full _profile_single_kernel loop (2 warmups, a 2-repeat and usually a 10-repeat pass, each preceded by a 1000 µs delay_kernel, plus cold-L2 circular buffers). +218 buckets is up to +2,616 and +10,464 tactic-profile pairs there.

And most of that work is unreachable. Both set tune_max_num_tokens=512, but _generate_optimization_profiles doesn't filter buckets above it (autotuner.py:1536-1544) and the max(x, 4096) floor applies anyway — while _find_nearest_profile clamps the runtime key to min(M, 512) (:1635-1638). So buckets 528..4096 are profiled and can never be looked up. That waste goes 27 → 223 buckets.

It also changes runtime behavior on that path. map_to_tuning_buckets is identity, so M = 144, 160, … 496 previously missed the cache and ran the fallback tactic -1 (:1098-1108); now they get an autotuned tactic. Plausibly an improvement, but it's a default-behavior change on the NVFP4 MoE path and nothing here measures it.

Suggestion: keep the dense stride for the JIT-warmup callers without imposing it on the CuTe DSL ones — a separate generator, or take the bound as a parameter so tune_max_num_tokens=512 actually caps the grid.

Two things that check out, so nobody re-derives them: the new test file is collected (unittest/_torch/misc is a directory entry at l0_h100.yml:25 pre-merge and l0_b200.yml:316 post-merge) — the auto-generated "not listed in test_lists… verdict: insufficient" note in the description is wrong. And the top-rounding expression is correct at x = 128 / 129 / 4096 / 8192 / <128.

…n the DeepGEMM warmup buckets

The stride-16 warmup grid applied `max(x, 4096)` unconditionally. That floor is
only needed by callers that leave `tune_max_num_tokens` unset (fp8SwapABGemmRunner
receives the current input size, not a maximum), and it silently overrode callers
that do declare one.

The two cute_dsl MoE ops declare `tune_max_num_tokens=512` and, unlike the
DeepGEMM warmup runners, are multi-tactic — so every bucket is profiled per
tactic. They were being handed 264 buckets, 224 of them above their own declared
max. Those are unreachable: `AutoTuner._find_nearest_profile` looks up with
`min(M, tune_max_num_tokens)`, so a bucket above the declared max can never be
selected. Honouring the declaration takes them to 40 buckets with identical
coverage of every reachable M.

Callers without a declared max are byte-identical to before, so the nvbug 6550749
fix is unaffected.

Signed-off-by: chenfeiz0326 <203214996+chenfeiz0326@users.noreply.github.com>
@chenfeiz0326

Copy link
Copy Markdown
Collaborator Author

Thanks again for pushing on this — the concern found a real bug, not just a cost question.

Your objection surfaced a regression I had introduced. The generator has four
call sites, not one. Two of them — CuteDslFp8BlockScalingMoERunner and its
NVFP4 sibling in cute_dsl_custom_ops.py — declare tune_max_num_tokens=512,
and my max(x, 4096) floor was silently overriding that declaration. They were
being handed 264 buckets, 224 of them above their own declared maximum. And
those are the worst possible place for it: unlike the DeepGEMM warmup runners,
those two are multi-tactic (get_valid_tactics enumerates mma_tiler_mn ×
cluster_shape_mn × split_k), so each excess bucket costs profiling
iterations per tactic.

4be5ddd adds an x_is_declared_max flag and wires it at the three call
sites that declare a maximum:

caller declares before after
cute_dsl MoE fp8 + nvfp4 (multi-tactic) 512 264 40
Fp8BlockScalingGemmRunner 4096 264 264
fp8SwapABGemmRunner (the nvbug's runner) 264 264

This is perf-neutral by construction, not by measurement.
AutoTuner._find_nearest_profile looks up the cache with
min(M, tune_max_num_tokens) (autotuner.py:1676-1680), so a bucket above a
declared max can never become a reachable cache key — it is profiled and then
never selected. Dropping it cannot change which tactic any runtime shape gets.
Two new tests pin both halves of that: no bucket may exceed the declared max,
and an exhaustive stride-1 sweep below the max must still find a bucket in
every 16-wide band, so the thinning cannot reintroduce the hole this PR exists
to close. 41 tests total.

fp8SwapABGemmRunner necessarily stays at 264, and I want to be explicit
that this is a limitation rather than a choice I'm defending as optimal. It
declares no tune_max_num_tokens, so the autotuner passes it the current input
size
(autotuner.py:1579-1582, "Use the current input size as the opt value")
— the floor is what stops a small first call from warming nothing above 120 and
leaving every larger M to JIT mid-iteration. Plumbing a real maximum means
threading max_num_tokens down through Linear (linear.py:1152), which
exposes none. That's a wider refactor than a bugfix should carry, and I'd rather
do it separately than smuggle it in here.

Its cost is the +2.7s I measured: +2.5% on startup, 12.2ms per bucket, and only
+4 nvcc compiles
(33 → 37 cubins — compile count is bounded by the BLOCK_M
ladder at ~17 configs/shape, not by bucket count). Against that: it removes a
2.2s in-window nvcc stall (iter 140: 2316ms → 114ms) and +55.9%
end-to-end
on the nvbug case.

Happy to split the cute_dsl thinning into its own PR if you'd rather review the
stride change alone.

@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: 2

🤖 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/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py`:
- Around line 252-255: Replace the self-comparison in the test loop with
independent assertions for the expected bucket boundaries or counts returned by
deep_gemm_gen_tuning_buckets. Cover representative inputs, including at least
one value at or above 128, while retaining coverage of the legacy default
behavior where appropriate.
- Around line 203-229: Define and enforce the input contract for
tune_max_num_tokens in deep_gemm_gen_tuning_buckets: either support arbitrary
declared maxima by adding boundary tests such as 64, 127, and 513 with expected
bucket limits, or validate and reject values below 128 and not aligned to 16.
Keep ModelConfig.max_num_tokens and TuningConfig.tune_max_num_tokens consistent
with the chosen contract.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 616da2e0-fef4-4543-a50f-ea68e9bfa992

📥 Commits

Reviewing files that changed from the base of the PR and between 247dcaf and 4be5ddd.

📒 Files selected for processing (4)
  • tensorrt_llm/_torch/custom_ops/cute_dsl_custom_ops.py
  • tensorrt_llm/_torch/custom_ops/torch_custom_ops.py
  • tensorrt_llm/_torch/utils.py
  • tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tensorrt_llm/_torch/utils.py

Comment thread tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py
Comment on lines +252 to +255
for x in (64, 128, 512, 2048, 4096, 8192, 9000):
assert deep_gemm_gen_tuning_buckets(x) == deep_gemm_gen_tuning_buckets(
x, x_is_declared_max=False
)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert legacy behavior independently.

deep_gemm_gen_tuning_buckets(x) and deep_gemm_gen_tuning_buckets(x, x_is_declared_max=False) use the same implementation. This comparison only detects a change to the default argument. It does not detect a regression in the default bucket sequence.

Assert stable expected boundary values or counts for representative inputs, including an input at or above 128.

🤖 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/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py` around lines 252
- 255, Replace the self-comparison in the test loop with independent assertions
for the expected bucket boundaries or counts returned by
deep_gemm_gen_tuning_buckets. Cover representative inputs, including at least
one value at or above 128, while retaining coverage of the legacy default
behavior where appropriate.

Source: Path instructions

@BowenFu

BowenFu commented Aug 4, 2026

Copy link
Copy Markdown

4be5ddd does what I asked and the reasoning holds — the min(M, tune_max_num_tokens) clamp at autotuner.py:1677-1680 really does make an above-max bucket unreachable, so dropping it cannot change tactic selection, and wiring it with functools.partial rather than a hardcoded number is right given tune_max_num_tokens is reassigned per call at torch_custom_ops.py:2017. Don't split it out — it fixes a regression this PR introduces, so it belongs here.

Two things on the new commit.

1. The floor now also fires below 128, which it never did on main. The clamp moved out of the guard:

if not x_is_declared_max:
    x = max(x, 4096)
x = min(x, 8192)
if x >= 128:

On main (utils.py:333-340) x = 64 returns 15 buckets, max 120 — the x >= 128 guard short-circuits before the floor is ever reached. At head it returns 264, max 4096. That path is live: fp8SwapABGemmRunner declares no tune_max_num_tokens, so autotuner.py:1578-1586 hands it the current input M, and it carries exclude_from_cache=True, so this re-runs on every process start.

I suspect you want this — it is what the docstring already claims the floor does — and the in-process profiling cache (autotuner.py:1181-1195) absorbs most of it whenever a larger-M call follows in the same autotune window. But nothing guarantees one does, it is a startup cost on a path the PR never measures, and it is not in the description. Either make it deliberate and say so, or restore the ordering.

2. test_default_is_unchanged_for_callers_without_a_declared_max cannot fail. x_is_declared_max defaults to False, so

assert deep_gemm_gen_tuning_buckets(x) == deep_gemm_gen_tuning_buckets(x, x_is_declared_max=False)

compares the function to itself. The one assertion with content, max(f(64)) == 4096, pins the new behavior — main gives 120 — so the test named for guarding no-change is what locks the change in, and it would report a restore as the regression. If the widening is intended, rename it and assert that intent; if not, this is the test that should have caught (1).

The rest checks out: functools / partial imports are present, Fp8BlockScalingGemmRunner at 4096 is unchanged by this commit, and the two 512-declared cute_dsl runners lose no reachable shape.

…8 guard

The previous commit hoisted `max(x, 4096)` above the `if x >= 128` guard while
adding the declared-max flag. That silently changed a path this fix has no reason
to touch: on main a current input size below 128 returns 15 buckets (max 120)
because the guard short-circuits before the floor is reached, but at that commit
f(64) returned 264 buckets (max 4096).

The path is live. fp8SwapABGemmRunner declares no tune_max_num_tokens, so the
autotuner passes it the current input M rather than a maximum, and it carries
`exclude_from_cache=True`, so the sweep re-runs on every process start. The cost
was never measured — the measured +2.7s figure was taken before the hoist — so
restore main's ordering rather than keep an unmeasured widening.

Also replaces test_default_is_unchanged_for_callers_without_a_declared_max, which
could not fail: it asserted f(x) == f(x, x_is_declared_max=False), comparing the
function to its own default. Its one assertion with content pinned the widened
behavior, so it would have reported a restore as the regression. The replacement
asserts main's literal output below 128, and a mutation test confirms it fails
when the hoist is reintroduced.

Signed-off-by: chenfeiz0326 <203214996+chenfeiz0326@users.noreply.github.com>
@chenfeiz0326

Copy link
Copy Markdown
Collaborator Author

Both findings are correct, and (1) is worse than you framed it. Fixed in 7f90d2e.

1. The floor firing below 128 — confirmed, and not intended.

Your numbers are exact: main gives f(64) → 15 buckets max 120; 4be5ddd gave
264 max 4096. I've restored main's ordering, both clamps back inside the guard:

if x >= 128:
    if not x_is_declared_max:
        x = max(x, 4096)
    x = min(x, 8192)

You offered "make it deliberate and say so, or restore the ordering." Restoring is
the only defensible option, for a reason worth stating plainly: my measured
commit 570c57e3 kept main's ordering.
The hoist appeared only in 4be5ddd,
the thinning commit. So the +2.7s / +2.5% / +4-compiles figures in the description
were taken against code that short-circuits below 128 — they never covered the
widened path. Keeping it would have meant shipping an unmeasured startup cost
while pointing at measurements that don't describe it. On a path that, as you
note, re-runs every process start thanks to exclude_from_cache=True. Verified
byte-identical to main at x = 1, 8, 32, 64, 120, 127, with the floor still firing
from 128 up.

2. The tautological test — confirmed, and it was worse than dead weight.

f(x) == f(x, x_is_declared_max=False) compares the function to itself; only
max(f(64)) == 4096 had content, and it pinned the new behavior. You're right
that the test named for guarding no-change was what locked the change in and
would have reported a restore as the regression. That is the precise mechanism by
which (1) escaped: I wrote the guard against the wrong oracle, so it confirmed
the bug instead of catching it.

Replaced with test_below_128_is_unchanged_from_main, asserting main's literal
output (tuple(range(8, 128, 8))) rather than the function's own default, plus
test_the_floor_still_fires_from_128_up so the short-circuit can't later be
mistaken for the floor having been deleted. I mutation-tested the replacement:
re-injecting the hoist fails 6 tests, and reverting goes green. 47 tests.

On your first comment, the tactic-count asymmetry you quantified is what
4be5ddd acted on — the multi-tactic cute_dsl runners were the ones eating the
excess, exactly as you said, and 512-declared now means 40 buckets rather than
264. Two further points from it I should acknowledge rather than let pass:

  • The runtime-behavior change on the NVFP4 MoE path is real and I have not
    measured it.
    M = 144, 160, … 496 previously missed the cache and ran fallback
    tactic -1; now they get an autotuned tactic. Thinning to the declared 512
    doesn't remove that — those M values are all below 512. It's plausibly an
    improvement and it's a default-behavior change on a path this PR isn't about.
    Happy to gate the dense stride to the two JIT-warmup callers with a separate
    generator if you'd prefer that over an unmeasured change there.
  • Thanks for independently confirming the CI collection and the top-rounding
    expression. The "not listed in test_lists" line in the description is
    auto-generated boilerplate and is wrong; I'll correct it.

Not splitting the thinning out, per your call.

@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

🤖 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/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py`:
- Around line 246-272: Register test_below_128_is_unchanged_from_main and
test_the_floor_still_fires_from_128_up in the corresponding test-list files
under both the test-db and qa directories. Preserve the existing list format and
placement conventions so both CI and manual QA pipelines discover and execute
the new tests.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 112b4524-7ad8-459f-981e-4d6170d715f8

📥 Commits

Reviewing files that changed from the base of the PR and between 4be5ddd and 7f90d2e.

📒 Files selected for processing (2)
  • tensorrt_llm/_torch/utils.py
  • tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tensorrt_llm/_torch/utils.py

Comment on lines +246 to +272
@pytest.mark.parametrize("x", [1, 8, 32, 64, 120, 127])
def test_below_128_is_unchanged_from_main(x):
"""A sub-128 current input size must return ONLY the low buckets.

Both clamps live inside the ``x >= 128`` guard, so this path is
byte-identical to main. That ordering is load-bearing and easy to lose:
hoisting ``max(x, 4096)`` above the guard turns f(64) from 15 buckets into
264. Nothing in this fix needs that, and it is an unmeasured startup cost on
a live path -- ``fp8SwapABGemmRunner`` declares no ``tune_max_num_tokens``,
so the autotuner hands it the *current* M, and ``exclude_from_cache=True``
means the sweep re-runs on every process start.

Asserted against main's literal output, not against the function's own
default: comparing ``f(x)`` to ``f(x, x_is_declared_max=False)`` compares the
function to itself and cannot fail.
"""
assert deep_gemm_gen_tuning_buckets(x) == tuple(range(8, 128, 8))


def test_the_floor_still_fires_from_128_up():
"""Above the guard the floor must still apply -- it is not dead code.

Complements the test above, so the sub-128 short-circuit cannot be mistaken
for the floor having been removed. A first call at M=128 warms out to 4096.
"""
assert max(deep_gemm_gen_tuning_buckets(128)) >= 4096
assert max(deep_gemm_gen_tuning_buckets(129)) >= 4096

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

test_file='tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py'
test_stem='test_deep_gemm_tuning_buckets'
test_functions=(
  'test_below_128_is_unchanged_from_main'
  'test_the_floor_still_fires_from_128_up'
)

for list_root in \
  tests/integration/test_lists/test-db \
  tests/integration/test_lists/qa
do
  echo "== ${list_root} =="
  if [[ ! -d "$list_root" ]]; then
    echo "MISSING: ${list_root}"
    continue
  fi

  rg -n -F "$test_file" "$list_root" || true
  rg -n -F "$test_stem" "$list_root" || true

  for test_function in "${test_functions[@]}"; do
    rg -n -F "$test_function" "$list_root" || true
  done
done

Repository: NVIDIA/TensorRT-LLM

Length of output: 238


Register the new tests in both test-list directories before merge.

The new tests test_below_128_is_unchanged_from_main and test_the_floor_still_fires_from_128_up are not registered in tests/integration/test_lists/test-db/ (CI) or tests/integration/test_lists/qa/ (manual QA). Without registration, these tests will not run in the CI or QA pipelines. Add both test functions to the appropriate list files.

🤖 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/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py` around lines 246
- 272, Register test_below_128_is_unchanged_from_main and
test_the_floor_still_fires_from_128_up in the corresponding test-list files
under both the test-db and qa directories. Preserve the existing list format and
placement conventions so both CI and manual QA pipelines discover and execute
the new tests.

Source: Path instructions

@BowenFu

BowenFu commented Aug 4, 2026

Copy link
Copy Markdown

7f90d2e closes both points. Verified rather than taken on trust:

  • Both clamps are back inside the x >= 128 guard, so the sub-128 path is byte-identical to main again. I also checked the reordering you were left with — main does min then max, head does max then min — and it is equivalent for every x >= 128 on the undeclared-max path, since the target interval [4096, 8192] is non-empty.
  • test_below_128_is_unchanged_from_main asserts against the literal tuple(range(8, 128, 8)) instead of against the function's own default, so it can now actually fail. test_the_floor_still_fires_from_128_up is a good complement — without it the short-circuit would be indistinguishable from the floor having been deleted.

One small thing left, and it is latent rather than live:

A declared max that is not a multiple of 16 still gets a bucket above it. top rounds up unconditionally, so f(513, x_is_declared_max=True) yields a top bucket of 528. That contradicts the flag's own contract — the whole reason for x_is_declared_max is that _find_nearest_profile clamps the lookup key to min(M, tune_max_num_tokens), which makes 528 exactly as unreachable as the buckets you just removed. It costs nothing today because both wired maxima (512 and 4096) are multiples of 16, but it will silently come back the first time someone declares e.g. 8000. top = min(top, x) when x_is_declared_max would close it.

Not blocking on that. From my side the change is good; I'm leaving approval until hyukn's thread is closed out, since the numbers I posted were an answer to his question and he should get to sign it off.

@chenfeiz0326

Copy link
Copy Markdown
Collaborator Author

Minimax2.5 is low priority model. Close this bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants