PyTorch炼丹秘籍:10招教你榨干GPU显存,模型训练提速20倍!

还在为GPU显存不够用发愁?这10个PyTorch内存优化技巧,帮你榨干GPU性能,加速模型训练,最高提速20倍!

原文标题:PyTorch内存优化的10种策略总结:在有限资源环境下高效训练模型

原文作者:数据派THU

冷月清谈:

本文针对GPU内存受限的深度学习模型训练,特别是大型语言模型和视觉Transformer,系统地介绍了10种PyTorch内存优化策略。这些策略包括:利用FP16和FP32的自动混合精度训练,使用BF16低精度训练,梯度检查点技术减少中间张量存储,通过梯度累积虚拟增大批量大小,使用FSDP进行张量分片和分布式训练,优化数据加载效率,使用原地操作减少内存碎片,激活和参数卸载,使用更精简的优化器如SGD,以及利用内存分析工具、TorchScript进行JIT编译、内核融合及torch.compile()进行动态内存分配等进阶优化技术。组合应用这些技术可显著降低内存消耗,提高训练效率,而不会明显损害模型性能和预测准确率。

怜星夜思:

1、文章提到了混合精度训练可以加速计算并减少内存消耗,但同时也提到某些层或操作仍需要FP32格式以避免数值不稳定。那么在实际应用中,我们该如何判断哪些层或操作需要保留FP32格式呢?有什么经验法则或者诊断方法吗?
2、文章中提到了梯度累积可以解决小批量大小导致预测性能下降的问题,但是也提到会显著增加训练时间。那么,有没有什么方法可以在使用梯度累积的同时,尽可能减少训练时间的增加呢?比如,动态调整累积的步数?
3、文章中提到了很多优化策略,感觉信息量很大。如果只能选择其中一两个策略在自己的项目中尝试,你会优先选择哪几个?为什么?

原文内容

来源:DeepHub IMBA
本文约3500字,建议阅读7分钟
本文将系统性地介绍多种内存优化策略。


在大规模深度学习模型训练过程中,GPU内存容量往往成为制约因素,尤其是在训练大型语言模型(LLM)和视觉Transformer等现代架构时。由于大多数研究者和开发者无法使用配备海量GPU内存的高端计算集群,因此掌握有效的内存优化技术变得尤为关键。本文将系统性地介绍多种内存优化策略,这些技术组合应用可使模型训练的内存消耗降低近20倍,同时不会损害模型性能和预测准确率。以下大部分技术可以相互结合,以获得更显著的内存效率提升。


1、自动混合精度训练


混合精度训练是降低内存占用的基础且高效的方法,它充分利用16位(FP16)和32位(FP32)浮点格式的优势。


混合精度训练的核心思想是在大部分计算中使用较低精度执行数学运算,从而减少内存带宽和存储需求,同时在计算的关键环节保持必要的精度。通过对激活值和梯度采用FP16格式,这些张量的内存占用可减少约50%。然而某些特定的层或操作仍需要FP32格式以避免数值不稳定。PyTorch对自动混合精度(AMP)的原生支持大大简化了实现过程。


混合精度训练 与 低精度训 有本质区别


关于混合精度训练是否会影响模型准确率的问题,答案是。混合精度训练通过精心设计的计算流程保持了计算精度。


混合精度训练原理



混合精度训练通过结合16位(FP16)和32位(FP32)浮点格式来保持计算准确性。使用16位精度计算梯度可显著加快计算速度并减少内存消耗,同时维持与32位分辨率相当的结果质量。这种方法在计算资源受限的环境中尤为有效。


"混合精度"一词更准确地描述了这一过程,因为并非所有参数和操作都转换为16位格式。实际训练过程中,32位和16位操作交替执行,形成混合精度计算流程。


如上图所示,该过程首先将权重转换为低精度(FP16)以加速计算,然后计算梯度,接着将梯度转回高精度(FP32)以确保数值稳定性,最后使用这些适当缩放的梯度更新原始权重。通过这种方式,混合精度训练可提高训练效率的同时保持网络的整体精度和稳定性。


使用torch.cuda.amp.autocast()可轻松实现混合精度训练,示例代码如下:


import torch
from torch.cuda.amp import autocast, GradScaler

# Assume your model and optimizer have been defined elsewhere.
model = MyModel().cuda()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
scaler = GradScaler()

for data, target in data_loader:
optimizer.zero_grad()
    # Enable mixed precision
    with autocast():
        output = model(data)
        loss = loss_fn(output, target)
    # Scale the loss and backpropagate
    scaler.scale(loss).backward()
    scaler.step(optimizer)
scaler.update()


2、低精度训练


除了混合精度训练,我们还可以尝试使用完整的16位低精度格式进行训练。由于16位浮点数的表示范围限制,这种方法可能导致NaN值出现。为解决这一问题,研究人员开发了多种专用浮点格式。其中,Brain Floating Point(BF16)是Google为此专门开发的一种广受欢迎的格式。与标准FP16相比,BF16提供了更大的动态范围,能够表示极大和极小的数值,使其更适合于深度学习应用中可能遇到的多样化数值情况。尽管较低精度可能在某些计算中影响精确度或导致舍入误差,但在大多数深度学习应用场景中,这种影响对模型性能的影响极小。



虽然BF16最初是为TPU设计的,但现在大多数现代GPU(Nvidia Ampere架构及更高版本)也支持这种格式。可以通过以下方法检查GPU是否支持BF16格式:


 import torch
print(torch.cuda.is_bf16_supported())  # should print True


3、梯度检查点


即便采用混合精度和低精度训练,大型模型在前向传播过程中产生的大量中间张量仍会消耗大量内存。梯度检查点(Gradient Checkpointing)技术通过在前向传播过程中选择性地仅存储部分中间结果来解决这一问题。在反向传播过程中,系统会重新计算缺失的中间值,这虽然增加了计算成本,但可以显著降低内存需求。


通过战略性地选择需要设置检查点的层,可以通过动态重新计算激活值而非存储它们来减少内存使用。对于具有深层架构的模型,中间激活值通常占据了内存消耗的主要部分,此时这种权衡尤为有效。以下是梯度检查点的实现示例:

 import torch

from torch.utils.checkpoint import checkpoint
def checkpointed_segment(input_tensor):

This function represents a portion of your model

which will be recomputed during the backward pass.

You can create a custom forward pass for this segment.

    return model_segment(input_tensor)

Instead of a conventional forward pass, wrap the segment with checkpoint.

output = checkpoint(checkpointed_segment, input_tensor)


采用此方法,在多数情况下可将激活值所需的内存减少40-50%。尽管反向传播现在包含额外的计算开销,但当GPU内存成为限制因素时,这种权衡通常是合理的。


4、使用梯度累积降低批量大小


在尝试上述方法后,一个自然的问题是:


为何不直接减小批量大小?


虽然这确实是最直接的方法,但通常使用较小批量大小会导致预测性能下降。简单减小批量大小虽然能显著降低内存消耗,但往往会对模型准确率产生不良影响。


如何在这两者之间取得平衡?


梯度累积(Gradient Accumulation)正是为解决这一问题而设计的技术。它允许在训练过程中虚拟增加批量大小,其核心原理是为较小的批量计算梯度,并在多次迭代中累积这些梯度(通常通过求和或平均),而不是在每个批次后立即更新模型权重。一旦累积的梯度达到目标"虚拟"批量大小,才使用这些累积的梯度更新模型参数。


然而需要注意,这种技术的主要缺点是显著增加了训练时间。


5、张量分片和分布式训练



对于即使应用上述优化后仍无法在单个GPU上容纳的超大模型,完全分片数据并行(Fully Sharded Data Parallel, FSDP)技术提供了解决方案。FSDP将模型参数、梯度和优化器状态分片到多个GPU上,这不仅使得训练超大模型成为可能,还能通过更合理地分配通信开销提高训练效率。



FSDP不是在每个GPU上维护完整的模型副本,而是将模型参数分配到多个可用设备上。在执行前向或反向传播时,系统仅将相关分片加载到内存中。这种分片机制显著降低了单个设备的内存需求,与前述技术结合使用,在某些情况下可实现高达10倍的内存降低效果。


FSDP可通过以下方式实现:


import torch
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP

Initialize your model and ensure it is on the correct device.

model = MyLargeModel().cuda()

Wrap the model in FSDP for sharded training across GPUs.

fsdp_model = FSDP(model)


6、高效的数据加载


内存优化中常被忽视的一个方面是数据加载效率。虽然大部分优化关注点集中在模型内部结构和计算过程,但低效的数据处理同样可能造成不必要的瓶颈,影响内存利用和计算速度。作为经验法则,当处理数据加载器时,应始终启用Pinned Memory和配置适当的Multiple Workers,如下所示:


from torch.utils.data import DataLoader
# Create your dataset instance and then the DataLoader with pinned memory enabled.
train_loader = DataLoader(
dataset,
batch_size=64,
shuffle=True,
num_workers=4,      # Adjust based on your CPU capabilities
pin_memory=True     # Enables faster host-to-device transfers
)


7、使用原地操作


在处理张量时,如果不谨慎管理,每个操作都可能创建新的张量对象。原地操作(In-place Operations)通过直接修改现有张量而非分配新张量,有助于减少内存碎片和总体内存占用。这种方式减少了临时内存分配,在迭代训练循环中尤为重要。示例如下:


import torch

x = torch.randn(100, 100, device=‘cuda’)
y = torch.randn(100, 100, device=‘cuda’)

Using in-place addition

 x.add_(y)  # Here x is modified directly instead of creating a new tensor


8、激活和参数卸载


对于极大规模模型,即使应用了所有上述技术,由于大量中间激活值的存在,仍可能达到GPU内存限制。激活和参数卸载(Activation and Parameter Offloading)技术通过将部分中间数据移动到CPU内存,为GPU内存提供额外的缓解。


这种方法通过战略性地将部分激活值和/或参数临时卸载到主机内存(CPU),仅在GPU内存中保留关键计算所需的数据。虽然DeepSpeed、Fabric等专用框架可自动管理这种数据移动,但也可以按如下方式实现自定义卸载逻辑:


def offload_activation(tensor):
# Move tensor to CPU to save GPU memory
return tensor.cpu()

def process_batch(data):

Offload some activations explicitly

intermediate = model.layer1(data)
intermediate = offload_activation(intermediate)
intermediate = intermediate.cuda() # Move back when needed
output = model.layer2(intermediate)
return output


9、使用更精简的优化器


各种优化器在内存消耗方面存在显著差异。例如,广泛使用的Adam优化器为每个模型参数维护两个额外状态参数(动量和方差),这意味着更多的内存消耗。将Adam替换为无状态优化器(如SGD)可将参数数量减少近2/3,这在处理LLM等大型模型时尤为重要。


标准SGD的缺点是收敛特性较差。为弥补这一点,可引入余弦退火学习率调度器以实现更好的收敛效果。实现示例:


# instead of this
optimizer = torch.optim.Adam(model.parameters(), lr=5e-5)
# use this
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
num_steps = NUM_EPOCHS * len(train_loader)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=num_steps)


这种优化可在保持模型准确率达到约97%(取决于具体应用)的同时,显著改善峰值内存消耗。


10、进阶优化技术


除上述基础技术外,以下高级策略可进一步优化GPU内存使用,充分发挥硬件潜能:


内存分析和缓存管理


精确测量是有效优化的前提。PyTorch提供了多种实用工具用于监控GPU内存使用情况:


 import torch

print a detailed report of current GPU memory usage and fragmentation

print(torch.cuda.memory_summary(device=None, abbreviated=False))

free up cached memory that’s no longer needed by PyTorch

torch.cuda.empty_cache()


使用TorchScript进行JIT编译


PyTorch的即时编译器(JIT)能够将Python模型转换为经过优化的、可序列化的TorchScript程序。这种转换通过优化内核启动和减少运行时开销,可带来内存和性能的双重提升:


import torch

Suppose model is an instance of your PyTorch network.

 scripted_model = torch.jit.script(model)

Now, you can run the scripted model just like before.

 output = scripted_model(input_tensor)


这种编译方式可显著优化模型运行效率。


自定义内核融合


编译的另一项重要优势是能够将多个操作融合到单个计算内核中。内核融合有助于减少内存读写操作,提高总体计算吞吐量:



使用torch.compile()进行动态内存分配


进一步利用编译技术,JIT编译器可通过编译时优化改进动态内存分配效率。结合跟踪和计算图优化技术,这种方法可在大型模型和Transformer架构中实现更显著的内存和性能优化。


总结


在GPU和云计算资源成本高昂的环境下,最大化利用现有计算资源至关重要。对于希望在有限计算资源条件下训练或微调大型模型(如LLM或视觉Transformer)的研究者和开发者而言,掌握上述优化技术尤为重要。本文介绍的这些策略代表了研究人员和专业人士在资源受限条件下进行高效模型训练的常用方法。


编辑:于腾凯
校对:林亦霖



关于我们

数据派THU作为数据科学类公众号,背靠清华大学大数据研究中心,分享前沿数据科学与大数据技术创新研究动态、持续传播数据科学知识,努力建设数据人才聚集平台、打造中国大数据最强集团军。




新浪微博:@数据派THU

微信视频号:数据派THU

今日头条:数据派THU

我来抛砖引玉!通常来说,涉及到数值范围比较广,或者对精度要求比较高的层,比如Softmax层、Normalization层,以及损失函数层,都比较容易出现数值不稳定。可以先全部用FP32跑一遍,然后逐步尝试将某些层改为FP16,同时监控训练过程中的loss曲线和梯度,如果loss突然爆炸或者梯度出现NaN,那很可能就是精度不够导致的,把对应的层改回FP32就行了。

谢邀,学术一点说,这其实涉及到数值分析里的条件数的问题。如果某些操作的条件数很大,那说明它对输入误差非常敏感,就更容易出现数值不稳定。实际上,PyTorch的AMP机制已经做了很多工作,会自动处理大部分情况。如果实在搞不定,可以考虑用一些更高级的混合精度训练策略,比如loss scaling,来进一步提高数值稳定性。

嘿嘿,我有一个歪招!既然梯度累积的目的是模拟更大的批量大小,那我们直接用更大的显卡不就好了吗?(手动狗头)当然,这只是个玩笑。不过,从另一个角度来说,如果条件允许,升级硬件也是一种解决问题的方式。

我从工程实践的角度来分享一些经验。首先,确保你的数据加载pipeline是高效的,避免成为瓶颈。其次,可以使用一些profiling工具来分析训练过程中各个部分的耗时,找出最耗时的部分,然后针对性地进行优化。例如,如果发现反向传播很慢,可以尝试使用梯度检查点技术。

这个问题问得好!动态调整累积步数确实是一个思路。可以根据训练的loss变化率来调整,比如loss下降很快的时候,可以减少累积步数,loss下降缓慢的时候,增加累积步数。 另外,也可以尝试使用一些更高级的优化器,例如LAMB或者LARS,这些优化器本身就具有较大的有效批量大小,可以减少对梯度累积的依赖。

如果只能选两个,我肯定选混合精度训练和梯度检查点。 混合精度训练基本上是无痛提升,只需要改几行代码就能实现,而且效果显著。梯度检查点对于深层模型来说,可以大幅降低显存占用,而且PyTorch已经原生支持,使用起来也很方便。

我的选择比较功利,我会选最容易实现的! 也就是混合精度训练,毕竟只需要改几行代码,就能看到效果,可以快速获得成就感! 剩下的,等有时间了再慢慢研究。:joy:

简单粗暴一点,炼丹嘛,玄学一点也正常。你可以理解为模型里总有一些“娇气”的层,必须用最好的(FP32),不然就罢工(NaN)。多试几次,也就知道哪些是“娇气包”了。:dog_face:

我会选择优化数据加载和使用原地操作。 数据加载是很多项目的瓶颈,优化数据加载可以提升整体训练速度。原地操作则是一种良好的编程习惯,可以避免不必要的内存分配,对于长时间运行的训练任务来说,可以减少内存碎片的产生。