大模型参数微调系列教程(一):基础参数 Fine Tuning

307 阅读20分钟

随着人工智能技术的飞速发展,大模型在诸多领域展现出了卓越的性能。然而,直接使用预训练好的大模型在特定任务上可能并非最优,因此大模型参数微调技术应运而生。参数微调是指在预训练模型的基础上,针对特定的任务和数据集,对模型的部分或全部参数进行小规模的调整,以适应新的任务需求。这种方法不仅可以节省大量的计算资源和训练时间,还能显著提升模型在特定任务上的性能。

大模型参数微调的原理是通过在大规模的通用数据集上进行预训练,学习到了丰富的通用特征和语义信息。但不同的下游任务具有不同的特点和需求,预训练模型可能无法直接满足这些需求。参数微调的核心思想是在预训练模型的基础上,使用特定任务的数据集对模型进行进一步训练,使得模型能够学习到与特定任务相关的特征和模式。

图片

常见的微调策略包括全量微调和部分微调两种方式。

全量微调: 对模型的所有参数进行调整。这种方法可以充分利用特定任务的数据集,使模型更好地适应新任务,但需要更多的计算资源和训练时间。

部分微调: 只对模型的部分参数进行调整,例如只调整模型的最后几层。这种方法可以在节省计算资源的同时,保持模型的大部分通用特征,适用于数据集较小的情况。

微调参数的选择

**
**

从大模型的运行性能角度(即提升计算效率、降低推理时间、减少内存占用)来看,主要需要调整参数来优化模型的以下几个方向。

图片
1. 批量大小 ( batch_size )**
**

增大batch_size 可以提高 GPU 利用率,但可能导致显存占用过高。如果显存充足,可以尝试增大 batch_size 来提高吞吐量,这样可以提高 GPU 计算效率,减少梯度更新次数,但可能影响泛化能力。如果显存不足,可以减小batch_size 来降低显存占用,提高泛化能力,但计算效率下降,过程中使用梯度累积来保持等效的训练效果。

对于AI 产品经理来说,计算评估batch size大小是必要的。从经验的角度讲,对于较小的模型,如简单的全连接网络,batch size可以设置得相对较大,如 64、128 或 256。对于大型的预训练模型,如 ResNet、BERT 等,由于模型本身占用大量显存,batch size可能需要设置得较小,如 8、16 或 32。

通常来说,小型模型的经验batch size设置适用于大多数常见的 GPU,包括消费级和一些入门级的专业 GPU,如 NVIDIA GeForce GTX 10 系列、RTX 20 系列、30 系列以及 AMD Radeon RX 5000 系列、6000 系列等消费级显卡,这些 GPU 的显存容量一般在 4GB - 16GB 之间。

对于大型模型的经验batch size设置,更多是考虑到一些高端专业 GPU 或数据中心级别的 GPU,如 NVIDIA A100、H100、A800 等,它们具有较大的显存容量,通常在 40GB - 80GB 甚至更高,但由于大型模型本身参数众多,计算复杂,即使是这些高端 GPU,也需要将batch size设置得相对较小,以避免显存不足。不过,具体的batch size还需要根据实际的模型和任务进行调整。

同时,AI 产品经理也可以通过适当的方式计算模型和数据所需显存。比如,模型的显存占用主要包括模型参数、中间激活值、梯度等。可以通过工具(如torch.cuda.memory_allocated() )来估算模型在不同batch size下的显存占用。接下来,AI产品经理需要计算数据所需显存:根据输入数据的维度和类型,计算一个样本所需的显存,然后乘以batch size得到数据所需的显存。为了避免显存溢出,通常需要留出一定的显存余量。

当然,AI 产品经理也可以通过逐步增加batch size,观察训练过程中是否出现显存溢出或性能下降的情况,从而确定合适的batch size。如下图显示了不同的batch size状态下对于整个训练效果的影响。

 

图片

可以看到,更大的 batch size 通常带来更高的训练效率(训练时间更短)。推理时间反而略微下降(因为大 batch 有更好的 GPU 利用率),精度则不是单调的,128 在本实验中达到了最佳。这里可以看到,Batch Size 对训练时间的影响远大于推理时间。训练过程中涉及大量梯度相关计算,显著比推理耗时更多。大 Batch 提高硬件并行效率,GPU 更适合处理大规模并行数据。较大的 Batch 能提高单轮训练的数据吞吐量,减少梯度更新次数(每轮训练走的数据更多)。训练过程本就耗时较多,因此更容易体现出 batch size 的优势。推理阶段计算固定且轻量,每张图像前向传播一次就够了,即使 batch size 从 32 到 256 提升,前向过程本就快,提升空间有限。

还有就是不难看出,Batch Size也会间接影响推理精度。小 batch 通常带来更“嘈杂”的梯度更新(波动大),可能导致模型训练不稳定。大 batch 可以提供更平滑、准确的梯度估计,从而有利于更好的收敛和泛化。同时,极小 batch 容易在训练集中过拟合。极大 batch 虽然训练更稳定,但有时泛化能力下降,表现为在测试集精度不如中等 batch。通常经验上:batch size 越大,适当增加学习率效果更好,但也更依赖正则化等技巧。此外,优化器对 batch size 也敏感 Adam 等优化器对不同 batch size 下的参数更新行为有不同表现。默认超参数往往是为中小 batch 调整的,过大或过小可能需要额外调整超参数以获得最优精度。

此外,Batch Size的增加,也会影响整个显存的使用情况。显存主要用于存放每个 batch 的 activations 和 intermediate tensors。batch 增加,模型同时需要“记住”更多样本的数据流图,显存自然线性上涨。

基于以上Batch Size 的影响,在这里做一个分析总结:

1.训练时间 vs Batch Size:

Batch 越大,每轮训练使用更多样本,提升并行计算效率,显著降低训练时间。

2. 推理时间 vs Batch Size:

推理阶段仅前向传播,增加 batch size 只轻微提升 GPU 利用率,对单轮推理时间影响较小。

3.显存使用 vs Batch Size:

每个 batch 中所有样本都需完整保留中间激活和特征图,batch 增大,显存自然线性上升。

4.精度影响:

batch 太小:梯度波动大,不利于收敛;

batch 太大:泛化能力可能下降,模型容易陷入局部最优;

通过实验可以看到设置batch=128 时得到最佳平衡(60.33%)。 总结起来,若追求训练效率,则尽量使用大 batch(如 128 或 256)。若追求精度和泛化,可尝试 64~128,适当引入学习率 warmup 或动态 batch 策略。 未来可引入 mixed precision 训练,继续放大 batch 上限,充分压榨显卡性能。

2. 计算精度 ( fp32 fp16 bf16 ) 优化

**
**

这里的模型参数调节是比较直接的方式,即直接针对模型所承载的参数定义来做调优。通过使用混合精度训练(Mixed Precision Training)可以显著加速计算,同时降低显存占用。通过启用 torch.cuda.amp(自动混合精度),减少不必要的高精度计算。训练时使用fp16(half-precision),在推理时也可以用 fp16 加快推理速度。fp16(半精度)的计算速度更快,显存占用更低,但可能会影响数值稳定性。bf16(Brain Floating Point)更稳定,但在某些硬件上支持有限(如 V100、A100 等)。

图片

当然,不是所有模型都适合混合精度训练。像一些对数值精度要求极高的模型,如涉及复杂数学运算或梯度值范围极广的模型,可能不适合直接采用混合精度训练。比如,某些基于强化学习的模型,其梯度计算较为敏感,低精度可能会导致训练不稳定。因此,可以先对模型进行小规模的混合精度训练实验,观察模型的收敛情况和最终性能。若模型在混合精度训练下无法收敛,或者性能大幅下降,那么该模型可能不适合混合精度训练。

关于GPU 支持情况,则是不同的 GPU 对混合精度训练的支持程度不同。例如,NVIDIA 的 Volta、Turing 和 Ampere 架构的 GPU 对 FP16 有很好的支持,而较新的架构还支持 BF16。此外,部分 CPU 也开始支持 BF16 计算,如 Intel 的第三代至强可扩展处理器。如果使用 CPU 进行推理或训练,需要确认 CPU 是否支持相应的低精度计算。在训练过程中,可以使用工具(如 NVIDIA 的nvidia-smi)监控显存使用情况。对比混合精度训练和单精度训练的显存占用,评估混合精度训练对显存的节省效果。

为了验证混合精度训练(Mixed Precision Training, AMP)可以加速计算并降低显存占用,我们可以运行一段对比实验代码。通过torch.cuda.amp.autocast() 进行混合精度推理。通过GradScaler() 进行梯度缩放,防止数值溢出。

对比实验中,通过Baseline (FP32) 训练来完成标准全精度 (float32) 训练,而使用AMP (FP16) 训练来启用混合精度加速。我们将计算训练时间、显存占用以及最终精度对比,以图示方式可视化两种模式的区别。对比代码将在 Kaggle 云端 GPU 上执行,使用ResNet50进行 CIFAR-10 分类训练,并对比 AMP与 FP32模式的训练性能。

图片

从如上实验结果可以看出AMP 训练时间更短。因为,通过利用 Tensor Core 进行 FP16 计算,加速矩阵运算。同时,减少数据存储需求,适合大模型训练。然而这里AMP的显存占与FP32一致,而并非像我们想象的那样降低了显存占用。这里可能有几个原因导致:AMP 可能确实降低了显存占用,但 PyTorch 的 CUDA 缓存机制导致表面上看起来没变。显存节省效果取决于 batch size,如果太小,AMP 优势不明显。BN 及部分计算仍可能使用 FP32,影响显存节省效果。

总结起来如下:

图片

3. 模型剪枝 ( Pruning )

为了减少模型中不重要的权重,使模型更轻量化,从而减少计算量,提高推理速度。可以去掉小于一定阈值的权重,减少不必要的计算。同时,通过结构化剪枝(Structured Pruning),裁剪整个通道或神经元,提高并行计算效率。而采用非结构化剪枝(Unstructured Pruning)可以剪掉个别权重,减少参数量。从性能权衡角度 剪枝后可能会影响模型精度,但可以通过轻量级再训练(Fine-tuning)进行恢复。

图片

本文将实现一个基于 PyTorch 进行大模型剪枝的代码示例。该代码包含以下内容:

1. 加载预训练模型:以ResNet50 为例(可换成其他大模型)。

2. 剪枝策略:对模型的Conv2d 层进行剪枝,删除低重要性的权重。具体的剪枝方法通过采用 L1 非结构化剪枝 (prune.l1_unstructured),删除重要性低的权重。

3. 调整参数:通过设定不同的剪枝比例,观察剪枝前后的影响。这里可以只对Conv2d 卷积层进行剪枝,比例设定50%(可调整)。prune.remove() 使剪枝生效。

4. 对比剪枝前后模型的性能对比如下

参数量对比:计算模型的总参数数目。

推理时间对比:测量同样输入下剪枝前后的推理时间。

精度对比:在测试集上评估剪枝对模型精度的影响。

图片

可以看到,剪枝后,整个模型的参数量减少了(这里我们设置的剪枝比例是50%,实际上运行的参数结果也是差不多参数量减少一半),可以说整个模型已经完全轻量化。此时,剪枝后的模型推理时间明显减少了。

然而,可以看到这个剪枝例子运行结果中,模型精度在剪枝后精度反而提升了。可能是如下的一些原因:

1.过拟合减少(Regularization Effect)

a.剪枝减少了冗余参数,相当于一种正则化(regularization),可能提升泛化能力,尤其是原始模型过拟合时。

b.适度剪枝可以减少不必要的参数,提高推理效率,同时减少噪声影响。

2.随机性影响

**
**

a.由于我们没有训练剪枝后的模型,而是直接对预训练权重进行剪枝,可能某些参数剪枝后对模型影响较小或起到了类似 Dropout 的作用。

b.在不同的随机初始化、测试集分布下,可能出现剪枝后测试精度小幅波动的情况。

3.剪枝比例较小,未影响主要特征提取能力

**
**

如果剪枝的比例不高(比如这里我们设置的为50%),可能只是移除了部分不太重要的通道,并未影响关键的特征提取能力,甚至有助于提高模型的判别能力。也就是说,我们在做剪枝的参数调节时,可以预见之后的精度变化是否在我们可容忍的范围内。如果是,那么说明模型剪枝比例是非常合理的,甚至还可以继续下调。

此外,有些时候,我们会发现为什么剪枝后显存占用不变?这是因为形式上的剪枝只“标记”了权重为零,但并未真正删除为零的权重存储单元。比如,如果使用pytorch中的torch.nn.utils.prune 只是将部分权重置零,而参数仍然占据显存空间。剪枝后仍然存储完整的参数张量,只是部分权重变为稀疏的零值。此外,PyTorch 运行时内存管理机制使得PyTorch 在 GPU 计算时,显存管理方式并不会立即回收未使用的内存。剪枝后的模型虽然参数变少,但内存池可能仍然持有之前的显存,不会立即释放。也有可能PyTorch 仍然用稠密张量(Dense Tensor) 来存储剪枝后的权重,稀疏张量未被优化存储 就不是真正的稀疏矩阵(Sparse Tensor)。这样,虽然计算量减少了,但显存占用基本没变。

那么如何让剪枝真正减少显存占用?这就需要用“结构化剪枝” + detach() + to_sparse() 来彻底移除剪枝后的参数!这里,通过彻底移除剪枝的零参数,真正减少模型大小。使用torch.cuda.empty_cache() 清理缓存,查看真实显存占用。可以考虑使用to_sparse() 存储稀疏矩阵,进一步优化显存使用。

图片

4. 层数冻结 ( tune_layers )

**
**

冻结部分层的参数,使得计算更快,减少显存占用。比如冻结了神经网络模型ShuffleNetV2 中的conv1:第一个卷积层,stage2:第一个主干阶段,这部分占据了较多参数计算量。那么,冻结后梯度不会计算,反向传播跳过这些层,这使得训练更快,显存开销减小(因梯度相关缓冲区不再分配)。

图片

我们可以利用freeze_layers 函数通过设置模型部分层的参数为不可训练状态(requires_grad = False)来实现参数冻结,以此达到加速训练或减少内存消耗的目的。整个遍历模型中所有可训练参数(包含其名称),named_parameters() 返回的是每个参数的名称和Parameter 对象。

图片

这里我们可以设置三种冻结级别:

1. "light" 冻结:

仅冻结第一层卷积层(conv1),这通常是模型的最底层用于提取特征信息,对初始特征提取影响最大,但参数相对少。

2. "medium" 冻结:

冻结第一层卷积层 + 第二阶段特征提取部分(stage2)。stage2 是 ShuffleNetV2 的一部分,冻结它会跳过更多前层梯度计算。

3. "heavy" 冻结:

冻结 conv1 + stage2 + stage3,进一步减少训练参数数量。

4. "full" 冻结:

表示仅训练最后一层。

图片

可以看到,当冻结不同的参数层后,整个模型的训练精度存在不同程度的下降,当然,整个训练时间和显存使用率也存在一定程度的降低,这符合整个模型冻结的初衷。

但是我们可以发现,当随着冻结层数的深入过程,整个精度下降的剧烈程度大于训练时间的减少预期。特别是在heavy这种冻结模式下,相对于medium这种模式,其精度降低预期下降太大,而时间和显存降低并不明显。这里我们针对这块可以考虑一些优化策略:比如只解冻最后几层进行微调,而不是整个模型,以减少计算开销。冻结低层特征提取部分,仅优化高层任务相关的参数。在性能权衡方面,可以通过冻结更多层可以提高训练速度,但可能会限制模型对新任务的适应能力。

5. 权重量化 ( Quantization )

**
**

优化策略可以使用torch.quantization进行Post-training Quantization (PTQ),无需重新训练即可加速推理。采用Quantization-Aware Training (QAT),训练过程中模拟量化,提高量化后的精度。

图片

这里我们选择三种不同的精度进行量化对比实验:fp32、fp16、int8。通过使用函数torch.quantization.quantize_dynamic(...),作为动态量化的 API,认为它是整个量化过程的入口函数。该函数会扫描你模型中的所有 nn.Linear 层,将它们的float32 权重压缩成int8,并替换成 PyTorch 提供的量化模块 nn.quantized.dynamic.Linear。

图片

注意这里不能对 FP16 模型使用动态量化,是因为torch.quantization.quantize_dynamic() 是专门为int8 动态量化设计的,不能对 FP16 模型使用。这个 API 的目标是只支持 dtype=torch.qint8(int8),动态量化原理是在推理时将输入动态量化成 int8,再用 int8 权重做矩阵乘法。那么,如何使用 FP16呢?因为,FP16 是属于混合精度/半精度推理优化,它和量化的实现机制不一样。

整个不同数据类型精度的量化涉及FP16中,使用.half()函数把模型的所有浮点参数从 float32 → float16,因为FP16 模型本质上还是 float-based,它只是把权重的精度从 float32 → float16,仍然需要 GPU 浮点支持。而对于int8 模型,则是采用函数torch.quantization.quantize_dynamic(),把部分层(如 Linear)权重压缩成 int8。可以看出动态量化 是为了让模型能跑在 CPU 上,使用的是整数乘加(int8 dot product),所以二者目标不同,路径不同,不能叠加使用。从性能权衡角度讲:int8 量化后会加速推理,但可能造成一定精度下降。fp16 量化相对稳定,适用于大多数 GPU。如果你想用 FP16 加速推理,可以直接这样做:

图片

通过如上代码首先将整个模型参数降为一半,再将整个tensor计算过程降为fp16的精度。

最后运行结果如下:

图片

这里我们针对的是动态量化,模型并未提前进行训练,因此当模型没有进行 fine-tune,所以它对 CIFAR10 不适配。 所以,此时三个模型精度都低,精度差异属于偶然抖动。基于结果我们可以得出如下几个比较关键的问题说明:

① 为何 FP16 模型推理时间最短?

这里我们的实验环境使用的是Kaggle GPU 环境,而FP16 运算只在 GPU 上有加速优势(NVIDIA Tensor Cores 支持半精度)。所以当用 .half().to(device) 且device=GPU 时,FP16 模型会触发硬件加速,非常快。

而 INT8 是 CPU-only 的量化模式,在 CPU 上运行推理,会慢很多(特别是在 Kaggle 的 CPU 上不太快),FP32 虽然也跑在 GPU 上,但比 FP16 计算量更大,自然略慢。

② 为何 FP16 模型文件大小最小?

当执行代码torch.save(fp16_model.state_dict(), "fp16.pth")函数段时,它会把所有参数以 float16 存储,相比 float32 精度参数体积砍半,自然最小。但注意PyTorch 的 quantize_dynamic 不会量化所有层,也不会自动裁剪浮点参数文件,因此INT8 的 .pth 其实仍包含了不少 FP32 权重 → 模型文件大小几乎没变,看起来“不像压缩了”。

③ 为何 INT8 的精度略高

**
**

这其实和没训练直接有关:因为加载的是pretrained=True 的 resnet18 模型,最后一层改成了 nn.Linear(..., 10),原因主要是:PyTorch 的“动态量化”目前只支持 nn.Linear,不支持nn.Conv2d。

那为什么卷积层不能动态量化呢?

图片

从技术角度讲,卷积计算涉及空间局部性(kernel sliding),量化误差传播影响更大,所以,动态量化时难以在运行时临时量化卷积权重+激活,再恢复较高精度。PyTorch 当前没有为 Conv2d 提供这样的「动态 INT8 运行时优化」,也未内建动态量化 kernel 实现。动态量化简单快捷,但只能量化 Linear 层,不能触碰Conv2d;要整网量化,就得用“静态量化”或“QAT”。

那问题来了:既然静态量化更好,为什么还需要动态量化呢?

这是因为部署简单,不需要校准数据。动态量化最大优势是无需训练数据、不需要跑任何数据即可量化。

很多场景下你手上可能没有原始训练数据,没时间/不方便再跑一轮数据做校准,只是想快速部署一个简单推理模型。这种情况下,动态量化就非常合适。

原文地址:https://mp.weixin.qq.com/s/WxLeXhxREtT-HUpRBIdBkA