[TRTLLM-15293][perf] Add tiered GVR CuTe DSL top-k decode kernels (stacked on #16457) - #16877
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: |
|
/bot run --disable-fail-fast |
|
PR_Github #65434 [ run ] completed with state
|
|
PR_Github #65480 [ run ] triggered by Bot. Commit: |
|
PR_Github #65480 [ run ] completed with state |
brnguyen2
left a comment
There was a problem hiding this comment.
Approving — the comments below are optional touch-ups, not blockers.
The correctness story for the ~3.4k lines of new tier kernels rests on the test matrix rather than line review, and the matrix holds up well — route table pinned host-side, knobs/kill-switch/memoization covered, adversarial tie plateaus, MTP, degenerate rows, and graph capture/replay all exercised. I traced the in-tree plateau-terminal change end to end (scratch-slot capture, fill ticket accounting, Phase-3 interaction) and it is sound; the two inline comments are follow-up material, not blockers.
One operational note beyond the inlines: TRTLLM_GVR_TIERS_DISABLE is the lever someone will reach for during an incident, and today it is discoverable only in the dispatch module docstring. A line in the perf/troubleshooting docs would make it findable without reading kernel source.
The merge of main (which brought in NVIDIA#16877) resolved the shared kernel file toward this branch's emission work and unintentionally dropped the plateau-terminal feature added there (4642d92, e382f98): when a bitwise-equal tie plateau wider than the candidate buffer straddles the K boundary, the bracket admits no threshold and the row previously fell through to the legacy give-up, leaving -1 pads (CI: 10/10 plateau_terminal params failed at the merge head). Port both commits onto the current drivers: - leader driver: keep this branch's slope-fit retry loop and append the budget-exhausted bisection collapse behind it (coherent undershoot-overflow guard; the retry's bracket widening marks a side stale with -1 and fails the guard) -> adjacent-float bracket sets done = 3 and a recount at the terminal threshold feeds Phase 3; - phase2_secant_search: same post-loop collapse ahead of the legacy give-up; - register-resident redundant driver: adjacent-float terminal inside the refine loop plus the post-loop collapse, warp-uniform by replay; - Phase 4: plateau fill from the tie class (ticket in the dedicated s_iscalars[7], seeded from the pre-P4 cand_count_p4 snapshot - the s_iscalars[0] slot is radix scratch by then), pad guard keyed on the captured s_iscalars[6] flag, for both the cs=1 and cs>1 leader paths. B200: plateau_terminal 10/10; full gvr decode file 744 passed / 1 xpassed / 0 failed; tiers file 95 passed / 0 failed. Signed-off-by: siyidNV <297196620+siyidNV@users.noreply.github.com>
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).
Varlen characteristics (length-mixed batches)
The grid above measures uniform-length batches (all rows at the cell's npad). On length-mixed batches both tiers lose to the in-tree kernel: they partition work by npad, not n_eff (
gvr_topk_decode_tp.py:1104,gvr_topk_decode_reg.py:386), so a short row in a wide batch still pays full-width DRAM traffic and degrades the sampled pivot, while the in-tree kernel sizes each row's work from seq_lens. Real-capture paired A/B, ratio = in-tree / tier time (uniform / even-mix ragged / 90-10 bimodal; * = order_row passed per the production rule; method and fix plan in this comment):The perf claims in this PR are therefore per-shape (uniform-length) claims. The varlen fix (n_eff-derived slicing and sampling, order_row honoring, then a
gvr_topk_decode_load_balance.py-style prepare/branch split if still needed) is deferred to a follow-up PR to keep this one at the reviewed kernel scope. Escape hatch until it lands:TRTLLM_GVR_TIERS_DISABLE=1.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.