Skip to content

Commit f2c39fe

Browse files
[CUDA] Add INT4 paged KV cache with per-channel scales (#32515)
### Description Adds an INT4 paged KV cache to the CUDA `PagedAttention` kernel. The cache stores signed 4-bit values packed two per byte in a `uint8` tensor, halving the cache footprint relative to INT8. The cache head dimension becomes `(head_size + 1) / 2`, and the logical element type is named by the existing `k_cache_dtype` / `v_cache_dtype` attributes, whose `'int4'` value was previously reserved and rejected during validation. Quantization reuses the existing static `PER_TENSOR` / `PER_CHANNEL` scales on inputs 14/15, so this introduces **no new quantization granularity and no new operator input**. Reads and writes go through the portable paged decode / gather paths, which fold scales in FP32. #### XQA decode FP16 INT4 XQA decode and speculative-decode specializations are included, and `PER_CHANNEL` scales reach them by folding the channel scale into the query: ``` score = Q · (K_int · s_c) = (Q · s_c) · K_int out_c = s_c · Σ_t p_t · V_int[t, c] ``` The fold is exact in real arithmetic, but the product is held in FP16, so a large channel scale saturates it and a zero cache code then turns the infinity into a `NaN`. The fold is therefore divided by the power of two just above `max|k_scale|`, and that normalizer is handed to XQA as its scalar K scale, which multiplies it back into `qkScale` once per CTA outside the K/V loop. A power of two rather than `max|k_scale|` itself matters: the division and the reapplication are both exact, so the fold adds no rounding of its own, and every normalized scale lands in `(0, 1]`, so `|Q * s| <= |Q|` and the store cannot overflow for any finite table. The reduction is one block on the compute stream, so the path stays CUDA-graph capturable and re-reads the table on every replay. INT4 is the most exposed case because its scale spans `max|K| / 7` rather than `max|K| / 127`, and INT4 XQA is only eligible with `PER_CHANNEL` scales. **Limit and opt-out.** FP16 spans about 40 binades, and an overflow-free normalizer must be at least `max|k_scale|`, so channels more than 24 binades below the largest flush to zero in the folded query. Calibrated tables sit far inside that budget — across the 128 per-(head, side) tables of a Qwen3.8-27B INT4 export the widest spans 4.9 binades — so this defaults on. `ORT_ENABLE_XQA_PER_CHANNEL_KV=0` routes `PER_CHANNEL` K decode and metadata-bounded speculative decode to the portable FP32 kernel for tables that do exceed it. `PER_TENSOR` K is unaffected either way. #### Build flag Everything is behind `onnxruntime_USE_INT4_KV_CACHE`. Builds with the option off compile unchanged and reject int4 caches during input validation. ### Motivation and Context A 4-bit paged KV cache halves long-context cache memory versus INT8. The paged cache allocation on Qwen3.8-27B at 1024 blocks measures 8,192 MiB with an INT8 cache; because `PER_CHANNEL` scales are static initializers rather than a scale cache, INT4 stores exactly half of that and adds nothing back. #### Decode performance Measured on Qwen3.8-27B (H200, SM90a, 2048 generated tokens, milliseconds per target forward), INT4 XQA versus the portable fallback: | prompt | batch | drafts | portable | XQA | speedup | |---:|---:|---:|---:|---:|---:| | 512 | 1 | 7 | 37.45 | 26.04 | 1.44x | | 8192 | 4 | 7 | 213.35 | 59.09 | 3.61x | | 32768 | 4 | 7 | 546.25 | 124.15 | 4.40x | | 32768 | 4 | 0 | 243.98 | 38.80 | 6.29x | Target-forward counts are identical between the two arms for the 8192 and both 32768 rows, so those are like-for-like. An unchanged control arm measured across the same two sessions drifted at most 4.8%. #### Accuracy MMLU-Pro, 800 questions, Qwen3.8-27B INT4 weights with DFlash2 speculative decoding (N=7), greedy with natural EOS, one H200 held exclusively for the run. The INT8 `PER_CHANNEL` paged KV cache is the baseline; only the KV cache format differs between arms, and the model weights are byte-identical. | arm | correct / 800 | accuracy | paired vs baseline | |---|---:|---:|---| | INT8 KV (baseline) | 659 | 82.375% | — | | baseline, identical rerun | 659 | 82.375% | 0 of 800 generations changed | | baseline at concurrency 4 (neutral control) | 663 | 82.875% | +0.50 pp, 284 generations changed | | **INT4 KV, `PER_CHANNEL`** | **661** | **82.625%** | **+0.25 pp, 27 wins / 25 losses, exact McNemar p = 0.89** | The two control rows are what make this readable. The identical rerun is token-exact, so the harness and engine are deterministic and any difference between arms is attributable to the arm. The neutral control changes nothing but request concurrency, yet it moves accuracy by +0.50 pp and rewrites a third of the generations — a larger swing than INT4 produces. So INT4 `PER_CHANNEL` is indistinguishable from INT8 on this task, and the resolution of the measurement is roughly ±2 pp, not ±0.2 pp. On GPQA-diamond (198 questions) the same INT4 `PER_CHANNEL` model scored 153/198 against 150/198 for INT8. These runs used the XQA decode path with the per-channel fold, which is the configuration this PR ships. This is the first of two stacked changes; #32521 adds per-token scales and Hadamard rotation on top. ### Testing `onnxruntime/test/python/transformers/test_paged_attention_int4.py` (new) covers packing/padding layout, prefill, chunked prefill, decode, split-KV, speculative decode, CUDA-graph replay, bfloat16 activations, scale extremes including subnormal and near-overflow scale tables, dispatch assertions that per-channel INT4 decode and speculative decode land on `DECODER_ATTENTION`, XQA fallback for ineligible static scales, invalid-contract rejection, and an int8/fp8 regression check that the existing cache types are unaffected. Dispatch is asserted, not inferred: the INT4 decode, speculative-decode and CUDA-graph tests require `SdpaKernel=XQA`, `test_int4_per_channel_xqa_matches_portable` and `test_per_channel_k_keeps_int8_xqa` compare XQA against the portable kernel through the opt-out, and the wide-range and non-finite scale tests pin portable behaviour with `ORT_ENABLE_XQA_PER_CHANNEL_KV=0`. **31/31 pass on an H200**, including `test_xqa_large_attention_scale_and_k_scale`, which drives an attention scale above one against a `FLT_MAX` channel scale on the XQA path. `test_paged_attention.py` gains a finite-output assertion for overridden scale maxima, an XQA assertion for large `PER_CHANNEL` K scales, and an opt-out test. Build configurations: `onnxruntime_USE_INT4_KV_CACHE=ON` and `=OFF` each build clean with zero warnings in the changed files, and `-DUSE_INT4_KV_CACHE=1` was confirmed present/absent on the actual compile lines. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 5894ba8 commit f2c39fe

23 files changed

Lines changed: 1698 additions & 151 deletions

cmake/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ cmake_dependent_option(onnxruntime_USE_FPA_INTB_GEMM "Build FpA IntB GEMM CUDA k
133133
cmake_dependent_option(onnxruntime_USE_FPA_INTB_GEMM_FULL
134134
"Build all FpA IntB GEMM CUDA kernel variants instead of the compact FP16 INT4/INT8 set" OFF
135135
"onnxruntime_USE_CUDA;onnxruntime_USE_FPA_INTB_GEMM" OFF)
136-
option(onnxruntime_USE_INT4_KV_CACHE "Build cuda kernels for int4 kv cache" OFF)
136+
option(onnxruntime_USE_INT4_KV_CACHE "Build cuda kernels for int4 kv cache" ON)
137137
option(onnxruntime_USE_FP8_KV_CACHE "Build cuda kernels for fp8 kv cache" ON)
138138
option(onnxruntime_QUICK_BUILD "Speed up build by skipping some kernels for faster development" OFF)
139139
# Raises the minimum driver to the CUDA 12.4 level (Linux >= 550.54.14, Windows >= 551.61); always on for CUDA >= 13.0.
@@ -1513,7 +1513,7 @@ if (Git_FOUND)
15131513
if (onnxruntime_QUICK_BUILD)
15141514
string(APPEND ORT_BUILD_INFO "quick-build=1, ")
15151515
endif()
1516-
if (onnxruntime_USE_INT4_KV_CACHE)
1516+
if (onnxruntime_USE_CUDA AND onnxruntime_USE_INT4_KV_CACHE)
15171517
string(APPEND ORT_BUILD_INFO "int4-kv-cache=1, ")
15181518
endif()
15191519
if (onnxruntime_USE_FP8_KV_CACHE)

docs/ContribOperators.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4804,9 +4804,9 @@ This version of the operator has been available since version 1 of the 'com.micr
48044804
<dt><tt>value</tt> (optional) : T</dt>
48054805
<dd>Value with shape (num_tokens, kv_hidden_size). Must be absent when 'kv_cache_layout' is 'LATENT'.</dd>
48064806
<dt><tt>key_cache</tt> : T_CACHE</dt>
4807-
<dd>Block-based key cache with shape (num_blocks, block_size, kv_num_heads, head_size). This is updated in place within the op. When 'kv_cache_layout' is 'LATENT' this is the only cache, and V is read from its leading v_head_size channels.</dd>
4807+
<dd>Block-based key cache with shape (num_blocks, block_size, kv_num_heads, cache_head_size), where cache_head_size is (head_size + 1) / 2 for packed INT4 and head_size otherwise. This is updated in place within the op. When 'kv_cache_layout' is 'LATENT' this is the only cache, and V is read from its leading v_head_size channels.</dd>
48084808
<dt><tt>value_cache</tt> (optional) : T_CACHE</dt>
4809-
<dd>Block-based value cache with shape (num_blocks, block_size, kv_num_heads, head_size). This is updated in place within the op. This should be the same shape as key_cache. Must be absent when 'kv_cache_layout' is 'LATENT'.</dd>
4809+
<dd>Block-based value cache with shape (num_blocks, block_size, kv_num_heads, cache_head_size), where cache_head_size is (head_size + 1) / 2 for packed INT4 and head_size otherwise. This is updated in place within the op. This should be the same shape as key_cache. Must be absent when 'kv_cache_layout' is 'LATENT'.</dd>
48104810
<dt><tt>cumulative_sequence_length</tt> : S</dt>
48114811
<dd>A tensor with shape (batch_size + 1). It specifies the cumulative sequence lengths between the packed entries in Q/K/V.</dd>
48124812
<dt><tt>past_seqlens</tt> : S</dt>
@@ -4839,17 +4839,17 @@ This version of the operator has been available since version 1 of the 'com.micr
48394839
<dt><tt>output</tt> : T</dt>
48404840
<dd>2D output tensor with shape (num_tokens, num_heads * v_head_size), which is (num_tokens, hidden_size) unless 'kv_cache_layout' is 'LATENT' with a narrower v_head_size.</dd>
48414841
<dt><tt>key_cache_out</tt> (optional) : T_CACHE</dt>
4842-
<dd>Block-based key cache with shape (num_blocks, block_size, kv_num_heads, head_size). This is always the same tensor as key_cache.</dd>
4842+
<dd>Aliases key_cache with the same shape and element type, including its packed dimension for INT4.</dd>
48434843
<dt><tt>value_cache_out</tt> (optional) : T_CACHE</dt>
4844-
<dd>Block-based value cache with shape (num_blocks, block_size, kv_num_heads, head_size). This is always the same tensor as value_cache. Must be absent when 'kv_cache_layout' is 'LATENT'.</dd>
4844+
<dd>Aliases value_cache with the same shape and element type, including its packed dimension for INT4. Must be absent when 'kv_cache_layout' is 'LATENT'.</dd>
48454845
</dl>
48464846

48474847
#### Type Constraints
48484848

48494849
<dl>
48504850
<dt><tt>T</tt> : tensor(float16), tensor(bfloat16)</dt>
48514851
<dd>Constrain input and output to float tensors.</dd>
4852-
<dt><tt>T_CACHE</tt> : tensor(float16), tensor(bfloat16), tensor(int8), tensor(float8e4m3fn)</dt>
4852+
<dt><tt>T_CACHE</tt> : tensor(float16), tensor(bfloat16), tensor(int8), tensor(float8e4m3fn), tensor(uint8)</dt>
48534853
<dd>Constrain the KV cache to float or quantized tensors.</dd>
48544854
<dt><tt>T_KV_SCALE</tt> : tensor(float)</dt>
48554855
<dd>Constrain KV cache scales to float tensors.</dd>

docs/OperatorKernels.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,7 @@ The **OpSet Version** column uses the following notation:
11041104
|GreedySearch|*in* input_ids:**I**<br> *in* max_length:**I**<br> *in* min_length:**I**<br> *in* repetition_penalty:**T**<br> *in* vocab_mask:**I**<br> *in* prefix_vocab_mask:**I**<br> *in* attention_mask:**I**<br> *out* sequences:**I**|1+|**T** = tensor(float), tensor(float16)|
11051105
|GridSample|*in* X:**T1**<br> *in* Grid:**T1**<br> *out* Y:**T2**|1+|**T1** = tensor(float)<br/> **T2** = tensor(float)|
11061106
|GroupNorm|*in* X:**T**<br> *in* gamma:**M**<br> *in* beta:**M**<br> *out* Y:**T**|1+|**T** = tensor(float), tensor(float16)|
1107-
|GroupQueryAttention|*in* query:**T**<br> *in* key:**T**<br> *in* value:**T**<br> *in* past_key:**T_CACHE**<br> *in* past_value:**T_CACHE**<br> *in* seqlens_k:**M**<br> *in* total_sequence_length:**M**<br> *in* cos_cache:**T**<br> *in* sin_cache:**T**<br> *in* position_ids:**tensor(int64)**<br> *in* attention_bias:**T**<br> *in* head_sink:**T**<br> *in* k_scale:**T_KV_SCALE**<br> *in* v_scale:**T_KV_SCALE**<br> *in* q_norm_weight:**T**<br> *in* k_norm_weight:**T**<br> *out* output:**T**<br> *out* present_key:**T_CACHE**<br> *out* present_value:**T_CACHE**<br> *out* output_qk:**T**|1+|**M** = tensor(int32)<br/> **T** = tensor(bfloat16), tensor(float16)<br/> **T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8)<br/> **T_KV_SCALE** = tensor(float)|
1107+
|GroupQueryAttention|*in* query:**T**<br> *in* key:**T**<br> *in* value:**T**<br> *in* past_key:**T_CACHE**<br> *in* past_value:**T_CACHE**<br> *in* seqlens_k:**M**<br> *in* total_sequence_length:**M**<br> *in* cos_cache:**T**<br> *in* sin_cache:**T**<br> *in* position_ids:**tensor(int64)**<br> *in* attention_bias:**T**<br> *in* head_sink:**T**<br> *in* k_scale:**T_KV_SCALE**<br> *in* v_scale:**T_KV_SCALE**<br> *in* q_norm_weight:**T**<br> *in* k_norm_weight:**T**<br> *out* output:**T**<br> *out* present_key:**T_CACHE**<br> *out* present_value:**T_CACHE**<br> *out* output_qk:**T**|1+|**M** = tensor(int32)<br/> **T** = tensor(bfloat16), tensor(float16)<br/> **T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8), tensor(uint8)<br/> **T_KV_SCALE** = tensor(float)|
11081108
|Inverse|*in* X:**T**<br> *out* Y:**T**|1+|**T** = tensor(double), tensor(float), tensor(float16)|
11091109
|Irfft|*in* X:**T**<br> *out* Y:**T**|1+|**T** = tensor(double), tensor(float), tensor(float16)|
11101110
|LinearAttention|*in* query:**T**<br> *in* key:**T**<br> *in* value:**T**<br> *in* past_state:**S**<br> *in* decay:**T**<br> *in* beta:**T**<br> *out* output:**T**<br> *out* present_state:**S**|1+|**T** = tensor(bfloat16), tensor(float), tensor(float16)|
@@ -1122,7 +1122,7 @@ The **OpSet Version** column uses the following notation:
11221122
|NhwcConv|*in* X:**T**<br> *in* W:**T**<br> *in* B:**T**<br> *out* Y:**T**|1+|**T** = tensor(float), tensor(float16)|
11231123
|PackedAttention|*in* input:**T**<br> *in* weights:**T**<br> *in* bias:**T**<br> *in* token_offset:**M**<br> *in* cumulative_sequence_length:**M**<br> *in* attention_bias:**T**<br> *out* output:**T**|1+|**T** = tensor(float), tensor(float16)|
11241124
|PackedMultiHeadAttention|*in* query:**T**<br> *in* key:**T**<br> *in* value:**T**<br> *in* bias:**T**<br> *in* token_offset:**M**<br> *in* cumulative_sequence_length:**M**<br> *in* attention_bias:**T**<br> *out* output:**T**|1+|**T** = tensor(float), tensor(float16)|
1125-
|PagedAttention|*in* query:**T**<br> *in* key:**T**<br> *in* value:**T**<br> *in* key_cache:**T_CACHE**<br> *in* value_cache:**T_CACHE**<br> *in* cumulative_sequence_length:**S**<br> *in* past_seqlens:**S**<br> *in* block_table:**S**<br> *in* cos_cache:**T**<br> *in* sin_cache:**T**<br> *in* slot_mapping:**S**<br> *in* head_sink:**T**<br> *in* q_norm_weight:**T**<br> *in* k_norm_weight:**T**<br> *in* k_scale:**T_KV_SCALE**<br> *in* v_scale:**T_KV_SCALE**<br> *in* attention_metadata:**S**<br> *out* output:**T**<br> *out* key_cache_out:**T_CACHE**<br> *out* value_cache_out:**T_CACHE**|1+|**S** = tensor(int32)<br/> **T** = tensor(bfloat16), tensor(float16)<br/> **T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8)<br/> **T_KV_SCALE** = tensor(float)|
1125+
|PagedAttention|*in* query:**T**<br> *in* key:**T**<br> *in* value:**T**<br> *in* key_cache:**T_CACHE**<br> *in* value_cache:**T_CACHE**<br> *in* cumulative_sequence_length:**S**<br> *in* past_seqlens:**S**<br> *in* block_table:**S**<br> *in* cos_cache:**T**<br> *in* sin_cache:**T**<br> *in* slot_mapping:**S**<br> *in* head_sink:**T**<br> *in* q_norm_weight:**T**<br> *in* k_norm_weight:**T**<br> *in* k_scale:**T_KV_SCALE**<br> *in* v_scale:**T_KV_SCALE**<br> *in* attention_metadata:**S**<br> *out* output:**T**<br> *out* key_cache_out:**T_CACHE**<br> *out* value_cache_out:**T_CACHE**|1+|**S** = tensor(int32)<br/> **T** = tensor(bfloat16), tensor(float16)<br/> **T_CACHE** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(int8), tensor(uint8)<br/> **T_KV_SCALE** = tensor(float)|
11261126
|QAttention|*in* input:**T1**<br> *in* weight:**T2**<br> *in* bias:**T3**<br> *in* input_scale:**T3**<br> *in* weight_scale:**T3**<br> *in* mask_index:**T4**<br> *in* input_zero_point:**T1**<br> *in* weight_zero_point:**T2**<br> *in* past:**T3**<br> *out* output:**T3**<br> *out* present:**T3**|1+|**T1** = tensor(int8)<br/> **T2** = tensor(int8)<br/> **T3** = tensor(float), tensor(float16)<br/> **T4** = tensor(int32)|
11271127
|QMoE|*in* input:**T**<br> *in* router_probs:**T**<br> *in* fc1_experts_weights:**T1**<br> *in* fc1_scales:**T2**<br> *in* fc1_experts_bias:**T**<br> *in* fc2_experts_weights:**T1**<br> *in* fc2_scales:**T2**<br> *in* fc2_experts_bias:**T**<br> *in* fc3_experts_weights:**T1**<br> *in* fc3_scales:**T2**<br> *in* fc3_experts_bias:**T**<br> *in* fc1_zero_points:**T1**<br> *in* fc2_zero_points:**T1**<br> *in* fc3_zero_points:**T1**<br> *in* router_weights:**T**<br> *in* fc1_global_scale:**T4**<br> *in* fc2_global_scale:**T4**<br> *in* fc1_act_scale:**T4**<br> *in* fc2_act_scale:**T4**<br> *in* fc1_act_block_scale:**T2**<br> *in* fc2_act_block_scale:**T2**<br> *out* output:**T**|1+|**T** = tensor(bfloat16), tensor(float16)<br/> **T1** = tensor(float8e4m3fn), tensor(uint8)<br/> **T2** = tensor(bfloat16), tensor(float16), tensor(float8e4m3fn), tensor(float8e8m0)<br/> **T4** = tensor(float)|
11281128
|QOrderedAttention|*in* input:**Q**<br> *in* scale_input:**S**<br> *in* scale_Q_gemm:**S**<br> *in* scale_K_gemm:**S**<br> *in* scale_V_gemm:**S**<br> *in* Q_weight:**Q**<br> *in* K_weight:**Q**<br> *in* V_weight:**Q**<br> *in* scale_Q_weight:**S**<br> *in* scale_K_weight:**S**<br> *in* scale_V_weight:**S**<br> *in* Q_bias:**S**<br> *in* K_bias:**S**<br> *in* V_bias:**S**<br> *in* scale_QKT_gemm:**S**<br> *in* scale_QKT_softmax:**S**<br> *in* scale_values_gemm:**S**<br> *in* mask_index:**G**<br> *in* past:**Q**<br> *in* attention_bias:**S**<br> *out* output:**Q**|1+|**G** = tensor(int32)<br/> **Q** = tensor(int8)<br/> **S** = tensor(float)|

docs/contrib_ops/cuda/gqa.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ INT4 caches are not supported by XQA. Quantized configurations that are ineligib
238238
dequantize-then-Flash-Attention fallback when available.
239239

240240
INT8 cache kernels are always built; FP8 (`onnxruntime_USE_FP8_KV_CACHE`, default ON) and INT4
241-
(`onnxruntime_USE_INT4_KV_CACHE`, default OFF) are gated by build options (see §11).
241+
(`onnxruntime_USE_INT4_KV_CACHE`, default ON) are gated by build options (see §11).
242242

243243
## 5. Attention Sink (`head_sink`) and Smooth Softmax
244244

@@ -466,7 +466,7 @@ These CMake options speed up CUDA builds during development. Pass them through
466466
|--------|---------|--------|
467467
| `onnxruntime_QUICK_BUILD` | `OFF` | Builds only the `hdim128` FP16/BF16 Flash Attention kernels. Greatly reduces compile time, but **changes dispatch**: shapes with `head_size != 128` fall back to Memory Efficient Attention because Flash is no longer compiled for them. Do not use it to characterize Flash-vs-arch behavior. |
468468
| `onnxruntime_USE_FP8_KV_CACHE` | `ON` | Builds the FP8 (E4M3) quantized KV-cache kernels (`-DUSE_FP8_KV_CACHE=1`). |
469-
| `onnxruntime_USE_INT4_KV_CACHE` | `OFF` | Builds the INT4 quantized KV-cache kernels (`-DUSE_INT4_KV_CACHE=1`). A `kv_cache_bit_width == 4` node errors out if this is off. |
469+
| `onnxruntime_USE_INT4_KV_CACHE` | `ON` | Builds the INT4 quantized KV-cache kernels (`-DUSE_INT4_KV_CACHE=1`). A `kv_cache_bit_width == 4` node errors out if this is off. |
470470

471471
Other ways to shorten the iteration loop:
472472

0 commit comments

Comments
 (0)