量化推理:用更低精度换取更高吞吐和更低显存

前面一篇我们讨论了 tensor parallel。

Tensor parallel 解决的是一个非常直接的问题:模型太大,一张 GPU 放不下,那就把模型权重切到多张 GPU 上,让多张 GPU 一起完成一次 forward。

这一篇我们讨论另一个方向:量化。

如果模型太大,我们不一定只能增加 GPU。还有一种办法是让模型本身变小。

更准确地说,是让模型权重、激活或者 KV cache 使用更低精度表示。

原本一个权重用 FP16 保存,需要 2 字节。

如果改成 INT8,理论上只需要 1 字节。

如果改成 INT4,理论上只需要 0.5 字节。

这样模型显存会下降,显存带宽压力也会下降。在某些硬件和 kernel 支持良好的情况下,推理速度也可能提升。

但量化不是免费的。

精度可能下降,kernel 会更复杂,某些操作需要反量化,某些量化格式对硬件支持要求很高。更重要的是,不同量化方法优化的对象并不一样。有的主要量化权重,有的量化激活,有的量化 KV cache,有的需要校准数据,有的更适合离线转换,有的更适合在线 serving。

所以这一篇不只是介绍几个名词,而是从 mini vLLM 的系统视角回答几个问题:

量化到底省了什么?

权重量化、激活量化和 KV cache 量化有什么区别?

INT8、INT4、FP8 分别适合什么场景?

GPTQ、AWQ 这类方法为什么存在?

量化如何影响 ModelRunner、AttentionBackend、Block Manager 和 benchmark?

量化首先解决的是显存问题

LLM 推理的显存主要来自几部分。

第一部分是模型权重。

模型越大,权重越多。FP16 或 BF16 下,每个参数通常占 2 字节。一个 7B 模型仅权重就大约十几 GB。一个 70B 模型仅权重就可能超过一百 GB。

第二部分是 KV cache。

KV cache 会随着并发请求数和上下文长度增长。模型权重是固定的,KV cache 是动态的。在线服务中,KV cache 很容易成为限制并发数的关键资源。

第三部分是中间激活和临时 buffer。

推理时不需要保存训练用的反向传播激活,但仍然会有 attention、MLP、通信和 kernel 需要的临时空间。

量化可以作用在不同部分。

如果量化权重,模型本身占用的显存会下降。

如果量化 KV cache,长上下文和高并发时的动态显存压力会下降。

如果量化激活,计算过程中的带宽和中间数据压力可能下降,但实现难度通常更高。

所以量化不是一个单点技术,而是一组降低数据精度的策略。

在 mini vLLM 里,我们可以先把它拆成三类:

weight quantization
activation quantization
KV cache quantization

这三类优化目标不同,系统接入位置也不同。

权重量化:让模型本身变小

最常见的量化是权重量化。

它把模型权重从 FP16 或 BF16 转成更低精度。

例如:

FP16 weights
转成
INT8 weights

或者:

FP16 weights
转成
INT4 weights

权重量化最直接的收益是减少模型显存。

如果一个模型 FP16 权重占 140GB,INT8 理论上可以接近减半,INT4 理论上可以接近四分之一。当然,真实系统还要保存 scale、zero point、group metadata 等额外信息,所以不会完全等于理论值。

权重量化还有一个潜在收益是减少显存带宽压力。

矩阵乘法时需要读取权重。如果权重更小,同样带宽下能读取更多参数,可能提升推理速度。

但这取决于 kernel。

如果硬件和 kernel 能直接使用低精度权重做高效矩阵乘法,收益会比较明显。

如果每次都要先把 INT4 或 INT8 权重反量化成 FP16 再算,性能收益可能被反量化开销抵消。

所以量化格式是否有高性能 kernel 支持非常重要。

这也是为什么推理引擎不能只在模型加载时把权重变小,还要让 ModelRunner 使用对应的量化 linear kernel。

从系统结构看,权重量化主要影响:

模型加载
Linear 层实现
权重存储格式
kernel backend

它对 Scheduler 的影响较小,但会影响显存容量和 forward 速度,进而间接影响吞吐和延迟。

INT8 和 INT4 的基本思想

量化的基本思想是用低精度整数近似浮点数。

例如一个浮点权重 w,可以近似表示成:

w ≈ scale × q

其中 q 是整数,scale 是缩放因子。

INT8 中,q 通常是 8 bit 整数。

INT4 中,q 是 4 bit 整数。

scale 用来把整数映射回浮点范围。

如果还使用 zero point,则可以表示成:

w ≈ scale × (q - zero_point)

量化的关键问题是:如何选择 scale 和 zero point,才能让低精度表示尽量接近原始权重。

最简单的是 per tensor quantization。

整个张量共用一个 scale。

这种方式简单,但误差可能较大,因为一个张量里不同通道的数值范围可能差异很大。

更常见的是 per channel 或 group-wise quantization。

也就是每个输出通道,或者每一组权重使用不同 scale。

这样更精细,误差更小,但 metadata 更多,kernel 也更复杂。

INT4 比 INT8 更省显存,但表示能力更弱,对量化方法和模型敏感度要求更高。

所以 INT4 通常需要更精细的量化算法,例如 GPTQ、AWQ 这类方法。

这也说明一个基本规律:

精度越低,显存越省,但保持模型质量越难

GPTQ 和 AWQ 解决什么问题

如果只是简单地把 FP16 权重线性映射到 INT4,模型质量可能明显下降。

因为 LLM 的不同层、不同通道对量化误差的敏感度不同。

有些权重误差对输出影响不大。

有些权重稍微偏一点,就会显著影响模型行为。

GPTQ 和 AWQ 这类方法,就是为了在低 bit 权重量化时尽量保持模型质量。

GPTQ 的思路更偏向后训练量化中的误差补偿。它会利用校准数据和近似二阶信息,逐层量化权重,并尽量补偿量化误差对输出的影响。

AWQ 的核心直觉是,并不是所有权重同等重要。它会关注激活分布,保护对模型输出更关键的通道,让量化后的模型质量更好。

这里不展开数学细节,因为 mini vLLM 系列重点是推理系统。

我们只需要抓住一点:

低 bit 权重量化不是简单数据类型转换
它通常需要专门算法来降低精度损失

对于推理引擎来说,GPTQ、AWQ 这类方法会带来不同的权重格式。

所以 ModelRunner 需要能识别量化配置,并选择对应的 linear kernel。

也就是说,量化不仅是模型文件的问题,也是执行 backend 的问题。

FP8:浮点低精度的另一条路线

除了整数 INT8、INT4,还有一种常见方向是 FP8。

FP8 仍然是浮点格式,只是位宽更低。

它相比 INT8 的一个优势是动态范围表达更自然,适合某些权重、激活或 KV cache 场景。

当然,FP8 的实际收益高度依赖硬件支持。如果硬件有高效 FP8 tensor core,FP8 可能非常有吸引力。如果硬件支持不好,理论格式再好也很难发挥。

从系统角度看,FP8 和 INT8、INT4 一样,都需要 backend 支持。

不是把张量 dtype 改成 FP8 就结束了。

你需要对应的 matmul kernel、attention kernel、scale 管理、精度策略和测试验证。

所以在 mini vLLM 中,FP8 可以作为一个 backend 能力来设计,而不是一开始写死到模型结构里。

例如:

QuantizationConfig:
  format = "fp8"
  target = "weights"

或者:

QuantizationConfig:
  format = "fp8"
  target = "kv_cache"

这样后面可以逐步扩展。

激活量化为什么更难

权重量化相对容易,因为权重是固定的。

模型加载后,权重不会随着请求变化。

我们可以离线量化权重,保存量化后的模型文件。

激活量化更难,因为激活是运行时产生的。

不同输入、不同 prompt、不同生成位置,激活分布都可能变化。

如果激活的 scale 选得不好,误差会变大。

激活量化通常需要动态量化或校准策略,也需要对应 kernel 在运行时处理 scale。

对于在线 serving 来说,激活量化还要考虑性能稳定性。

如果为了量化激活引入大量额外统计和转换,可能反而影响延迟。

所以 mini vLLM 第一版不建议从激活量化开始。

更合理的路线是:

先支持权重量化
再考虑 KV cache 量化
最后再探索 activation quantization

权重量化最容易落地,也最容易看到模型显存下降。

KV cache 量化和 serving 系统结合更紧密,对高并发长上下文很有意义。

激活量化则更依赖硬件和 kernel 生态,可以放到更后面。

KV cache 量化:在线 serving 的关键方向

对于推理服务来说,KV cache 量化非常重要。

因为在高并发、长上下文场景下,KV cache 可能占用大量显存。

前面我们已经用 Block Manager 和 PagedAttention 提高了 KV cache 的分配效率。

但那只是减少空间浪费,并没有改变每个 KV 元素本身的大小。

如果 KV cache 从 FP16 降到 INT8 或 FP8,理论上每个 token 的 cache 显存可以进一步下降。

这会带来几个收益。

第一,同样显存可以容纳更多并发请求。

第二,同样上下文长度下 free blocks 更充足。

第三,长上下文场景下显存带宽压力可能下降。

但 KV cache 量化也很敏感。

因为 decode 阶段每一步都要读取历史 K 和 V。量化误差会直接影响 attention score 和 value 聚合,进而影响生成质量。

尤其是 K 的误差可能影响注意力分布,V 的误差会影响聚合内容。

所以 KV cache 量化需要非常谨慎。

从系统角度看,它主要影响:

KV cache pool 的 dtype
Block Manager 的 block size 与字节计算
PagedAttention backend 的读取和反量化逻辑
显存容量估算
正确性和质量测试

如果 KV cache 是 INT8,PagedAttention 读取 K/V 时可能需要反量化,或者 kernel 内部直接处理量化 K/V。

这意味着 AttentionBackend 必须知道 KV cache 的量化格式。

所以量化会让 AttentionBackend 抽象更重要。

量化如何影响 Block Manager

Block Manager 管理的是 KV cache blocks。

在 FP16 版本里,每个 block 的大小大致由这些因素决定:

block_size
num_layers
num_kv_heads
head_dim
K 和 V 两份
dtype_size

如果 dtype_size 从 2 字节变成 1 字节,单个 block 的显存占用就会下降。

这意味着同样显存下可以有更多 physical blocks。

所以 Block Manager 的初始化需要根据 KV cache dtype 计算 block pool 容量。

以前可能是:

num_blocks = available_memory / bytes_per_block_fp16

现在要变成:

num_blocks = available_memory / bytes_per_block_quantized

但量化格式可能需要额外 metadata。

例如每个 block、每个 head、每组 channel 可能需要 scale。

那么 block 的真实占用是:

quantized_kv_data_bytes + scale_metadata_bytes

这意味着 Block Manager 不能只知道 dtype,还要知道 quantization layout。

对于第一版 mini vLLM,可以先简化成:

FP16 KV cache
FP8 KV cache
INT8 KV cache

每种格式有固定 bytes_per_element。

scale metadata 可以先作为后续扩展。

但文章里要明确,生产级 KV cache 量化通常需要更精细的 scale 管理。

量化如何影响 PagedAttention

PagedAttention 负责根据 block table 读取历史 K 和 V。

如果 KV cache 是 FP16,它可以直接读取 FP16 K/V 参与 attention。

如果 KV cache 是 INT8 或 FP8,读取后还需要按照量化格式解释。

一种简单做法是:

读取量化 K/V
反量化成 FP16
参与 attention

这种方式容易理解,但可能不够快。

因为每次 decode 都要读取大量历史 KV。如果反量化产生额外中间张量,性能可能受到影响。

更好的方式是在 attention kernel 内部融合反量化。

也就是说,PagedAttention kernel 根据 block table 读取量化 K/V,同时读取 scale,然后在寄存器或片上存储中完成反量化和 attention 计算。

这就要求 AttentionBackend 支持不同 KV cache dtype。

接口上可以这样体现:

attention_backend.forward_decode(
    query,
    kv_cache,
    block_tables,
    context_lens,
    slot_mapping,
    kv_cache_dtype,
    kv_scale,
)

当然,具体参数会更复杂。

这里重点是:量化不能只改存储,attention 读取路径也必须一起改。

这就是系统设计中的连锁反应。

一个看似局部的优化,往往会影响多个模块。

量化如何影响 ModelRunner

权重量化主要影响 ModelRunner。

因为权重 linear 层不再是普通 FP16 linear。

例如一个普通 MLP 里有:

hidden = x @ W

量化后,W 可能是 INT4 格式,并且按 group 保存 scale。

ModelRunner 不能直接调用普通 torch linear。

它需要使用量化 linear kernel。

可能是:

AWQLinear
GPTQLinear
FP8Linear
INT8Linear

这些 kernel 的输入输出 dtype、权重 layout、scale layout 都可能不同。

所以 ModelRunner 最好不要在模型结构里到处写 if else。

更好的做法是模型加载时,根据 QuantizationConfig 构建对应的 layer。

例如:

如果没有量化:
  使用普通 Linear

如果是 AWQ:
  使用 AWQLinear

如果是 GPTQ:
  使用 GPTQLinear

如果是 FP8:
  使用 FP8Linear

这样 forward 主流程保持一致。

这也是很多推理框架的基本设计:量化格式在模型加载和 kernel 选择阶段处理,而不是在 Engine 的调度层处理。

Scheduler 不应该关心模型是 FP16 还是 INT4。

它只关心这轮请求能不能跑。

但量化改变了模型执行速度和显存容量,所以 benchmark 会反映它的影响。

精度损失如何评估

量化一定要评估质量。

不能只看速度和显存。

最基本的质量评估可以分三层。

第一层是 logits 对齐。

给定同一个输入,比较 FP16 模型和量化模型的 logits 差异。

这适合早期调试,但不能完全代表生成质量。

第二层是困惑度或验证集指标。

用一批文本数据计算量化前后的 perplexity 变化。这个指标能反映语言建模能力是否明显下降。

第三层是任务级评估。

例如问答、摘要、代码生成、数学推理等任务上的表现。

对于技术博客里的 mini vLLM,可以先做简单的对齐和生成样例,不必完整跑大型 benchmark。

但要明确一个原则:

量化优化必须同时报告性能和质量

如果 INT4 让吞吐提升很多,但输出质量明显下降,那它未必适合某些场景。

不同应用对质量损失的容忍度不同。

日志分类、粗略检索增强问答、某些格式化任务可能能接受轻微损失。

数学推理、代码生成、严肃问答可能更敏感。

所以量化方案没有绝对最好,只有适合具体场景。

Benchmark 量化时要看什么

量化 benchmark 不能只看模型能不能加载。

至少要看几个指标。

第一,模型权重显存下降多少。

这是权重量化最直接的收益。

第二,最大可承载并发数是否增加。

如果模型权重更小,剩余显存更多,KV cache block pool 可能更大,从而能处理更多并发请求。

第三,TTFT 是否变化。

权重量化可能影响 prefill 速度。KV cache 量化可能影响长 prompt 和长上下文下的 attention 速度。

第四,TPOT 是否变化。

decode 阶段大量读取权重和 KV cache,量化可能改善,也可能因反量化开销变差。

第五,吞吐是否提升。

尤其是 output tokens/s。

第六,输出质量是否可接受。

可以用 greedy 输出对齐、perplexity 或小型任务集评估。

第七,kernel 占比变化。

如果量化后 linear 变快,但 attention 或 sampling 成为瓶颈,那么整体收益可能小于预期。

这说明量化 benchmark 要结合系统内部 profiler。

不要只看最终 tokens/s。

要知道瓶颈从哪里转移到了哪里。

量化和 Tensor Parallel 的关系

量化和 tensor parallel 可以组合。

Tensor parallel 用更多 GPU 分担模型。

量化让每份权重更小。

如果一个模型原本需要 4 张 GPU 才能放下,权重量化后可能只需要 2 张。

或者仍然使用 4 张 GPU,但每张 GPU 剩余显存更多,可以容纳更大的 KV cache,从而支持更高并发或更长上下文。

但组合后也会更复杂。

Tensor parallel 下的量化权重需要按 rank 切分。

不同 rank 的量化 scale 和 metadata 也要正确加载。

Row parallel、column parallel 的量化 linear kernel 需要支持分布式切分。

lm_head 如果被切分,量化 logits 计算和 sampling 也会更复杂。

所以 mini vLLM 第一版可以先分别实现:

单 GPU 权重量化
单 GPU KV cache 量化实验
多 GPU FP16 tensor parallel

等这些都稳定后,再组合:

tensor parallel + weight quantization
tensor parallel + KV cache quantization

系统工程里,组合复杂度往往比单个功能更高。

所以逐步验证很重要。

第一版 mini vLLM 该怎么支持量化

对 mini vLLM 来说,第一版量化支持可以分成两个目标。

第一个目标是权重量化模型加载。

可以先支持一种成熟的权重量化格式,比如 INT8 或某种常见 INT4 权重格式。重点是让 ModelRunner 能识别量化配置,并使用对应 linear 层。

第二个目标是 KV cache dtype 配置。

让 Block Manager 能根据 KV cache dtype 计算 block pool 大小,让 AttentionBackend 能处理不同 dtype 的 KV cache。

第一版不一定要完整实现复杂 INT4 kernel。

可以先支持更容易理解的路径:

FP16 baseline
INT8 weight-only
FP8 或 INT8 KV cache 的教学实验

这里的“教学实验”可以先追求正确性和显存估算,不急着做生产级性能。

最重要的是把配置流打通。

一个量化配置可以长这样:

class QuantizationConfig:
    weight_dtype: str
    kv_cache_dtype: str
    quant_method: str | None

Engine 初始化时读取它。

ModelRunner 根据 weight_dtype 和 quant_method 加载模型。

Block Manager 根据 kv_cache_dtype 计算 block capacity。

AttentionBackend 根据 kv_cache_dtype 选择读取和反量化路径。

Benchmark 记录不同配置下的显存、吞吐和质量。

这样,量化就不是零散功能,而是系统配置的一部分。

小结

这一篇我们讨论了量化推理。

量化的核心目标是用更低精度表示模型权重、激活或 KV cache,从而降低显存占用和带宽压力,并在 kernel 支持良好的情况下提升推理速度。

权重量化主要减少模型本身的显存占用。INT8 比较稳健,INT4 更省显存但更依赖 GPTQ、AWQ 这类量化方法来控制质量损失。FP8 则是另一条低精度浮点路线,收益高度依赖硬件和 kernel 支持。

KV cache 量化对在线 serving 很重要,因为高并发和长上下文下,KV cache 会成为主要显存压力来源。它会影响 Block Manager 的 block 容量估算,也会影响 PagedAttention 的读取和反量化路径。

激活量化更复杂,因为激活是运行时动态产生的,对 scale 管理和 kernel 支持要求更高,可以放到后续阶段。

从系统角度看,量化会影响 ModelRunner、AttentionBackend、Block Manager 和 benchmark。它不应该只是把 dtype 改一下,而应该通过 QuantizationConfig 贯穿模型加载、kernel 选择、KV cache 布局和性能评估。

量化 benchmark 必须同时看性能和质量。显存下降、吞吐提升都很重要,但如果输出质量明显下降,就不能简单认为优化成功。

到这里,mini vLLM 已经具备了处理更大模型和更高并发的两条路线:tensor parallel 用多 GPU 分担模型,量化用低精度减少模型和 cache 的资源占用。

下一篇文章,我们会回到服务接口层,讨论如何兼容 OpenAI API。

一个推理引擎再强,如果不能以用户熟悉的协议提供服务,就很难接入现有应用。我们会实现 /v1/completions/v1/chat/completions,并讨论流式 SSE、错误格式、请求参数映射和多轮对话模板。