[Kernel] Harden top_k_per_row against NaN and under-filled output - #50201
[Kernel] Harden top_k_per_row against NaN and under-filled output#50201Smallfu666 wants to merge 1 commit into
Conversation
…nder-filled output The histogram path of topKPerRowJob (top_k_per_row_prefill / top_k_per_row_decode) mishandles NaN logits (issue vllm-project#49896): - extractBinIdx bins positive-NaN bit patterns above +inf, so NaN-dominated rows put the threshold bin on a NaN bin. - The insertion-sort final pass compares NaN logits (all comparisons false), so every threshold-bin element computes outIndex 0: one slot is written repeatedly and the remaining smemOutput slots keep uninitialized shared memory, which the unconditional final store loop then emits as indices (observed: 2143289344 = 0x7FC00000, a float32 NaN bit pattern reinterpreted as an int32 index). Downstream consumers gather block_table with these indices -> illegal memory access (DeepSeek-V4 long prefill on SM12x). Fix (data-driven, arch-independent): - Exclude NaN logits from selection in both histogram phases, via a bit-pattern test that is robust under --use_fast_math. - Pre-fill smemOutput with -1 (and -FLT_MAX for the logits half in the multiple-blocks-per-row variant) so under-filled rows produce the documented -1 padding of the short-row path instead of leaking uninitialized shared memory. - Default the per-step threshold state to "emit every matching element" so rows with fewer than topK non-NaN candidates terminate cleanly instead of reading an unset threshold bin. - Initialize the radix-sort padding indices to -1 so padding lanes can never write garbage into smemOutput. - Preserve -1 padding in the strided final store (do not rebase the sentinel by rowStart). Adds NaN-input regression tests covering the prefill insertion-sort and radix-sort variants and all three decode paths (insertion, radix, split+merge). Validated on NVIDIA H200 (SM90): the 25-line canary from vllm-project#49896 fails before the fix (garbage indices -> device-side gather asserts) and passes after; the existing test_top_k_per_row.py suite passes unchanged. Per the issue, the defect is data-driven (NaN input) rather than SM120-specific. Signed-off-by: Han-Yin Chang <nick20350@gmail.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
|
I have a GB10 (DGX Spark, sm_121) and the thread only has sm_90 and sm_120 so far, so I built both sides of this and ran the canary from #49896. Built Short version: the fix works here.
Five runs at 83%, clean every time, and nothing outside Worth flagging about this platform: the published 0.26.0 aarch64 wheel has no sm_121 cubin and no PTX, so a GB10 is actually running the sm_120 binary. I ran the A/B under the default codegen and again forcing One thing I think is worth a second look, and I might be wrong about it. The canary's "unwritten" count doesn't seem to be measuring unwritten slots. Every output slot always gets written: the store loop at the end of the histogram path is unconditional over I checked by keeping the input byte-identical and only changing the sentinel. If that's right, the ~500 unwritten rows on sm_120 and the 0 to 95 I see here aren't an architecture difference, just different collision rates. The signals that held everywhere I looked were out-of-range indices and the Last thing, for the affected range: this also reproduces on the released 0.24.0 wheel, not just 0.25.1 and 0.26.0. Only Happy to run your new |
|
Thanks @yashb98 — sm_121 coverage is exactly what this thread was missing, and the note that the published aarch64 wheel runs the sm_120 binary on GB10 is useful on its own. On the sentinel observation: agreed. The final store loop is unconditional over One caveat: when the parent kernel aborts mid-run (the device-side assert we hit on H200 in the finite < top_k case), rows past the failure point may genuinely never reach the store — a separate condition from what you measured on completed runs. For the initialization side, compute-sanitizer initcheck on the patched build reported 0 errors; we did not run initcheck on the parent, so your sentinel A/B is the best direct evidence for the garbage interpretation there. On the affected range: we only tested back to 0.25.1, so I won't claim earlier from our side, but your 0.24.0 repro is consistent with the history — only And yes, sm_121 runs of |
|
Thanks, and agreed on the mid-run-abort point. My runs all completed, so what I measured is sentinel collisions on completed runs. Rows past a device-side assert are a different case, and I'll keep the two apart in any future wording. And since ck was only run on the patched build, the sentinel A/B stays the parent-side evidence. I won't generalize it past runs. On the sm_121 runs: yes, I'll take all three against your head.
Same toolchain as before (nvcc 13.0, gcc 13.3, torch 2.11.0+cu130, driver 580.142, aarch64). I'll post pass/fail and anything sm_121-specific that shows up. It's my only GPU and it has a queue, so give me a few days, but these go first. |
Purpose
Addresses the kernel-hardening portion of #49896: prevent
top_k_per_row_prefill(and the sharedtopKPerRowJobused by the decode/split variants) from emitting uninitialized or invalid indices when NaN values leave fewer thantop_kselectable candidates.Root cause in
csrc/libtorch_stable/sampler.cu:extractBinIdxbins positive-NaN bit patterns above +inf, so NaN-dominated rows place the histogram threshold bin on a NaN bin; the insertion-sort final pass's NaN comparisons all evaluate false, so every threshold-bin element writes slot 0 and the remainingsmemOutputslots keep uninitialized dynamic shared memory, which the unconditional final store loop emits as indices. With fewer thantop_knon-NaN candidates,smemThresholdBinIdxis additionally read unset. Downstream gathers with the garbage indices then hit device-side asserts / illegal memory access.Changes (kernel
+42/−1, tests+209):isNaNLogit()bit-pattern test (robust under--use_fast_math, HIP-compatible via__float_as_uint);distributeToBinsandprocessBins, kept consistent so histogram counts match prefix sums;top_kcandidates exist (smemThresholdBinIdx = kNumBins,smemFinalBinSize = 0);smemOutputpre-filled with-1(multi-block variant also-FLT_MAXlogit halves),finalIndices[ii] = -1init in the radix path, and a-1-preserving rebase in the strided final store — under-filled rows now return-1padding like the short-row path instead of garbage.Scope notes:
rowLen <= topK) keeps its documented "return all candidate positions" behavior (memory-safe); [Bugfix] Route DSv4 sparse-indexer prefill top-k around NaN-broken kernel path on SM12x #49897's torch fallback excludes NaN even for short rows — deliberate, called out for reviewers.Test Plan
Hardware: H200 / SM90 (driver 580.65.06), CUDA 13.0 (nvcc 13.0.48), torch 2.13.0+cu130, kernel built from source (
_C_stable_libtorch) for both parent and patched in the same job/toolchain, on top of current main (7f4c52f2). The defect is data-driven (NaN input), per the issue not SM120-specific — confirmed: the unmodified kernel fails identically on H200.-7, checking garbage / unwritten / out-of-range / NaN-selected rows).tests/kernels/test_top_k_per_row.py: 16 NaN cases covering prefill insertion-sort and radix-sort variants (4869-row and 12544-row shapes) and decode insertion / radix / split+merge (8192 / 16384 / 250000), NaN fractions 0.5 / 0.83 / 1.0, asserting: all indices in[-1, rowLen), no unwritten sentinel survives, exactlymin(top_k, num_finite)valid indices with-1suffix padding, and selected values equal to a NaN-excludedtorch.topkreference.test_top_k_per_row.py.compute-sanitizermemcheck on the canary + prefill NaN + decode NaN cases, and initcheck on the canary (patched build).Test Result
AcceleratorError); NaN tests FAILEDgarbage_rows=0 unwritten_rows=0 nan_selected_hist_rows=0 out_of_range_rows=0); tests assert finite indices +-1padding-1on histogram rowscompute-sanitizer: memcheck ×3 and initcheck ×1 allERROR SUMMARY: 0 errors..soswapped back → 16 failed; patched restored → canary PASS.