Redian新闻
>
谁动了我的显存?——深度学习训练过程显存占用分析及优化

谁动了我的显存?——深度学习训练过程显存占用分析及优化

公众号新闻

深度学习自然语言处理 分享
作者:游凯超 (知乎,已授权)
编辑:西柚

在大语言模型时代,不仅语言模型变得越来越大,而且几乎所有的模型都想变得越来越大,试图在模型变大之后观察到一些涌现出来的能力。

模型变大之后,最突出的问题就是显存不够用了。本文对深度学习训练过程中的显存占用问题进行一些具体分析,加深我对训练过程的理解,能够进行一些简单的显存优化操作。如果读者们有更多的相关资料、优化技巧,欢迎在评论区补充。

进NLP群—>加入NLP交流群

显存占用概述

深度学习训练过程中的显存占用,大致可以分为三部分:

  • 框架占用,例如pytorch框架的cuda context会占用大约几百MB显存.

  • 模型参数相关的占用,比如7B的模型以FP16格式要占用14GB显存。此处还包括优化器、梯度相关的参数占用,全量微调的情况下,梯度与参数一样大,优化器状态是梯度的1~2倍(SGD为1倍,Adam为2倍)。如果使用DDP进行多卡训练,则还需要乘以显卡数量;如果使用FSDP进行多卡训练,显存占用与显卡数无关,但是会增加通信开销。

  • 特征相关的占用,这部分显存占用是最复杂的,因为它与模型的具体计算流程有关。很多地方只会笼统地说这类占用与batchsize成正比,但是具体的比例系数很难分析。

本文希望详细解析特征相关的显存占用到底是多少。

统计方法

我们用一个样例程序,来使用不同的方法、在不同的情况下计算这样一个简单的函数。具体程序为:

import torch

# Create two tensors with 1GB memory footprint each, initialized randomly, in fp16 format
# For a tensor of float16 (2 bytes), 1GB of memory can hold 1GB / 2B = 500M elements
tensor_size = 512 * 1024 * 1024 
x = torch.randn(tensor_size, dtype=torch.float16, device='cuda')
y = torch.randn(tensor_size, dtype=torch.float16, device='cuda')

# Record current memory footprint, and reset max memory counter
current_memory = torch.cuda.memory_allocated()
torch.cuda.reset_peak_memory_stats()

def compute(x, y):
    return (x + 1) * (y + 1)

z = compute(x, y)

# Record the additional memory (both peak memory and persistent memory) after calculating the resulting tensor
additional_memory = torch.cuda.memory_allocated() - (current_memory + 1e9)
peak_memory = torch.cuda.max_memory_allocated()
additional_peak_memory = peak_memory - (current_memory + 1e9)

print(f"Additional memory used: {additional_memory / (1024 ** 3)} GB")
print(f"Additional peak memory used: {additional_peak_memory / (1024 ** 3)} GB")

在这个函数计算过程中,输入,输出不可避免地要占用显存。我们希望在不同情况下、改变不同的计算方式,观察/理解为了计算这个函数所需要的额外显存。

这里需要区分两个概念:峰值显存占用 与 持续显存占用 。在计算一个函数的过程中,我们可能创建了很多中间结果,它们需要临时占用显存;但是当函数计算完成之后,只有一部分结果需要持续存在(直到反向传播结束),另一部分可以被释放。上述示例小脚本,会分别输出持续显存占用和峰值显存占用。

一:不需要计算梯度的情况

上述示例脚本,直接运行的结果是:

Additional memory used: 0.06867742538452148 GB
Additional peak memory used: 2.0686774253845215 GB

也就是说,函数运行期间需要大约2GB的显存占用,运行结束之后几乎不占显存。

具体来说,函数计算过程中需要创建两个临时变量,乘积结果放在中。因此大约需要2GB的显存来存储临时变量,它们在计算结束后会被释放。

至于为什么持续显存占用不严格为0、峰值显存占用不严格为2GB,这就与pytorch的具体显存管理策略、对象的显存布局有关,我们暂时不关心这部分内容。

二:需要计算梯度的情况

我们把计算函数改写为:

def compute(x, y):
    x.requires_grad_(True)
    y.requires_grad_(True)
    return (x + 1) * (y + 1)

得到的结果为:

Additional memory used: 2.0686774253845215 GB
Additional peak memory used: 2.0686774253845215 GB

也就是说,需要计算梯度时,计算过程中的临时变量并不会被释放,反而会持续存在于显存中,等待后续用于反向传播计算。

这个问题可以变得更复杂一些,如果我们让一个输入要求梯度、一个参数不要求梯度,会发生什么呢?

def compute(x, y):
    x.requires_grad_(True)
    return (x + 1) * (y + 1)

得到的结果是:

Additional memory used: 1.0686774253845215 GB
Additional peak memory used: 2.0686774253845215 GB

可以看到,计算完成后释放了一个临时变量,还有一个临时变量持续存在。这是因为我们只要求能计算梯度,不用计算梯度。

有意思的是,大部分人看到这里,都觉得既然不需要计算梯度,那么肯定是这个临时变量被释放了。然而,事实上是这个临时变量被释放掉了。

为了说清楚这个问题,我们用具体的值来区分,这里的值是1,的值是2,于是临时变量的值是2,的值是3。通过计算结果记录的中间变量的值,我们可以区分具体记录了哪个中间结果。

def compute(x, y):
    x.zero_()
    y.zero_()
    x += 1
    y += 2
    x.requires_grad_(True)
    z = (x + 1) * (y + 1)
    print(z.grad_fn._saved_other.mean().item())
    return z

这段代码的运行结果是:

3.0
Additional memory used: 1.0686774253845215 GB
Additional peak memory used: 2.0686774253845215 GB

可以看到,虽然是要求梯度,但是在计算过程中保留的变量却是

为了从原理上理解这个现象,我们来看看反向传播的本质:梯度求导。

考虑神经网络中的某个函数,输入为两个参数,输出为将继续参与后续运算,得到损失函数。反向传播的任务,就是在已知的情况下,计算

不失一般性而言,的函数。于是,为了反向传播,我们需要完整记录。这是最简单粗暴的方法。

实际上,对于很多简单函数来说,偏导数的表达式并不复杂。以本文的小脚本为例,,于是只和有关。也就是说,为了计算的反向传播,只需要记录的值。

于是,我们就能理解,为什么需要梯度(对应地也需要梯度)时,反向传播记录的是

三:不使用自动微分计算梯度

有什么办法能够绕开自动微分的限制,使得显存开销更低吗?

有的,答案就是pytorch提供的torch.autograd.Function

我们把计算部分的代码替换成Function的实现,直接用一个算子实现的功能:

from torch.autograd import Function

class AddMulFunction(Function):
    @staticmethod
    def forward(ctx, x, y):
        ctx.save_for_backward(x, y)
        return (x + 1) * (y + 1)

    @staticmethod
    def backward(ctx, grad_output):
        x, y = ctx.saved_tensors
        grad_x = grad_output * (y + 1)
        grad_y = grad_output * (x + 1)
        return grad_x, grad_y

func = AddMulFunction.apply

def compute(x, y):
    x.requires_grad_(True)
    y.requires_grad_(True)
    return func(x, y)

输出结果为:

Additional memory used: 0.06867742538452148 GB
Additional peak memory used: 2.0686774253845215 GB

这个算子也能够进行反向传播,而且计算结束之后并不会占用显存。这是因为我们在它的backward函数里手动计算了这个算子的梯度,使得它不用记录临时变量也能进行反向传播。

从这个算子的实现中,我们能清晰地看到ctx.save_for_backward函数,它为反向传播过程记录必要的参数。

关于torch.autograd.Function,有一个细节值得注意:torch.autograd.Function设计的初衷就是为了让高级用户绕开自动微分的限制,因此torch.autograd.Function的forward和backward函数执行过程中,并不会记录梯度操作。大致可以理解为:torch.autograd.Function的forward和backward函数执行过程被包裹在 with torch.no_grad()环境中。

例如,我们把计算代码改成:

from torch.autograd import Function

class AddMulFunction(Function):
    @staticmethod
    def forward(ctx, x, y):
        ctx.save_for_backward(x, y)
        z = (x + 1) * (y + 1)
        print(z.requires_grad)
        print(z.grad_fn)
        return z

    @staticmethod
    def backward(ctx, grad_output):
        x, y = ctx.saved_tensors
        grad_x = grad_output * (y + 1)
        grad_y = grad_output * (x + 1)
        return grad_x, grad_y

func = AddMulFunction.apply

def compute(x, y):
    x.requires_grad_(True)
    y.requires_grad_(True)
    return func(x, y)

z = compute(x, y)

print(z.requires_grad)
print(z.grad_fn)

输出结果为:

False
None
True
<torch.autograd.function.AddMulFunctionBackward object at 0x7fea4304a5e0>
Additional memory used: 0.06867742538452148 GB
Additional peak memory used: 2.0686774253845215 GB

即使和是需要梯度的,在Function的forward函数中,是不需要梯度的。然而,当走出forward函数之后,pytorch会为它加上需要梯度的标志,并且通过grad_fn属性记录其反向传播需要执行的函数。

通过这一细节,我们可以理解,为什么定义了AddMulFunction之后,不能直接使用AddMulFunction.forward函数,而必须用func = AddMulFunction.apply

以上涉及的内容,其实就是“算子融合”,通过手动计算反向传播过程,节约不必要的显存开销。上述算子还可以进一步优化,把峰值显存占用也降下来。感兴趣的朋友可以试试。

我们日常使用的很多算子,都是融合过的。

以sigmoid算子为例,如果我们自己来实现:

def compute(x):
    x.requires_grad_(True)
    z = 1 / (1 + torch.exp(-x))
    return z

z = compute(x)

输出结果为:

Additional memory used: 2.0686774253845215 GB
Additional peak memory used: 3.0686774253845215 GB

峰值显存占用为3GB,持续显存占用为2GB。

如果改为pytorch自带的已经融合过的算子:

def compute(x):
    x.requires_grad_(True)
    z = torch.nn.Sigmoid()(x)
    return z

z = compute(x)

输出结果为:

Additional memory used: 0.06867742538452148 GB
Additional peak memory used: 0.06867742538452148 GB

峰值显存占用与持续显存占用几乎都是0!

这是怎么做到的呢?

  • sigmoid函数是element-wise的函数,只需要申请一次显存,把所有的操作都变成in-place,再把这块显存作为输出内容,就不用申请临时空间了。
  • sigmoid函数 的导数是,为了计算反向传播,只需要记录输出。而在我们的示例程序中,z原本就会保留,因此sigmoid函数的反向传播记录的就不用额外占用空间。

算子显存占用分析中的记账问题

上述分析中,关于sigmoid算子显存占用为0的结论并不严谨。它占用的显存刚好是我们的输出,因此没有算在它的显存开销中。

为了更准确地反映这一问题,我们让它多计算几次:

def compute(x):
    x.requires_grad_(True)
    for i in range(5):    
        x = torch.nn.Sigmoid()(x)
    return x

z = compute(x)

计算5次,额外占用显存为4GB:

Additional memory used: 4.0686774253845215 GB
Additional peak memory used: 4.0686774253845215 GB

大体上来说,一个算子持续占用的显存,就是它在前向传播过程中保存下来的变量所占的显存。但一个程序占用的显存总量,并不能用全部算子占用的显存数进行求和,因为这些变量之间可能有重复(正如我们的示例中的输入变量、输出变量那样)。

总结

本文介绍了深度学习训练过程中的显存占用分析方法、自动求导与手动算子融合、优化等技术原理。算子融合是深度学习编译器等技术的核心,而算子优化目前还需要人工设计。对算子优化感兴趣的朋友,可以看看FlashAttention论文(参见《Flashattention: Fast and memory-efficient exact attention with io-awareness》),它是一个十分优雅的算子优化的例子。

注:

如何查看pytorch自带算子为反向传播保存的变量?可以通过输出的grad_fn属性的dir(var.grad_fn)看到,里面的_saved_xxx就是为了反向传播保存的变量。

对于乘法,这个属性是_saved_other,因为乘法的梯度是另一个变量;对于sigmoid算子,这个属性是_saved_result,因为sigmoid的梯度和计算结果有关。

大部分的pytorch算子都可以通过这种方式获得保存的具体变量内容,例如卷积算子保留了以下内容:_saved_bias_sym_sizes_opt/_saved_dilation/_saved_groups/_saved_input/_saved_output_padding/_saved_padding/_saved_stride/_saved_transposed/_saved_weight.其中大部分都是卷积的配置(例如padding大小、stride大小等内容),真正对显存占用影响最大的就是_saved_input_saved_weight

附上这部分代码,感兴趣的读者可以用它来分析pytorch自带算子的具体计算机制。

var = z
names =[k for k in dir(var.grad_fn) if k.startswith('_saved')]
for k in names:
    v = getattr(var.grad_fn, k)
    if isinstance(v, torch.Tensor):
        print(k, v.shape)
    else:
        print(k, v)


原文链接:

https://zhuanlan.zhihu.com/p/641894014



进NLP群—>加入NLP交流群

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
博士申请 | 香港中文大学(深圳)韩俊老师招收深度学习全奖博士/博后/RA托特——追求纯alpha 北大系深度学习科班团队玩转量化(市场篇)托特:北大系深度学习科班团队进军量化(公司篇)港中文李教授:基于深度学习的语音识别算法研究|收获一作论文与导师推荐信!是谁动了我的奶酪包?好吃到根本藏不住……NAEYC邀请函:全美幼教年会深度学习之旅零基础深度学习教程爆火!专为手机阅读打造,月超23万人下载,155页内容免费开放OpenCV实战:从图像处理到深度学习的全面指南《僭越之殇》(12)——忘川之河谁动了我的车险不能再蹉跎岁月了Transformer后继有模!MSRA提出全新大模型基础架构:推理速度8倍提升,内存占用减少70%Electron末日来了?又一应用将其抛弃!WhatsApp强制推行原生应用:速度更快、内存占用更少深度学习三巨头之一 Yann LeCun:大语言模型带不来 AGI博士申请 | 纽约大学张赛骞老师招收高性能深度学习方向博士/硕士/远程实习博士申请 | 佛罗里达大学邵尉老师招收深度学习/CV/医学影像方向全奖博士生深度学习研究员等三大岗位,佳期投资热招AI人才多伦多卢玛城堡:里面是啥样?托特——追求纯alpha 北大系深度学习科班团队玩转量化(风控篇)降龙十八掌:这套优化transformer内存占用的组合技值得收藏LLaMA微调显存需求减半,清华提出4比特优化器STTT | 中山大学陈语谦团队发表世界最大的中医药数据库,并提供基于深度学习的中西药不良反应预测算法价格启示录:谁动了我的奶酪?相同法案的不同命运——深度解析佛州与德州关于禁止中国人购买地产的法案风波 | 移投路直播间Are You a Predator ! 你是不是侵略者?———— 对话比尔•盖茨 (2000年2月18日)港中文教授:基于深度学习的RNA测序方法研究|收获一作论文与导师推荐信!谁动了老百姓的救命钱?母亲节 - 致母亲运用深度学习技术,海富通基金量化团队打造AI+投资整編師,以整編七十四師為例,人數有三萬多,幾乎相當於一個軍.3萬多 ...从非同一的视角看社会历史中的“魔力”——阿多诺的分析及其启示深度学习机器视觉与生成式AI 「云集|职挂云帆」ML学习小组VALSE 2023 | 下一代深度学习的思考与若干问题——焦李成专家State of GPT:大神Andrej揭秘OpenAI大模型原理和训练过程|GGView博士申请 | 美国中佛罗里达大学娄钱老师招收深度学习方向全奖博士生
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。