Prhub

#38479 [Attention Backend] TurboQuant: 2-bit KV cache compression with 4x capacity

原始 PR 作者 vibhavagarwal5 合并时间 2026-04-15 10:57 文件变更 27 提交数 10 评论 188 代码增减 +2963 / -3

执行摘要

TurboQuant 注意力后端实现 2-4 倍 KV 缓存压缩

主存容量限制了 LLM 服务的典型上下文长度。在线 KV 缓存压缩允许相同硬件支持 2-4 倍的上下文长度,对内存受限场景(长序列、大批量)至关重要。TurboQuant 利用 WHT 旋转,使旋转后的各坐标近似独立,实现高效的标量量化,同时保持高精度。社区发现 QJL(有损压缩)有损精度,因此省略;WHT 旋转 + Lloyd-Max 提供了更好的质量/压缩平衡。

此 PR 引入了 KV 缓存压缩的一个有前途的方向。由于其巨大的性能和影响范围,建议在充分审查后合并。社区讨论揭示了关键的质量问题和修复,应优先集成已验证的修复(FP8 值默认,3位打包错误,Ampere FP8 兼容性)。考虑将 TurboQuant 作为 v1 的可选功能,并在文档中明确限制(仅全注意力,不适用于混合模型)。对于内存受限的生产场景,它提供了巨大的价值。

讨论亮点

1. 独立后端 vs 集成:@mgoin 最初希望集成到现有后端,但经过 sig-quantization 会议讨论,决定采用独立后端以隔离开发和追求极端性能。
2. 质量控制:早期测试显示 GSM8K 得分为 0%,社区追查到默认 4 位值量化过度。@varjoranta 建议 FP8 值默认(k8v4),@MidasMining 测试显示值比键对精度更敏感,支持非对称 K/V 位分配。
3. 3 位 MSE 打包错误:@varjoranta 和 @Cklaus1 发现 tq4 预设中 3 位 MSE 路径损坏,原因为线性桶化扫描和硬编码 2 位中心数。作者确认修复。
4. 边界层保护:@mgoin 质疑将 TQ_BOUNDARY_LAYERS 作为环境变量的合理性,作者解释首尾层精度敏感性的必要性。最终改用已有的 kv_cache_dtype_skip_layers 参数。
5. 代码优化讨论:@lishunyang12 指出 .item() 导致主机-设备同步、流重叠方向倒置、_ensure_on_device 损坏 register_buffer 语义、连续预填充缓冲区重复分配等问题。作者逐一修复。
6. 范围削减:作者决定将首次合并限制为仅全注意力和统一滑动窗口模型,排除混合模型以加速审查(基于与 @mgoin 的讨论)。
7. KV cache spec 兼容性:@lishunyang12 指出 isinstance 检查可能意外包含 MLAAttentionSpec,要求显式白名单。

实现拆解

  1. 配置系统:在 vllm/model_executor/layers/quantization/turboquant/config.py 中定义 4 个命名预设,每个预设指定 key_quant_bits、value_quant_bits 和 norm_correction。通过 --kv-cache-dtype 选择预设,由 TurboQuantConfig 类解析并计算槽大小和位数。
  2. 量化基础:在 centroids.py 中实现 Lloyd-Max 最优量化器,使用梯形法则积分替代 scipy 依赖。在 quantizer.py 中生成 WHT 旋转所需的随机符号。
  3. 注意力后端:在 turboquant_attn.py 中实现 TurboQuantAttentionBackend,负责预填充(标准注意力 + 量化存储)和解码(分步 Triton 内核,逐步解码注意力和值累积)。后端注册为 TURBOQUANT 并支持 4 种 CacheDType
  4. Triton 内核:在 triton_turboquant_store.py 中实现融合存储内核(_tq_fused_store_fp8_tq_fused_store_mse),将桶化、残差范数、索引打包和值量化融合为单个内核。在 triton_turboquant_decode.py 中实现分步解码内核(stage1 完成评分和值累积,stage2 执行 log-sum-exp 约简)。
  5. 引擎集成:修改 attention.py 以在层初始化时调用 _init_turboquant_buffers(分配符号、质心和预分析缓冲区)。在 kv_cache_interface.py 中添加 TQFullAttentionSpec,覆盖 real_page_size_bytes。更新 cache.py 中的 CacheDType 字符串映射。更新 arg_utils.py 以支持边界层跳过(通过 kv_cache_dtype_skip_layers 或环境变量)。
  6. 测试和文档:添加 tests/quantization/test_turboquant.py,验证配置解析、打包大小、量化器正确性和 WHT 符号。在 docs/design/attention_backends.md 中记录新的后端。
文件 模块 状态 重要度
vllm/model_executor/layers/quantization/turboquant/config.py 配置层 added 9.36
vllm/v1/attention/backends/turboquant_attn.py 注意力后端 added 9.25
vllm/model_executor/layers/quantization/turboquant/centroids.py 量化器 added 9.04
tests/quantization/test_turboquant.py 测试 added 8.14
vllm/model_executor/layers/attention/attention.py 注意力层 modified 7.8
vllm/v1/attention/ops/triton_turboquant_decode.py 操作核 added 7.74
vllm/v1/attention/ops/triton_turboquant_store.py 操作核 added 7.74
vllm/v1/kv_cache_interface.py 接口层 modified 7.59

关键符号

TurboQuantConfig.from_cache_dtype TurboQuantAttentionImpl.forward solve_lloyd_max triton_turboquant_store triton_turboquant_decode_attention _init_turboquant_buffers

关键源码片段

vllm/v1/attention/backends/turboquant_attn.py core-logic

注意力后端核心实现,管理预填充存储和解码流程

# SPDFileCopyrightText: Copyright contributors to the vLLM project
"""TurboQuant attention backend for vLLM.Prefill: Standard scaled dot-product attention on uncompressed K/V,
         then quantize K and store K+V into combined cache slot.
Decode:  Compute TQ attention scores from compressed cache,
         unpack FP16 values, softmax + weighted sum.Cache layout (no leading 2 dimension):
  (num_blocks, block_size, num_kv_heads, slot_size)
  where slot_size = key_packed_size + value_fp16_size
"""import functools
import math
from dataclasses import dataclass
from typing import ClassVarimport torch
from vllm.config.cache import CacheDType
from vllm.v1.attention.backend import AttentionBackend, AttentionImpl, AttentionType, MultipleOf
from vllm.v1.attention.ops.triton_turboquant_decode import triton_turboquant_decode_attention
from vllm.v1.attention.ops.triton_turboquant_store import triton_turboquant_storeclass TurboQuantAttentionBackend(AttentionBackend):
    """Attention backend using TurboQuant KV-cache compression."""
​
    accept_output_buffer: bool = True
    forward_includes_kv_cache_update: bool = False
​
    supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16]
    supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
        "turboquant_k8v4",
        "turboquant_4bit_nc",
        "turboquant_k3v4_nc",
        "turboquant_3bit_nc",
    ]
​
    @staticmethod
    def get_name() -> str:
        return "TURBOQUANT"
​
    @staticmethod
    def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
        return [16, 32, 64, 128]
​
    @classmethod
    def supports_attn_type(cls, attn_type: str) -> bool:
        return attn_type == AttentionType.DECODER
​
    @staticmethod
    def get_impl_cls() -> type["TurboQuantAttentionImpl"]:
        return TurboQuantAttentionImpl
​
    @staticmethod
    def get_builder_cls() -> type["TurboQuantMetadataBuilder"]:
        return TurboQuantMetadataBuilder
​
    @staticmethod
    def get_kv_cache_shape(
        num_blocks: int,
        block_size: int,
        num_kv_heads: int,
        head_size: int,
        cache_dtype_str: str = "turboquant_4bit_nc",
    ) -> tuple[int, ...]:
        """Combined K+V cache shape — no leading 2 dimension.        Standard backends use (2, num_blocks, block_size, num_kv_heads, head_size).
        TQ packs K+V into one slot per head: (num_blocks, block_size, num_kv_heads, slot_size_aligned).
        """
        from vllm.model_executor.layers.quantization.turboquant.config import TurboQuantConfig
        from vllm.model_executor.layers.quantization.turboquant.config import TQ_PRESETS
        tq_config = TurboQuantConfig.from_cache_dtype(cache_dtype_str, head_size)
        slot = tq_config.slot_size
        # Round up to even boundary for alignment
        aligned_slot = ((slot + 1) // 2) * 2
        return (num_blocks, block_size, num_kv_heads, aligned_slot)
vllm/model_executor/layers/quantization/turboquant/centroids.py data-contract

Lloyd-Max 量化器实现,替代 scipy 依赖

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Lloyd-Max optimal scalar quantizer for TurboQuant.After rotating a d-dimensional unit vector by a random orthogonal matrix,
each coordinate approximately follows N(0, 1/d) for d >= 64.
We solve the Lloyd-Max conditions to find optimal centroids.Based on: turboquant-pytorch/lloyd_max.py (Zandieh et al.)
"""import math
from functools import lru_cacheimport torch
​
​
def _gaussian_pdf(x: float, sigma2: float) -> float:
    return (1.0 / math.sqrt(2 * math.pi * sigma2)) * math.exp(-x * x / (2 * sigma2))
​
​
def _trapz(f, a: float, b: float, n: int = 200) -> float:
    """Trapezoidal numerical integration (replaces scipy.integrate.quad)."""
    h = (b - a) / n
    result = 0.5 * (f(a) + f(b))
    for i in range(1, n):
        result += f(a + i * h)
    return result * h
​
​
def solve_lloyd_max(
    d: int,
    bits: int,
    max_iter: int = 200,
    tol: float = 1e-10,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Solve Lloyd-Max optimal quantizer for N(0, 1/d) distribution.    Args:
        d: Vector dimension (determines variance = 1/d).
        bits: Number of quantization bits.
        max_iter: Maximum Lloyd-Max iterations.
        tol: Convergence tolerance.    Returns:
        centroids: Sorted tensor of 2^bits optimal centroids.
        boundaries: Sorted tensor of 2^bits - 1 decision boundaries.
    """
    n_levels = 2 ** bits
    sigma2 = 1.0 / d
    sigma = math.sqrt(sigma2)
​
    def pdf(x):
        return _gaussian_pdf(x, sigma2)
​
    lo, hi = -3.5 * sigma, 3.5 * sigma
    centroids = [lo + (hi - lo) * (i + 0.5) / n_levels for i in range(n_levels)]
​
    for _ in range(max_iter):
        boundaries = [
            (centroids[i] + centroids[i + 1]) / 2.0 for i in range(n_levels - 1)
        ]
        edges = [lo * 3] + boundaries + [hi * 3]
        new_centroids = []
        for i in range(n_levels):
            a, b = edges[i], edges[i + 1]
            num = _trapz(lambda x: x * pdf(x), a, b)
            den = _trapz(pdf, a, b)
            new_centroids.append(num / den if den > 1e-15 else centroids[i])
​
        if max(abs(new_centroids[i] - centroids[i]) for i in range(n_levels)) < tol:
            break
        centroids = new_centroids
​
    boundaries = [(centroids[i] + centroids[i + 1]) / 2.0 for i in range(n_levels - 1)]
    return (
        torch.tensor(centroids, dtype=torch.float32),
        torch.tensor(boundaries, dtype=torch.float32),
    )
​
​
@lru_cache(maxsize=32)
def get_centroids(d: int, bits: int) -> torch.Tensor:
    """Get precomputed Lloyd-Max centroids (cached)."""
    centroids, _ = solve_lloyd_max(d, bits)
    return centroids
vllm/model_executor/layers/attention/attention.py data-contract

集成 TQ 缓冲区初始化到注意力层,预分配解码中间缓冲区

def _init_turboquant_buffers(
    self, cache_dtype: str, head_size: int, prefix: str
) -> None:
    """Initialize TurboQuant rotation/projection matrices and centroids.    Registers buffers so model.to(device) moves them to GPU *before*
    the memory profiler runs. Pre-allocate decode intermediate buffers
    to avoid OOMs on the first decoder call.
    """
    from vllm.model_executor.layers.quantization.turboquant.centroids import get_centroids
    from vllm.model_executor.layers.quantization.turboquant.config import TurboQuantConfig
    from vllm.model_executor.layers.quantization.turboquant.quantizer import generate_wht_signs
    from vllm.model_executor.models.utils import extract_layer_index
​
    tq_config = TurboQuantConfig.from_cache_dtype(cache_dtype, head_size)
    # Each layer uses a unique seed (stride 1337) to decorrelate quantization errors
    layer_idx = extract_layer_index(prefix)
    seed = tq_config.seed + layer_idx * 1337
​
    self.register_buffer(
        "_tq_signs",
        generate_wht_signs(head_size, seed=seed),
    )
    self.register_buffer(
        "_tq_centroids",
        get_centroids(head_size, tq_config.centroid_bits),
    )
    self._tq_config = tq_config
​
    # Pre-allocate decode intermediate buffers so the profiler doesn't give all memory to KV cache
    _vllm_cfg = get_current_vllm_config()
    B = _vllm_cfg.scheduler_config.max_num_seqs
    Hq = self.num_heads
    S = _vllm_cfg.attention_config.tq_max_kv_splits_for_cuda_graph
    D = head_size
    self.register_buffer("_tq_mid_o_buf", torch.empty(B, Hq, S, D + 1, dtype=torch.float32), persistent=False)
    self.register_buffer("_tq_output_buf", torch.empty(B, Hq, D, dtype=torch.float32), persistent=False)
    self.register_buffer("_tq_lse_buf", torch.empty(B, Hq, dtype=torch.float32), persistent=False)

评论区精华

独立后端 vs 集成 设计

@mgoin 担心独立后端维护成本,建议集成到现有后端。经 sig-quantization 讨论,决定采用独立后端以隔离开发和极端性能。

结论:采用独立后端策略,作为首个版本。 · 已解决

质量控制:默认值量化位宽 正确性

@mgoin 报告 GSM8K 0% 结果。@varjoranta 建议 FP8 值默认。@MidasMining 测试显示值比键对精度更敏感。

结论:默认预设改为 k8v4(FP8 键 +4 位值),修复质量问题。 · 已解决

3 位 MSE 打包错误 正确性

@varjoranta 和 @Cklaus1 发现 tq4 预设的 3 位 MSE 路径损坏,硬编码 2 位中心数。@vibhavagarwal5 确认修复。

结论:修复 3 位打包路径,线性桶化改为通用 constexpr 二进制搜索。 · 已解决

边界层保护设计 设计

@mgoin 质疑 TQ_BOUNDARY_LAYERS 环境变量,倾向使用已有的 kv_cache_dtype_skip_layers。@vibhavagarwal5 解释首尾层精度敏感性和 Mamba 模型的页面大小冲突。

结论:迁移至 kv_cache_dtype_skip_layers,并添加边界层跳过文档。 · 已解决

KV cache spec 兼容性:isinstance 回归 正确性

@lishunyang12 指出 single_type_kv_cache_manager 中将 type()== 改为 isinstance() 会意外包含 MLAAttentionSpec 子类,可能导致故障。

结论:改为显式白名单 FullAttentionSpec 和 TQFullAttentionSpec。 · 已解决

风险与影响

  • 性能回归:解码吞吐量比基线低 20-30%(k8v4 为 79%),但长序列预填充持平或略快。TPOT 和 TTFT 在短序列上更高。对于内存受限的长上下文场景,整体收益仍可观。
  • 质量风险:极端预设(3bit_nc)显示 GSM8K 下降 5-10%(82% vs 87.5%)。边界层跳过可缓解,但环境变量默认值(最早/最后 2 层)可能不足以保护所有模型。
  • 兼容性风险:仅与 v1 注意力后端配合使用。缓存布局无前导 2 维度,可能破坏 KV 卸载、前缀缓存等功能。不适用于混合注意力模型(Mamba+Attention)。未测试与 LoRA、推测解码的互操作。
  • 维护风险:独立后端复制了其他后端的部分逻辑,增加未来重构和 API 变更的维护成本。
  • 用户:简单启用(--kv-cache-dtype turboquant_k8v4)可获得 2.6 倍压缩,适合长上下文和大批量场景。用户可能需要调节环境变量优化特定模型的质量/性能。
  • 系统:对于典型模型,内存节省约 2.6x,可立即扩展上下文长度或批量大小。对于内存受限的部署(如单 GPU),其影响可达数倍。
  • 团队:需要维护独立注意力后端和 Triton 内核。社区贡献活跃,需要整合。当前范围限制(仅全注意力)降低了覆盖面,但为后续扩展留下了空间。
质量退化在极端预设下 解码性能降级 20-30% 独立后端兼容性未知 MLA 回归因 isinstance 更改 内置基准测试代码不应合并

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论