模型量化与蒸馏:INT8/INT4 与部署权衡

系统讲解 LLM 量化与蒸馏:对称/非对称量化、per-channel 与 group-wise 粒度、GGUF/AWQ/GPTQ/NF4/FP8 格式对比,给出 llama.cpp 与 AutoAWQ 的实操命令,以及知识蒸馏、剪枝与组合压缩的部署权衡与评估方法。

1. 先算一笔显存账

很多"跑不动大模型"的问题,本质是显存算术没做对。权重只是其中一部分。

权重显存 = 参数量 × 每参数字节数
KV Cache = 2 × n_layers × n_kv_heads × head_dim × seq_len × 精度字节
激活/中间张量 ≈ batch × seq_len × hidden × 若干倍

以 Llama-3-8B(32 层、8 个 KV head、head_dim 128)为例:

精度每参数字节权重显存KV/1k token8k 上下文总占用
FP16216 GB128 MB~17 GB
INT818 GB128 MB~9 GB
INT40.54 GB128 MB~5 GB
INT4 + KV INT80.54 GB64 MB~4.5 GB

结论:量化把权重压到 1/4,但 KV Cache 不受影响。长上下文场景里 KV Cache 才是主项,这也是为什么需要单独做 KV 量化。KV Cache 的优化详见 KV Cache 与显存优化 。

2. 量化的基本原理

量化的目标是把浮点权重映射到低位宽整数,同时尽量保留信息。

2.1 对称 vs 非对称

对称量化:   q = round(x / scale)                 , scale = max|x| / (2^(b-1) - 1)
非对称量化: q = round(x / scale) + zero_point    , scale = (max - min) / (2^b - 1)
  • 对称:无 zero_point,计算快,适合权重(分布近似零均值)。
  • 非对称:多一个 zero_point,动态范围利用更充分,适合激活(ReLU 后全正)。

2.2 粒度:per-tensor / per-channel / group-wise

粒度越细,误差越小,但元数据开销越大。

粒度说明典型误差适用
per-tensor整个张量一个 scale大激活
per-channel每个输出通道一个 scale中INT8 权重
group-wise每 32/64/128 个权重一组小INT4 权重(GPTQ/AWQ)
per-token每个 token 动态算 scale小激活(SmoothQuant)

INT4 必须用 group-wise,否则精度崩得厉害。常见 group_size=128:Llama-3-8B 的 q_proj 权重形状 [4096, 4096],按 128 分组后每组 32 个 scale(因为按输入维度分组),元数据开销约 32 / 128 = 25%……这正是为什么 INT4 实际显存节省只有约 60~70% 而非 75%。

2.3 离群值(Outlier)问题

LLM 激活里存在极少数幅度极大的通道(outlier),per-tensor 量化会被它们拖垮。解决方案有两类:

  1. 通道级缩放:SmoothQuant 把激活的难度"迁移"到权重上(数学上等价变换)。
  2. 保留离群通道为 FP16:LLM.int8() 的做法,检测出 outlier 维度后单独走 FP16 分支。
# SmoothQuant 核心:s = max(|X|)^alpha / max(|W|)^(1-alpha)
import torch
def smooth_scale(act_max: torch.Tensor, w_max: torch.Tensor, alpha: float = 0.5):
    return (act_max.pow(alpha) / w_max.pow(1 - alpha)).clamp(min=1e-5)
# X' = X / s, W' = W * s  →  X'W' = XW 保持不变,但分布更友好

3. 主流量化格式对比

格式位宽粒度是否需要校准推理支持特点
GGUF Q4_K_M4~5 混合k-quant 块不需要llama.cpp / Ollama混合精度,效果好
AWQ4group 128需要vLLM / TGI / AutoAWQ保护显著通道,精度高
GPTQ4/3/8group 128需要vLLM / TGI / ExLlama逐层误差补偿
bitsandbytes NF44block 64不需要transformers训练/推理通用,QLoRA
FP8 (E4M3)8per-tensor/ch不需要H100 / vLLM硬件原生,无解量化开销
INT8 (LLM.int8)8混合不需要transformersoutlier 走 FP16

3.1 GGUF 的命名规则

GGUF 的量化名如 Q4_K_M 含义是:

  • Q4:4 bit。
  • K:使用 k-quant 分块量化(块内再分层)。
  • M:Medium,混合策略——对敏感层(如 attn_v、ffn_down)用更高位宽。

常见档位与 7B 模型体积:

档位体积(7B)相对 FP16 困惑度增幅
Q8_0~7.2 GB+0.01%
Q6_K~5.5 GB+0.05%
Q5_K_M~4.8 GB+0.12%
Q4_K_M~4.1 GB+0.35%
Q3_K_M~3.3 GB+1.8%
Q2_K~2.6 GB+8% 以上

经验阈值:Q4_K_M 是质量与体积的甜点;Q3 以下只在极端受限设备上用。

4. 量化实战

4.1 llama.cpp 转换与量化

# 1. 从 HF 转换到 GGUF FP16
python convert_hf_to_gguf.py ./Qwen2.5-7B-Instruct \
  --outfile qwen2.5-7b-f16.gguf --outtype f16

# 2. 量化为 Q4_K_M
./llama-quantize qwen2.5-7b-f16.gguf qwen2.5-7b-q4km.gguf Q4_K_M

# 3. 量化后立刻跑困惑度,确认退化可接受
./llama-perplexity -m qwen2.5-7b-q4km.gguf -f wiki.test.raw

llama-perplexity 输出 Final estimate: PPL = 6.0234,与 FP16 的 5.9 对比,增幅在 2% 以内即可接受。

4.2 AWQ 量化(AutoAWQ)

AWQ 的核心假设:权重中约 1% 的显著通道承载了大部分信息,按激活幅度放大这些通道再做量化,可以显著降低误差。

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model_path = "Qwen/Qwen2.5-7B-Instruct"
quant_path = "qwen2.5-7b-awq"

model = AutoAWQForCausalLM.from_pretrained(model_path, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_path)

quant_config = {
    "zero_point": True,
    "q_group_size": 128,
    "w_bit": 4,
    "version": "GEMM",
}

# 需要校准集,用领域内文本效果更好
model.quantize(tokenizer, quant_config=quant_config, calib_data="pileval")
model.save_quantized(quant_path)
tokenizer.save_pretrained(quant_path)

校准集的选择很关键:用你业务领域的文本做校准,通用校准集(pileval)在垂直领域上可能差 1~3 个点。

4.3 bitsandbytes NF4(QLoRA 场景)

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",           # NormalFloat4,对正态分布最优
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,      # 二次量化 scale,再省 0.4 bit
)

model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    quantization_config=bnb,
    device_map="auto",
)

NF4 的 double_quant 是它比朴素 INT4 更好的关键:对量化 scale 再做一次 8bit 量化,把元数据开销从 ~0.5 bit 压到 ~0.127 bit。这也是 QLoRA 能在单张 24GB 卡上微调 7B 的原因,微调流程见 /llm-fine-tuning/。

4.4 ONNX Runtime 量化

python -m onnxruntime.quantization.preprocess --input model.onnx --output model_pre.onnx
python -m onnxruntime.quantization.quantize \
  --input model_pre.onnx --output model_int8.onnx \
  --quant_format QDQ --op_types MatMul,Attention \
  --calibrate_dataset calib_data/

ONNX 路线在 CPU 与端侧优势明显:QDQ 格式保留了量化/反量化节点,便于不同后端自行融合。

5. 量化感知训练(QAT)

PTQ(训练后量化)在 4 bit 以下往往掉点严重,此时需要 QAT:在训练中模拟量化误差,让模型"学会"适应低精度。

import torch
import torch.nn as nn

class FakeQuant(nn.Module):
    """模拟量化-反量化的前向,梯度用 STE 直通。"""
    def __init__(self, bits: int = 4, group_size: int = 128):
        super().__init__()
        self.qmax = 2 ** (bits - 1) - 1
        self.group_size = group_size

    def forward(self, w: torch.Tensor) -> torch.Tensor:
        orig_shape = w.shape
        wg = w.reshape(-1, self.group_size)
        scale = wg.abs().amax(dim=1, keepdim=True) / self.qmax
        scale = scale.clamp(min=1e-8)
        q = torch.round(wg / scale).clamp(-self.qmax - 1, self.qmax)
        # 反量化回浮点,STE 让梯度直接穿透 round
        wq = (q * scale).reshape(orig_shape)
        return w + (wq - w).detach()

QAT 的代价是需要完整训练管线与数据,通常只在"PTQ 掉点不可接受"时使用。QAT 与量化感知的更多变体见 量化感知训练 。

6. 知识蒸馏

蒸馏(Knowledge Distillation)走的是另一条路:不压缩权重位宽,而是用大模型(Teacher)教小模型(Student),把能力迁移到更小的架构上。

6.1 硬标签 vs 软标签

L = α · CE(student_logits, hard_label) + (1-α) · KL(student_logits/T, teacher_logits/T)
  • 硬标签:只学正确答案,信息量少。
  • 软标签:学 Teacher 的完整概率分布(含"哪些错误答案也合理"的暗知识),T 是温度。
import torch.nn.functional as F

def distillation_loss(student_logits, teacher_logits, labels, T=4.0, alpha=0.7):
    soft = F.kl_div(
        F.log_softmax(student_logits / T, dim=-1),
        F.softmax(teacher_logits / T, dim=-1),
        reduction="batchmean",
    ) * (T * T)   # 乘 T^2 保持梯度尺度
    hard = F.cross_entropy(student_logits, labels)
    return alpha * hard + (1 - alpha) * soft

6.2 蒸馏的三个层次

层次蒸馏什么代表工作
输出层logits / 概率分布Hinton 原始方法
中间层隐层表示、注意力矩阵DistilBERT、MiniLM
数据层用 Teacher 生成训练数据Alpaca、Orca、蒸馏数据集

**数据蒸馏(Data Distillation)**是 LLM 时代最主流的做法:让强模型生成大量高质量 (指令, 回答) 对,再对小模型做 SFT。它绕过了"对齐中间层"的架构约束,可以跨架构、跨 tokenizer 迁移。

# 用 Teacher 生成指令数据,再用 LoRA 微调 Student
prompts = load_seed_prompts("seeds.jsonl")
dataset = []
for p in prompts:
    answer = teacher.generate(p, temperature=0.7, top_p=0.95)
    if quality_filter(answer):        # 规则 + 打分模型过滤
        dataset.append({"instruction": p, "output": answer})
save_jsonl(dataset, "distill_train.jsonl")

6.3 蒸馏的取舍

维度量化蒸馏
目标同架构、低位宽更小架构
训练成本低(PTQ)/ 中(QAT)高(需生成数据 + SFT)
速度提升显存 ↓,算力 ↓ 有限算力 ↓↓(参数少)
能力上限接近原模型受 Student 容量限制
可逆性可反量化不可逆

一句话:要省显存选量化,要省算力选蒸馏,两者可叠加。

7. 组合策略:量化 + 蒸馏 + 剪枝

实际生产里三者常组合使用:

大模型 (70B FP16)
  └─ 蒸馏 → 小模型 (7B FP16)         [省算力]
      └─ 剪枝 → 稀疏 7B              [省参数,需硬件支持]
          └─ 量化 → 7B INT4          [省显存]
              └─ KV 量化 → INT8 KV   [省长上下文显存]

剪枝(Pruning)分结构化与非结构化:非结构化稀疏(50% 零权重)在通用 GPU 上几乎不加速,除非用支持 2:4 稀疏的 Ampere+ 硬件;结构化剪枝(整头/整层删除)能真实降 FLOPs,但需要重训练恢复能力。

8. 部署权衡与评估

8.1 选型决策树

需要微调?
├─ 是 → QLoRA (NF4 + LoRA),单卡 24GB 可训 7B
└─ 否 → 有 GPU?
        ├─ 是(A100/H100)→ AWQ 4bit + vLLM,吞吐最优
        ├─ 是(消费级 4090)→ GPTQ/AWQ 4bit,group 128
        └─ 否(CPU / Mac)→ GGUF Q4_K_M + llama.cpp

本地与私有化部署的完整方案见 /llm-local-deployment-private/。

8.2 评估指标

量化后必须评估,不能只看"能不能跑起来"。

指标方法可接受阈值
困惑度 PPLllama-perplexity相对 FP16 增幅 < 2%
任务准确率MMLU / GSM8K / 业务集下降 < 2 个点
输出一致性与 FP16 输出的 KL / 相似度相似度 > 0.9
首 token 延迟TTFT 实测量化后应下降或持平
吞吐tokens/s @ batch4bit 应提升 1.5~3x

8.3 常见陷阱

  • 量化后输出变啰嗦:低位宽下模型倾向重复,用 repetition_penalty=1.05~1.1 缓解。
  • 校准集不匹配:用英文校准集量化中文模型,中文任务掉点明显。
  • AWQ/GPTQ 与 LoRA 冲突:不能对已量化权重直接训练(除 QLoRA 的 NF4 路径),需先合并再量化。
  • 忘记量化 embed/lm_head:这两层参数量占比大(词表 15 万 × 4096),不量化则省不下多少。

9. 量化内核与硬件加速

量化能不能真正提速,取决于内核实现,而不是位宽本身。低位宽省的是显存带宽,而 decode 阶段恰恰是带宽瓶颈。

9.1 为什么 decode 阶段量化收益最大

阶段瓶颈量化收益
Prefill算力(矩阵乘大)有限,甚至因解量化变慢
Decode显存带宽(每步读全部权重)显著,带宽需求降为 1/4

Decode 每生成一个 token 都要把全部权重从显存读一遍。7B FP16 是 14GB,A100 带宽 2TB/s,理论下限 14 / 2000 ≈ 7ms/token;INT4 只需 3.5GB,理论下限约 1.75ms/token。这就是 4bit 能带来 2~4 倍加速的原因。

9.2 解量化开销

朴素做法是"先反量化成 FP16 再算",这会引入额外开销,抵消部分收益。优化做法是融合内核(Fused Kernel):在寄存器里就地解量化并累加,避免写回显存。

// 伪代码:GEMV 中融合反量化
__global__ void gemv_int4(const uint8_t* w_packed, const half* scales,
                          const half* x, half* out, int N, int K) {
    int row = blockIdx.x;
    float acc = 0.f;
    for (int k = threadIdx.x; k < K; k += blockDim.x) {
        uint8_t packed = w_packed[row * (K / 2) + k / 2];
        int q = (k % 2 == 0) ? (packed & 0x0F) : (packed >> 4);
        float w = (q - 8) * __half2float(scales[row * (K / 128) + k / 128]);
        acc += w * __half2float(x[k]);   // 就地反量化,不落显存
    }
    atomicAdd(&out[row], acc);
}

Marlin、ExLlamaV2、AWQ 的 GEMM 内核都做了类似融合,实测比朴素实现快 1.5~2 倍。

9.3 FP8:新一代硬件的原生选择

Hopper(H100)与 Ada 架构原生支持 FP8(E4M3 / E5M2)。相比 INT4,FP8 的优势是:

  • 动态范围大(E4M3 可表示到 ±448),不需要复杂的离群值处理。
  • 无需解量化,Tensor Core 直接吃 FP8,内核简单。
  • 精度损失小,通常 PPL 增幅 < 0.5%。

代价是显存只省一半(8 bit vs 4 bit)。选型:显存充足且有 H100 → FP8;显存紧张 → INT4。vLLM 已内置 FP8 KV Cache:

vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --quantization fp8 \
  --kv-cache-dtype fp8

10. 端侧量化的额外约束

端侧(手机、浏览器、嵌入式)的约束与服务器完全不同。

约束服务器端侧
内存带宽1~3 TB/s20~100 GB/s
算子支持完整受 NPU/DSP 限制
支持位宽4/8/16通常 8 bit 优先
功耗不受限严格(热与电池)
模型加载常驻显存需考虑冷启动

端侧的现实结论:INT8 往往是端侧的最优解,因为多数移动 NPU(高通 Hexagon、苹果 ANE)对 INT8 有原生支持,而 INT4 需要软件模拟,实际并不更快。

10.1 端侧量化实践要点

  • 优先选择 NPU 原生支持的位宽(多为 INT8),而非一味追低位宽。
  • 权重需按硬件要求重新排布(如 NCHW → NHWC、通道分组),转换工具链(Core ML Tools / TFLite Converter / ONNX Runtime Mobile)会自动处理。
  • 使用 mmap 加载权重,让操作系统按需分页,降低冷启动内存峰值。
  • 浏览器端用 WASM SIMD 或 WebGPU,量化模型体积直接决定首屏加载时间。

11. 评估脚本

把评估固化成脚本,避免"凭感觉觉得没掉点"。

import torch, json
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset

def perplexity(model, tokenizer, texts, max_len=2048) -> float:
    model.eval()
    nlls, count = [], 0
    for text in texts:
        ids = tokenizer(text, return_tensors="pt", truncation=True,
                        max_length=max_len).input_ids.to(model.device)
        if ids.size(1) < 8:
            continue
        with torch.no_grad():
            out = model(ids, labels=ids)
        nlls.append(out.loss.float() * (ids.size(1) - 1))
        count += ids.size(1) - 1
    return torch.exp(torch.stack(nlls).sum() / count).item()

def compare(fp16_path: str, quant_path: str, dataset: str = "wikitext-2-raw-v1"):
    ds = load_dataset("wikitext", dataset, split="test")
    texts = [t for t in ds["text"] if len(t.strip()) > 200][:200]

    results = {}
    for name, path in [("fp16", fp16_path), ("quant", quant_path)]:
        tok = AutoTokenizer.from_pretrained(path)
        mdl = AutoModelForCausalLM.from_pretrained(path, device_map="auto")
        results[name] = perplexity(mdl, tok, texts)
        del mdl
        torch.cuda.empty_cache()

    delta = (results["quant"] / results["fp16"] - 1) * 100
    results["delta_pct"] = round(delta, 3)
    print(json.dumps(results, indent=2))
    return results["delta_pct"] < 2.0   # 阈值 2%

if __name__ == "__main__":
    ok = compare("Qwen2.5-7B-Instruct", "qwen2.5-7b-awq")
    print("PASS" if ok else "FAIL")

把这段脚本接入 CI,每次换量化方案或升级校准集都自动跑一遍,退化超过阈值直接 fail。

小结

量化的本质是用可控的精度损失换显存与吞吐,蒸馏的本质是用训练成本换推理算力。二者解决不同瓶颈,可以叠加。

工程上的默认选择:本地与 Mac 用 GGUF Q4_K_M,GPU 服务端用 AWQ/GPTQ 4bit + group 128,微调场景用 bitsandbytes NF4,长上下文再叠加 KV INT8。无论哪种组合,都要用困惑度与任务准确率双重校验,并把校准集换成业务领域文本——这一步带来的收益往往比换量化算法更大。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「llm」更多文章

  1. 端侧推理:移动端与浏览器部署
  2. RAG 评估体系:召回、忠实度与自动化指标
  3. 长上下文优化:注意力稀疏化与 KV Cache 管理