数值格式:FP16 · BF16 · FP8 · INT8 · INT4¶
基线:vLLM 0.26.0 · 模型 Qwen2.5-7B-Instruct · 单张 RTX 4090(24 GB)
本页的 --dtype 与 --kv-cache-dtype flag 均针对 vLLM 0.26.0 经 Context7 核实(ADR-0004)。位布局事实与范围/精度算术是精确的;任何加速数字都是示例 / 量级参考。本课讲格式;量化方法(GPTQ/AWQ/SmoothQuant)是 Part 4。
1 · 直觉 & 为什么重要¶
LLM 里的每个权重、激活、KV 缓存项,归根结底都是一小撮比特。用多少比特、以及如何切分,一次决定三件事:模型吃多少 VRAM、每步有多少字节跨过 HBM 那条线、以及你注入多少数值误差。因为推理是 memory-bound(带宽受限),中间那件事是决定性的——把每个数的比特数减半,大致就把搬运的字节减半,从而大致让 decode 吞吐翻倍。 数值格式就是那个吞吐杠杆;量化只是在不弄坏模型的前提下把它拉下来的艺术。
所以在碰任何一种量化方法之前,你需要对格式本身有清晰的图景:老牌的 FP32/FP16/BF16、激进的 FP8(两种口味)、以及权重量化所栖身的整数 INT8/INT4。整场游戏是范围(能表示多大/多小的数)与精度(能多细地区分相邻的数)之间的权衡——而浮点与整数以根本不同的方式做这个权衡。把本课学对,Part 4 就变成「哪种方法保住精度」,而非「等等,e4m3 是啥?」。→ 见术语表的 FP8 / INT8 / INT4、Per-tensor / per-channel / per-group、KV-cache quantization。
2 · 心智模型¶
一个浮点数是 符号 · 尾数 · 2^指数——指数买范围,尾数买精度。一个整数格式是 一个共享 scale · 一个小整数——所有范围都住在 scale 里,所有值共享它。
FLOATS (per-value exponent: range and precision both baked into each number)
bit layout S = sign, E = exponent (range), M = mantissa (precision)
FP32 S EEEEEEEE MMMMMMMMMMMMMMMMMMMMMMM 1+8+23 range ~1e38 the reference
FP16 S EEEEE MMMMMMMMMM 1+5+10 range ~6e4 precise, SMALL range -> overflow risk
BF16 S EEEEEEEE MMMMMMM 1+8+7 range ~1e38 FP32's range, COARSE precision
FP8e4m3 S EEEE MMM 1+4+3 range ~448 FP8 for weights/activations
FP8e5m2 S EEEEE MM 1+5+2 range ~6e4 FP8 with more range, less precision
INTEGERS (one shared scale s for a whole tensor/channel/group)
INT8 [ -128 .. 127 ] real value r ≈ s·(q - z) 8 bits/number
INT4 [ -8 .. 7 ] real value r ≈ s·(q - z) 4 bits/number <- weight-only quant
q = stored integer, s = scale, z = zero-point
要握住两个形状:
- 固定位宽下,范围与精度是一架跷跷板。 FP16 与 BF16 都是 16 位,但 FP16 把 5 位给指数 / 10 位给尾数(精确,但超过 ~65504 就溢出),而 BF16 给 8/7——和 FP32 一样的指数,于是它在 FP32 不溢出处永不溢出,代价是更粗的尾数。正是这一次重分配让 BF16 在深度学习里胜出:模型张量跨越巨大的动态范围,溢出到
inf是致命的,而一点舍入噪声是能扛的。 - 浮点每个值自带范围;整数共享一个 scale。 一个 INT8 张量是
−s·128到s·127之间 256 个等距档位。这极其高效(8 位、无指数)——前提是值被良好缩放、且不被少数离群值主宰——而这恰是 Part 4 量化方法要管理的张力(per-channel/per-group scale、离群值处理)。
3 · 原理与数学¶
一个浮点值,符号 \(s\in\{0,1\}\)、\(E\) 个指数位(bias \(2^{E-1}-1\))、\(M\) 个尾数位,对规格化数而言:
指数宽度定范围——最大幅度大约 \(\sim 2^{2^{E-1}}\)——而尾数宽度定精度——相邻可表示数之间的相对间隙 \(\approx 2^{-M}\)。比较 16 位格式:
- FP16(\(E=5, M=10\)):max ≈ \(65504\),相对步长 \(\approx 2^{-10}\approx 0.001\)。精确,但超过 65504 就成
inf。 - BF16(\(E=8, M=7\)):max ≈ \(3.39\times10^{38}\)(FP32 的范围),相对步长 \(\approx 2^{-7}\approx 0.008\)。比 FP16 粗八倍,但基本不可溢出——当张量跨越很多数量级时的正确权衡。
FP8 把这推到 8 位,用两种标准化切分:E4M3(max ≈ \(448\),精度更高)用于权重/激活,E5M2(max ≈ \(57344\),范围更大)用于梯度/需要范围之处。二者都需要一个缩放因子把真实张量映进它们微小的范围——这正是为什么 vLLM 的 FP8 KV 缓存警告它「没有合适的缩放因子可能导致精度下降」。
整数格式彻底丢掉逐值指数。真实值 \(r\) 由存储整数 \(q\)、共享 scale \(s\)、zero-point \(z\) 重建:
量化误差被半个步长界住,\(|r - \hat r| \le s/2\),所以更小的 scale(更紧的值域,或更细的粒度——per-tensor vs per-channel vs per-group)意味着更小的误差。INT8 给 \(2^8=256\) 档;INT4 只 \(2^4=16\) 档,这就是为什么 4-bit 几乎总是只量化权重(权重能扛;带离群值的激活通常不能)。
最后是显存的故事,也正是全部要点:每个数的字节数 \(= \text{bits}/8\)。\(N\) 参数的权重耗 \(N\cdot\text{bits}/8\) 字节;KV 缓存 在 FP8 存储时按同样比例缩小。BF16 → INT4 是 VRAM 占用与每步搬运字节的 4× 削减——而既然 decode 是带宽受限的,这个比例几乎直接流入吞吐。
4 · 完整可跑代码 + 逐行讲解¶
这段可离线运行——纯 CPU,只需 numpy。它打印格式参考表、在真实值上演示 BF16-vs-FP16 的范围/精度跷跷板、再做一次 INT8 量化往返,让你看见误差界 \(s/2\)。
"""数值格式探索器:布局表、浮点范围/精度、INT8 往返(CPU)。"""
import numpy as np
# --- Part 1:参考表(位宽 + 标准最大值都是精确事实)---
FORMATS = [
# name, bits, sign, exp, mant, ~max note
("FP32", 32, 1, 8, 23, "3.40e+38", "reference"),
("FP16", 16, 1, 5, 10, "6.55e+04", "precise, small range"),
("BF16", 16, 1, 8, 7, "3.39e+38", "FP32 range, coarse"),
("FP8 E4M3", 8, 1, 4, 3, "4.48e+02", "FP8 weights/act"),
("FP8 E5M2", 8, 1, 5, 2, "5.73e+04", "FP8 more range"),
("INT8", 8, 0, 0, 0, "s * 127", "integer + scale"),
("INT4", 4, 0, 0, 0, "s * 7", "integer + scale"),
]
print(f"{'format':9} {'bits':>4} {'S':>2} {'E':>2} {'M':>3} {'~max':>10} note")
for name, bits, s, e, m, mx, note in FORMATS:
e_s = str(e) if e else "-"
m_s = str(m) if m else "-"
print(f"{name:9} {bits:>4} {s:>2} {e_s:>2} {m_s:>3} {mx:>10} {note}")
# --- Part 2:BF16 vs FP16 —— 范围与精度的跷跷板 ---
def to_bf16(x):
"""截断式 BF16(向零舍入)。真实硬件是就近舍入;
这里的要点是 *字段宽度*,不是最后一位。"""
u = np.float32(x).view(np.uint32)
u = (u >> 16) << 16 # 丢掉低 16 个尾数位
return u.view(np.float32)
print("\nvalue 1/3 (precision test):")
print(f" fp32={float(np.float32(1/3)):.6g} "
f"fp16={float(np.float16(1/3)):.6g} bf16={float(to_bf16(1/3)):.6g}")
print("value 1e5 (range test):")
print(f" fp32={float(np.float32(1e5)):.6g} "
f"fp16={float(np.float16(1e5)):.6g} bf16={float(to_bf16(1e5)):.6g}")
# --- Part 3:INT8 对称量化往返 ---
w = np.array([-2.5, -0.3, 0.0, 0.8, 3.1, 12.0], dtype=np.float32)
scale = np.abs(w).max() / 127 # per-tensor 对称 scale s
q = np.round(w / scale).astype(np.int8) # 存储整数,落在 [-128, 127]
deq = q.astype(np.float32) * scale # 重建 r ≈ s*q(z = 0)
print(f"\nINT8 scale s = {scale:.5f} (error bound s/2 = {scale/2:.5f})")
print(f" q = {q.tolist()}")
print(f" max abs error = {np.abs(w - deq).max():.5f}")
逐行讲解:
- Part 1 — 参考表。位宽与标准最大值(FP16 65504、FP8-E4M3 448、FP8-E5M2 57344)是固定事实,作为数据打印,没有需要算错的算术。把
S/E/M列读成「这 16(或 8)位去了哪」。 to_bf16— 靠截断 FP32 的低 16 尾数位来模拟 BF16(BF16 字面上就是「少 16 个尾数位的 FP32」)。真实硬件就近舍入;截断已足够展示宽度效应。- Part 2 — 两个探针。
1/3测精度:FP16 的 10 位尾数落得比 BF16 的 7 位更近。1e5测范围:它在 FP32 与 BF16 里没问题,却在 FP16 里溢出成inf(超过 65504)。每个测试各有一个赢家——这就是跷跷板。 - Part 3 — 对称 INT8:
scale = max|w| / 127把最大幅度映到 ±127;round(w/scale)存整数;q*scale重建。报告的最大误差保持在s/2之下,正是 §3 的界。
预期输出(精确算术,非跑分):
format bits S E M ~max note
FP32 32 1 8 23 3.40e+38 reference
FP16 16 1 5 10 6.55e+04 precise, small range
BF16 16 1 8 7 3.39e+38 FP32 range, coarse
FP8 E4M3 8 1 4 3 4.48e+02 FP8 weights/act
FP8 E5M2 8 1 5 2 5.73e+04 FP8 more range
INT8 8 0 - - s * 127 integer + scale
INT4 4 0 - - s * 7 integer + scale
value 1/3 (precision test):
fp32=0.333333 fp16=0.333252 bf16=0.332031
value 1e5 (range test):
fp32=100000 fp16=inf bf16=99840
INT8 scale s = 0.09449 (error bound s/2 = 0.04724)
q = [-26, -3, 0, 8, 33, 127]
max abs error = 0.04409
范围测试里的 fp16=inf 正是训练与推理默认选 BF16 的全部理由,而 max abs error = 0.04409 < s/2 把量化误差界具体化了。
5 · Lab —— 在 vLLM 里翻转格式、看 VRAM 挪动¶
GPU Lab
- 最低显存: 24 GB(加载
Qwen2.5-7B-Instruct) - 建议 AutoDL 卡型: RTX 4090(24 GB)
- 预估耗时 / 花费: ~15 分钟 · ~¥1 GPU 时长(示例)
- 平台: NVIDIA CUDA(默认)
- 非 NVIDIA: FP8 KV 缓存支持受硬件门控——据 vLLM,CUDA 11.8+ 支持
fp8/fp8_e5m2,ROCm 支持fp8(=fp8_e4m3);其他后端不同。请查你平台的 vLLM 构建。
两个已核实的 vllm serve flag 让你在真实权重与真实 KV 缓存上感受范围/精度权衡。用 --dtype 设计算 dtype(权重/激活):
# BF16(本模型默认):FP32 的范围,FP32 一半的字节
vllm serve Qwen/Qwen2.5-7B-Instruct --dtype bfloat16 --max-model-len 8192
独立地,用 --kv-cache-dtype 把 KV 缓存存成 FP8(已核实字面量:auto、fp8 = fp8_e4m3、fp8_e5m2):
# KV 缓存用 FP8 e4m3:KV 缓存 ~2x 更小 -> 24 GB 里能塞更多并发序列
vllm serve Qwen/Qwen2.5-7B-Instruct --kv-cache-dtype fp8_e4m3 --max-model-len 8192
观察什么: 用 FP8 KV 缓存,每 token 的 KV 占用(KV 缓存 的 \(\kappa\))大致减半,于是 vLLM 能在同样 24 GB 里放下更多序列的 KV——在 Part 0A 指出的那个瓶颈上直接赢下吞吐。看启动日志报告的 KV 缓存 block 数增长,并留意 vLLM 自己的警告:FP8 KV 缓存「没有合适的缩放因子可能导致精度下降」。把权重量化到 INT4/INT8(AWQ/GPTQ)是一种方法,不只是翻个 dtype——那是 Part 4;这里你已感受了 dtype 杠杆,下一步学会安全地拉动它。
6 · 常见坑 / 反直觉点¶
- 「比特越少总是越快、且基本免费。」 更快,常常是;免费,不是。低于 ~8 位、尤其对激活(有离群值),朴素量化会跌下精度悬崖——这正是 Part 4 方法(per-group scale、SmoothQuant、离群值处理)存在的原因。
- 把 BF16 与 FP16 混为一谈。 同样大小,相反权衡:BF16 = FP32 的范围、粗精度;FP16 = 细精度、小范围(超过 65504 溢出)。深度学习选 BF16 是因为溢出致命而舍入噪声不致命。
- 无 scale 的 FP8 是一把走火枪。 E4M3 顶到 ~448;喂未缩放的张量,一切饱和。FP8 永远带一个缩放因子上路——就是 vLLM 警告的那个「合适的缩放因子」。
- INT4 用于激活。 INT4 只量化权重常见且能用;INT4 激活几乎从不幸存,因为离群值把 16 档预算炸穿。搞清你在量化哪个张量。
--dtype(计算)≠--kv-cache-dtype(存储)。 它们是分开的旋钮:你可以跑 BF16 权重配 FP8 KV 缓存。--dtype auto只是选模型声明的 dtype。- 「位宽决定精度。」 粒度同样要紧:per-group INT4 在某些张量上能胜过 per-tensor INT8,因为更紧的 scale 意味着更小的误差界 \(s/2\)。
7 · 面试连线¶
- 数值格式与精度 —— 本课为你准备的高频题:按位布局与范围-vs-精度权衡比较 FP16/BF16/FP8/INT8/INT4,说为什么 BF16 在 DL 里胜过 FP16,并解释为什么低比特格式能加速 memory-bound 的 decode。
8 · 小结 & 延伸阅读¶
一句话: 一个数值格式在固定比特预算下,用范围(指数位,或 scale)换精度(尾数位,或整数宽度)——BF16 为了安全保住 FP32 的范围,FP8/INT8/INT4 缩小字节来买 memory-bound decode 的吞吐,而量化(Part 4)随后努力保住精度。
延伸阅读:
- Kalamkar 等 —— A Study of BFLOAT16 for Deep Learning Training —— 8 位指数为何胜出。
- OCP / NVIDIA-Arm-Intel —— FP8 Formats for Deep Learning —— E4M3 / E5M2 规格及其最大值。
- vLLM 文档 —— Engine arguments(
--dtype)与 KV 缓存 dtype 配置,基线 v0.26.0。 - GPU 硬件 那节课 —— 为什么更少的字节/数几乎直接映射到 decode 吞吐。
- 量化原理 课(Part 4)—— 这些格式在这里变成真正的量化方案(scale、zero-point、粒度)。
9 · 自测小问¶
FP16 与 BF16 都是 16 位。它们之间到底权衡了什么,深度学习为何偏爱 BF16?
它们重分配同样的 16 位:FP16 = 5 指数 / 10 尾数(精度细,但 max ≈ 65504 → 之外溢出成 inf);BF16 = 8 指数 / 7 尾数(和 FP32 一样的范围,≈ \(3.4\times10^{38}\),但精度粗约 8 倍)。DL 偏爱 BF16 是因为模型张量跨越巨大动态范围——溢出到 inf 会腐蚀整个计算,而多一点舍入噪声可以容忍。这里范围安全胜过精度。
为什么量化到更低比特格式尤其能加速 LLM decode,BF16 → INT4 大约快多少?
Decode 是 memory-bound:每步时间由跨 HBM 搬运的字节(权重 + KV 缓存)决定,而非 FLOPs。字节/数 \(= \text{bits}/8\),所以 BF16(16 位)→ INT4(4 位)是每步搬运字节的 4× 削减,对带宽受限的工作负载而言,这几乎直接流入 ~4× 的 decode 吞吐(示例;真实增益因开销与反量化而更低)。FLOP 数几乎无关紧要,因为 GPU 本来就在等内存空转。
E4M3 与 E5M2 有何区别,为什么 INT4 通常只用于权重、而非激活?
E4M3(4 指数 / 3 尾数,max ≈ 448)精度更高;E5M2(5 指数 / 2 尾数,max ≈ 57344)范围更大——动态范围要紧时选 E5M2,精度要紧时选 E4M3,二者都需缩放因子。INT4 只给 16 档,所以对权重(平滑、有界的分布)没问题,但通常在激活上崩掉——激活的逐 token 离群值需要 16 档在不产生巨大误差下覆盖不了的范围——由只量化权重的 INT4 或 Part 4 的离群值感知方法处理。