Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions tensorrt_llm/_torch/custom_ops/cute_dsl_custom_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -4707,7 +4707,9 @@ def get_tuning_config(self) -> TuningConfig:
if key not in self.tuning_config_cache:
self.tuning_config_cache[key] = TuningConfig(
dynamic_tensor_specs=(DynamicTensorSpec(
0, 0, deep_gemm_gen_tuning_buckets), ),
0, 0,
functools.partial(deep_gemm_gen_tuning_buckets,
x_is_declared_max=True)), ),
constraint_specs=(ConstraintSpec(2, 0,
fp4_scale_infer_shape), ),
use_cold_l2_cache=True,
Expand Down Expand Up @@ -5105,7 +5107,9 @@ def get_tuning_config(self) -> TuningConfig:
if key not in self.tuning_config_cache:
self.tuning_config_cache[key] = TuningConfig(
dynamic_tensor_specs=(DynamicTensorSpec(
0, 0, deep_gemm_gen_tuning_buckets), ),
0, 0,
functools.partial(deep_gemm_gen_tuning_buckets,
x_is_declared_max=True)), ),
constraint_specs=(
ConstraintSpec(2, 0, fp4_scale_infer_shape),
ConstraintSpec(4, 0, lambda shapes: shapes[0][0]),
Expand Down
5 changes: 3 additions & 2 deletions tensorrt_llm/_torch/custom_ops/torch_custom_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import os
import threading
from dataclasses import replace
from functools import lru_cache
from functools import lru_cache, partial
from typing import ClassVar, List, Mapping, Optional, Tuple, Union

import torch
Expand Down Expand Up @@ -1957,7 +1957,8 @@ def _(
class Fp8BlockScalingGemmRunner(TunableRunner):
tuning_config = TuningConfig(
dynamic_tensor_specs=(DynamicTensorSpec(
0, 0, deep_gemm_gen_tuning_buckets), ),
0, 0, partial(deep_gemm_gen_tuning_buckets,
x_is_declared_max=True)), ),
tune_max_num_tokens=4096,
)

Expand Down
97 changes: 92 additions & 5 deletions tensorrt_llm/_torch/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import threading
from dataclasses import dataclass
from enum import Enum, IntEnum
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple

import torch
from torch.nn import functional as F
Expand Down Expand Up @@ -323,13 +323,100 @@ def get_last_power_of_2_num_tokens_buckets(max_num_tokens) -> List[int]:
return tuple(num_token_buckets[::-1])


def deep_gemm_gen_tuning_buckets(x: int):
# DeepGEMM selects (BLOCK_M, BLOCK_N, num_stages, swap_ab) as a pure function
# of M, and every breakpoint of that function sits at M % 16 == 1. Two sites in
# csrc/jit_kernels/heuristics/sm100.hpp are the *entire* M dependence:
# * :62-63 the non-swap_ab BLOCK_M pick, which steps at M = 33 and M = 65;
# * :230 ceil_div(expected_m, block_m) in the config score, which steps 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}).
# num_stages is a closed form over block_m/block_n only, and num_sms is
# constant, so neither adds a breakpoint. Each band is therefore an aligned
# union of [16k+1, 16k+16] cells, and every such cell contains exactly one
# multiple of 16 -- so a stride-16 grid provably touches every band, for any
# (N, K), not merely the shapes measured below.
#
# A stride of 128 leaves ~1 band in 8 unwarmed. Those bands are then JIT'd by
# whichever live iteration first lands in one, putting ~2.2s of nvcc *inside*
# the measured window. Sizing the stride to the 16-token quantum is what makes
# the warmup complete; the numbers here are measured, not assumed:
#
# stride buckets nvcc compiles nvcc time bands left unwarmed
# 128 46 28 64s 7 <-- nvbug 6550749
# 32 76 33 88s 1 (misses M=385, a band
# exactly 16 wide)
# 16 136 34 81s 0 COMPLETE
#
# (Those counts come from a standalone sweep bounded at M <= 2048; the clamp
# below makes the production list larger -- 264 buckets -- without adding
# compiles, since it adds no new BLOCK_M rungs.)
#
# 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. End to end on the nvbug 6550749 case the DeepGEMM cache goes
# from 33 cubins to 37. The extra buckets are warm GEMM calls at ~4.3ms each,
# they run during autotuning at startup, and they cannot change which kernel
# steady-state inference picks -- selection is a function of M either way.
# See tests/unittest/_torch/misc/test_deep_gemm_tuning_buckets.py.
DEEP_GEMM_BLOCK_M_QUANTUM = 16


def deep_gemm_gen_tuning_buckets(x: int,
x_is_declared_max: bool = False
) -> Tuple[int, ...]:
"""Generate the M values to autotune, so DeepGEMM JIT-compiles every config.

Args:
x: Upper bound on M.
x_is_declared_max: True when the caller sets ``tune_max_num_tokens``, so
``x`` is that declared maximum rather than the current input size.
Buckets above it are then dropped instead of profiled: M is clamped
with ``min(M, tune_max_num_tokens)`` for the runtime lookup (see
``AutoTuner._find_nearest_profile``), so a higher bucket can never be
a reachable cache key. Pass it via ``functools.partial`` on the spec
rather than repeating the numeric limit, which would desync from a
``tune_max_num_tokens`` that is reassigned per call.

Returns:
Ascending M values to profile. Stride is
``DEEP_GEMM_BLOCK_M_QUANTUM`` above 128, which touches every distinct
DeepGEMM config for any (N, K), and the top of the range is included.
"""
buckets = tuple(range(8, 128, 8))
# Clamp x to be between 4096 and 8192.
# Clamp x to be at most 8192, and -- only when we are guessing -- at least
# 4096.
#
# The lower clamp is load-bearing for callers that leave
# tune_max_num_tokens unset (fp8SwapABGemmRunner): autotuner.py then passes
# the *current input size* here rather than a maximum (see autotuner.py,
# `Use the current input size as the opt value`). Drop the floor there and a
# small first call (say M=64) would warm nothing above 120, so every larger
# M would JIT mid-iteration.
#
# A caller that *does* declare a maximum needs no such guess, and profiling
# past it is pure waste -- the runtime lookup is clamped to the declared max,
# so those buckets are unreachable. Honouring it keeps the two cute_dsl MoE
# ops (which declare 512, and unlike the DeepGEMM warmup runners are
# multi-tactic) at 40 buckets instead of 264.
#
# Both clamps stay INSIDE the `x >= 128` guard, as on main: a caller whose
# current input size is below 128 keeps returning just the low buckets. See
# test_below_128_is_unchanged_from_main -- hoisting the floor above the guard
# silently turned f(64) from 15 buckets into 264, an unmeasured startup cost
# on a path this fix has no reason to touch.
if x >= 128:
if not x_is_declared_max:
x = max(x, 4096)
x = min(x, 8192)
x = max(x, 4096)
buckets += tuple(range(128, x, 128))
# Round the top up to a whole quantum, and include it. The only multiple
# of 16 inside a band [16k+1, 16k+16] is its *top*, so a bucket >= x is
# required to warm the band that x itself lives in -- and a half-open
# range(128, x, 16) provides none. That left the band containing
# 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.

return buckets


Expand Down
Loading
Loading