跳到主要内容
推理优化 · 更新于 2026年10月7日

4.6 量化选型与 vLLM 实战

按瓶颈选择量化候选,理解 vLLM 量化格式与 Kernel 的关系,并完成 FP16 与 AWQ INT4 的吞吐和生成质量对照

量化选型 vLLM AWQ compressed-tensors Benchmark

方法已经讲完了,现在把它们放回同一个部署问题:手里有一个模型和一张 GPU,应该先试哪种量化?选项可以很多,第一步却很具体——找出当前最紧的约束,再筛掉硬件和推理后端不支持的路径。 这一节先建立候选顺序,然后沿用第 3 章的 vLLM 环境,对比 FP16 与 AWQ INT4。

📑 目录


1. 从瓶颈出发选候选

graph TD
    A["确认 GPU、检查点格式与 Kernel 兼容"] --> B["确定当前最紧的约束"]
    B --> C["权重容量或读取带宽:评估 GPTQ / AWQ INT4"]
    B --> D["KV 容量或长上下文:评估 FP8 KV 等方案"]
    B --> E["矩阵计算:评估 W8A8,支持时评估原生 FP4"]
    C --> F["与 FP16 / BF16 基线比较目标任务质量"]
    D --> F
    E --> F
    F --> G{"质量是否达标?"}
    G -->|是| H["比较目标负载下的延迟与吞吐"]
    G -->|否| I["重校准、保留敏感层或提高位宽后重新评测"]

图中的分支代表优先试验的方向,可以组合使用。例如模型权重装不下、长上下文缓存也不足时,可以先验证 INT4 权重,再加入 FP8 KV Cache。硬件条件是候选的筛选项,质量门槛则是每条分支都要经过的一关。

首要约束优先候选下一步确认什么
精度退化容忍度低以 FP16/BF16 为基线,先评估 INT8 或 FP8 W8A8目标任务质量;8-bit 同样可能不达标
权重装不下、小 Batch 权重带宽紧GPTQ/AWQ INT4Group Size、Kernel、敏感层和容量
长上下文或并发被 KV 限制FP8 KV;有对应实现时评估更低位宽方案Attention 后端、Scale、长文本质量
GEMM 计算占比高设备支持的 W8A8;Blackwell 上可评估原生 FP4实际算子路径及端到端收益

如果只是没有量化检查点,可以先通过 LLM Compressor、GPTQModel 或 NVIDIA Model Optimizer 等工具离线生成兼容格式。校准与导出本身是一段独立流程,本节主实验使用已有检查点,集中验证部署效果。

2. vLLM 怎样识别量化模型

2.1 方法、格式与 Kernel 要分开看

名称主要处在哪一层加载时看什么
GPTQ、AWQ量化方法及其常见检查点格式位宽、Group Size、零点、权重排列
compressed-tensors可承载多种量化方案的格式/配置体系实际方案可能是 INT8、INT4 或 FP8 等
FP8、NVFP4、MXFP4数值表示及对应缩放方案检查点配置、硬件与后端
Marlin 等执行 Kernel是否匹配该权重布局与设备

vLLM 通常会读取模型配置中的量化信息,选择相应的加载与执行路径。--quantization 用于显式选择或确认某条支持的路径;除文档明确支持的在线量化方案外,它不会替你把任意 FP16 检查点离线转成 AWQ/GPTQ。

⚠️ 注意:显式写 --quantization awq 会关闭 vLLM 的 Kernel 自动升级。该版本在检查点可转换时会打印类似“you specified quantization=awq explicitly, so forcing awq”的日志并退回普通 AWQ Kernel。想要更快的路径,应省略该参数交给自动识别,或在设备支持时显式指定 awq_marlin。这也说明启动日志里的实际 Kernel 必须核对。

以 vLLM v0.19.0 文档为本节接口基线(与第 3 章的实验环境一致;第 5 章等后续内容以更新的版本为基线,升级后请按下文提示重新核对支持矩阵),下面是不同入口的含义;本地路径需要换成已导出的兼容检查点:

# 已有 AWQ 检查点,由配置识别位宽与可用 Kernel
vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ --dtype half

# 已有 compressed-tensors 检查点,由配置识别具体方案
vllm serve /path/to/compressed-tensors-model

# 已有 GPTQ 检查点,由配置及硬件选择相应路径
vllm serve /path/to/gptq-model

# 已有受支持的 NVFP4 检查点,先核对设备及导出格式
vllm serve /path/to/nvfp4-model

# 文档支持的在线 FP8 路径;激活与权重行为以该版本实现为准
vllm serve Qwen/Qwen2.5-7B-Instruct --quantization fp8

在线 FP8 在该版本文档里称为 Online Dynamic Quantization:除 lm_head 外的线性层权重转成 FP8 E4M3 并使用 Per-tensor Scale,激活在每次前向动态缩放,不需要校准数据,文档同时提示这种模式的延迟收益有限。FP8 说明

由于转换发生在加载阶段,峰值显存可能高于最终量化后的权重占用。早期版本文档曾明确写出”需要先按原精度载入整个模型”,该提示后来被删除,因此实际行为应以所用版本和启动日志为准。若原始模型加载就会超出显存,可优先准备离线量化检查点。

上述几条命令是不同配置的入口,单次只启动一个。INT8 W8A8 的校准与导出可参考 LLM Compressor 路径,NVFP4 的导出与加载可参考 Model Optimizer 路径。版本升级后应重新检查支持矩阵及启动日志。

2.2 先保存实验环境

沿用第 3 章的 Linux/CUDA 环境;这里不重复安装。实验前至少记录:

python -c "import vllm, torch; print('vLLM:', vllm.__version__); print('PyTorch:', torch.__version__); print('CUDA:', torch.version.cuda)"
nvidia-smi

同时记录模型仓库的 revision,最好先下载固定快照,再把后面的模型参数替换为本地路径。GPU 型号、驱动、vLLM 版本、检查点配置和实际 Kernel 都是结果的一部分。下面没有预填吞吐数字,读者需要在自己的设备上测量。

3. 启动 FP16 与 AWQ 服务

先用原始模型跑 FP16 基线:

vllm serve Qwen/Qwen2.5-7B-Instruct \
    --served-model-name quant-demo \
    --dtype half \
    --max-model-len 4096 \
    --max-num-seqs 32 \
    --gpu-memory-utilization 0.9 \
    --generation-config vllm \
    --no-enable-prefix-caching

完成请求、保存日志并停止这个进程,确认显存释放后,再运行 AWQ:

vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ \
    --served-model-name quant-demo \
    --dtype half \
    --max-model-len 4096 \
    --max-num-seqs 32 \
    --gpu-memory-utilization 0.9 \
    --generation-config vllm \
    --no-enable-prefix-caching

两边保持相同的激活类型、上下文限制、并发上限与缓存精度。这里不传 --quantization,让 vLLM 从检查点配置识别方案并选择可用 Kernel,避免人为锁定较慢的路径。原始 Qwen 检查点以 BF16 发布,这里明确使用 FP16 作为本实验基线,便于与 AWQ 的 FP16 激活路径比较。AWQ 模型卡

记录每次启动日志中的模型权重占用、可用 KV Token 容量和实际量化 Kernel。AWQ 权重减少后,vLLM 可能把腾出的预算用于 KV Cache,因此总显存占用未必按四分之一缩小。

4. 控制变量测离线吞吐

4.1 先回答一个有限的问题

下面的实验测量:相同输入 Token、固定输出长度、相同引擎配置下,一批请求完成得多快? 它不提供在线 TTFT/TPOT 的尾延迟,也不代表服务在真实到达率下的表现。在线压测的方法留给第 9 章。

关闭前面的在线服务,将下面代码保存为 compare_quant.py。脚本每次只加载一个模型,预热后测量三轮,保存输出 Token/s、总 Token/s 与生成样例。两种模型使用同一个 tokenizer 生成输入,吞吐阶段关闭 Prefix Cache,并忽略 EOS 以固定实际输出量。

import argparse
import json
import statistics
import time
from pathlib import Path

from transformers import AutoTokenizer
from vllm import LLM, SamplingParams


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--model", required=True)
    parser.add_argument("--tokenizer", default="Qwen/Qwen2.5-7B-Instruct")
    parser.add_argument("--quantization", default=None)
    parser.add_argument("--num-prompts", type=int, default=64)
    parser.add_argument("--input-len", type=int, default=512)
    parser.add_argument("--output-len", type=int, default=256)
    parser.add_argument("--save", required=True)
    args = parser.parse_args()
    if min(args.num_prompts, args.input_len, args.output_len) <= 0:
        parser.error("请求数和 Token 长度必须为正")
    if args.input_len + args.output_len > 4096:
        parser.error("输入与输出之和不能超过本实验的 4096 Token 上限")

    tokenizer = AutoTokenizer.from_pretrained(args.tokenizer)
    # 构造可重复的等长输入。这里只测速度,不用这批重复文本评质量。
    prompts = []
    for i in range(args.num_prompts):
        unit = tokenizer.encode(
            f"请求编号 {i}。请解释量化对推理显存和计算的影响。",
            add_special_tokens=False,
        )
        ids = (unit * (args.input_len // len(unit) + 1))[:args.input_len]
        prompts.append({"prompt_token_ids": ids})

    llm = LLM(
        model=args.model,
        tokenizer=args.tokenizer,
        quantization=args.quantization,
        dtype="half",
        max_model_len=4096,
        max_num_seqs=32,
        max_num_batched_tokens=2048,
        gpu_memory_utilization=0.9,
        kv_cache_dtype="auto",
        enable_prefix_caching=False,
        generation_config="vllm",
        seed=0,
    )
    params = SamplingParams(
        temperature=0, max_tokens=args.output_len, ignore_eos=True, seed=0
    )
    llm.generate(prompts, params, use_tqdm=False)  # 完整负载预热,不计时
    measurements = []
    for _ in range(3):
        start = time.perf_counter()
        outputs = llm.generate(prompts, params, use_tqdm=False)
        elapsed = time.perf_counter() - start
        output_tokens = sum(len(o.outputs[0].token_ids) for o in outputs)
        expected = args.num_prompts * args.output_len
        if output_tokens != expected:
            raise RuntimeError(f"实际输出 {output_tokens},预期 {expected}")
        input_tokens = sum(len(o.prompt_token_ids) for o in outputs)
        measurements.append({
            "seconds": elapsed,
            "output_tokens_per_second": output_tokens / elapsed,
            "total_tokens_per_second": (input_tokens + output_tokens) / elapsed,
        })

    # 质量样例恢复正常 EOS,并使用一致的 Chat Template;不纳入吞吐计时。
    questions = [
        "只返回 JSON:name 为 Alice,count 为整数 3,不要 Markdown 围栏。",
        "有 17 箱货物,每箱 24 件,发出 89 件后还剩多少件?给出计算。",
        "写一个 Python 函数,判断字符串是否为回文,忽略大小写和非字母数字字符。",
    ]
    chats = [[{"role": "user", "content": q}] for q in questions]
    texts = [tokenizer.apply_chat_template(
        chat, tokenize=False, add_generation_prompt=True
    ) for chat in chats]
    examples = llm.generate(
        texts, SamplingParams(temperature=0, max_tokens=512, seed=0),
        use_tqdm=False,
    )
    result = {
        "config": vars(args),
        "runs": measurements,
        "median_output_tokens_per_second": statistics.median(
            r["output_tokens_per_second"] for r in measurements
        ),
        "examples": [
            {"question": q, "answer": o.outputs[0].text}
            for q, o in zip(questions, examples)
        ],
    }
    Path(args.save).write_text(
        json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

4.2 分别运行两个进程

python compare_quant.py \
    --model Qwen/Qwen2.5-7B-Instruct \
    --save fp16.json

python compare_quant.py \
    --model Qwen/Qwen2.5-7B-Instruct-AWQ \
    --save awq-int4.json

第一条结束后再运行第二条,避免两个引擎争用显存。两边各会提交 64 个请求,每请求输入 512、输出 256 Token,引擎同时处理的序列上限为 32。可以再给两条命令都加上 --num-prompts 1 --input-len 64,单独观察小 Batch 场景;每组结果分别保存。

计时包围完整的阻塞式 generate() 调用,包含本地引擎调度和输出整理,模型下载、加载、首次预热不计入吞吐。进行严肃性能对比时,保持 GPU 空闲状态一致,交替运行两种配置并观察波动;需要分析算子时间时,再使用第 9 章的 Profiling 方法。

5. 生成质量怎样比较

脚本里的三条问题是冒烟检查,帮助发现明显的格式、算术和代码能力退化:

样例检查方法
JSON 输出能解析为对象,字段和值正确,count 为整数
算术应得到 17×24−89=31917\times24-89=319,过程与答案一致
回文函数检查空串、A man, a plan, a canal: Panama、ab 等输入

两份文本逐字不同并不等于质量退化,温度为 0 也无法保证不同数值路径输出完全相同。应按任务要求评分,统计通过率变化。

正式评测需要独立于校准集,并覆盖实际业务:中文指令、代码、数学、结构化输出、长文档问答等。固定 Prompt、Chat Template、最大生成长度与评分规则,再比较基线和量化模型。PPL 可作为语言建模损失的补充指标,但不能替代代码正确率或检索准确率。

如果某类任务退化明显,先检查模板、截断和量化配置;确认评测一致后,再考虑更细粒度、保留敏感层高精度、提高位宽,或重新校准。只有在 PTQ 仍达不到目标且具备训练条件时,再评估 QAT。

6. 读懂结果与常见问题

建议把两份 JSON 与启动日志整理成下面的表。没有实际运行的项目保留为空,不用论文或其他设备的数字代填:

指标FP16 基线AWQ INT4
模型 revision / vLLM / GPU待记录待记录
实际量化 Kernel不适用或基线 GEMM待记录
模型权重占用 / KV Token 容量待记录待记录
输出 Token/s 中位数(3 轮)待测待测
总 Token/s 与输入输出长度待测待测
任务通过率 / 失败样例待评测待评测

输出 Token/s 的比值可以作为这组固定负载的加速比。把输入 Token 也计入的总吞吐应另列,不能用两种不同口径互相比较。

现象优先检查
AWQ 检查点无法加载是否使用真正的 AWQ 检查点,配置与工具版本是否匹配
INT4 装得下却比 FP16 慢实际 Kernel(是否被 --quantization awq 锁回普通路径)、矩阵形状、反量化开销和当前瓶颈
总显存几乎没有下降KV 池是否利用了省出的权重空间,查看缓存 Token 容量
小 Batch 快,大 Batch 改善不大权重搬运占比是否下降,GEMM 是否转向计算受限
长文本质量下降KV 精度、Scale、长度截断及长文本校准/评测覆盖

若 FP16 基线在当前设备上无法运行,应如实记录容量限制。可以减少共同负载或在足够显存的设备上重做对照;只跑出量化模型,能够证明部署可行,还无法得出相对 FP16 的速度提升。

📝 总结

  • 先从权重、KV 或计算瓶颈选候选,再检查硬件与后端兼容性。
  • 方法、检查点格式、数值类型和执行 Kernel 共同决定部署行为。
  • 对照实验需要固定模型来源、输入输出、引擎配置与测量口径。
  • 量化的最终价值是满足质量和延迟要求后的资源或吞吐收益。

🎯 自我检验清单

  • 能根据当前瓶颈说明首先尝试哪种量化及原因
  • 能解释 compressed-tensors 为什么没有唯一的位宽
  • 能区分压缩比、离线吞吐加速比与在线延迟指标
  • 能指出这个实验控制了哪些变量,以及还需要哪些质量评测

📚 参考资料