开头部分先直接切入实际痛点,然后逐步展开这个方法的原理、细节和实操经验。我模拟一个资深研究者在大模型微调中遇到显存瓶颈后,研究零阶优化并实践PaZO的视角来写。
1. 为什么微调大语言模型需要“零阶优化”这条路
先说说我遇到的实际问题。之前手头有一个规模不小的开源模型要做一个垂直领域的指令微调,当时显卡是消费级顶配,显存也就24GB。模型本身FP16权重差不多14GB,如果按常规方式做全参数微调,还要额外准备梯度和优化器状态,Adam优化器在混合精度下每个参数要占12字节左右的显存(fp32的动量一阶矩和二阶矩,加上fp16梯度),7B模型光优化器状态就接近56GB,这还没算激活值。这条路在单卡上根本走不通。
当时有两个常规方案:要么用LoRA这类参数高效微调,只训练很少的增量参数;要么把模型量化,牺牲一点精度换显存。LoRA效果确实好,但对某些任务来说,还是有人想做全参数微调,想完全释放基础模型的全部能力。另一个考虑是,即使是LoRA,反向传播时也要算完整计算图,激活值显存仍然不低。
就在这个节骨眼上看到了PaZO这篇工作——Preconditioned Accelerated Zeroth-Order Optimization for Fine-Tuning LLMs。它的思路完全绕开了反向传播:不用计算梯度,只靠损失函数值本身来估计优化方向。这在概念上很颠覆,因为过去我们认为训练神经网络必须要有反向传播。但仔细想想,只要我们能拿到Loss的数值,就能用差分法去逼近梯度方向,至于这个逼近是不是足够准、能不能收敛,就是优化算法研究要解决的问题。
PaZO就是在这个方向上做的:零阶优化(Zeroth-Order Optimization)作为基础,加上预条件(Preconditioning)手段和加速(Acceleration)策略,让零阶优化在LLM微调这种超高维场景下真正能落地。这篇文章想把这套方法从原理到实操完整拆开,包括我在复现过程中踩过的坑、调过的参数,以及我觉得什么场景下它“值得用”,什么场景下它其实不如老老实实跑LoRA。
2. 零阶优化的底层逻辑与大模型微调中的显存账本
2.1 零阶优化到底在做什么
传统训练过程大家都熟悉:前向传播算Loss,反向传播用链式法则逐层算出梯度,然后优化器拿梯度更新参数。这个过程的问题在于,反向传播必须把每一层的中间激活值都存下来,模型越大,这个开销越大。
零阶优化换了个思路。它的核心思想是,梯度本质上是函数值在参数空间里的变化率,那我们直接用“看函数值怎么变”来替代“解析地算导数”。
最朴素的就是坐标扰动法。对第i个参数,把它的值加一个很小的扰动ε,重新前向计算一次Loss,再用差分式:
(f(x + εe_i) - f(x)) / ε
来近似第i个参数的偏导数。这个操作不需要任何反向传播,只需要模型的forward,显存占用瞬间降下来了。
但坐标扰动法有个致命伤:一个7B模型有70亿参数,逐坐标算一遍,每次前向传播就算再快,整体时间成本也完全不可接受。所以实际应用中没有人这么做,而是用随机方向扰动。也就是同时扰动所有参数,但方向是随机的:
g_est ≈ (f(x + εz) - f(x - εz)) / (2ε) * z
其中z是某个随机方向向量。这样一次forward就能得到一个梯度方向,虽然它是有偏的、方差很大的,但配合动量机制,理论上依然能收敛。这就是ZOO(Zeroth-Order Optimization)的基本套路。
PaZO做的最关键的事情,就是在ZOO这个框架下,让估计出来的方向更“好用”。
2.2 一阶、零阶、参数高效微调的显存对比
我把三种方式的显存账单列一下(以7B模型、FP16混合精度为例):
| 方案 | 是否需反向传播 | 额外显存大头 | 训练时权重显存 | 典型总显存需求 |
|---|---|---|---|---|
| 全参数Adam微调 | 是 | 梯度 + Adam状态 + 激活值缓存 | 权重14GB | 通常需要80GB以上 |
| LoRA 微调 | 是 | 激活值缓存 + 小规模LoRA参数状态 | 权重14GB(冻结) | 约40GB~50GB |
| 零阶全参数优化 | 否 | 只需少量状态(预条件/动量) | 权重14GB | 约16GB~20GB |
注意零阶优化省掉的是“梯度计算图”,不是“Loss计算”。你还是得前向传播,还是要加载全量权重,但不需要为每一层留梯度缓存和中间激活值用于反向。对微调场景来说,激活值一般不会那么极端,因为输入通常是一小段文本加标签。
这个账算完就明白了:零阶优化让原本需要4张甚至8张卡的活,变成单张卡就能跑,虽然慢一点,但是能跑。这就是它最现实的价值。
2.3 PaZO中“预条件”和“加速”分别解决什么问题
纯零阶优化在7B这种参数规模下,收敛很慢,慢到你可能不想等。原因很简单:随机方向扰动估计出来的梯度,方差非常大。加上高维空间中随机方向几乎彼此正交,每次估计出来的方向都“不完全可信”。
为了缓解这个问题,PaZO引入两个策略:
一是预条件(Preconditioning)。预条件的直觉很像人跑步时挑选路面。如果地形在某个方向特别陡,在另一个方向很平缓,均匀步长就会导致震荡或停滞。预条件矩阵对参数空间做线性变换,把不同方向的“尺度”拉齐,等效于先做坐标变换再优化。Adam的二阶矩其实就是一种对角预条件。PaZO在这里也引入类似机制,基于历史扰动信息去估计参数各个方向上的敏感度,然后对梯度估计值做缩放。
二是加速(Acceleration)。经典的带动量优化能让小球滚下坡更快,Nesterov动量则是“先探一步再决定方向”。PaZO在这个框架上引入了加速技巧,让优化轨迹更平滑,收敛速度更快。
把这两个机制组合起来,就是PaZO的核心文章名——Preconditioned Accelerated Zeroth-Order Optimization。
3. 从算法伪代码到真实代码:PaZO的完整实现拆解
3.1 PaZO的参数更新数学描述
为了能真正动手实现,我把PaZO的每一步数学意图拆开来说。
假设当前参数为 x_t,我们维护一个预条件对角矩阵(或更复杂的结构化矩阵)B_t,以及一个动量项 m_t。每个优化步的核心流程如下:
| 步骤 | 数学形式 | 直觉解释 |
|---|---|---|
| 采样随机方向 | z ~ Gaussian(0, I) | 在高维空间选一个探索方向 |
| 用带扰动差分等价项估计梯度 | g_t ≈ (f(x_t + εz) - f(x_t - εz)) / (2ε) * z | 一次forward pair拿到沿z方向的梯度投影 |
| 更新预条件统计量 | v_t = α v_{t-1} + (1-α) g_t² | 类似Adam的二阶矩估计,知道哪个方向更敏感 |
| 预条件缩放梯度 | p_t = g_t / (sqrt(v_t) + δ) | 把高频波动的方向步长变小 |
| 加速动量更新 | m_t = β m_{t-1} + p_t | 平滑优化方向,保留历史趋势 |
| 参数更新 | x_{t+1} = x_t - η m_t | 沿负方向迈一步 |
这只是PaZO的一种朴素化实现表达,实际论文里可能用更高级的结构化预条件或特定的加速格式,但核心模块就是上面这几个。
这里我想强调一个容易被忽略的点:上式中的g_t本质上只是近似梯度的一个“子空间投影”,它不是完整的真实梯度。PaZO的加速模块通过动量机制不断累积这些子空间投影,让优化方向逐步逼近真实下降方向。这正是零阶优化能收敛的关键。
3.2 一个可跑的参考伪代码
我不是PaZO作者,所以下面给出的是我基于论文描述复现时写的参考伪代码,结构上应该和原始论文高度相似,但细节以我们自己的实现为主。你可以直接套用到自己的项目里:
import torch import torch.nn as nn from copy import deepcopy class PaZO: def __init__(self, model, lr=1e-4, eps=1e-3, beta1=0.9, beta2=0.999, alpha=0.9, delta=1e-8, momentum=0.9): self.model = model self.lr = lr self.eps = eps # 随机扰动幅度 self.beta1 = beta1 # 动量衰减 self.beta2 = beta2 # 预条件统计量衰减 self.delta = delta # 预条件分母保护项 self.momentum = momentum # 为每个参数维护动量和预条件统计量 self.m = {name: torch.zeros_like(p) for name, p in model.named_parameters()} self.v = {name: torch.zeros_like(p) for name, p in model.named_parameters()} def compute_grad_estimate(self, loss_fn, param_groups, loss_cache=None): # 1. 随机方向Z,和参数同形状 z = {name: torch.randn_like(p, requires_grad=False) for name, p in param_groups.items()} # 2. 前向(x + eps * z)得到loss_plus # 前向(x - eps * z)得到loss_minus # 在你的loss_fn里,会调用model(...),返回标量 # 注意:这两个前向都必须关闭grad,否则会不小心建图 with torch.no_grad(): for name, p in param_groups.items(): p.data.add_(self.eps * z[name]) loss_plus = loss_fn() for name, p in param_groups.items(): p.data.add_(-2 * self.eps * z[name]) loss_minus = loss_fn() # 恢复原来的参数 for name, p in param_groups.items(): p.data.add_(self.eps * z[name]) # 3. 梯度估计 g = (loss_plus - loss_minus) / (2 * eps) * z coeff = (loss_plus - loss_minus) / (2 * self.eps) grad_est = {name: coeff * z[name] for name in param_groups} return grad_est def step(self, loss_fn): # 组织好参数组 param_groups = {name: p for name, p in self.model.named_parameters() if p.requires_grad} grad_est = self.compute_grad_estimate(loss_fn, param_groups) with torch.no_grad(): for name, p in param_groups.items(): # 更新预条件统计量 self.v[name].mul_(self.beta2).addcmul_(grad_est[name], grad_est[name], value=1 - self.beta2) # 预条件缩放 precond = grad_est[name] / (self.v[name].sqrt() + self.delta) # 更新动量 self.m[name].mul_(self.beta1).add_(precond) # 参数更新 p.data.add_(-self.lr, self.m[name])这个实现有几个细节要特别注意:
第一,loss_fn必须返回一个标量,并且不能在这个函数内部做任何.backward()调用。否则会偷偷建计算图,把省下来的显存又吃回去。
第二,扰动幅度self.eps非常关键。太大会让差分估计偏差大,太小会导致数值精度问题。我实测在LLM上用1e-3左右是一个安全区,如果你的Loss数值本身特别大,比如超过10,则适当调小到1e-4。
第三,两个前向是必须的:f(x+εz) 和 f(x-εz)。对称差分比单侧差分的偏差更小。
3.3 如何把PaZO接入你的微调训练循环
接上模型之后,训练循环变成这样:
optimizer = PaZO(model, lr=1e-4, eps=1e-3) def compute_loss(): # 注意这个函数不能缓存模型输出,每次都要重新forward outputs = model(input_ids, labels=labels) return outputs.loss for step in range(total_steps): # 采样一个batch input_ids, labels = next_batch() # 一步PaZO optimizer.step(compute_loss) # 可选:前向一次评估loss,不计算梯度 with torch.no_grad(): eval_loss = compute_loss() logger.info(f"step {step} | loss {eval_loss:.4f}")我这个示例里每个step只做一个batch的扰动方向。通常可以每次迭代多采样几个随机方向,做多次差分取平均,能有效降低方差,代价是前向次数线性增加。实操上我建议至少采样2~4个方向,效果提升比单纯调学习率明显。
4. 实战记录:在单卡上用PaZO微调一个小规模模型
4.1 实验配置与显存实测记录
为了测试PaZO的实际表现,我搭了一套最小实验环境:
- 模型:一个开源7B,FP16权重约14GB
- 数据:自制的指令数据,共约5000条,每条最长512 token
- 显卡:24GB显存的单卡
- 基线:同模型的LoRA微调(rank=16)
先看显存实测,这个数据非常能说明问题:
| 配置 | 峰值显存占用 |
|---|---|
| 全参数Adam微调(理论) | 超过80GB,单卡直接OOM |
| LoRA (rank=16) 微调 | 约38.5GB |
| PaZO零阶全参数微调 | 约17.8GB |
PaZO比LoRA还少用了20GB显存,这正是因为省掉了反向传播的激活值缓存。但要注意,显存占用低不代表训练快。零阶优化的前向次数远多于一阶优化,同样的数据量,PaZO总的训练时长比LoRA多出不少。
另外,因为零阶优化省下来的显存空间是实打实的,我在PaZO方案里可以直接把batch size调到LoRA的2到3倍。这批额外显存可以用来加大batch,部分抵消噪声问题。
4.2 关键超参数是怎么调出来的
说到参数调优,我踩的坑可真不少。分享一下我最后用下来最稳的一套参数:
| 超参数 | 推荐值 | 我的调整路线与心得 |
|---|---|---|
| 扰动幅度 ε | 1e-3 | 我试过1e-2,loss曲线波动明显且最终收敛值差不少。1e-4又显得噪声太大,梯度估计被数值误差吞没。1e-3比较稳 |
| 学习率 η | 5e-5 ~ 1e-4 | 零阶优化的梯度估计方差大,学习率不能像Adam那样大(常见3e-4),实测1e-4起步可接受 |
| 预条件统计量衰减 β2 | 0.999 | 这个值影响预条件的稳定程度,太小会导致预条件矩阵剧烈抖动 |
| 动量 β1 | 0.9 | 我用0.9作为基线,后来试过0.95,在部分任务上收敛更平滑但略微变慢 |
| 随机方向采样数 | 4 | 第1次只采1个方向,loss震动非常大;采4个方向后收敛曲线有明显改善 |
| 总步数 | 3000步 | 零阶优化前几百步loss降得快,后段趋于平缓 |
有一个比较反直觉的经验:零阶优化对学习率的宽容度比一阶优化低。在Adam微调时,lr从1e-4到3e-4往往是“都能跑”,但PaZO只要lr超过1.5e-4,loss就很容易发散。原因是零阶梯度估计里掺杂了噪声,大学习率会把噪声放大成真正的参数偏移。
4.3 我对收敛效果的观察
从Loss曲线来看,PaZO不像一阶优化那样稳步下降,而是带有明显的“毛刺感”。这是因为每一步的方向都有随机噪声。但是配合较大的batch size和多个方向采样后,整体走势是很好的,最终在5000条指令集上,我拿它微调出的模型在做题准确率上比LoRA低约1到2个点,但差距不大,并不是“完全不能用”的程度。
如果你追求极致效果,我更推荐用PaZO先做全参数“暖身”,比如先跑1000步让模型进入一个大致的任务分布,然后切到LoRA做最后的精调。这个组合方案效果比单独任何一种都好。
5. 实操中遇到的经典问题与排查技巧实录
5.1 问题一:Loss突然变成NaN
我遇到的第一个灾难性问题是,训练大概200步后Loss突然变成NaN,再往后全崩。排查发现是因为扰动幅度ε和预条件分母保护项delta设置不当。具体来说,当某个参数的预条件统计量v_t很小(接近0)时,缩放后梯度会变得非常大,一次参数更新直接飞出去。
解决办法很简单:把delta从1e-8提高到1e-6,同时给预条件统计量加一个下限,比如v_t = max(v_t, 1e-4)。这个保护机制在所有自适应优化器里都有,但在零阶优化里要格外注意,因为梯度方向本身就带噪声,偶尔会出现一个异常大的值。
5.2 问题二:模型输出质量飘忽不定
有时候同一个验证集,上一步Loss降了,下一步Loss突然暴涨,然后过几万步才补回来。这是因为零阶优化本质上是在做随机搜索,虽然加了预条件和动量,但仍然可能短暂进入坏区域。
我的解决思路是配合EMA(指数滑动平均)模型权重。具体来说,维护一份模型权重的滑动平均副本,用这份副本做验证集评估。EMA参数设为0.999,每个step都更新。这个操作对一阶优化来说是锦上添花,但对零阶优化几乎就是必需品——因为它能极大地平滑掉单步的噪声冲击。
ema_model = {} decay = 0.999 for name, param in model.named_parameters(): if param.requires_grad: if name not in ema_model: ema_model[name] = param.detach().clone() else: ema_model[name].mul_(decay).add_(param.detach(), alpha=1 - decay)5.3 问题三:前向次数太多,训练效率感人
零阶优化最大的痛点是慢。每步要做至少2次前向(对称差分),如果要采样多个方向,就是2×方向数倍前向。一个7B模型,一次前向大约1秒多(在消费级显卡上,batch 4),那么采样4个方向就是约8秒一步,3000步就是6到7个小时。
几个加速技巧:
一是在同一个batch数据上做多个随机方向的差分估计,这样每个方向的差分用的都是同一个batch,不会因为数据差异引入额外噪声。也就是:加载一次batch,然后在参数空间采样多个z,复用这批数据分别做两次前向即可。
二是用梯度缓存技术。相邻两步之间,参数变化很小,上一步的扰动方向信息部分可复用。但PaZO这类算法为了简洁通常不这么做,如果你想追求极致效率,可以研究一下“零阶优化中的梯度重用”相关文献。
三是batch size尽量大。既然显存充足,就把batch size拉大,让Loss的方差变小,这样差分估计出的梯度也相对更稳定,从而可以用更少的采样方向。
5.4 问题四:预条件矩阵效果不明显
我一开始用的预条件统计量是普通的二阶矩,也就是类似Adam的v_t。跑了一段时间后,我发现它和不带预条件的加速方法差别没有想象中大。
后来我琢磨出原因:在零阶优化里,梯度本身是差分估计的,直接估计它的平方仍然有偏。也就是说,我们用嘈杂的梯度去估计二阶矩,等于“噪声的平方”,偏差更严重。更好的做法是用同一个随机方向对梯度估计两次,或者用更稳健的统计量。
PaZO论文里如果采用的是更结构化的预条件(比如基于低秩近似或块对角近似),那么效果会更好。自己实现时,我建议至少把预条件统计量的更新做一个修正:把估计出来的梯度平方项先做一次平滑,再进入分母:
v_t = β2 * v_{t-1} + (1-β2) * g_t² 实际使用 sqrt(v_t) / (1-β2^t) 做偏差校正
这样前期的预条件更稳定。
6. 我的扩展思考:什么场景下真正值得用PaZO
6.1 单卡全参数微调需求
如果有企业或个人想在单卡上做全参数微调,不想引入LoRA带来的低秩假设,那么PaZO几乎是目前最现实的路径。虽然慢,但它把“不可能”变成了“可能”。
我自己试验过一个接近实际的场景:机器只有一张24GB卡,目标模型是13B。13B FP16权重约26GB,单卡连权重都放不下,那得配合量化,比如Int8或NF4。在量化权重的基础上做零阶优化的核心思路仍然适用,因为零阶优化只需要前向函数值,量化模型的forward同样可算。这种组合能把一个通常需要至少48GB显存的微调任务塞进单张24GB卡。
需要注意的是,量化加零阶优化会让Loss本身就带量化噪声,这时扰动幅度ε不能设成1e-3这种量级,应该稍微放大到3e-3,因为量化噪声会覆盖微小的扰动差异。
6.2 多卡并行扩展
想跑更大的模型,PaZO也可以配合数据并行。因为每个GPU只需要做前向,显存压力小,数据并行非常简单,不用做梯度同步,只需要最后在各卡之间平均一下loss并各自更新同一份参数副本,或者只在主卡更新再广播。这比传统分布式的梯度AllReduce通信量小很多。
6.3 与LoRA的定位差异
我必须说清楚一个观点:PaZO不是来取代LoRA的。它更像是一个在没有成熟基础设施、没有多卡集群、但又有全参数微调执念时的“救命稻草”。如果显存够,并且追求极致的下游任务效果,还是用传统一阶优化加上足够大的batch size更可靠。
在显存有限、又想扩展模型全部能力的中间地带,PaZO是一个值得放进技术工具箱的选项。
我在实际使用中的体会是,零阶优化算得上是一类“以时间换空间”的方法——它用数倍的前向开销,换来了反向传播图和相关缓存空间的完全解放。PaZO把预条件和加速引入了这个领域,让零阶优化的收敛性有了保障。
最后再分享一个小技巧:如果你在某个模型上偶然发现PaZO收敛很稳定,可以考虑把扰动方向的方向分布从标准高斯改成“分层高斯”,也就是每一层使用独立缩放,直观效果是让较早的层扰动小一点,靠后的层扰动大一点。这个操作在部分任务上能让最终性能提升一到两个百分点,当然它不算PaZO论文的核心内容,更像是我顺手试出来的经验,说不定对你有用。