网络优化
网络优化可以从以下四个方向展开:
- 使用更有效的优化算法
- 使用更好的参数初始化、数据预处理方法
- 修改网络结构(激活函数、残差连接、归一化)
- 使用更好的超参数优化方法
优化算法
正则化
正则化(Regularization) 是一种通过在模型损失函数中引入额外约束,来抑制过拟合(Overfitting)并提高模型泛化能力的技术
常见的正则化方法
这些正则化的目的大多都是为了让部分权重变为0或非常小,保留关键特征
(1) L1 正则化 (Lasso)
在原始损失函数中增加权重绝对值的和
- 特点: 倾向于产生稀疏权重矩阵(使许多权重变为 0)。
- 用途: 常用于特征选择,自动剔除不重要的特征。
(2) L2 正则化 (Ridge / 权重衰减)
在原始损失函数中增加权重平方的和
- 特点: 倾向于让权重值变得非常小,但不会变为 0。
- 用途: 防止模型对某些特征过度敏感,是最通用的正则化手段。
(3) Dropout (丢弃法)
在训练过程中,随机让一部分神经元停止工作(输出置为 0)。
- 直观理解: 强迫模型不要过度依赖特定的神经元组合,从而学习到更具鲁棒性的特征。
(4) 提前止步 (Early Stopping)
在训练过程中,当验证集上的误差开始上升时(即便训练集误差仍在下降),立即停止训练。
minibatch与梯度积累
一般而言,batchsize较大时会获得较稳定的训练结果(因此可以设置较大学习率)。但batchsize会被GPU显存所限制,因此可以通过梯度累积的方式,使用较小的batchsize模拟大batchsize的训练效果:
1 | batch_size = 16 |
该代码通过16的batchsize模拟了64batchsize的训练效果,具体其实就是每4个batch才进行一层更新权重。
对于pytorch,在计算了每个batch中各样本的loss后,会对其进行求均值处理,因此上述代码一次循环中的loss中包括了16个loss的均值,因此其反向传播计算的梯度也是一个平均梯度;而pytorch中,如果不手动对梯度进行清零(zero_grad()),其会自动在得到新梯度时求和,因此上述代码需要使用:
loss = loss / accumulation_steps
对每个batch的平均loss根据accumulation_steps再次进行平均处理,最后每4个batch后生成的loss才是batchsize=64时得到的平均loss,其梯度也为4个batch的平均梯度
如果不进行均值处理,则等同于学习率被放大同等倍数,会导致训练不稳定
为什么能降低显存?
由反向传播的公式我们可以得知,在计算每个参数梯度的过程中,我们需要中间所有神经元的激活值(activation),而这些激活值是十分消耗内存的。但我们在计算完梯度过后,就不需要这些激活值了(backward过后会立刻释放这些内存),因此minibatch就使得原本一次性占用很多的激活值内存被拆分成了多次计算
batchsize影响
对于引入了batch进行局部梯度优化的神经网络训练(大多数训练都采取该策略),batchsize越大会令每个batch的梯度方差越小,从而使训练更加稳定,举个例子:
假设一个针对猫狗的二分类图像识别,当batchsize较小时,一个batch可能大部分由猫组成,而另一个batch可能又大部分由狗组成,这将导致这两个batch的梯度大概率完全不同,甚至是相反的方向(由之前对反向传播的推到中可得知某层参数的偏导与之前层的激活函数梯度与该层输入值有关),最终导致参数更新左右震荡
学习率调整
学习率衰减
无需多言,训练后期为了避免在最优点附近来回震荡,要对学习率进行衰减处理,常见操作有:
- 分段衰减
- 逆时衰减
- 指数衰减
- 余弦衰减
其中逆时、指数衰减呈类exp(-x)形状,余弦衰减呈余弦(0-pi)形状,因此在训练前期前者衰减会大于后者,当然,各衰减方法都有可调参数可具体控制衰减情况,不用过于纠结。
学习率预热
由于神经网络在训练最初参数是随机初始化的,因此在刚开始训练时梯度会较大,而学习率最开始也较大,容易出现训练不稳定的情况。因此可以在最初几轮采用较小学习率,让学习率在一定预热回合内逐渐上升为初始学习率。(在SNN中貌似很常用,在常规神经网络中貌似不是很必要)
梯度估计修正(优化器选择)
之前我们说到现在大部分神经网络训练都使用batch进行梯度估计以代替整个训练集上的真正梯度,这样做会导致每个batch的平均梯度具有一定随机性(与全局最优梯度不一致),因此出现了许多通过使用一段时间(多个batch)内的平均梯度以代替单个batch梯度的方法缓解梯度估计的随机性。
这在pytorch中直接体现在使用不同的优化器,这也就是为什么对梯度的管理(除了计算)都是通过优化器类的方法实现(如step,zero_grad)
而SGD优化器则是直接使用当前batch的估计梯度进行更新,最为简单,但这并不意为着SGD不好,相反,SGD在精细调优后和训练后期可以实现更稳定、精准的效果。
另一个常见的Adam优化器则使用了动量法+自适应学习率的方式进行梯度估计修正,动量法可以考虑到多个batch的平均梯度,降低梯度估计随机性;同时Adam算法使用了自适应学习率,使其在固定学习率下也可以有较好训练效果(但最好还是手动添加一个学习率衰减)
梯度爆炸问题
当某次反向传播时梯度突然暴增会导致更新参数时反而远离最优点,因此可以采用一些方法避免:
- 梯度裁剪:设置梯度阈值,当超过阈值时按比例缩小至阈值
- 使用归一化稳定每一层神经网络的输出
模型量化
模型量化的核心思想朴素而又强大:将模型内部那些用于计算和存储的数字,从“高精度”格式,转换为“低精度”格式,从而缩小模型尺寸,降低显存占用。
例如:将数据类型从32位的FP32转换为8位的INT8,意味着表示每一个参数(权重)所用的比特数减少了4倍,也就代表着模型的整体尺寸降低了约4倍
量化对象
- 权重量化:对模型的权重参数进行量化,主要是为了压缩模型体积,降低其在硬盘上的存储占用和在推理时加载到内存(显存)中的空间。
- 激活值量化:即对激活函数值进行量化,核心目标是为了实现极致的推理加速。难度更高,因为激活值会随着输入发生剧烈变化
- K V cache量化:关于KV cache原理可查看注意力机制原理与实现 | 小董的BLOG,K V cache的存在目的是提升推理速度,但是也导致了显存占用提升(特别是在长序列任务中),因此对其进行量化的主要目标是节省推理时的显存占用
各精度
FP32(32位单精度浮点数):8位指数,23位尾数
FP16 (半精度浮点数):5位指数,10位尾数,梯度值很容易超出其表示范围而变成无穷大(上溢),或因太小而变成0(下溢),导致训练过程非常不稳定。
BF16 (Bfloat16):8位指数,7位尾数,保留了和FP32完全相同的8位指数,这意味着它的动态范围和FP32一样巨大,从根本上解决了FP16的溢出问题。但作为代价,它的尾数位只有7位,精度是三者中最低的。
FP8:有E5M2和E4M3等组合
INT8:常用。对于深度学习模型,尤其是那些被证明存在大量冗余的过参数化大模型,用256个等级来近似描述权重或激活值的分布,在许多情况下已经足够。实践表明,从FP32量化到INT8,模型的精度损失通常可以控制在非常小的范围内。
另外,现代GPU(如NVIDIA的Tensor Core)和CPU(如支持AVX指令集的Intel处理器)内部都集成了专门为8位整数矩阵乘法设计的、高度优化的计算单元。这些硬件可以直接执行
INT8xINT8的运算,速度远超浮点计算。INT4:比特数从32位降到了4位,带来了惊人的8倍压缩率,但也就意味着仅有16个等级去描述原本复杂的浮点数分布,必须依赖更复杂、更精巧的量化算法
PTQ与QAT
- PTQ:训练后量化,常用
- QAT:训练中量化,具体做法是使用一个fake quants,在拿到某个layer的高精度结果后(FP32),将其量化为低精度(INT4), 然后再的dequantize回FP32,此时这个FP32其实是带有误差的,让这个带有误差的fp32作为下一个layer的输入,继续网络训练。
QAT效果普遍会比PTQ好一些
参数初始化与数据预处理
这一块感觉没啥好说的,就只在这里列出,不展开说了,初始化感觉是比较玄学的东西
而对于预处理,当数据量不足或不平衡时,可以通过重采样、数据增强等方式增加数据量
修改网络结构
Normalization(标准化)
这里需要强调的是,在深度学习当中,我们提到的Normalization其实是在做标准化操作——将数据转化为正态分布形式(均值为0,方差为1)。而对于标准的归一化,则通常在数据集处理时使用:将数据线性缩放到固定的区间(通常为0~1或-1~1)
Normalization的本质是通过线性变换,将原本分布参差不齐的数据映射到一个标准的、可控的数值空间,从而防止神经元激活函数输出过大/小导致梯度爆炸/消失,同时也可以让loss更加平滑,避免反复振荡。
所有的归一化都遵循一个标准公式:
其中μ、σ为均值和方差,ε为防止分母为0的极小值,它们负责将x中心化、缩放——映射到标准正态分布上
γ和β是一个可学习的参数,负责把将经过标准正态分布缩放、平移到更适合激活函数的分布区间,它们也通过反向传播更新,是深度学习中的重要部分
而对于不同的归一化方法,其不同就体现在μ和σ计算的数据范围上
Batch Normalization
BN 是在整个 Batch 范围内,对每一个通道独立进行计算,即:
计算均值和方差时固定通道C_i,计算batch size x H x W个值的均值和方差,因此batch size的大小直接决定了其均值和方差的估计可信度
由于BN是在Channels维度做归一化,因此它可以在不破坏数据空间结构的情况下归一化,适合于CNN结构
Layer Normalization
LN 是在单个样本内部进行标准化,在序列数据中,常表现为对每个token单独进行标准化
而由于LN将一个样本内部的不同特征(即通道)映射在了同一分布上,使其在NLP任务(RNN,Transformer)中非常有效,因为句子中每个词(Token)的特征表示应该在一个量级上。
而在CNN中则一般不使用,因为其破坏了空间结构
对于nn.LayerNorm,可以指定normalized_shape = [C, H, W]进行指定范围内的计算,当输入为单个值时,默认对最后一维进行计算
RMS normalization
公式中省略中心化(减去均值),计算量更小。
Instance Normalization
在单个样本的单个通道内独立计算均值、方差(即在HxW个值上计算),常用于风格迁移,会损失样本统计信息(对比度、语义等),仅保留内容信息(形状)
Group Normalization
在LN基础上,仅选择特定个数的通道进行归一化(将单个样本的C个通道平均分为若干组,对每个组做LN)
由于IN可以保留样本的局部结构,并且不依赖batch size,因此某些情况下可以用于CNN。另外,其分组参数为一个经验参数,不好确定
| 方法 | 统计范围 |
|---|---|
| BN | batch 维 |
| LN | 单样本所有特征 |
| IN | 单样本单通道 |
| GN | 单样本通道分组 |
| RMSNorm | 单样本特征(但不减均值) |
模型剪枝
什么是 模型剪枝(Model Pruning)-CSDN博客
模型剪枝的基本思想是识别并移除对模型性能影响较小的部分,同时通过微调恢复精度。剪枝的目标是生成一个更小、更高效的模型,适用于低功耗或低延迟场景。
剪枝对象:
- 权重剪枝(Weight Pruning):移除单个权重(连接),通常基于权重的大小(如绝对值较小的权重被剪除)。这会导致稀疏矩阵,适合稀疏计算加速。
- 结构化剪枝(Structured Pruning):
- 通道剪枝(Channel Pruning):常用,移除整个卷积通道(filters),保持模型结构规则,适合通用硬件加速。
- 神经元剪枝:移除整个神经元或节点。
层剪枝:移除整个网络层(较少见)。 - 混合剪枝:结合权重和结构化剪枝。
剪枝粒度:
细粒度剪枝:操作单个权重,灵活但需要稀疏矩阵支持。
粗粒度剪枝:操作通道、层等结构化单元,硬件友好但可能损失更多精度。
剪枝依据:
基于幅度(Magnitude-based):移除绝对值较小的权重或通道(L1范数常用,也可以使用L2),假设其贡献较小。
基于重要性(Importance-based):通过某些指标(如Hessian矩阵、梯度、特征图激活)评估权重或通道的重要性。
基于正则化:在训练时引入L1/L2正则化,鼓励权重趋向于零,便于后续剪枝。
剪枝策略:
一次性剪枝(One-shot Pruning):在训练后直接剪枝,然后微调。
迭代剪枝(Iterative Pruning):分多次逐步剪枝,每轮剪枝后微调,精度损失较小。
训练时剪枝(Pruning during Training):在训练过程中动态剪枝(如通过掩码或正则化)。
微调(Fine-tuning):
剪枝后,模型精度通常会下降,因此需要通过微调(继续训练)恢复性能。
微调可以针对剪枝后的模型进行,也可以结合知识蒸馏等技术进一步提升性能。
分布式训练
并行策略可以分为数据并行和模型并行。顾名思义,数据并行将计算过程中的数据在多张GPU上分批次处理;而模型并行则是将模型权重切分到不同GPU上。在超大规模模型训练时,通常二者一起使用
数据并行(Data Parallel, DP)
所谓数据并行,就是由于训练数据集太大;因此,将数据集分为N份(不是将每个batch分),每一份分别装载到N个GPU节点中,同时,每个GPU持有一个完整的模型副本,分别基于每个GPU中的数据去进行梯度求导。最后,通过每个GPU得到的梯度计算得到当前batch平均梯度,再根据该平均梯度更新模型。具体的并行流程当前主要有两种(pytorch下):
torch.nn.DataParallel(过时)
基于单进程多线程实现,简单来说就是有一个主GPU负责计算各GPU得到的loss,然后分发各GPU的loss,反向传播计算梯度,然后主GPU汇总梯度,更新参数,最后将更新后的参数再加载到其他GPU上。
1 | net = torch.nn.DataParallel(model, device_ids=[0, 1, 2]) |
它使用简单,但缺点明显:
- 单进程速度慢,只能在单机多卡上实现且不能使用 Apex 进行混合精度训练。
- 主卡瓶颈,由于每个batch中主GPU的和其他GPU都要进行一次同步,因此会存在通信瓶颈。且主卡和其他卡GPU利用率会严重不均衡(主卡负担更大,容易超显存)
- 无法与模型并行组合使用
torch.nn.DistributedDataParallel(DDP)
基于多进程实现,每个进程都有独立的优化器,执行自己的更新过程。也就是说不再存在“主卡”的概念。各进程梯度计算完成之后,各进程需要将梯度进行汇总平均,然后再由 rank=0 的进程,将其广播(broadcast,对于DP,则是All-Reduce,带宽要求更高)到所有进程后,各进程用该梯度来独立的更新参数,相比于DP,DDP只广播一个平均梯度,各GPU根据该平均梯度自己更新(由于初始参数相同,更新完后的权重也必定相同),传输的数据量大大减少
每个 GPU 对应一个进程,适用于单机和多机情况,真正实现分布式训练,并且因为每个进程都是独立的 Python 解释器,DDP 避免了 GIL 带来的性能开销。
ZeRO (Zero Redundancy Optimizer)
理论上来说ZeRO属于数据并行的一种算法,但其内部对显存的管理也达到了模型并行的性能
在训练大模型时,显存主要被以下三个部分占据(统称为模型状态):
- Optimizer States (优化器状态): 如 Adam 中的一阶和二阶动量。
- Gradients (梯度): 计算出的权重更新值。
- Parameters (参数/权重): 模型本身的权重。
其中,优化器状态为显存占用最大头
| 阶段 | 参数更新位置 | 更新时的通信操作 | 更新后的显存状态 |
|---|---|---|---|
| ZeRO-1 | 仅优化器状态分片 | Reduce-Scatter (梯度) + All-Gather (状态推导出的更新量) | 每个 GPU 存有完整参数和梯度 |
| ZeRO-2 | 梯度与状态均分片 | Reduce-Scatter (梯度) + All-Gather (更新后的参数) | 每个 GPU 仅存有部分梯度和状态 |
| ZeRO-3 | 参数、梯度、状态均分片 | 每次计算层时临时 All-Gather 参数,计算完丢弃 | 全程无冗余,显存随 GPU 数量线性扩展 |
其中,对于ZeRO-3来说,每个GPU上保存的就已经不是完整权重的模型了,但其会通过多GPU的通信实现完整模型的效果
模型并行
模型并行,即模型被分割并分布在一个设备阵列上,每一个设备只保存模型的一部分参数。
模型并行分为流水线并行和张量并行:
- 流水线并行:为层间并行,对模型不同的 Transformer 层间进行分割。数据在设备间依次传递(点对点通信),类似工厂流水线。
- 张量并行:为层内并行,对模型 Transformer 层内进行分割,计算某一层时,所有GPU协同计算该层的不同部分,需要高频的AllReduce通信同步结果。
流水线并行
对于最原始的流水线并行(朴素流水线),其实可以直接理解成一个串联结构(我也不知道为什么叫并行),因此其存在一个明显的缺陷:在任意给定时刻,除了一个 GPU 之外的其他所有 GPU 都是空闲的
因此为了减少中间空出来的bubble time,现在常用的是微批次流水线并行(microbatch):
其核心思路就是把每个batch再次拆分,让每个设备计算完一个micro batch后就传到下一设备,然后当前设备计算下一个micro batch,而不是等一个设备完整计算完整个batch
张量并行
张量并行从数学原理上来看就是:
对于linear层(attention中的q,k,v投影层):把矩阵分块进行计算,然后把结果合并;对于非linear层,则不做额外设计。
这一块就先不展开了,目前本人用不到。