[None][perf] Add tiered GVR CuTe DSL top-k decode kernels (stacked on #16457) - #16877
[None][perf] Add tiered GVR CuTe DSL top-k decode kernels (stacked on #16457)#16877longcheng-nv wants to merge 18 commits into
Conversation
4f95451 to
78df9cb
Compare
|
Pushed Context: while building a unified-dispatch framework on top of this branch, a full-grid re-measure of the exact shipped head revealed that the original calibration harness measured the reg tier with a faster experimental streaming |
|
/bot run |
|
PR_Github #62363 [ run ] triggered by Bot. Commit: |
|
Pushed |
|
/bot run |
1 similar comment
|
/bot run |
|
PR_Github #62390 [ run ] triggered by Bot. Commit: |
|
PR_Github #62363 [ run ] completed with state |
|
PR_Github #62390 [ run ] completed with state
|
|
/bot run --disable-fail-fast |
|
PR_Github #62444 [ run ] triggered by Bot. Commit: |
|
/bot run |
|
PR_Github #62475 [ run ] triggered by Bot. Commit: |
|
PR_Github #62444 [ run ] completed with state |
|
PR_Github #62475 [ run ] completed with state
|
|
Pushed |
|
/bot run --disable-fail-fast |
|
PR_Github #62499 [ run ] triggered by Bot. Commit: |
|
PR_Github #62499 [ run ] completed with state
|
|
Pushed The other 4 failures in that run are unrelated to this PR (A100 |
|
/bot run --disable-fail-fast |
|
PR_Github #62528 [ run ] triggered by Bot. Commit: |
|
PR_Github #62528 [ run ] completed with state
|
|
Pushed Correctness: 9,515/9,515 cases pass the in-measurement tie-aware exactness check on real decode captures; 0 harness failures. Performance vs the #16457 kernel: gm 1.3996x, floor 0.9516x, zero cases below 0.909 (no case more than 10% slower), win rate 67.2%. Per-model 1.4056 / 1.4063 / 1.3772; all 109 production layers win (worst 1.273x). The run also isolated the cost of the follow-up work itself: in-tree arm drift 1.0051x, bsx arm drift 1.0060x vs the previous full grid — the comment prune, policy de-duplication, 16-bit exact-tail enablement, P4 helper extraction and plateau terminal are all performance-neutral. It additionally caught a mistake in the earlier band recalibration: the nearest-power-of-two bucket for 131072 mixes npad=131136 (which holds the layers that force the routing) with npad~163776 (which only rounds into that bucket and runs 1.36-1.60x ahead), so the bs=8 extension was giving up 58 winning cells to protect 5. |
|
/bot run --disable-fail-fast |
|
PR_Github #62620 [ run ] triggered by Bot. Commit: |
…ived wave budget, env soft-fail, shared tie-aware checker, 16-bit exact-tail default revert Address the five review comments on NVIDIA#16877: 1. order_row: the guard now ACCEPTS and ignores order_row (the LJF hint dsa.py computes for every batch with num_rows >= 2*num_sms). The bsx tiers launch per-row CTAs and never consume the permutation, so rejecting it silently turned bsx off for exactly the large-batch shapes the fallback-band table keeps in service; accepting it aligns deployment behavior with the benched natural-row-order mesh. New TRTLLM_BSX_DISABLE kill switch replaces order_row as the test-side (and operational) force-in-tree mechanism. New test asserts guard acceptance, per-row index-set equality with/without the permutation, and the num_rows >= 2*num_sms production shape. 2. tp_cluster_size: the 296 co-residency budget is now derived as 2 * multi_processor_count (cached; bit-identical on B200), with an explicit note that every other constant remains frozen B200 calibration (family measured HW-invariant in prior cross-arch A/Bs). 3. 16-bit p4_exact_tail default REVERTED to fp32-only (measured): 16-bit quantization puts value plateaus at the K boundary on virtually every input, so the ambiguity gate fires constantly - B200 envelope (bf16 K512/K1024 x 16k-262k x BS 1-256, same-process paired, 26 cells x 2 input kinds) costs gm 1.29-1.36x, worst 2.27x, while typical bf16 inputs are already value-exact without the tail (48/48 paired runs). The repair stays available via the knob; the adversarial 16-bit test now opts in explicitly. 4. env knobs: malformed values fail soft (warn once + baked default) instead of raising ValueError on the first decode step; whitespace tolerated. Covered by a new soft-fail test. 5. _tie_aware_check: single canonical implementation moved to conftest.py, injected as the tie_aware_check fixture (collected by pytest regardless of rootdir/package-resolution style); both test modules drop their copies. The gvr module keeps its pinned-inputs ref-vals memo via the checker's ref_vals_cache parameter. Both suites green locally on B200 (bsx 94 passed / 8 skipped, gvr 684 passed / 144 skipped). Signed-off-by: longcheng-nv <243710427+longcheng-nv@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed
Both suites green locally on B200 at the new head (bsx 94 passed / 8 skipped; gvr 684 passed / 144 skipped). Note on the last CI round (pipeline 51940): all 5 failures were infrastructure — 4x H100 CPP unit tests died in cmake configure on a nanobind /bot run --disable-fail-fast |
|
/bot run --disable-fail-fast |
There was a problem hiding this comment.
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/attention/sparse/conftest.py`:
- Around line 28-36: Update the _tie_aware_check_impl signature so
ref_vals_cache uses the explicit type dict[tuple[int, int, int, int, int],
torch.Tensor] | None instead of dict = None, preserving its default value and
existing cache behavior.
🪄 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: af540bb7-a420-40cc-9959-ac1ae0c3c679
📒 Files selected for processing (7)
tensorrt_llm/_torch/custom_ops/cute_dsl_custom_ops.pytensorrt_llm/_torch/cute_dsl_kernels/blackwell/top_k/gvr_topk_decode.pytensorrt_llm/_torch/cute_dsl_kernels/blackwell/top_k/gvr_topk_decode_bsx_dispatch.pytensorrt_llm/_torch/cute_dsl_kernels/blackwell/top_k/gvr_topk_decode_bsx_tp.pytests/unittest/_torch/attention/sparse/conftest.pytests/unittest/_torch/attention/sparse/test_cute_dsl_bsx_topk_decode.pytests/unittest/_torch/attention/sparse/test_cute_dsl_gvr_topk_decode.py
🚧 Files skipped from review as they are similar to previous changes (2)
- tensorrt_llm/_torch/cute_dsl_kernels/blackwell/top_k/gvr_topk_decode.py
- tensorrt_llm/_torch/cute_dsl_kernels/blackwell/top_k/gvr_topk_decode_bsx_tp.py
|
PR_Github #65208 [ run ] triggered by Bot. Commit: |
…F013) Signed-off-by: longcheng-nv <243710427+longcheng-nv@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
/bot run --disable-fail-fast |
|
PR_Github #65212 [ run ] triggered by Bot. Commit: |
|
PR_Github #65208 [ run ] completed with state |
|
/bot run --disable-fail-fast |
|
CI update (pipeline 52997, commit The 3 remaining failures are unrelated to this PR: Re-triggering to get a clean run. |
|
/bot run --disable-fail-fast |
|
PR_Github #65212 [ run ] completed with state
|
|
PR_Github #65258 [ run ] triggered by Bot. Commit: |
|
PR_Github #65258 [ run ] completed with state |
| # cr in {1, 4} / npad <= 262144 decode rows route to the | ||
| # direct/reg/tp CuTe DSL tiers; everything else (half-prec, LB, | ||
| # oversize npad, hw cluster cap) falls through to the in-tree | ||
| # kernel below. ``order_row`` (the LJF hint dsa.py computes for |
There was a problem hiding this comment.
Why ignore the order_row ? It's better to make the behavior keep consistent with the paramter.
There was a problem hiding this comment.
order_row only tells the in-tree persistent kernel which rows to start first (longest-job-first, so a long row does not start last and become the straggler). It never changes results: output row i always holds row i's top-K, whatever the order. The tiers launch one CTA (or cluster) per row and the hardware scheduler decides execution order, so the hint has nothing left to control.
Being "consistent" the other way, i.e. rejecting calls that carry it, would switch the tiers off for every batch with num_rows >= 2*num_sms, because dsa.py sends the hint for all such batches, and those are the shapes the tp tier exists for.
brnguyen2 raised the same question in the thread above. The accept-and-ignore contract is documented in the dispatcher docstring, and test_tiers_accepts_order_row checks both halves: the index sets do not change under the permutation, and a large batch with order_row stays on the tiers.
| # Host-only guard — no device sync. The op signature and output | ||
| # contract are unchanged (unordered int32 indices, -1 pad only | ||
| # for degenerate rows). | ||
| if _is_bsx_supported(logits, pre_idx, seq_lens, output_indices, |
There was a problem hiding this comment.
I think the name of bsx is not very good, whose meaning is not easy to follow. The new updates in this MR mainly related to how to read/resident the logits. Current naming and switch method seems like a new kinds of algo. How about the following naming?
gvr_topk_decode_reg/smem.py: read/resident logits into registers/smem.
gvr_topk_decode_fallback.py 【direct】: don't need gvr, fallback to radix impl.
gvr_topk_decode_streaming.py: not resident, every time we load from gmem.
Please don't use direct/tp like naming. It's not very clear.
Note: I just give an example.
There was a problem hiding this comment.
Agreed, and done in 4e4abbd — the working name is gone. You are right that these are optimizations of the same GVR algorithm (the tiers differ only in where the row's logits are resident while Guess–Verify–Refine runs), so the naming now says exactly that: files are gvr_topk_decode_{dispatch,direct,reg,tp}.py, the op-facing symbols are tiered_topk / is_tiered_topk_supported, the env knobs are TRTLLM_GVR_* (kill switch: TRTLLM_GVR_TIERS_DISABLE), and the test module is test_cute_dsl_gvr_topk_tiers.py. On your reg/smem suggestion: reg (register-resident) and direct (whole-row collect) match your read/resident framing; the third tier streams the row rather than holding it in SMEM, so it keeps its tp (throughput) name. Pure rename commit, zero logic change; both suites green post-rename (tiers 95, GVR 684). PR title and description updated to "tiered GVR".
| return fn | ||
|
|
||
|
|
||
| def is_bsx_supported( |
There was a problem hiding this comment.
I would suggest remove the bsx concept. And switch kernels directly from the previous existing gvr kernel impl and the newly add kenrel variants.
There was a problem hiding this comment.
The naming half is done in 4e4abbd (no more "bsx" anywhere — see the reply on the naming thread). On folding the switch into the previous kernel instead of a dispatcher module: the routing is deliberately a separate host-pure module rather than branches inside the existing kernel's launcher, for three reasons. (1) It is not one switch — it is a guard (dtype/shape/alignment/hw-cluster-cap), a measured per-(npad,BS) fallback-band table, a tier route, and an env-knob family with caching; inlining that into the existing kernel file would couple its correctness-critical code to routing policy that will be recalibrated. (2) The dispatcher is the documented operational surface (kill switch + knobs in one docstring) and is what the unit tests pin (route table, band table, env soft-fail, cluster-cap memoization). (3) The previous kernel stays a plain callable with zero new coupling — it is also the fallback arm the differential tests compare against, which requires it to be reachable without the new code. So the concept that remains is just "the GVR tiers + their dispatcher", named as such.
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """BSX throughput (tp) GVR Top-K tier — CuTe DSL, Blackwell SM100. |
There was a problem hiding this comment.
What's the difference between this kernel and the previous exiting kernel?
There was a problem hiding this comment.
Both run the same Guess-Verify-Refine loop and return the same contract. The differences are in how it executes, and they matter specifically at large batch:
-
The existing kernel finds the cutoff by re-counting: several passes over the row, each with a tighter threshold. That is cheap while the row sits in L2. At BS 16-1024 the batch working set is hundreds of MB, rows are not L2-resident, and every extra pass is a DRAM pass. So tp samples the row (every 32nd float4) to estimate the cutoff once, then makes a single streaming pass that counts and collects candidates at the same time. A bad estimate costs one re-stream, and the hint ladder from the previous decode step makes bad estimates rare.
-
The existing kernel is persistent: one cluster cooperates on one row, split into slices. tp launches
BS x CSCTAs, one cluster per row, with CS picked so two waves of rows fill the SMs (min_blocks_per_mp=2). Its parallelism scales with the batch rather than the row. -
Candidates are packed
(key<<32 | idx)u64 into CTA0's SMEM (capped at kC), and CTA0 alone runs the final 4x8-bit radix select with tie-aware emit. The existing kernel instead keeps per-slice candidates and selects cooperatively across the row slices. -
Scope: tp is fp32-only, no LB mode, npad <= 262144. The existing kernel keeps everything else, plus the mid-N shapes where L2-resident re-counting genuinely wins; the fallback-band table routes those back to it.
The module docstring maps each phase (P1/P2a/P2b/P2c/P3/P4) to the CUDA original.
| from cutlass.cutlass_dsl import dsl_user_op | ||
| from cutlass.utils.smem_allocator import SmemAllocator | ||
|
|
||
| from .gvr_topk_decode_bsx_tp import ( |
There was a problem hiding this comment.
How about abstact the commons into a util file, and the use them in the kernel file.
There was a problem hiding this comment.
They are shared already, through inheritance rather than a util file: GvrRegKernel subclasses GvrTpKernel (this import block), so Phase 1, _row_n_eff, exchange_counts and the DSMEM idioms come from the tp module, along with the 11 imported PTX wrappers (_f2u_bits, _shfl_*, _mapa_shared_cluster, ...).
The few helpers defined locally differ on purpose rather than being copies: the atomicAdd here is CTA-scope relaxed because a wider scope stops ptxas from warp-aggregating it (docstring note 1), and the cluster sync is the aligned variant that cg::cluster.sync() parity needs. The direct tier has nothing worth extracting: no GVR loop, just a whole-row radix select.
If you would rather have the shared helpers in a neutral _ptx_common.py so the tp file is not their owner, that is a mechanical move and I can do it in this PR. I held off only to avoid touching all four files again right after the rename.
|
How about the perf on varlen inputs? |
brnguyen2
left a comment
There was a problem hiding this comment.
Remaining items are description-level; the code itself looks ready.
-
Ticket: a new kernel family of this size (+5.9k lines, production dispatch change) should carry a TRTLLM JIRA ticket in the title rather than
[None]. -
Description under-reports the in-tree kernel changes. The description says everything outside the guard "falls back to the #16457 kernel unchanged", but this PR changes that kernel too: the Phase-2 plateau terminal (
done=3) plus the Phase-4 plateau fill change output on rows whose boundary tie class is wider than kC (previously the give-up path could emit-1pads / arrival-order picks; now they're tie-aware filled — an improvement, but a behavior change a bisect will land on), the exact-tail radix select was deduplicated into_p4_exact_tail_radix_select, and the launch-tuning policy moved intoGvrTopKKernel.pick_tuning/pick_cluster_sizewith the runner as an adapter. Please list these in the description so in-tree output changes are attributable to this PR. -
Kill switch visibility:
TRTLLM_BSX_DISABLEis the operational escape hatch for the new path; it deserves a line in the PR description so on-call can find it without reading the dispatcher docstring.
| return False | ||
| # Cluster cap: fall back to the in-tree kernel (dispatcher-level) rather | ||
| # than silently degrading the tier's cluster shape. | ||
| if route_cluster_size(bs, npad, top_k) > _query_max_cluster_size(): |
There was a problem hiding this comment.
route_cluster_size re-runs route() + the _parse_reg string parse on every call, and is_bsx_supported runs on every eager forward() — so the ~2.5-3µs host cost that the _DISPATCH_CACHE comment (line 234) says motivated caching the dispatch is still paid per call by the guard, on kernels the same comment says run <20µs. Memoize the cluster-cap verdict keyed on (bs, npad, top_k) (cleared in _reset_env_cache, like _DISPATCH_CACHE), or fold the cap check into _bind so one cache covers both. Irrelevant under CUDA-graph replay, but the eager decode path pays it.
There was a problem hiding this comment.
Fixed in abee8b8 — the cluster-cap verdict is memoized exactly as you framed it: _CAP_OK_CACHE keyed on (bs, npad, top_k), populated on first miss in is_bsx_supported, and cleared by _reset_env_cache() alongside _DISPATCH_CACHE (the verdict embeds the routed tier, so it inherits the same env dependence). Steady-state eager forward() now pays a dict hit instead of route() + _parse_reg + tp_cluster_size. Covered by test_bsx_cluster_cap_verdict_memoized (verdict correctness vs a fresh recompute, memo population, and reset clearing).
| locals; :func:`_reset_env_cache` re-reads for tests): | ||
| TRTLLM_BSX_TP_BS unset/-1 -> baked per-npad bands; 0 -> disable (2^30); | ||
| else the bs threshold at which the tp tier takes over. | ||
| TRTLLM_BSX_DENSE_BS same, for the dense (tb=1024) reg tiers. |
There was a problem hiding this comment.
The env-knob list here omits TRTLLM_BSX_FALLBACK_BANDS (introduced at line 131, tested via the bands fixture). Since this docstring is the reference for the knob family, add it — one line: 0 disables the fallback-band table so bsx serves every guarded shape.
There was a problem hiding this comment.
Fixed in abee8b8 — TRTLLM_BSX_FALLBACK_BANDS added to the docstring knob list (0 disables the measured fallback-band table so bsx serves every guarded shape; unset/other keeps it active). It parses through _env_threshold, so the existing malformed-values-fail-soft note already covers it.
| here — the import runs the other way). NOTE: only this term scales | ||
| with the device; every other constant in ``tp_cluster_size`` and the | ||
| dispatch band tables is frozen B200 calibration (the kernel family | ||
| measured HW-invariant across B200/B300 in the op9/op17 cross-arch |
There was a problem hiding this comment.
Internal development codenames (op9/op17 here, op#26 at lines 38 and 1201) survived the codename-scrub commit (2a1950c), which removed the same references from gvr_topk_decode.py. Rephrase to match — e.g. "in earlier cross-arch A/Bs" — so the scrub is complete.
There was a problem hiding this comment.
Fixed in abee8b8 — all three survivors scrubbed to match the codename-scrub commit conventions: lines 38 and 1201 keep the load-bearing "in-tree R0 parity" anchor and drop the campaign reference; the _two_waves_rows comment now reads "measured HW-invariant in earlier B200/B300 cross-arch A/Bs" (your phrasing, with the two architectures kept since that is the fact the device-independent bands rest on). Also re-swept every file this PR touches with the codename patterns — zero remaining hits.
…ob, scrub leftover codenames; pin gvr tests to the in-tree path Review follow-ups (PR NVIDIA#16877): - dispatch: is_bsx_supported re-ran route()+_parse_reg (~2.5-3us host) on every eager forward — the cost _DISPATCH_CACHE exists to avoid. Memoize the cluster-cap verdict per (bs, npad, K); cleared by _reset_env_cache since routes depend on the env thresholds. Covered by test_bsx_cluster_cap_verdict_memoized. - dispatch: document TRTLLM_BSX_FALLBACK_BANDS in the module-docstring env-knob list (it was introduced with the band table but missing from the knob family reference). - bsx_tp: scrub three internal development codenames that survived the codename-scrub commit (two pre-existing, one introduced by the SM-count follow-up); neutral phrasing, load-bearing facts kept. - gvr tests: autouse fixture pins TRTLLM_BSX_DISABLE=1 — this module tests the in-tree kernel contract, but its op-level fp32 cases fall inside the bsx envelope and were silently routed to the bsx tiers (which also ignore the explicit cluster_size they parametrize over). Mirrors (inverted) the bands-off fixture in the bsx test module. Tested: bsx dispatcher/guard subset 9 passed; gvr fp32/K2048 op-level subset 96 passed / 32 skipped (pre-existing skips) on B200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: longcheng-nv <243710427+longcheng-nv@users.noreply.github.com>
|
/bot run |
|
PR_Github #65313 [ run ] triggered by Bot. Commit: |
|
PR_Github #65313 [ run ] completed with state
|
…ons, not a different algorithm Review follow-up (@limin2021): pure rename, zero logic change. The three tiers run the same Guess-Verify-Refine algorithm as the existing kernel and differ only in where the row's logits are resident (direct / reg / tp), so the development codename goes away in favor of GVR-family names: - files: gvr_topk_decode_bsx_{dispatch,direct,reg,tp}.py -> gvr_topk_decode_{dispatch,direct,reg,tp}.py (git mv); test_cute_dsl_bsx_topk_decode.py -> test_cute_dsl_gvr_topk_tiers.py (l0_b300.yml updated) - symbols: bsx_topk -> tiered_topk, is_bsx_supported -> is_tiered_topk_supported - env knobs: TRTLLM_BSX_{DISABLE,FALLBACK_BANDS,TP_BS,DENSE_BS} -> TRTLLM_GVR_{TIERS_DISABLE,FALLBACK_BANDS,TP_BS,DENSE_BS} - the unreachable 'gvr(cs=16,tb=512)' route label becomes 'cluster(cs=16,tb=512)' so 'GVR' unambiguously names the algorithm family, not one tier - prose: 'bsx' -> 'the (GVR) tiers'; 'in-tree kernel' still names the pre-existing single kernel (gvr_topk_decode.py) Tested post-rename on B200: tiers suite 95 passed / 8 skipped; GVR suite 684 passed / 144 skipped (4-way GPU-sharded, counts identical to the pre-rename baseline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: longcheng-nv <243710427+longcheng-nv@users.noreply.github.com>
|
/bot run |
|
PR_Github #65321 [ run ] triggered by Bot. Commit: |
|
PR_Github #65321 [ run ] completed with state
|
What
Adds a tiered GVR CuTe DSL top-K family (three tiers behind one host-side dispatcher) as a guarded fp32 fast path inside the existing
trtllm::cute_dsl_gvr_topk_decodeop — signature unchanged, no call-site change; everything outside the guard (bf16/fp16, load-balance mode, npad > 256K, banded shapes) takes the in-tree #16457 kernel. Rebased ontomain. Net result on 9,515 real decode-capture cases: gm 1.3996× vs the in-tree kernel, worst case 0.9516× — no case regresses more than 10%.Operational kill switch:
TRTLLM_GVR_TIERS_DISABLE=1disables the tiered fast path entirely — every call takes the in-tree kernel path. Read once per process at first use (TRTLLM_GVR_FALLBACK_BANDS/TRTLLM_GVR_TP_BS/TRTLLM_GVR_DENSE_BStune routing; see the guard details in Design).This PR also changes the in-tree kernel itself (the #16457 review follow-ups; full detail in the follow-ups section below). What a bisect landing here should know:
done=3) plus the Phase-4 plateau fill now complete the row tie-aware — previously the give-up path could emit-1pads or arrival-order picks. Both old and new outputs are "some K indices"; the new one is a valid tie-aware top-K, and the emitted index set on such rows changes with this PR.p4_exact_tailgains 16-bit support but the default stays fp32-only (forcing it on bf16 measured gm 1.29–1.36× slower, worst 2.27×); bf16/fp16 production behavior is unchanged._p4_exact_tail_radix_select(byte-identical PTX verified for thep4_tail_fast=Falsevariant); the launch-tuning policy moves intoGvrTopKKernel.pick_tuning/pick_cluster_sizewith the runner as a thin adapter (sweep test pins runner == kernel policy).Naming (review follow-up): the family's development codename "BSX" is gone — the tiers are optimizations of the same GVR algorithm, not a different one, so files are
gvr_topk_decode_{dispatch,direct,reg,tp}.py, the op-facing symbols aretiered_topk/is_tiered_topk_supported, and the env knobs areTRTLLM_GVR_*.Design
The problem being solved. Each decode step, the DSA indexer hands this op a batch of rows; each row is up to 262,144 fp32 scores, and the op must return the indices of the K largest (K = 512/1024/2048). The op runs every layer, every step, so it needs to be fast at every batch size from 1 to 1024.
The one idea everything builds on: if you somehow knew the value of the K-th largest score, selection would be a single cheap pass — keep everything above that cutoff. So the whole game is getting a good cutoff estimate cheaply, and then proving it safe before trusting it. That is GVR (Guess–Verify–Refine):
The guess quality only affects speed, never correctness: every emitted index is justified by exact counts on the current row.
Kernel selection. One host-side chain of four questions picks the implementation per call (pure function of
(BS, npad, K, dtype)— no device sync, CUDA-graph safe):flowchart TD OP["cute_dsl_gvr_topk_decode(...)"] --> G{"① dtype and shape<br/>supported by the tiers?"} G -- no --> IT["in-tree #16457 kernel"] G -- yes --> BAND{"② is (npad, BS) a bucket where<br/>the in-tree kernel measured faster?"} BAND -- yes --> IT BAND -- no --> TPQ{"③ batch big enough<br/>to stream (tp)?"} TPQ -- yes --> TP(["tp tier"]) TPQ -- no --> DQ{"④ row short enough<br/>to collect whole?"} DQ -- yes --> DIR(["direct tier"]) DQ -- no --> REG(["reg tier"])The same chain, evaluated over the whole plane — where any (npad, BS) call lands (identical for K = 512/1024/2048; generated from the dispatch code):
The three GVR tiers run the same Guess–Verify–Refine loop; they differ in where the row's data lives while it runs, which is what actually decides speed at each shape:
direct(short rows, npad ≤ 12288): the row is small enough to skip guessing entirely. One CTA loads the whole row and runs one exact radix select over it. There is nothing to estimate when you can afford to look at everything.reg(longer rows, small batch): the row is too big to skip estimation but the batch is small, so latency is what matters — and the enemy of latency is touching DRAM twice. The CTA(s) assigned to a row load it into registers once; the Guess (from the previous step's top-K), every Verify count, and the final Refine all run against those registers. DRAM is read exactly one time per element, period. Two variants exist (reg-L with 512-thread blocks for the smallest batches, reg-D with 1024-thread blocks when there are enough rows to keep the machine busy); the dispatch table picks per shape.tp(large batch): with hundreds of rows in flight the machine is throughput-bound, and holding every row in registers no longer pays. Each row is streamed: first a small sample of the row estimates the cutoff (no per-row history needed at this scale), then one pass over the row keeps only the scores above it. A statistical guard rail ("lean-pivot admission") kicks in when the sample says the kept set came out much fatter than K — it tightens the cutoff before the expensive part rather than after. Rows short enough to fit the candidate buffer (npad ≤ kC) skip all of this and just collect everything.in-tree #16457is the dtype-generic GVR kernel already onmain, unchanged. It serves everything the tiers decline (bf16/fp16, load-balance mode, npad > 256K, cluster shapes over the device limit) — and the map's mid-table cells, where it is genuinely faster: those rows fit in L2 cache, so re-scanning a cached row is nearly free, and its strategy of repeatedly re-counting to shrink the kept set beats the tiers' one-shot estimates there (measured, >1.10× on at least one production layer per routed bucket). Routing those buckets back is what caps this PR's worst case at 0.95× by construction.Reading the map's other edges: at small batch,
directgives way toregexactly where the row stops fitting one CTA's buffer (12288); at large batch,tptakes over at a BS threshold that drops as rows get longer (256 → 128 → 16) — a longer row is more work per row, so fewer rows are needed before streaming keeps the whole GPU busy. Band boundaries sit at values like 24576 and 98304 because the fallback table buckets npad by nearest power of two.Guard details (①): the tiers require fp32 logits, K ∈ {512, 1024, 2048}, cr ∈ {1, 4}, next_n ≥ 1 with num_rows divisible by next_n, npad ≤ 262144 and a multiple of 64, contiguous 16B-aligned tensors, and the routed tier's cluster size within the device limit.
order_row(the row-scheduling hintdsa.pysends for every batch with num_rows ≥ 2×num_sms) is accepted and ignored — the tiers launch per-row CTAs and never consumes the permutation.counters(load-balance mode) always takes the in-tree path. Env controls:TRTLLM_GVR_TIERS_DISABLE=1turns the fast path off entirely,TRTLLM_GVR_FALLBACK_BANDS=0disables the band table (②),TRTLLM_GVR_TP_BS/TRTLLM_GVR_DENSE_BSoverride the tier thresholds (③); malformed values log a warning and fall back to the baked defaults instead of failing the decode step.Performance (B200, fp32, real SWE-bench decode captures, paired same-rep cold-L2 nsys)
Full-mesh re-measure of this PR's head — 865 real decode-capture cells x 11 batch sizes = 9,515 paired cases, 8 GPUs, 0 harness failures, and a tie-aware exactness check inside every case (see Correctness).
vs the #16457 kernel now on
main, shipped operator (band table on):By model: K=2048/cr=1 1.4056x · K=1024/cr=4 1.4063x · K=512/cr=4 1.3772x
By sequence length (npad = post-compression row width; token length ~ npad x cr):
By batch size:
By layer: all 109/109 production layers win — worst 1.273x, medians 1.37-1.41x, best 1.540x. (The loss tail is a per-(layer, shape) phenomenon; the band table absorbs it.)
The same run also isolates the cost of everything added on top of the reviewed tier commits: measured against the previous full grid, the in-tree arm drifts 1.0051x and the tier arm 1.0060x — i.e. the follow-up work (comment prune, launch-policy de-duplication, 16-bit exact-tail capability, P4 helper extraction, plateau terminal) costs nothing measurable on either side.
Development A/B additionally falsified (measured, component-isolated): exact-count admission as a wholesale replacement (gain and harm co-sourced), a K-scaled candidate-budget diet (pure harm), ladder-quantile re-placement under the shipped admission, cluster-size occupancy cuts, multi-pass-straggler hypotheses (the residual band is single-pass; the gap is candidate-set fatness), and a conditional lean-pivot for the register tier (short kernels cannot amortise an extra cluster round trip).
Review follow-ups from #16457 (resolved here)
The follow-up items committed to reviewers on #16457 all land in this PR (
f47dda5c,3959d327):pick_configsplit intopick_cluster_size+pick_tuning(kernel = single source); runner_pick_tuningis now a thin adapter, cluster auto-pick delegates; the intentional divergence is documented (runner asserts on 32B misalignment,launchdowngrades); new sweep test pins runner == kernel policyp4_exact_tail=Trueis exact for every dtype; new adversarial test (two distinct 16-bit values in one fine bin straddling K, fp16 + bf16) opts in explicitly@cute.jithelper; byte-identical PTX verified for thep4_tail_fast=Falsevariant (465,875 B)3959d327). Confirmed site: the admission path's tie-plateau fail-soft landeddone=1on the undershoot side, so Phase 4 padded the tail with-1. Both terminals now first collapse the bracket by bounded bisection to ADJACENT floats — every in-bracket value is then bitwise-equal, a genuine tie class — so Phase 4 emits the sure winners and a ticketed fill completes the row from that class (any (K-count)-subset of a tie class is a valid tie-aware completion). The guard requires a coherent undershoot-overflow bracket with both counts current, so the retry path's widened brackets are excluded; non-plateau undershoot keeps the documented-1encoding. New adversarial test: a plateau wider than the candidate buffer straddling K, fp32 + fp16 x {rank-scatter cs=1, cs=4, histogram-snap}, 6/6. Implementation note now in the code: the terminal is captured into a dedicated SMEM slot before Phase 4, because Phase 4 reuses that scalar slot as radix scratch. Follow-up (9eadcf20): Phase 2 has two secant drivers — the SMEM/leader one and a register-resident redundant-warp one used atcluster_size == 1— and the terminal initially landed only in the former, so the same plateau still padded with-1on the classic admission path. Both terminals are now mirrored into the register-resident driver (warp-uniform, so the counting barrier cadence is unchanged), and the adversarial matrix grew to 5 variants × 2 dtypes (addingenable_r0=Falseat cluster size 1 and 4, the route that exposed the gap), 10/10.Gate for the above: GVR top-K suite 684 passed / 144 skipped, tiers suite 94 passed / 8 skipped (order_row acceptance, kill switch, env soft-fail tests added in the review round), plus the full-mesh re-measure in the Performance section.
Test Coverage
test_cute_dsl_gvr_topk_tiers.py(103 cases, shared tie-aware checker inconftest.pyas a fixture, registered inl0_b300.yml; CI wall-clock managed by pinning cases onto a minimal covering set of JIT variants: 570 s → 306 s, no code-path loss).test_cute_dsl_gvr_topk_decode.pyfull suite green.Draft checklist (before ready-for-review)
PR Checklist
🤖 Generated with Claude Code
Dev Engineer Review
trtllm::cute_dsl_gvr_topk_decode.#16457kernel for unsupported cases.K,next_n, compression ratio, hardware limits, and calibrated fallback bands.TRTLLM_BSX_DISABLEand soft-failing environment configuration.GvrTopKKernel.pick_cluster_sizeandGvrTopKKernel.pick_tuning.CODING_GUIDELINES.md.QA Engineer Review
Test changes
test_cute_dsl_bsx_topk_decode.pywith coverage for:pre_idx, poisoned tails, ties, and MTP behavior.test_cute_dsl_gvr_topk_decode.pywith:tie_aware_checkfixture inconftest.py.tests/integration/test_lists/test-db/l0_b300.ymlto exclude the BSX and GVR test modules from thepre_mergePyTorch attention list.Coverage verdict
l0_b300.yml, but the entries exclude them from that test list.