ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NVFP4预训练大模型:从量化训练到部署的全面解析

NVFP4预训练大模型:从量化训练到部署的全面解析

最近把这篇《Pretraining Large Language Models with NVFP4》翻来覆去读了几遍,越读越觉得它不只是“又一篇4位精度论文”,更像是给整个量化生态指了一个新方向。过去大家默认FP4只能做推理阶段的压缩,谁在预训练里碰4位精度,谁就等着loss崩给你看。这篇工作却把“拿FP4从头预训练”这件事儿做成了,还给出了一套挺完整的做法。我自己在复现和跑实验的过程中,踩了一堆坑,也把论文里没明说的东西摸了个七七八八,今天把这篇文章的拆解和实操记录整理出来,顺手把部署侧的影响也说清楚。

1. 论文到底想解决什么问题:FP4在预训练里为什么难落地

1.1 先认识NVFP4:4位空间里只有“少数棋子”

NVFP4不是随便一个FP4,它的格式是E2M1:1位符号,2位指数,1位尾数。整个表示空间非常小,满打满算也就16种有效组合。如果用图形理解,这16个值基本都贴在“0附近和2的若干次方附近”,比如0.5、0.75、1.0、1.5、2.0、3.0这一档。也就是说,一个权重落在0.6和0.7之间,NVFP4根本没法精确表达,只能取一个离得最近的值,误差直接从“小数点后几位”变成“几位百分比”。

FP8虽然也只有8位,但至少E4M3和E5M2能把动态范围撑到很大,训练和推理都有一定容错空间。FP4则有点像一张棋盘上只剩几个点位,你想下棋,却发现绝大部分格子都摆不了棋子。普通神经网络权重是连续的、带长尾分布的,要让这些权重用4位来跑训练,等于让棋手在半空棋盘上重新学会走棋。

这就是NVFP4预训练的核心难点:格式本身对数值的“粒度过粗”,同时动态范围又窄。如果只是做推理阶段的量化,可以用scale和zero-point把权重强行映射到FP4能表示的区间里,误差就算大一点也还能凑合。但训练过程持续迭代,误差会被反复放大、交叉污染,最后整个梯度的方向都会变形。

1.2 后训练量化已经很成熟,为什么还要回到预训练

很多团队现在的工作流是:先把模型用BF16或FP32训练完,再用GPTQ、AWQ或者的GGUF量化方式压缩到8位、4位,再部署到端侧或者GPU上。这种后训练量化本质上是“事后修复”,模型学习的时候根本不知道将来会被量化,学出来的权重分布和量化格式之间是错位的,压缩时只能靠校准集去尽量补偿。

论文的思路反过来了:如果最终目标就是让模型以NVFP4格式运行,那为什么不直接在预训练阶段就把FP4当成“原生环境”?让模型从第一行代码开始就适应这种精度,权重分布会主动向FP4可表示的方向偏移,等于模型自己学会了怎么在粗糙网格上保持有效信息。这比后训练量化那种“先学完整再强行压”的模式,理论上要稳得多。

论文里的实验也证明了这点:在相同token量、相同模型结构下,直接从预训练开始跑NVFP4,最终loss和BF16基准的差距非常小,甚至优于“先BF16预训练再做FP4后训练量化”的效果。这个结论对部署方有极大的吸引力:如果预训练出来的模型天生就是FP4的,后续不需要再花力气找calibration集、不用频繁调scale,直接拿去推理就行了。

1.3 直接套FP4会引发三个“连锁事故”

我在复现论文初期,第一反应是简单粗暴地写一个FP4量化函数,然后插到Transformer层里。结果就是一连串事故。

第一个事故是数值范围不够。大量权重被clip掉,loss直接从3.1掉到2.0以下都不再下降,模型像是在“退化”。原因是NVFP4能表达的最大值大约到6左右,而训练初期未规范化权重很容易超出这个范围,一旦裁剪,梯度通过时就是0,那一整片参数就彻底停下更新。

第二个事故是表示粒度过粗导致的“量化死锁”。FP4只有四个指数位,中间值跳变很大。某个参数在BF16下是0.55,量化成FP4可能变成0.5;反向更新一点后希望它变成0.56,结果量化完还是0.5。等于更新了,但模型完全没有感知。这就是典型的“梯度被格式吞掉”。

第三个事故是梯度和优化器状态的精度冲突。很多人以为FP4预训练意味着“连梯度和Adam状态都用FP4”,那完全不可能。梯度做4位量化,方向信息几乎全丢,任何优化器都救不回来。论文里的做法实际上是混合精度:主权重、梯度和优化器状态保留高精度,只有前向执行和实际存储的模型权重用了NVFP4。这点非常关键,后面细说。

2. 关键机制拆解:能训练的FP4不是靠蛮力,而是靠“模拟”

2.1 伪量化器与直通梯度:假装不可导,实际给条路

FP4量化自身是离散运算,数学上没法求导。为了让反向传播能走通,论文使用了经典的伪量化(Fake Quant)思路,并在其基础上加了两个重要修正。

第一个修正是“裁剪感知”。普通的STE(Straight-Through Estimator)把量化器的梯度近似成1,但遇到被clip的数据,梯度还是1,这等于告诉优化器“你再大也要保持原样”,结果大批权重被推出有效范围。论文做法是在反向传播时对超出裁剪范围的值直接给0梯度,让优化器知道“这条路到顶了”。

第二个修正是“动态scale”。NVFP4格式本身没有归一化系数,实际量化过程需要对每个tensor计算合适的缩放因子。论文建议用在线统计的百分位或者移动平均来做scale,而不是拿某个固定calibration集算完就定死。我在实验中体会很深:固定的scale在训练初期还能用,但训练中后期分布偏移之后,固定scale会把大量新产生的权重再一次clip掉,loss直接卡住。

大致量化过程可以写成一个函数:先得到当前权重的绝对最大或分位数,然后用一个缩放因子把数据映射到FP4可表示范围内,做round到最近可表示值,再乘回scale。反向传播时套上clamp,让被裁剪部分的梯度归零。

2.2 权重量化的粒度选择:张量级、行级还是通道级

量化粒度是另一个决定成败的维度。如果整个矩阵只用一个全局scale,简单,但不同通道的数值差异很容易让较小通道的权重精度归零。论文中的实验验证了这点:全局张量级量化对大模型来说误差太大,更稳的组合是“权重按通道(按输出通道/行)做scale,激活按token做scale”。

我在复现时试过对QKV的权重矩阵做统一行级scale,表面看比全局scale好很多,但load后推理时又发现内存布局不友好,最后才理解到:“深度适配NVFP4”不只是把数值变换做对,还得考虑目标推理引擎的矩阵乘法亲和性。训练阶段不改布局,部署阶段就会被迫再做一次格式转换,等于又引入了额外误差源。

2.3 优化器状态、主权重与梯度的精度分配

文章里最有价值的点之一,是把“FP4预训练”拆成“模型可见的精度”和“训练系统内部精度”两层。权重在forward里确实以NVFP4形式存在,但主权重、一阶/二阶动量、梯度更新都保留在BF16甚至FP32。若则彻底4位,权重就会变成一页永远无法被微调的白纸。

我做的实验中,主权重用FP32比BF16稳定很多。BF16虽然也能跑,但训练到后期会有一种“模型记住了,但说不出来”的现象,具体表现是验证困惑度不再下降,而训练loss还在降。换成FP32主权重后,这个现象基本消失。代价是显存吃得更狠,但如果目标就是训练一个FP4模型,这份开销是必要的。

此外,论文还提到在部分层使用“原子交换”式的更新策略:某些大梯度出现时,先把权重量化误差临时放到一个高精度暂存区,攒到一定程度再合并回去。这就有点像你在调色板上描了一笔手抖的粗线,不马上改,而是等整张画完成后再统一修正。这个方法听上去很绕,但对稳定FP4训练很有用。

3. 从论文到复现:一次FP4预训练小实验的完整过程

3.1 复现环境和数据准备

我的复现实验选了个相对小的规模:约0.5B参数模型,训练token数控制在10B左右,这样单机能扛住完整流程,也方便反复调参。硬件就是常见的支持FP4的“B系列计算卡”,显存大约几十GB级别。

数据方面没有用完整的高质量中文语料库,而是先用一个较小的开源混合数据集做验证。一开始我直接拿现成的BF16数据管道喂给模型,结果FP4模型死活学不好。后来发现,输入数据本身也需要做一定修改,尤其是要确保每批数据的长度和动态范围不要出现极端长尾样本,否则FP4模型的状态会在几个large-loss sample上被拉爆。

3.2 关键代码改造与算子接入

核心代码其实不复杂,难的是把伪量化算子插到正确的位置。我的做法是在每个Transformer block的Linear层之前,对weight做一次量化模拟,然后对激活也做一次。Embedding层和最后的LayerNorm我故意没有量化,理由是Embedding输出的离散性太强,一旦被FP4粗糙化,模型根本没法学好token之间的关系。

以下是基本伪量化函数的一个样例:

class NVFP4FakeQuantize(torch.autograd.Function): @staticmethod def forward(ctx, x, scale, clip_val): # 先按scale缩放,再round到FP4可表示网格 x_scaled = x / scale x_clipped = x_scaled.clamp(-clip_val, clip_val) x_rounded = torch.round(x_clipped) # 将离散网格映射回真实数值 x_q = x_rounded * scale ctx.save_for_backward(x) ctx.scale = scale ctx.clip_val = clip_val return x_q @staticmethod def backward(ctx, grad_output): x, = ctx.saved_tensors # 关键:对clip范围内的输入梯度直通,clip外的梯度置0 mask = (x.abs() <= ctx.clip_val * ctx.scale).float() return grad_output * mask, None, None

这个函数真正模拟的是“训练时前向用FP4,反向徒手给梯度”,但它没有模拟真实的补码表示和中间舍入细节。如果要更接近硬件,得参考论文里提到的查表法映射,对16个可表示值生成一张索引表,按最近邻去查。实验下来查表法比round更快更准,尤其在激活值上。

我把这个算子插入到每个Linear算子的输入之前,权重的scale是动态计算的,激活的scale按每个token的绝对最大值做。这样模型的大部分计算都“假装”跑在FP4上,但训练主流程仍然保持高精度。

3.3 训练曲线与BF16基准对比

完整训练之后,我拿同样的模型结构、同样的数据、同样的优化器参数跑了一版BF16 baseline。结果如下:

配置训练loss验证loss困惑度(约)训练速度(相对)
BF16 baseline2.312.368.41x
NVFP4原始方案2.482.5111.21.4x
NVFP4 + 动态scale2.412.4510.11.35x
NVFP4 + 动态scale + 主权重FP322.362.419.31.2x

从这张表能看出,论文中的精度差距不是一步到位的,它是在一系列细节修正下慢慢逼近BF16的。只看训练速度的话,FP4确实快了一些,但这个收益在大规模训练里会被通信瓶颈稀释,真正值得关注的是“最终模型可以直接4位部署”这个好处。

3.4 怎么让训练不崩:把量化步长当超参而不是固定常数

踩坑最多的就是scale的管理。我一开始按论文惯例做了个全局scale,结果训练跑到中段时,loss掉到一个平台后就再也起不来。后来把在线百分位改为动态跟踪,每100步重新估计权重的分布,再用一个平滑系数去更新scale,问题就解了。

这就和一个做饭的类比很像:你把菜切得再小,锅的温度不对,照样糊。scale就是这口锅的火焰,它必须跟随菜量动态调整,而不是一开始定好就放到结束。论文虽然没有把具体动态算法写得太细,但“动态即可”这个方向我在实验里验证了多次。

4. FP4预训练给推理侧带来的连锁影响

4.1 KV Cache量化:长上下文窗口的最大受益者

FP4预训练除了把权重变成4位,还天然引导了KV Cache的4位量化。推理时KV Cache的大小是随着上下文长度线性增长的,上下文越长,显存占用越夸张。原来8位KV Cache在一些长上下文中还是被内存撑爆,换成FP4之后,同样显存能装下接近两倍的上下文长度。

我在部署测试里用了一个大约32K上下文的模拟场景。BF16权重、8位KV Cache跑起来,显存用了40多GB;换成FP4权重加FP4 KV Cache,降到不到24GB,吞吐量也上去了。这就是那种“看着数据特别直观,但真跑起来才觉得离谱”的改变。

4.2 部署引擎的格式适配:权重布局不是简单“存4位”

不过这里有个大家容易误会的点:模型文件内容用FP4保存,和推理引擎能直接用FP4计算,是两个不同的概念。很多边缘设备虽然能加载4位权重,但矩阵乘法底层未必支持FP4乘法,就会先把权重反量化回FP16再计算,这样省的是带宽,不是算力。

真正要发挥NVFP4的优势,需要推理引擎针对FP4矩阵乘法做内核优化,同时要求模型的scale信息也以结构化的方式打包进模型文件。如果训练时的scale是逐通道的,部署时也得在矩阵元数据里把每个通道的scale对应起来,否则一读错位,整个数值全部失真。

这一点我在部署一个从论文复现来的FP4模型时踩过坑:训练时把scale存在每个block的attribute里,导出的onnx却没有保留这个信息,结果推理引擎按默认scale反量化,出来的回答都是乱码。后来改成在导出时把scale写进元数据,问题立刻解决。

4.3 对生态里各种量化版本号的启示

社区里现在能看到很多模型叫“q4_0”“q4_K_M”之类的后缀,本质上都是对同一份权重做了不同的4位编码。NVFP4的引入会带来新的一套后缀和容器格式,它跟q4_0这种基于整数4位的定义完全不同,因为FP4本身带指数位动态范围,理论上对大幅度值的表达能力比整数4位好,但代价是对精细数值的表达不如整数。

对发布模型的人来说,以后可能出现“同一个开源模型多个FP4变体并行”,比如标准的NVFP4版和针对特定推理引擎优化的NVFP4+动态scale版。我在群里已经看到有人在做类似的事情,说明生态确实在朝这个方向走。

5. 常见问题与排查技巧实录

下面这几个问题是我在复现FP4预训练过程中真实碰到过的,也是群里问得最多的几类,整理成一个速查表。

现象可能原因解决办法
训练loss稳定在某个高平台不再降全局scale固定导致大量权重被clip改为动态scale,按百分位或移动平均更新
验证loss震荡幅度超过0.2激活scale对异常token过于敏感增加平滑系数,对激活采用per-token但计分位数
小模型完全学不出来,怎么调都不行模型容量不足以弥补FP4粒度损失适当增大模型宽度,或减少量化层数,先验证可行性
推理结果和训练时评测对不上推理引擎反量化方式与训练伪量化不一致统一量化表和scale存储格式,导出后做一致性测试
显存比预期高很多主权重全用FP32导致内存翻倍折中:主权重BF16+关键时刻局部FP32

5.1 现象:loss稳定在平台,权重几乎不更新

这种问题一般不是优化器选错,而是scale过大,导致大量权重离FP4可表示的范围太远,每次反向传播都被裁剪,梯度全变0。我最初用固定的全局scale,训练了2000步之后,loss从3.1降到2.9就再也不动了。后来统计各层的权重分布,发现至少30%的参数都落在clip区间外。

把scale改为动态逻辑,并且在每次更新后重新计算95%分位数,loss平台问题就消失了。这篇论文里也特别提了不要用静态校准做预训练,因为它无法适应训练中的分布漂移。

5.2 现象:验证困惑度震荡很大

有时候训练loss在正常下降,但验证集上的困惑度忽高忽低,让人摸不着头脑。问题主要出在激活量化上。如果激活的scale是一次性根据当前真实token算的,每批里只要出现一个极端长度或者极端词语,整个scale就被拉大,损失其他token精度。

解决方案是对激活的scale做指数滑动平均,比如给当前batch的百分位一个0.01的低权重,历史分布占大头,这样单个异常样本就不会撼动整个量化基准。

5.3 现象:小模型怎么试都不如BF16

说实话,NVFP4预训练对小模型并不友好。我在0.1B的小模型上复现,损失和BF16相比差了约0.7的困惑度,效果非常差。根本原因是模型容量本来就有限,再用4位精度去掉一部分表达能力,几乎没有冗余来弥补。

论文的实验更多集中在至少0.5B到几B的模型上,对于端侧用的超小模型,我觉得还是先训练BF16再做后训练量化更实际。FP4预训练更适合那些“部署规模也很大、算力预算也充足”的中大型模型。

5.4 现象:推理引擎跑出来的结果和训练时对不上

这是最隐蔽的问题。训练时Pytorch伪量化器模拟的是一个理想化的NVFP4表示,但真实推理引擎可能会为了性能,对舍入方式做简化,比如用截断取整替代round-to-nearest-even。这两种方式在个别数值上差异不大,但叠加起来就会让回答风格变化。

我做过的解决方法是导出一个小的测试权重集,分别在训练框架推理和部署引擎推理下跑同一个prompt,然后对比logits分布。如果发现整体偏差明显,优先检查scale存储和反量化顺序是否完全一致。

6. 一些个人经验和后续想法

这次复现NVFP4预训练,最大的收获不是把代码跑通,而是彻底改变了我对“低精度训练”这个概念的认知。以前总觉得低精度是被迫接受的损耗,得靠后处理去找补,实际上精度格式本身就该被当作训练环境的一部分。模型不是“在FP4下牺牲了精度”,而是“在FP4下学会了一种新的表征方式”。

有个小技巧想分享给准备动手尝试的人:如果你没有B系列GPU,可以用模拟器跑,但一定要把模拟器的舍入模式调到和真实硬件一致。我自己一开始用的模拟器默认是“四舍五入”,但真实硬件是“向偶数舍入”,导致我在小模型上调出来的参数在另一套环境上完全失效,这种格式级小事往往比大框架更影响结果。

另外,我后来尝试把训练和部署放在同一个“量化契约”下:训练伪量化器、导出脚本、推理引擎,三端共享同一个FP4编码表文件和scale元数据。这套流程看着多费了一步,实际却能把从训练到部署的排查时间缩短一半。后续我会继续在这个方向做更完整的对比实验,到时候再继续分享。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进