[CUDA] Normalize the PER_CHANNEL K scale folded into Q for paged XQA - #32520
Merged
Tianlei Wu (tianleiwu) merged 2 commits intoSep 10, 2026
Conversation
XQA folds the per-channel K scale into the query and stores the product in T, while the portable kernel keeps it in FP32. A large scale saturated FP16 there, and a zero cache code turned that infinity into NaN, so the two backends could disagree on valid input. INT4 is the most exposed because its scale spans max|K| / 7 rather than max|K| / 127, but the fold is shared with INT8 and FP8. Divide the fold by max|k_scale| and pass that maximum to XQA as its scalar K scale, which it multiplies back into qkScale once per CTA. The correction is exact, bounds the folded query by max|q| at any scale, and adds no inner-loop work: only a single-block max reduction over the scale table, launched on device so the step stays capturable. INT4 dequantizes into FP16 shared memory, so isKVCacheQuantized is false and the scalar scale was ignored; gate that on a separate predicate instead. NOT BUILT: no CUDA toolkit on the dev machine. Needs a GPU build plus the perf comparison against the previous head before this is merged.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The folding expression can still overflow before normalization, and affected INT8/FP8 extremes remain untested.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Normalizes PER_CHANNEL K scales before paged XQA query folding to prevent FP16 saturation and NaNs.
Changes:
- Adds device-side maximum-scale reduction and normalization.
- Reapplies the normalizer inside XQA.
- Updates INT4 regression coverage and documentation.
File summaries
| File | Description |
|---|---|
test_paged_attention_int4.py |
Tests large-scale INT4 parity. |
xqa/mha_impl.cuh |
Enables scalar scales for INT4 XQA. |
paged_attention.cc |
Allocates normalizer scratch memory. |
paged_attention_impl.cu |
Implements reduction, folding, and scale forwarding. |
attention_data.h |
Adds normalizer scratch pointer. |
paged_attention.md |
Documents normalized folding. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Tianlei Wu (tianleiwu)
merged commit Sep 10, 2026
4879861
into
tlwu/20260909/int4_kv_per_channel
4 of 5 checks passed
Tianlei Wu (tianleiwu)
deleted the
tlwu/20260909/int4_kv_fold_scale_norm
branch
September 10, 2026 00:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Stacked on #32515 — review that one first. The base branch is
tlwu/20260909/int4_kv_per_channel, so the diff here is the single commit on top; it retargets tomainonce #32515 merges.Paged XQA folds a
PER_CHANNELK scale into the query and stores the product inT, while the portable kernel keeps the same product in an FP32 shared-memory tile. A large scale saturates FP16 there, and a zero cache code then turns that infinity intoNaN, so the two backends can disagree on otherwise valid input:This addresses the correctness thread on #32515. INT4 is the most exposed because its scale spans
max|K| / 7instead ofmax|K| / 127, roughly 18x larger for identical data, and INT4 XQA is only eligible withPER_CHANNELscales — but the fold itself is shared with INT8 and FP8, so this is not INT4-specific.Approach
kCacheScaleis already applied as a scalar intoqkScale, once per CTA and outside the K/V loop. So the fix needs no change in the inner loop:max|k_scale|(PagedMaxAbsScaleKernel, a single-block reduction launched on device so the step stays capturable).qkScale.The correction is exact, and the folded query is bounded by
max|q|at any scale magnitude.INT4 dequantizes into FP16 shared memory, so
cacheElemSize == 2andisKVCacheQuantizedis false, which is why the scalar scale was previously ignored on that path. That predicate is now separate from "the cache elements are narrower thanT". Both INT4 translation units defineXQA_PAGED_INT4, so decode and speculative decode are covered.Cost
No inner-loop work is added and shared memory is unchanged, so XQA eligibility is unaffected. The only new work is one small max reduction (1024 floats at H256) per node per step, amortized under CUDA-graph replay.
Validation status
Not built — there is no CUDA toolkit on the machine this was written on. This needs a GPU build and a perf comparison against #32515 before it leaves draft.
Host-side numerical checks against an FP32 reference:
q=100, k_scale=1000, zero codeNaN18750.0, exactAccuracy improves at ordinary magnitudes too, since normalizing makes better use of the FP16 mantissa.
test_int4_xqa_large_per_channel_k_scale_matches_portablewas moved into the regime that previously producedNaN: it drivesq * k_scaleto 1e6 with every K code zero, which is theinf * 0path. The codes are zeroed deliberately — with nonzero codes a scale that large makes softmax one-hot, and the argmax is then fp16-sensitive and flaky.Reviewer notes
PER_CHANNELXQA as well, since they share the fold. Those paths previously passed a null scalar scale; they now receive the normalizer. Please exercise them alongside INT4.uint8caches alone, leaving INT8/FP8 exactly as today.