三种微调方式跑同一批代码补全测试,显存占用和候选质量的差距比想象中大

三个方法在同一批 1000 条代码补全测试里跑完,QLoRA 的显存只有全参微调的 1/3 不到,但候选质量差距比很多人预期的要小;LoRA 在显存和质量之间卡在中间,全参微调只在特定子集上拉开明显优势。下面把实测数据和调参过程摊开说。

测试环境和数据设置

先说清楚跑分环境,不然显存数字没有参考意义。测试机是一张 A100 80G PCIe,驱动 535.104.05,CUDA 12.2,PyTorch 2.1.2,Transformers 4.36.2,PEFT 0.7.1,bitsandbytes 0.41.3。基座模型选了 CodeLlama-7b-hf,因为 7B 规模在单卡上既能跑全参微调,又方便对比 LoRA 和 QLoRA,不会出现全参直接爆显存、其他两个轻松跑的情况。

测试数据是内部从 GitHub 上扒的 Python 函数补全任务,共 1000 条,每条给函数签名和前 3-12 行函数体,要求补全后续 1-20 行。这块数据量不算大,但足够跑出稳定的显存和指标对比。评测指标用 Exact Match(EM)和 CodeBLEU,另外单独统计了补全结果能通过 python -m py_compile 的语法通过率。

训练配置统一:batch size 8,梯度累积 4 步,等效 batch size 32,训练 3 个 epoch,学习率 2e-5 全参 / 2e-4 LoRA 和 QLoRA,max seq len 1024,warmup 比例 0.03,optimizer 都是 AdamW。三个方法都从同一个 base 模型出发,没有先做指令微调。

显存对比:QLoRA 省得离谱

先给三个方法训练时的峰值显存:

  • 全参微调:67.4 GB
  • LoRA(r=16,alpha=32,target q_proj/v_proj):52.8 GB
  • QLoRA(4-bit NF4,双重量化,r=16,alpha=32):21.7 GB

QLoRA 比全参省了 45.7 GB,只用了全参 32% 左右的显存。这个差距比我想象中大,因为很多人以为 7B 模型全参微调在 80G 卡上离爆显存还远,但实际 67.4 GB 已经相当紧张,再加长序列或大 batch 就会 OOM。LoRA 省了约 14.6 GB,主要来自优化器状态和梯度不再为全参保存,但基座模型的 fp16/bf16 权重和激活值还在。

有意思的是,如果只看推理阶段,三个方法加载后候选生成的显存几乎一样——都在 14-15 GB 左右。因为 LoRA 和 QLoRA 在推理时可以把 adapter 合并回基座权重,或者用 adapter 分支计算,额外开销很小。所以微调阶段的显存优势在推理时没有体现。如果你只是加载一个调好的模型做补全,不用太在意当初是 QLoRA 还是全参微调出来的。

候选质量:全参没赢多少

质量对比这块,结果比我预想的更「反直觉」。先看三个方法的整体指标:

方法 EM CodeBLEU 语法通过率
全参微调 41.7% 0.763 92.4%
LoRA 39.2% 0.749 91.8%
QLoRA 37.8% 0.738 90.9%

全参微调比 QLoRA 的 EM 高 3.9 个百分点,CodeBLEU 高 0.025,语法通过率高 1.5 个百分点。这个差距存在,但考虑到显存差 45 GB,性价比完全不在一个量级。LoRA 和 QLoRA 之间的差距更小,EM 只差 1.4 个百分点。

真正拉开差距的是特定子集。我把测试数据按补全长度切成了三段:短补全(1-5 行,共 412 条)、中补全(6-10 行,共 338 条)、长补全(11-20 行,共 250 条)。长补全子集上,全参微调的 EM 是 28.4%,QLoRA 只有 21.2%,差距 7.2 个百分点;短补全子集上全参 58.1%,QLoRA 55.3%,差距只有 2.8 个百分点。也就是说,补全越长、越需要模型捕捉全局上下文和深层语法约束时,全参微调的容量优势才显现出来。短补全场景下,三个方法基本可以互相替代。

另一个值得注意的细节:QLoRA 生成的候选在「语法通过但逻辑不对」的比例上明显更高。我抽了 50 条 QLoRA 输出错误但语法通过的 case 看,发现 QLoRA 更容易生成「看起来像代码但实际是编造 API 或变量」的补全,比如调用不存在的 torch.utils.module_tracker,或者引用未定义变量。全参微调的错误更多是逻辑偏差,比如边界条件处理不对。这可能和 4-bit 量化对权重精度的影响有关——模型对 token 级别的语法模式保留得不错,但对跨多行的语义一致性把握更弱。

LoRA 参数选择对结果的影响

我额外跑了一组 LoRA 的 r 值对比,因为实战中很多人纠结 r 选 8、16 还是 32。用的是同一批数据,其他配置不变:

LoRA 配置 峰值显存 EM CodeBLEU
r=8, alpha=16 51.9 GB 37.1% 0.735
r=16, alpha=32 52.8 GB 39.2% 0.749
r=32, alpha=64 54.6 GB 39.6% 0.752
r=64, alpha=128 57.3 GB 39.5% 0.751

r 从 8 提到 16 提升最明显,EM 涨了 2.1 个百分点。r=16 到 32 只涨了 0.4 个百分点,r=32 到 64 基本持平甚至略微下降。显存方面 r 每翻一倍大约多占 2-3 GB。对于 7B 代码模型,r=16 是明显的甜点位,再往上加 rank 收益递减。r=8 在短补全上表现尚可(EM 52.4%),但中长补全掉得比较快。

QLoRA 我同样试了 r=8、16、32,结论和 LoRA 一致:r=16 性价比最高,r=32 几乎不涨点。另外 QLoRA 用 NF4 和 FP4 的差距不大,NF4 的 EM 比 FP4 高 0.6 个百分点,显存多占约 0.8 GB。双重量化(double quantization)能再省 1.2 GB 显存,质量几乎没有损失。

训练时长和稳定性

训练时长也值得记一笔。同样 3 个 epoch,全参微调用时 47 分钟,LoRA 38 分钟,QLoRA 41 分钟。LoRA 反而最快,因为不需要处理量化反量化的开销,且 optimizer state 更小。QLoRA 比 LoRA 慢约 8%,主要来自 bitsandbytes 的量化/反量化计算。全参最慢,但差距没有显存那么悬殊。

稳定性方面,全参微调对学习率更敏感。我试过 3e-5 的全参学习率,训练到第二个 epoch 末 loss 开始震荡,验证集 EM 反而从 40.1% 掉到 38.3%。LoRA 和 QLoRA 在 1e-4 到 3e-4 区间内表现都比较稳定,没有明显的过拟合迹象。对于代码补全这种任务,LoRA 系方法对超参的鲁棒性更好,这在实际调参时能省不少时间。

怎么选:给实战老手的建议

如果你有一张 80G 卡,基座模型 7B,目标只是跑通代码补全微调,三个方法都能跑。但不同场景下的最优选择差别很大:

选 QLoRA 的情况:显存受限(比如 24G/32G 卡),或者你需要同时跑多个实验、显存要留给其他进程。质量损失在短补全场景下可以接受,长补全场景下如果业务容忍度较高(比如补全结果有人工 review),QLoRA 是性价比最高的选择。

选 LoRA 的情况:单卡 48G 以上,想要比 QLoRA 稍好一点的质量,同时不想碰全参微调的显存和调参麻烦。LoRA 在 r=16 下的质量和全参差距很小,训练也最快,是大多数场景下的默认选择。

选全参微调的情况:你的核心场景是长补全(10 行以上),或者对 EM 有硬性指标要求,且显存不是瓶颈。全参微调在长补全上的优势是实打实的,但这个优势只在数据分布足够多样、训练数据量足够大时才会稳定出现。如果只有几千条数据,全参和 LoRA 的差距可能被噪声淹没。

最后补一个我踩过的坑:QLoRA 训练时如果直接加载 4-bit 模型做推理评估,需要在 model.generate() 之前调用 model = model.merge_and_unload() 或者保持 adapter 分支计算,否则生成的 token 会非常差,EM 直接掉到 10% 以下。这个问题在 Transformers 4.36 的默认行为下尤其容易踩,因为 save_pretrained 默认只存 adapter 权重,重新加载时如果不走 PEFT 的加载路径,就会用 4-bit 基座直接生成,结果一塌糊涂。


常见问题

QLoRA 训练出来的模型在推理时需要量化环境吗?

不需要。QLoRA 训练完成后可以把 adapter 合并回基座权重,再用 fp16/bf16 全精度保存和加载。合并后的模型推理精度和普通 fp16 模型一样,不依赖 bitsandbytes 或量化环境。只是合并时需要在 GPU 上操作,因为反量化过程需要显存。

LoRA 的 target modules 除了 q_proj 和 v_proj,还要不要加 k_proj、o_proj?

对于 7B 代码模型,我实测只加 q_proj 和 v_proj 的 EM 是 39.2%,加上 k_proj 和 o_proj 后 EM 是 39.4%,几乎没提升,但显存和训练时间各增加约 5-8%。如果不确定,可以先只调 q/v,跑一轮看效果再决定要不要加。

为什么全参微调在长补全上优势更明显?

因为长补全需要模型在生成时保持更长的上下文一致性和语法约束,这依赖更精细的权重更新。LoRA 的低秩约束限制了权重变化的自由度,在短距离依赖上够用,但在跨越 10 行以上的语义绑定和变量作用域跟踪上会逐渐暴露容量不足。4-bit 量化的 QLoRA 则叠加了权重精度损失,进一步放大了这个问题。