1. 参数空间切分到底在解决什么问题
大模型训练这件事,外行看热闹,内行看显存。很多人第一次接触LLM训练时,最直观的感受就是:模型大得离谱,显存永远不够,训练速度永远比预期慢。但真正做过一段时间之后你会发现,显存只是表象,底层真正卡住你的,是参数空间的利用效率。
什么叫参数空间?你可以把它想象成一块巨大的农田。传统训练方式是把整块田一次性翻一遍,不管这块田里哪片土壤肥沃、哪片是盐碱地,全都用同样的力度去犁。结果就是:肥沃的地方可能被过度翻耕,贫瘠的地方又没得到足够关注。对应到LLM训练里,就是所有参数共享同一个学习率、同一个更新策略、同一个优化器状态,但实际上不同层、不同模块、甚至同一层内不同方向的参数,它们对最终loss的贡献差异是巨大的。
这就是“divide parameter space”这个思路要解决的核心问题:把参数空间按照某种有意义的维度切开,对不同区域采用不同的训练策略。这件事听起来简单,但真正落地时会遇到一堆工程和算法上的取舍。我最近花了不少时间在这个方向上做实验,踩了不少坑,也总结出了一些能直接抄作业的方案,下面完整拆解一遍。
先明确一下适用人群:如果你正在做7B以上规模的模型微调或预训练,显存吃紧、训练效率上不去、loss曲线总是卡在某个平台期,那这套思路对你直接有用。如果你只是跑跑小模型做demo,可以先收藏,等规模上来之后再回来看。
2. 参数空间切分的核心思路与方案选型
2.1 为什么不能一刀切地训练所有参数
要理解切分的必要性,先得理解LLM训练中参数的实际行为差异。我拿一个13B模型做过统计,在标准预训练过程中,embedding层的梯度范数和最后几层transformer block的梯度范数能差出两个数量级。这意味着什么?如果你用同一个学习率去更新,要么embedding层更新太慢学不动,要么后面几层更新太猛直接发散。
更细一点看,即使在同一层内部,attention的Q、K、V、O四个投影矩阵的梯度分布也完全不同。Q和K负责计算注意力权重,V负责信息传递,O负责输出投影。实际训练中V和O的梯度通常比Q和K更稳定,而Q和K在训练初期波动极大。传统做法是给整个模型设一个全局学习率,再靠warmup和gradient clipping硬扛,但这本质上是用工程手段掩盖了参数空间本身的不均匀性。
注意:这里说的梯度差异不是理论推导,是我在实际训练日志里逐层打印grad norm观察到的。不同模型架构会有差异,但整体趋势一致。
2.2 切分维度的选择:按层、按模块还是按方向
切分参数空间有几个主流维度,每个维度背后的逻辑和适用场景不一样。
按层切分是最粗粒度的做法。典型方案是底层用较小学习率、顶层用较大学习率,因为底层学的是通用特征,顶层学的是任务相关特征。这个思路在BERT时代就有,但放到LLM上需要调整,因为LLM的层间差异比BERT大得多。我的经验是:对于预训练,底层和顶层的学习率比例可以设到1:3左右;对于微调,这个比例可以拉到1:5甚至1:10。
按模块切分更细一些。把attention模块、FFN模块、LayerNorm参数、embedding层分别对待。FFN模块参数量通常占整个模型的2/3,但梯度稀疏性也最高,适合用较大的学习率配合稀疏更新。Attention模块参数少但影响大,适合用较小学习率精细调整。LayerNorm参数只有两个向量,但控制着整个层的输出分布,通常需要单独设一个很小的学习率,甚至在某些微调场景下直接冻结。
按方向切分是最激进的方案,也是最近研究比较多的方向。核心思想是不再对每个参数单独更新,而是把参数矩阵做奇异值分解,对不同的奇异方向采用不同的更新强度。这个方案理论优雅,但工程实现复杂,显存开销也大,目前更适合研究而不是生产。
下面这张表是我在实际项目中总结的选型参考:
| 切分维度 | 实现难度 | 显存开销 | 适用场景 | 典型收益 |
|---|---|---|---|---|
| 按层切分 | 低 | 几乎无额外开销 | 预训练、全量微调 | loss下降快5-10% |
| 按模块切分 | 中 | 少量额外状态 | 指令微调、领域适配 | 收敛稳定性提升明显 |
| 按方向切分 | 高 | 1.5-2倍参数显存 | 研究实验 | 理论收益大,落地难 |
| 混合切分 | 中高 | 中等 | 大规模训练 | 综合收益最佳 |
2.3 优化器状态的分组管理
切分参数空间之后,优化器状态也需要跟着分组。Adam系列优化器会为每个参数维护一阶矩和二阶矩,如果所有参数共享同一个优化器实例,那切分就只停留在学习率层面,没有真正深入到状态管理。
我的做法是给每个参数组创建独立的优化器状态,但共享同一个优化器类。具体来说,用PyTorch的param_groups机制,把不同组的参数分开传入优化器,每组可以独立设置lr、betas、eps、weight_decay。这样做的好处是:不同组的二阶矩估计不会互相干扰,对于梯度尺度差异大的参数组,自适应学习率的效果会更好。
代价是显存。每个参数组独立维护状态意味着优化器状态的总量不变,但分组之后PyTorch的内部实现可能会有一些额外开销。实测下来,分组数量控制在5-8组比较合适,再多的话管理复杂度上升,收益递减。
3. 核心细节解析与实操要点
3.1 参数分组的具体策略
分组不是随便分的,得有依据。我通常按以下流程操作:
第一步,跑一个短的warmup阶段,大概100-200步,记录每个参数组的梯度范数均值和方差。这一步的目的是拿到数据,而不是训练模型。
第二步,根据梯度统计做聚类。梯度范数接近、方差接近的参数归为一组。实际操作中不需要跑复杂的聚类算法,按层和模块的天然边界分就够了,因为同一层同一模块内的梯度统计通常比较接近。
第三步,为每组设定学习率。基准学习率设为全局的1倍,然后根据梯度范数做缩放。梯度范数大的组,学习率调小;梯度范数小的组,学习率调大。缩放系数我一般控制在0.3到3之间,超出这个范围说明分组不合理,需要重新调整。
# 参数分组示例 param_groups = [ {'params': model.embed_tokens.parameters(), 'lr': base_lr * 0.3}, {'params': model.layers[:8].parameters(), 'lr': base_lr * 0.5}, {'params': model.layers[8:24].parameters(), 'lr': base_lr * 1.0}, {'params': model.layers[24:].parameters(), 'lr': base_lr * 1.5}, {'params': [p for n, p in model.named_parameters() if 'norm' in n], 'lr': base_lr * 0.1}, ] optimizer = torch.optim.AdamW(param_groups, betas=(0.9, 0.95), weight_decay=0.1)提示:LayerNorm参数的学习率一定要小,我试过用全局学习率去更新LayerNorm,训练到中期loss会突然抖动,排查了很久才发现是norm参数更新过猛导致输出分布偏移。
3.2 梯度裁剪的分组处理
全局梯度裁剪是标准操作,但切分参数空间之后,全局裁剪会有一个问题:某个组的梯度特别大时,会把所有组的梯度都缩掉,导致梯度小的组几乎不更新。
解决方案是分组裁剪。对每个参数组单独计算梯度范数,单独裁剪。裁剪阈值可以统一设,也可以根据组的梯度统计动态调整。我通常用统一阈值,因为动态调整容易引入额外超参,调起来麻烦。
具体实现时要注意,PyTorch的clip_grad_norm_默认是对所有参数一起算的,需要手动按组调用。代码大概长这样:
for group in optimizer.param_groups: torch.nn.utils.clip_grad_norm_(group['params'], max_norm=1.0)这个改动很小,但效果很明显。我做过对比实验,分组裁剪相比全局裁剪,在13B模型上loss能多降0.02左右,别小看这个数字,在大模型上已经是很可观的提升了。
3.3 学习率调度的分组适配
学习率调度也需要跟着分组走。传统cosine schedule是对全局学习率做衰减,分组之后,每个组的基础学习率不同,但衰减曲线可以共享同一个形状。
我的做法是:定义一个全局的调度因子,范围从1衰减到0.1,然后每个组的实际学习率等于该组基础学习率乘以调度因子。这样既保持了调度的统一性,又保留了组间的差异。
warmup阶段需要特别注意。不同组的warmup步数可以不同,梯度大的组warmup长一些,梯度小的组warmup短一些。但为了简化实现,我通常统一warmup步数,靠基础学习率的差异来补偿。
4. 实操过程与核心环节实现
4.1 环境准备与基线复现
在开始切分实验之前,必须先有一个可靠的基线。我用的环境是PyTorch 2.1加CUDA 12.1,模型是LLaMA架构的13B,训练数据是混合后的中文和英文语料,序列长度4096,batch size通过梯度累积做到512。
基线训练跑5000步,记录loss曲线、梯度范数曲线、显存占用。这一步不能省,因为后面所有对比都要以这个基线为参照。我见过有人直接上切分方案,结果loss不降反升,最后发现是基线本身就没调好,跟切分没关系。
基线配置如下:
| 参数 | 值 |
|---|---|
| 全局学习率 | 3e-4 |
| 优化器 | AdamW |
| betas | (0.9, 0.95) |
| weight_decay | 0.1 |
| warmup步数 | 500 |
| 调度器 | cosine |
| 梯度裁剪 | 全局1.0 |
| 精度 | bf16 |
4.2 分组方案的具体实施
基线跑通之后,开始实施分组。我按层把模型分成5组:embedding、底层(0-7层)、中层(8-23层)、顶层(24-39层)、norm参数。每组的学习率缩放系数分别是0.3、0.5、1.0、1.5、0.1。
第一次跑的时候遇到了一个问题:顶层学习率放大到1.5倍之后,训练到300步左右loss突然飙升。排查发现是顶层的梯度范数本身就不小,再放大学习率直接导致更新步长过大。后来把顶层系数降到1.2,问题解决。
这个坑说明一个事:分组学习率的缩放系数不能拍脑袋定,必须结合梯度统计来设。我后来的做法是,先跑100步warmup,打印每组的平均梯度范数,然后按范数的反比来设缩放系数,再手动微调。
4.3 训练过程中的监控与调整
分组训练之后,监控指标也要跟着细化。除了全局loss,我还会记录每组的梯度范数、每组的参数更新幅度、每组的loss贡献。
参数更新幅度这个指标特别有用。计算方式是:每步更新后,计算该组参数的L2变化量除以参数本身的L2范数。这个比值反映了参数更新的相对强度。如果某个组的比值长期接近0,说明这组参数几乎没在学;如果比值长期大于0.01,说明更新过猛,可能需要调小学习率。
我在实际训练中观察到,embedding组的更新幅度通常最小,顶层组的更新幅度最大,这跟学习率的设置是一致的。但如果发现某组的更新幅度跟预期不符,那就说明分组策略需要调整。
4.4 完整训练流程与结果对比
完整训练跑下来,分组方案相比基线有几个明显改善:
第一,loss下降更快。在同样的5000步内,分组方案的最终loss比基线低0.03左右。这个差距在训练初期不明显,从1000步之后开始拉开。
第二,训练更稳定。基线的梯度范数曲线有明显的尖峰,分组方案的曲线平滑很多。这意味着可以用更大的学习率或者更短的warmup,进一步提升训练效率。
第三,显存占用略有增加。分组之后优化器状态的管理开销增加,显存多了大概3%。这个代价可以接受。
第四,调参复杂度上升。分组方案引入了更多的超参数,需要更多的实验来调优。如果算力有限,需要权衡收益和成本。
5. 常见问题与排查技巧实录
5.1 分组之后loss不降反升怎么办
这是最常见的问题。原因通常有三个:分组学习率设置不合理、分组梯度裁剪阈值不当、优化器状态分组后betas不匹配。
排查顺序是:先检查学习率,把分组学习率全部设回全局值,看loss是否恢复正常。如果恢复,说明是学习率问题,逐步调整各组的缩放系数。如果没恢复,检查梯度裁剪,把分组裁剪改回全局裁剪。还没恢复的话,检查优化器的betas设置,确保每组用的betas跟基线一致。
我遇到过一次特殊情况:分组之后loss在前200步正常,之后突然发散。最后发现是某一组的weight_decay设错了,比其他组大了一个数量级。这种低级错误在手动配置param_groups时很容易犯,建议用配置文件管理每组参数,不要硬编码。
5.2 如何判断分组是否合理
分组合理性的判断标准有两个:组内梯度统计的一致性,组间梯度统计的差异性。
具体操作是:训练100步后,打印每组的梯度范数均值和标准差。如果某组的标准差比均值还大,说明组内参数行为差异大,需要进一步细分。如果两组之间的均值差异小于20%,说明这两组可以合并。
我一般会把分组数量控制在5-8组。太少的话切分效果不明显,太多的话管理成本高,而且每组的数据量少,梯度统计不可靠。
5.3 显存不够时的取舍策略
分组会增加显存开销,如果显存本来就紧张,需要做取舍。优先级排序是:先保证embedding和norm分组,这两组的学习率跟其他组差异最大,分组收益最高。然后是顶层和底层分组,最后是中间层细分。
如果显存实在不够,可以考虑只做学习率分组,不做优化器状态分组。也就是所有参数共享一个优化器实例,但通过param_groups设置不同的学习率。这样显存开销几乎为零,但收益也会打折扣。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| loss不降反升 | 学习率设置不当 | 恢复全局学习率对比 | 调整缩放系数 |
| 训练中期发散 | 某组更新过猛 | 检查各组更新幅度 | 调小该组学习率 |
| 某组参数几乎不更新 | 学习率过小或梯度被裁剪 | 打印该组梯度范数 | 调大学习率或裁剪阈值 |
| 显存溢出 | 分组过多 | 检查优化器状态占用 | 减少分组数量 |
| 收敛速度变慢 | 分组过细 | 对比基线收敛曲线 | 合并相似组 |
| 梯度范数尖峰 | 分组裁剪阈值不当 | 对比全局裁剪 | 调整裁剪阈值 |
注意:分组方案不是万能的。如果基线本身就没调好,分组只会让问题更复杂。先把基线调到合理水平,再考虑切分。
6. 参数空间切分的扩展思路
6.1 与LoRA等参数高效方法的结合
参数空间切分和LoRA这类方法并不冲突,反而可以结合。LoRA的本质是在原始参数旁边加一个低秩增量,训练时只更新增量。如果把LoRA的增量也做分组,不同层的LoRA用不同学习率,效果会更好。
我试过在LoRA微调时,对底层LoRA用0.5倍学习率,顶层用2倍学习率,相比统一学习率,最终效果有提升。这个思路可以进一步扩展到其他参数高效方法,比如prefix tuning、adapter等。
6.2 动态切分:训练过程中调整分组
静态分组是在训练开始前定好的,但训练过程中参数的行为会变化。训练初期梯度大的组,到后期可能变小。动态切分就是根据训练过程中的统计量,定期调整分组和学习率。
这个思路理论上更优,但实现复杂度高,而且频繁调整分组会破坏优化器状态的连续性。我目前的建议是:如果训练步数在1万步以内,静态分组就够了;如果训练步数超过5万步,可以考虑在中期做一次重新分组。
6.3 切分粒度与模型规模的关系
模型越大,参数空间的不均匀性越明显,切分的收益也越大。7B以下的模型,切分收益有限,可能不值得增加的管理复杂度。13B到70B的模型,切分收益比较明显。100B以上的模型,切分几乎是必须的,因为全局统一学习率很难让所有参数都训练充分。
这个规律背后的逻辑是:模型越大,层间和模块间的功能分化越明显,参数的行为差异也越大。小模型各层功能相对同质,统一学习率的问题不突出。
我个人在实际操作中的体会是,参数空间切分这件事,核心不是算法有多复杂,而是对模型训练行为的观察要足够细致。你得知道哪些参数在学什么、学得快还是慢、更新猛还是弱,然后才能做出合理的切分决策。工具和框架只是辅助,真正的功夫在观察和判断上。