Repository navigation
[HIP] [Bugfix] sample_kernels: partial-vector and prologue-tail defects - #7373
Conversation
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
One backend per PR: PR title tags & labels: |
READY FOR HUMAN REVIEWPR Self-ReviewRevision Author self-review accepted. Readiness report: Author answers
Show the author's self-review answersBehavior and intentValidation and unverified scopePR-specific questionChanged op testsPR state
Copilot findings
Code and op-test hygiene
Required CI
Shadow mode reports what would block but keeps the status successful. Set |
cfcf192 to
e18a0d2
Compare
|
@aiter-bot review |
[P2] Use negative infinity for the argmax sentinel
kvp thread_kvp{0, -FLT_MAX};The masked out-of-range lanes also use For example: logits = torch.full((1, N), float("-inf"), device="cuda")
logits[0, expected_token] = -torch.finfo(torch.float32).maxThe reference argmax returns Please use negative infinity as the neutral value in kvp thread_kvp{0, -INFINITY};
kvp tmp_kvp{0, -INFINITY};
tmp_kvp.value =
in_range ? opus::cast<float>(vec_pre[i]) : -INFINITY;This allows a real Please also add an FP32 regression test covering this case. |
ROCm/aiter PR #7373 — [HIP] [Bugfix] sample kernels: keep token ids inside [0, vocab)Fixes two defects in the shared prologue/tail pipeline of the three sampler impls in Review (advisory):
|
e18a0d2 to
0a1dca6
Compare
|
Thanks — reproduced and fixed in [P2]
One note on scope: I kept The fp32 regression test is in While re-checking the perf numbers I also had to withdraw a claim of my own. @aiter-bot was right that one sample per arm could hide a real regression: with 7 runs per arm and a per-arm spread under 0.5%, That cost is the per-lane bounds select in the 16-wide body. I tried hoisting it — only the row's last vector can straddle N, so the whole-vector case could branch around the select — and it came out worse (10.97 us at M=1 vs 10.363 shipped), so the straight-line select is what ships. Also confirming @aiter-bot's inferred finding on I left it out of this PR deliberately: it is a different kernel and a different defect class (out-of-bounds write, not a wrong index), it is pre-existing, and the obvious fix — bounding the descriptor at |
Three defects, all from the same shape: the row's last vector is partial when N % VecSize != 0, and the buffer bound that is supposed to cover for that is enforced per dword, so it cannot separate element N-1 from element N at a 2-byte dtype. Read side -- every sampler could return a token id outside [0, vocab): - A thread whose first element is already past N skips the prologue load, so its registers stay undefined and `k` is never advanced. The tail guard `(k - vec_stride) < N` is then evaluated on a negative value, passes, and the body writes `key = k - vec_stride + i` -- a negative id. Fires when N <= (BlockSize - 1) * VecSize. - The lanes past N fall outside the row. For the outer-exponential kernels the noise descriptor ends exactly at N, so the 0 it yields lands in the divisor and exp(x - max) / (0 + eps) wins the argmax for any logits. Every other entry point takes its out-of-range value from the logits descriptor, so it only wins when the whole row is negative -- a -inf channel mask, say. - argmax_impl neutralised with -FLT_MAX, which raw fp32 logits can equal, so such a logit tied with the sentinel and lost it on ArgMax's smaller-key rule. It now uses -INFINITY. The sampling impls keep -FLT_MAX: their candidates are exp(..)/u >= 0 so no real value reaches it, and -inf there would turn `value * ratio` into NaN when the running-max rescale underflows ratio to 0. Write side -- exponential_kernel's partial last store reached one element past the row at a 2-byte dtype, clobbering the next row. Shrinking the descriptor to N drops a real element instead (elements N-1 and N share a dword), so the tail is masked in software; one thread per row takes it. The read-side lanes are neutralised rather than the loops shortened -- the trip count has to stay a compile-time constant or the VecSize-wide bodies stop being unrolled (greedy_sample, VecSize=16, measured 5x slower with a data-dependent bound). Results in the previously-correct region are bit-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0a1dca6 to
5243300
Compare
|
Changing my mind on scope — the Two reasons: 1. The stated justification turned out to be the weak half. I said it was "a different kernel and a different defect class", but the real reason was the clause after it — that bounding the descriptor at
The buffer bound is enforced per dword, so at an odd 2. It was going to cost a merge conflict for no benefit. Both changes land in What changed in the kernel is three lines: a whole vector takes the original vectorised store, and the one thread per row whose vector straddles Verification is in the description; the short version is that |
Motivation
Every sampler in
csrc/kernels/sample_kernels.cucan return a token id outside[0, vocab)— negative, or past the end of the row — andexponential_kernelin the same file can write one element past its row. All of it comes from one shape: the row's last vector is partial whenN % VecSize != 0, and the buffer bound meant to cover for that is enforced per dword, so it cannot separate elementN-1from elementNat a 2-byte dtype.Surfaced as a side finding while reviewing ROCm/ATOM#2914, reported there at vocab 1000 and 4097 on gfx950; reproduced here on gfx942, so it is not arch-specific.
Current production vocabularies happen to sit in the safe region (129280, 151936, 163840 are all multiples of the vector width and far above one block's span), which is why this has gone unnoticed. Any model with a small vocabulary, any draft head, or any vocabulary that is not a multiple of the vector width gets a silently wrong token.
Technical Details
Root cause
All three
*_implfunctions share one software-pipelined prologue/tail structure, and both defects live in it:A — undefined registers and a negative index. A thread whose first element is already past
Nfails the prologue check, sovec_prestays undefined andkis never advanced. At the tail,k - vec_strideis negative,< Npasses, and the body runs on undefined registers and writes a negative key.Fires when
N <= (BlockSize - 1) * VecSize.B — out-of-range lanes in the last vector. When
N % VecSize != 0the row's last vector is partial and its trailing lanes fall outside the row. What they pick up differs by which descriptor they come from, and that decides how easily the defect fires — not whether it is present:buffer_e(the caller's noise) is built with extent exactlyN, so a lane pastNyields 0. That 0 lands in the divisor,exp(x - max) / (0 + eps)is enormous, and it wins the argmax for any logits. This is the outer-exponential path.buffer_i(the logits) has its extent rounded up to a dword (oob_i), so a lane pastNyields 0 at a 4-byte dtype and, for the first lane past an oddNat a 2-byte dtype, real memory. Either way it only wins when it becomes the row maximum, i.e. when every real logit in the row is negative — a-infchannel mask produces exactly that. This is the pathgreedy_sampleand the inner-exponential kernels take.Fires when
N % VecSize != 0.Write side:
exponential_kernelThe same partial-last-vector shape, on the store rather than the load.
exponential_kernel's last store coversk .. k + VecSize - 1, and its descriptor extent is rounded up to a dword by the sameoob_oidiom. At a 2-byte dtype withN % VecSize != 0that puts elementNinside the extent, so the store writes one element past the row.The descriptor cannot fix this, and the rounding is not an oversight. The buffer bound is enforced per dword, so at an odd
NelementsN-1andNshare one and the hardware can only keep or drop both. Measured both ways on a 2-row bf16 buffer:oob_oNSo the rounding is what protects the last valid element, and the price is the spill. The fix masks the tail in software instead: a whole vector takes the original vectorised store, and the one thread per row whose vector straddles
Nwrites its in-range elements scalar. The philox draw is unchanged, so the noise sequence is identical.Which kernel gets which
On the read side, all five entry points are affected by both mechanisms. They differ only in how easily B fires:
BlockSize x VecSizerandom_sample_outer_exponential_implmixed_sample_outer_exponential,random_sample_outer_exponential(T != 0)N <= 4092N % 4 != 0, any logits — the 0 is in the divisorrandom_sample_implmixed_sample,random_sample(T != 0)N <= 4092N % 4 != 0and the row is all-negativeargmax_implgreedy_sampleN <= 16368N % 16 != 0and the row is all-negativeargmax_implmixed_sample_outer_exponential,mixed_sampleatT == 0N <= 4092N % 4 != 0and the row is all-negativeMeasured on the unpatched tree, M=4,
T=1.0, every logit-10.0:mixed_sampleandrandom_samplereturn[4098, 4097, 4099, 4099]atN=4097and go out of range atN=4095andN=50001as well, in both fp32 and bf16.Fix
Both are fixed by neutralising the lanes past
N, not by shortening the loops:has_prerecords whether the prologue actually loaded, and the tail is gated on it.Nare neutralised instead of using their loaded value, and the argmax update is additionally gated on(base + i) < N. Both halves of the neutralised pair are set, so no key at or pastNreaches the reduction.argmax_implneutralises with-INFINITY, not-FLT_MAX(thanks @junhaha666). It compares raw logits, so-FLT_MAXis a value the input can take: a row whose only finite logit is-FLT_MAXtied with the{0, -FLT_MAX}sentinel and lost it on ArgMax's smaller-key rule, returning token 0. Reproduced on fp32 at N = 17 / 4095 / 16385 for bothgreedy_sampleand thetemperature == 0mixed path, and now covered by the check. The two sampling impls keep-FLT_MAX: their candidates areexp(..)/u >= 0so no real value can reach the sentinel, and-infthere would turnthread_kvp.value * ratiointo NaN whenever the running-max rescale underflowsratioto 0.Keeping the trip count a compile-time constant matters: a first version used
for(int i = 0; i < vec_size_i && (base + i) < N; i++)andgreedy_sample(VecSize=16) measured 5x slower — 10.0 -> 58.3 us at M=64, N=151936 — because the data-dependent bound stops the body being unrolled. A neutralised lane can never displace a real one:aiter::ArgMaxprefers the smaller key on equal values, and the neutral value is now unreachable by any real candidate on every path.The out-of-bounds lanes still take part in the running max and (for
NeedSum) the sum. That is argmax-neutral — the online-softmax rescale is self-consistent for any running max, andsum_softmaxis a per-block scalar that divides every candidate equally — but-FLT_MAXkeeps them out of both anyway, so the reduction no longer depends on what the out-of-bounds read returned.Test Plan
op_tests/test_sample.pygainscheck_vocab_edges(), run after the perf sweep — it asserts, and it draws from the same global RNG the sweep does, so running it first would both hide the perf tables on a regressed tree and change the inputs every table is measured on.It sweeps 15 vocabularies x {bf16, fp32} x {normal, all-negative} rows across all five entry points plus the
T == 0dispatch intoargmax_impl, asserting against a torch reference wherever the noise is caller-supplied or the path is an argmax, and a range check for the two that draw their own noise. fp32 is swept becauseoob_i's dword rounding makes the 2-byte case data-dependent while fp32 reads a hard 0.The existing
l_n = [129280, 151936][-1:]is left alone: both of its values are multiples of the vector width and far above one block's span, so neither mechanism is reachable from the perf sweep by construction — which is why the dedicated check exists rather than more sweep shapes.A second check,
check_exponential_row_bounds(), covers the store side: it fills a two-row buffer with a sentinel, runsaiter.exponentialon row 0 only, and asserts both that no sentinel is left in row 0 (every element was written) and that row 1 is untouched (nothing was written past). Both halves are needed — each of the two descriptor settings satisfies exactly one of them.Test Result
One MI300X (gfx942),
rocm/atom-dev:latest, torch 2.10.0+rocm7.2.4.Correctness. On the unpatched tree the new check fails immediately:
With the fix, all 15 vocabularies x both row signs pass, and the existing sweep reports
err = 0on every row:Store side, two-row buffer, row 0 filled by
aiter.exponential:Read side, representative before/after, M=4, bf16, T=1.0, eps=1e-10:
mixed_sample_outer_exponential[-1262, -1262, -3093, -1262]mixed_sample_outer_exponential[4097, 4097, 4098, 4097]greedy_sample[-1864, -11483, -11783, -8300]argmaxgreedy_sample[163842, 163842, 163842, 163841]argmaxA range check alone is not a sufficient detector — at
N=256the unpatched kernel returned[-1262, -1262, -1262, 0], where the in-range0is also the wrong token. The new check compares against a reference for that reason.No behaviour change in the previously-correct region. Same seed, same inputs,
mixed_sample_outer_exponentialoutput is bit-identical before and after:[1577, 4028, 379, 2909][1577, 4028, 379, 2909][8124, 379, 4642, 497][8124, 379, 4642, 497][34579, 37439, 27804, 48357][34579, 37439, 27804, 48357][160052, 34517, 134986, 1179][160052, 34517, 134986, 1179]Performance. N=151936, same JIT build system on both arms (not against the shipped
.so), median of 7 timed runs per arm per shape with the observed min/max:greedy_samplegreedy_samplegreedy_samplemixed_sample_outer_exponentialmixed_sample_outer_exponentialmixed_sample_outer_exponentialmixed_samplemixed_samplemixed_samplegreedy_sampleat small M costs a real 3-4%, not noise — the per-arm spread there is under 0.5%, so the delta sits well outside it. An earlier revision of this PR reported "within run-to-run spread" off single samples per arm; that claim was wrong and is withdrawn. Everything else is at or under 1.3%.That 3-4% is the per-lane bounds select in a 16-wide unrolled body, and the alternatives are worse: shortening the loop instead costs 5x (above), and branching the whole-vector case around the select — only the row's last vector can straddle N — measured 10.97 us at M=1 against the shipped 10.363, so it is not taken.
black --checkandruff checkare clean onop_tests/test_sample.py.Known Limitations
check_vocab_edges()runs on the default dtype (bf16) only.VecSizeis dtype-independent here, so the boundaries do not move, but fp16/fp32 are not swept.oob_irounds the logits descriptor up to a dword, so at a 2-byte dtype the descriptor genuinely extends past the tensor's last element by 2 bytes; that is pre-existing and out of scope here.argmax_implthe tail's(k - vec_stride) < Nclause is now always true givenhas_pre, and is kept only as a defensive restatement.PR Self-Review
Reviewed revision
5243300
Behavior and intent
Every sampler in
sample_kernels.cucould return a token id outside[0, vocab)— negative, or past the end of the row — and the existing behavior is insufficient because nothing rejects or corrects it: the kernel writes the bad index and the caller consumes it as a token. Two independent defects in the shared prologue/tail pipeline produce it. (A) A thread whose first element is already pastNfails the prologue check, so its vector registers stay undefined andkis never advanced; at the tail,k - vec_strideis negative, the< Nguard passes, and the body runs on undefined registers and writes a negative key. (B) WhenN % VecSize != 0the row's last vector is partial and the lanes pastNfall outside the row; for the outer-exponential kernels the noise descriptor ends exactly atN, so the 0 it yields lands in the divisor andexp(x - max) / (0 + eps)wins the argmax for any logits, while every other entry point takes its out-of-range value from the logits descriptor and is therefore hit only when the whole row is negative. A third defect was raised in review and is also fixed:argmax_implneutralised with-FLT_MAX, a value raw fp32 logits can equal, so such a logit tied with the sentinel and lost it on the smaller-key rule; it now uses-INFINITY. After this PR the lanes pastNare neutralised in both key and value and the tail is gated on whether the prologue actually loaded, so the result no longer depends on what the out-of-range read returned. A fourth defect of the same shape sits on the write side:exponential_kernel's partial last store reaches one element past the row at a 2-byte dtype and clobbers the next row, and the descriptor cannot mask it (shrinking the extent toNdrops a real element instead, since at an oddNelementsN-1andNshare a dword), so the tail is masked in software. Behavior in the previously-correct region is bit-identical (table under Test Result).Validation and unverified scope
Measured on one MI300X (gfx942, one GPU), image
rocm/atom-dev:latest, torch 2.10.0+rocm7.2.4,HIP_VISIBLE_DEVICES=0, with this branch's tree onPYTHONPATHandmodule_sampleJIT-rebuilt from source on both arms (so the comparison is not against the image's prebuilt.so, which differs by up to 17% on its own).python op_tests/test_sample.py -m 1 64 512with the fix:vocab-edge check passed over [256, 1000, 2048, 4092, 4095, 4096, 4097, 4099, 8193, 16368, 16383, 16385, 50001, 163840, 163841], and everyerrcolumn in all three sweep tables is 0.origin/maincsrc/kernels/sample_kernels.cu, same JIT build system: exits 1 at the first case —AssertionError: greedy_sample torch.bfloat16 N=256 all_negative=False out=tensor([-15771, -15467, -3562, -12315]). The new check is therefore known to fail without the fix, not only to pass with it.T == 03-12 of 15 vocabularies per category, range-only inner-exponential 4-6, everything else 10-14; no category scored 0.greedy_sampleat M=1/64 costs a real 3-4%, which the repeated runs put well outside the <0.5% per-arm spread. The claim is corrected rather than softened. An earlier version of this fix that shortened the loop instead of neutralising the lanes measured 5x slower ongreedy_sample; that version is not what is committed here.check_exponential_row_boundspasses at fp32 and bf16 for N = 4094 / 4095 / 4096 / 4097 / 16385; against a pristineorigin/mainkernel the three odd-N bf16 cases fail (row 1 clobbered), and an experimental build with the descriptor shrunk toNinstead leaves one element of row 0 unwritten — which is why the fix is in software rather than in the extent.black --checkandruff checkclean onop_tests/test_sample.py.Unverified: gfx950 (the arch of the original report in ROCm/ATOM#2914 — the defect is in arch-independent C++ and reproduces on gfx942, but no gfx950 run was done); dtypes other than bf16 and fp32; multi-GPU; the rest of
op_tests/beyondtest_sample.py; CI results at this head; generated ISA was not diffed.PR-specific question
Question 3 — which architecture, dtype and shape boundaries select the changed kernel path, and what invariant prevents unsupported inputs from reaching it.
There is no arch gate: these are plain HIP kernels with no
gfxbranch and no arch-specific dispatch, so the changed code is live on every supported card. The dtype axis isVLLM_DISPATCH_FLOATING_TYPES_rmTorchon the input tensor;VecSizeis fixed per call site (16 forgreedy_sample,sizeof(float4)/sizeof(float) = 4for the rest) and does not vary with dtype, so the shape boundaries below are the same for fp16/bf16/fp32. The shape axis isN(the vocabulary) againstBlockSize * VecSize, which is 16384 forgreedy_sampleand 4096 for the other four entry points.The honest answer to the second half is that no invariant prevented unsupported inputs from reaching it — that is the bug. There is no size check, no assert and no documented minimum
N: a vocabulary at or below(BlockSize - 1) * VecSize, or any vocabulary not a multiple ofVecSize, was accepted and silently produced a corrupt index. Current production vocabularies (129280, 151936, 163840) sit in the safe region only by coincidence of being multiples of 4 and far above one block's span, which is why this went unnoticed. After this PR everyNis handled and no invariant is required.Changed op tests
op_tests/test_sample.py(the only op test this PR changes) gainscheck_vocab_edges()andcheck_exponential_row_bounds(), both called from the sweep driver after the perf tables.What it covers: 15 vocabularies x {bf16, fp32} x {normal, all-negative}. It asserts
greedy_sampleequalsargmax; thatmixed_sample_outer_exponentialandrandom_sample_outer_exponentialequal the softmax/exponential race computed in torch on the same noise; that both mixed kernels attemperature = 0equalargmax(that is theargmax_impl<VecSize=4>dispatch, whichgreedy_samplenever reaches — it only ever entersargmax_impl16 elements wide); and thatmixed_sampleandrandom_samplereturn an in-range index, those two drawing their own noise so only the range is checkable. The vocabularies straddle both block spans and both mechanisms:256 / 1000 / 2048 / 4092 / 16368for the undefined-register path,4095 / 4097 / 4099 / 8193 / 16383 / 16385 / 50001 / 163841for the partial-vector path,4096 / 163840as in-region controls. The all-negative rows are what make mechanism B visible on every path except the outer-exponential one.Each category was confirmed to have independent detection power by running the check against a pristine
origin/mainmodule_sampleand recording every failure rather than asserting on the first: theT == 0categories catch 3-12 of 15 vocabularies each (they had no coverage at all before), the range-only inner-exponential categories catch 4-6, and the rest 10-14.Why it is not redundant: the file's existing coverage is the perf sweep over
l_n = [129280, 151936][-1:], and both of those values are multiples of the vector width and far above one block's span, so neither mechanism is reachable from it by construction — adding shapes tol_nwould also only report a non-zeroerrcolumn rather than fail, sincecheckAllclosereturns a ratio and does not raise. The sweep'stest_mixed_sampledoes exerciseT == 0, but only at N=151936, which is in the safe region.check_exponential_row_boundsis likewise the only coverage ofaiter.exponentialat a vocab that is not a multiple of the vector width: the sweep calls it only at N=151936 and only on a single row, so neither the spill nor a dropped tail element would be visible there. A separate asserting check is the only way to gate this in CI. A range assertion alone would also have been insufficient: atN=256the unpatched kernel returns[-1262, -1262, -1262, 0], where the in-range0is also the wrong token, which is why the check compares against a reference wherever the noise is caller-supplied.Submission Checklist