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 token | 8k 上下文总占用 |
|---|---|---|---|---|
| FP16 | 2 | 16 GB | 128 MB | ~17 GB |
| INT8 | 1 | 8 GB | 128 MB | ~9 GB |
| INT4 | 0.5 | 4 GB | 128 MB | ~5 GB |
| INT4 + KV INT8 | 0.5 | 4 GB | 64 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 量化会被它们拖垮。解决方案有两类:
- 通道级缩放:SmoothQuant 把激活的难度"迁移"到权重上(数学上等价变换)。
- 保留离群通道为 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_M | 4~5 混合 | k-quant 块 | 不需要 | llama.cpp / Ollama | 混合精度,效果好 |
| AWQ | 4 | group 128 | 需要 | vLLM / TGI / AutoAWQ | 保护显著通道,精度高 |
| GPTQ | 4/3/8 | group 128 | 需要 | vLLM / TGI / ExLlama | 逐层误差补偿 |
| bitsandbytes NF4 | 4 | block 64 | 不需要 | transformers | 训练/推理通用,QLoRA |
| FP8 (E4M3) | 8 | per-tensor/ch | 不需要 | H100 / vLLM | 硬件原生,无解量化开销 |
| INT8 (LLM.int8) | 8 | 混合 | 不需要 | transformers | outlier 走 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 评估指标
量化后必须评估,不能只看"能不能跑起来"。
| 指标 | 方法 | 可接受阈值 |
|---|---|---|
| 困惑度 PPL | llama-perplexity | 相对 FP16 增幅 < 2% |
| 任务准确率 | MMLU / GSM8K / 业务集 | 下降 < 2 个点 |
| 输出一致性 | 与 FP16 输出的 KL / 相似度 | 相似度 > 0.9 |
| 首 token 延迟 | TTFT 实测 | 量化后应下降或持平 |
| 吞吐 | tokens/s @ batch | 4bit 应提升 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/s | 20~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。无论哪种组合,都要用困惑度与任务准确率双重校验,并把校准集换成业务领域文本——这一步带来的收益往往比换量化算法更大。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。