Repository navigation
Conversation
|
Documentation preview: https://vllm--40633.org.readthedocs.build/en/40633/ |
ab04bea to
ba7a480
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces INT4 and INT2 per-token-head KV cache quantization for the V1 attention backend, employing Hadamard pre-rotation and sub-byte packing techniques. The implementation refactors quantization logic into a modular factory system and extracts shared Triton JIT helpers for attention operations. Feedback from the review emphasizes the necessity of validating that head sizes are powers of two for Hadamard-based modes to avoid assertion failures and suggests ensuring numerical consistency in the INT4 reshape kernel by using truncated scales during quantization to match the inference path.
ba7a480 to
0757de1
Compare
Introduce sub-byte packed KV cache quantization for per-token-head
dynamic scales, with two new modes:
* ``INT4_PER_TOKEN_HEAD``: 2 × int4 per byte, random Hadamard
pre-rotation, asymmetric scale + zero-point (zp steganographed in
the low 4 bits of the float32 scale mantissa).
* ``INT2_PER_TOKEN_HEAD``: 4 × int2 per byte, full Hadamard
pre-rotation, Lloyd-Max centroid lookup (4 centroids over N(0, 1)).
The attention read path for these modes is bespoke (split-Q dot
across PACKING_FACTOR interleaved streams, inline centroid / nibble
unpack, per-INT4 asymmetric zp correction, multi-stream accumulators)
and lives in a new module ``vllm.v1.attention.ops.triton_quant_kv``
with a small factory registry:
* ``QuantKVFactory`` is the protocol owning the write path
(``reshape_and_cache``) and optionally the read path
(``unified_attention``) for one ``KVQuantMode``.
* Sub-byte packed modes override both (bespoke read kernel); INT8 /
FP8 per-token-head modes override only the write path and use the
common kernel via a constexpr branch.
* Unused modes pay zero import / compile cost — factories are
lazy-imported from ``_MODULES`` on first use.
Other changes:
* Move the INT8 / FP8 per-token-head cache write out of
``triton_reshape_and_cache_flash`` into the new factory module.
* Dispatch in ``triton_unified_attention`` for the packed modes;
the common kernel handles every other mode as before.
* Promote ``cast_kv_tile`` to ``triton_attention_helpers`` so the
common kernel and the packed kernel share a single implementation.
* Extend ``triton_attn.py`` and ``cache.py`` to recognise the new
``int4_per_token_head`` / ``int2_per_token_head`` cache dtypes.
Stacked on the 2D/3D unification refactor — the common-path helpers
it extracts are reused by the packed kernel so the split-Q version
shares prologue, softmax bookkeeping, segment reduction, ALiBi,
softcap, qq_bias and sliding-window handling with the common path.
Tests live in ``tests/quantization/test_per_token_kv_cache.py`` and
``tests/models/quantization/test_per_token_kv_cache.py``; a batch-
invariance regression covering the new cache dtypes is added to
``tests/v1/determinism/test_matmul_batch_invariant.py``.
Signed-off-by: JartX <sagformas@epdcenter.es>
0757de1 to
d485ea1
Compare
Split the 1330-line packed_per_token_head.py into three files so that no module exceeds ~680 lines, making the diff easier to review: * _packed_reshape.py — INT4 / INT2 reshape (write) kernels, Lloyd-Max quantize/dequant helpers, shared Python launcher _run_reshape_kernel. * _packed_attention.py — _attn_packed read kernel (shared by INT4 and INT2 via PACKING_FACTOR constexpr) and its _launch_packed_attn wrapper. * packed_per_token_head.py — only the _PackedFactory base class, Int4PerTokenHeadFactory / Int2PerTokenHeadFactory, and the register() calls that wire them into the lazy registry. No behavior changes; this is purely a file split. Signed-off-by: JartX <sagformas@epdcenter.es>
|
UNQUANT KVCACHE
INT8 PER TOKEN HEAD
FP8 PER TOKEN HEAD
FP8
INT4 PER TOKEN HEAD
INT2_PER_TOKEN_HEAD
|
KV Cache Quantization Benchmark — Serving ResultsConfiguration: 100 concurrent requests · 512 input tokens / 128 output tokens per request · 51,200 total input tokens · 12,800 total generated tokens. Summary Table
ObservationsAll variants completed 100/100 requests with zero failures. Performance ranking is consistent across throughput and latency dimensions: FP16 > FP8 > FP8/INT8 per-token-head > INT4 per-token-head > INT2 per-token-head. The gap between FP8 and INT8 per-token-head is essentially noise (~0.9% on total throughput, ~0.7% on Mean TPOT), which is the expected behavior on this hardware given equivalent compute paths at that bit width. INT4 and INT2 pay a visible throughput cost (~13% and ~16% respectively) — the tradeoff only makes sense when the memory budget justifies it. Raw per-run outputs preserved below for reference. Raw ResultsFP16 (baseline)FP8FP8 per-token-headINT8 per-token-headINT4 per-token-headINT2 per-token-head |
|
Before starting the review @tdoublep @bringlein @mgoin @kylesayrs , please note the following pull request (PR), which includes the decode and prefill kernels for per token head. This is based on the previous PR, but I've been working on them for a few weeks. If it's alright with you, since only the per token head branches are being addressed, I'd like to continue in the next PR, even though it has one more file and the dispatcher flow to the kernels. I know these are additional flows to consider compared to int2/int4, but I'd say the gains are significant enough to justify the effort. Thank you so much for your time. If you agree, I'll continue with that PR; otherwise, we'll continue with the stable and tested changes from the current one and add the kernels in another PR. |
|
Hi @JartX, the pre-commit checks have failed. Please run: uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-filesThen, commit the changes and push to your branch. For future commits, Tip Is
|
The INT8 reshape kernel computed q = clamp(x / scale, -128, 127) and relied on the implicit fp32 → int8 cast inside tl.store. That cast truncates toward zero, biasing every K and V slot by up to 0.5 LSB in the direction of zero. The bias is signed and accumulates over context length, contaminating the KV cache progressively and showing up as repetition / N-gram loops in long generations (e.g. think mode on Qwen3.5 with kv_cache_dtype=int8_per_token_head). The kernel docstring already describes the intended behaviour (q = clamp(round(x / scale), ...)) — only the round call was missing. Add an IS_INT_QUANT constexpr that selects explicit round-half-away- from-zero (+0.5 / -0.5 then to int32, same pattern the INT4 packed quantizer uses) when the destination is int8. FP8 leaves the implicit cast intact: fp8's native RNE on the cvt instruction is the correct rounding for sub-integer values, and forcing integer rounding there would destroy the fp8 precision below 1.0. INT4 path (separate file) already rounds correctly; INT2 uses Lloyd-Max centroids and does not round. The read-side HIP prefill kernel (paged_prefill_attn_rdna3_v2_int8.cu) and the Triton decode kernel were both audited as part of this investigation — softmax guards, block-boundary lookup and mask handling are all correct. The bug was strictly on the write side. Signed-off-by: JartX <sagformas@epdcenter.es>
|
This pull request has merge conflicts that must be resolved before it can be |
Cache RHT D₁ signs per (d, round_idx, device, dtype) instead of just (d, round_idx, device). This prevents implicit fp32 promotion when multiplying fp16 tensors by fp32 signs (53× faster for fp16). Signs are ±1.0, exactly representable in any IEEE float format. Signed-off-by: JartX <sagformas@epdcenter.es>
This PR is stacked on #40631, so please merge that one first. It is the feature half of the split from #39074
The main change here is the addition of two new KV cache quantization modes, both using per-(token,head) dynamic scales:
INT4_PER_TOKEN_HEAD: packs 2 int4 values per byte. It applies a random Hadamard pre-rotation to make the inputs more Gaussian, then uses asymmetric quantization with a 4-bit zero-point steganographically stored in the low 4 bits of the float32 scale mantissa. The asymmetry is corrected on the score side with a sum(Q) * zp term.
INT2_PER_TOKEN_HEAD: packs 4 int2 values per byte. It uses a full Hadamard pre-rotation and a Lloyd-Max centroid lookup for dequantization, with 4 centroids fitted over N(0,1). The d1.5 scale factor is folded in at write time.
On the read side, these modes do not fit cleanly into the common kernel. The packed layout introduces several structural differences from the non-packed path: the Q split into 2 or 4 interleaved streams depending on PACKING_FACTOR, packed KV loads, inline unpacking and centroid lookup, split-dot score computation with optional zero-point correction, and per-stream accumulators. Because of that, this PR adds a dedicated _attn_packed kernel that handles both modes behind a single PACKING_FACTOR: tl.constexpr switch. Internally, that still means a fair amount of mode-specific logic, including 13 internal branches just to separate the INT4 and INT2 paths.
To keep the shared kernel clean, dispatch for these modes now lives behind a small factory registry under vllm/v1/attention/ops/triton_quant_kv/.
QuantKVFactory is the base protocol for a single KVQuantMode. It owns the write path (reshape_and_cache) and can optionally own the read path (unified_attention) as well. The "factory" naming comes from feedback from @bringlein: implementations are lazy-imported, only instantiated on first use, and add no overhead for modes that are never used.
The sub-byte packed modes override both read and write paths. INT8 and FP8 per-token-head override only the write path and continue using the common kernel’s existing constexpr branches on the read side. This also moves the write kernel for INT8/FP8 per-token-head out of triton_reshape_and_cache_flash.py and into the factory module, so each mode now has a single home instead of splitting write and read logic across different files.
The factory also reuses most of the same helper logic as the common kernel: 9 of the 12 helpers from triton_attention_helpers.py, covering the prologue, softmax bookkeeping, ALiBi, softcap, qq_bias, sliding window masking, and segment reduction, plus reduce_segments imported across files. The only pieces that remain bespoke are the structural parts required by the packed read path.