第 8 章 FP8FP4NF4

第 8 章 sub-8-bit 格式:FP8 / FP4 / NF4 / 三元

第 8 章 sub-8-bit 格式:FP8 / FP4 / NF4 / 三元

第 7 章的 LLM 量化把权重压到了 4bit,这已经是整数量化的主流极限。但再往下,还有一整个世界:FP8、FP4、NF4,甚至只有 1.58 bit 的三元网络。这些"sub-8-bit 格式"各有各的编码逻辑,也各有各的代价。这一章用原书仓库 ch8/ 目录的五个脚本(ch8_fp8_formats.py、ch8_fp4_blockscale.py、ch8_qlora_nf4.py、ch8_ternary.py、ch8_pareto_frontier.py),把它们逐个拆开。

8.1 FP8:浮点,但是 8 位

ch8_fp8_formats.py 研究的是 FP8 的两种格式:E4M3(4 位指数 + 3 位尾数)和 E5M2(5 位指数 + 2 位尾数)。它们不是整数,而是"更小号的浮点数"——保留了浮点数的动态范围优势。

# 来自 ch8/ch8_fp8_formats.py(核心)
class FP8Spec:
    def __init__(self, exp_bits, mantissa_bits, bias, ...):
        self.exp_bits = exp_bits
        self.mantissa_bits = mantissa_bits
        ...

def enumerate_fp8_values(spec):
    # 枚举 FP8 能表示的所有值,画数轴
    ...

def fp8_encode_decode_demo(spec):
    # 编码 → 解码 往返,验证精度
    ...

E4M3 vs E5M2 的取舍(对应 run_compare 实验):

  • E4M3:4 位指数,动态范围中等,但 3 位尾数精度更高。适合权重和激活的前向传播(精度要求高)。
  • E5M2:5 位指数,动态范围更大,但只有 2 位尾数。适合反向传播的梯度(梯度的动态范围变化大,但对精度的要求相对宽松)——这也是 NVIDIA 在 Hopper/Ada GPU 上 FP8 训练的默认搭配:前向 E4M3、反向 E5M2。

FP8 是"硬件原生的 sub-8-bit 格式"——NVIDIA H100 等 GPU 有 FP8 张量核,所以 FP8 的加速是真实的、不用模拟的。但 fp8_encode_decode_demo 也提醒你:FP8 毕竟只有 8 位,run_perplexity 会显示 FP8 量化大模型时的困惑度变化——它比 INT8 更接近 FP16 的表现(因为动态范围好),但依然有损失。

8.2 FP4:blockwise 缩放是救命稻草

ch8_fp4_blockscale.py 研究 FP4(E2M1,2 位指数 + 1 位尾数)。4 位浮点,每格只有 16 个可表示的数,直接 per-tensor 量化会惨不忍睹。FP4 能用的唯一原因是 blockwise scaling(分块缩放):

# 来自 ch8/ch8_fp4_blockscale.py(核心)
def quantize_block_fp4(tensor, block_size=16, ...):
    # 1. 把张量切成 block(例如每 16 个元素一块)
    # 2. 每块算一个 absmax 作为缩放因子
    # 3. 块内元素除以缩放因子 → 量化到 FP4 → 乘回缩放因子
    ...

为什么 per-tensor FP4 不行、blockwise FP4 就行:第 3 章的粒度教训在 4bit 浮点上被放大到极致。per-tensor 的单个 scale 无法同时照顾不同 block 的量级,blockwise 让每个局部区域有自己的 scale,才把 FP4 的精度拉到可用水平。这正是 NVIDIA 的 FP4 推理(Blackwell 架构)所用的方法——quantize_int4_per_tensor 和 quantize_block_fp4 的困惑度对比会直观展示这一点。

block size 的权衡:block 越小,缩放越精细、精度越高,但 scale 的数量越多、压缩率越低。run_blockscale 会扫不同 block size,画出精度 vs 压缩率曲线。和 INT4 group 量化(第 3.5 节)是同一套权衡逻辑,只是编码从整数换成了浮点。

8.3 NF4:QLoRA 的 4bit 秘密武器

ch8_qlora_nf4.py 研究 NF4(NormalFloat4)——QLoRA(Dettmers et al., 2023)用来把大模型微调成本打下来的 4bit 格式。NF4 不是均匀网格,也不是浮点,而是从正态分布分位数构造的非均匀码本。

# 来自 ch8/ch8_qlora_nf4.py(核心)
def build_nf4_codebook(offset=0.9677083):
    """从正态分布分位数构造 16 级 NF4 码本。"""
    # 1. 正侧 8 级:分位数均匀分布在 (0.5, offset]
    pos_quantiles = np.linspace(0.5, offset, 9)[1:]     # 8 个值
    pos_levels = norm.ppf(pos_quantiles)                # 分位点 → 值
    # 2. 负侧 7 级:分位数均匀分布在 [1-offset, 0.5)
    neg_quantiles = np.linspace(1 - offset, 0.5, 8)[:-1]
    neg_levels = norm.ppf(neg_quantiles)
    # 3. 合并 + 归一化到 [-1, 1]
    all_levels = np.sort(np.concatenate([neg_levels, [0.0], pos_levels]))
    return all_levels / np.abs(all_levels).max()

NF4 设计的精妙之处:

  1. 为什么用分位数:权重分布近似正态,把 16 级放在等概率的分位点上(每级覆盖相同的概率质量),量化误差期望最小。这是信息论意义上的最优。
  2. 非对称分格:7 负 + 0 + 8 正——正侧多一格。因为 absmax 归一化后权重分布略偏正,多一格给概率质量集中的地方(源码注释 #A 专门解释了这个故意的非对称)。
  3. 0 必须精确可表示:为了权重稀疏性,码本里必须有 0。

验证码本:源码里还有 bitsandbytes 的参考码本 BNB_NF4_CODEBOOK,自己构建的码本要和它对得上(run_codebook 实验)。

QLoRA 的完整流程(run_qlora 实验):把基础模型冻结成 NF4,插上少量可训练的 LoRA 低秩适配器,在低显存下微调大模型。run_memory 会算出:同样的 7B 模型,FP16 微调要 ~28GB 显存,NF4 + LoRA 只要 ~7GB——这就是 QLoRA 让普通人在单卡上微调大模型成为可能的秘密。

8.4 三元 1.58-bit:BitNet 的极端压缩

ch8_ternary.py 研究的是最极端的格式:三元(ternary)——每个权重只有三个值 {-1, 0, +1},理论上 1.58 bit/权重(log2(3))。

# 来自 ch8/ch8_ternary.py(核心)
def quantize_ternary_naive(w):
    """朴素三元:w_q = sign(w) * mean(|w|)。"""
    scale = w.abs().mean()
    return torch.sign(w) * scale
    # 问题:无法产生 0 值 → 退化成二值网络!

def quantize_ternary_twn_per_tensor(w, threshold_factor=0.7):
    """TWN(Ternary Weight Networks):加阈值,让小权重归零。"""
    abs_w = w.abs()
    threshold = threshold_factor * abs_w.mean()      # τ = 0.7 * E[|w|]
    mask = abs_w > threshold                          # 超过阈值的才激活
    alpha = abs_w[mask].mean()                        # 激活权重的均值作 scale
    return torch.sign(w) * alpha * mask

两个细节非常有意思:

  1. 朴素三元是陷阱:sign(w) * mean(|w|) 根本产生不了 0(除非权重精确为 0),实际退化成了二值网络,丢失了三元"可以稀疏化不重要权重"的核心优势(源码注释 #A 专门强调了这一点)。
  2. TWN 的阈值:τ = 0.7 * E[|w|] 是 Li et al. (2016) 证明的、对高斯权重在 MSE 目标下的最优阈值。低于阈值的权重被置 0——这是三元网络能够"剪枝"的机制。

脚本还实现了 BitNet b1.58 的 BitLinear 模块:

# 来自 ch8/ch8_ternary.py 的 BitNet 量化
def weight_quant_bitlinear(w, eps=1e-5):
    """BitNet b1.58 权重量化:absmean → round → clamp(-1, 1)。"""
    scale = 1.0 / w.abs().mean().clamp(min=eps)
    return (w * scale).round().clamp(-1, 1) / scale

def activation_quant_bitlinear(x, eps=1e-5):
    """BitNet b1.58 激活量化:per-token absmax INT8。"""
    scale = 127.0 / x.abs().amax(dim=-1, keepdim=True).clamp(min=eps)
    return (x * scale).round().clamp(-128, 127) / scale

BitNet b1.58 用 microsoft/bitnet-b1.58-2B-4T 做端到端验证——权重只有 {-1,0,+1},激活用 per-token INT8,run_perplexity 会告诉你这种极端压缩下困惑度会涨多少。结论通常很诚实:1.58-bit 大幅省显存省带宽,但质量损失显著,它更适合"专门为它训练的模型"(BitNet 系列是从头训练的,不是事后量化现有模型)。

8.5 Pareto 前沿:把所有格式放一张图上

ch8_pareto_frontier.py 做了一件非常有工程价值的事:在 OPT-6.7B 上,把这一章的所有格式(FP8-E4M3、FP8-E5M2、FP4-block、NF4、三元、INT8、INT4)放到同一张精度 vs 压缩率图上,画出 Pareto 前沿。

graph LR
    A[所有格式] --> B[每个格式计算 困惑度 & 模型大小]
    B --> C[画 Pareto 图]
    C --> D[选出'不吃亏'的格式组合]

怎么读 Pareto 前沿:图上的点,如果一个格式在"更小的模型 + 更低的困惑度"上都优于另一个,那后者就被前者支配(不在前沿上)。Pareto 前沿上的点,是"想再小一点就得牺牲精度、想再准一点就得牺牲压缩"的那些取舍。选型建议就藏在图里——你的硬件能承受多少显存、你的任务能接受多少困惑度损失,交点就是答案。

8.6 本章小结

格式 位宽 编码 典型用途 关键要点
FP8 E4M3 8 浮点 前向传播 精度好,硬件原生
FP8 E5M2 8 浮点 反向梯度 动态范围大
FP4 blockwise 4 浮点+分块缩放 推理 必须 blockwise 才可用
NF4 4 正态分位码本 QLoRA 微调 分布匹配,0 可表示
Ternary 1.58 {-1,0,+1} BitNet 需阈值产生 0,专为它训练的模型
INT8/INT4 8/4 整数 通用 第 3/7 章已详述

选型三句话:

  1. 有硬件支持就用硬件原生格式:FP8(H100+)、FP4(Blackwell+)的加速是真实的。
  2. 微调大模型用 NF4:QLoRA 是低显存微调的事实标准。
  3. 追求极限压缩才碰三元:1.58-bit 的代价是质量,且需要模型本身是为它设计的。

到这里,量化算法层面的内容就完整了。从第 9 章开始,我们把量化模型真正送进生产——部署管线、GGUF/llama.cpp、边缘移动端,最后是一个完整的 4bit LLM 服务器 + 端侧视觉管线案例。


作者: itech001 来源: 公众号:AI人工智能时代(the-ai-era) 网站: https://www.theaiera.top/ 关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top

本文首发于 AI人工智能时代,转载请注明出处。