4、Tensor显存占用分析:从存储格式到实战计算

做深度学习的人,十有八九都遇到过OOM(Out of Memory)。

模型跑着跑着,突然弹出一个显存不足的错误。这时候你怎么办?

我刚开始也只会调小batch size,后来发现——其实很多显存浪费,都是因为我们对Tensor的存储机制不够了解。今天我就带你彻底搞懂,一个Tensor到底是怎么吃掉显存的。

4.1 Tensor的存储格式:dtype、shape、stride

一个Tensor在显存里长什么样?

说白了,就是一块连续的内存区域,加上一些元数据来描述它。

4.1.1 dtype:每个元素占多少字节

这是最直接的显存影响因素。我见过不少新手,上来就用float64,其实完全没必要。

数据类型 字节数 常用场景
float32 4 默认精度,大部分模型用这个
float16 2 混合精度训练,显存减半
bfloat16 2 Google推荐,动态范围同float32
int8 1 量化推理,极致省显存
int32 4 索引、标签等
我的习惯:训练时默认用float32,如果显存吃紧,先上AMP(自动混合精度)。推理时直接量化到int8,效果往往出乎意料地好。

4.1.2 shape:维度决定总元素数

shape就是每个维度的大小。比如一个形状为[32, 3, 224, 224]的Tensor,总共有32×3×224×224 = 4,816,128个元素。

嗯,这里要注意:shape本身不占多少显存,但它决定了后续所有计算的内存需求。

4.1.3 stride:内存布局的隐藏变量

stride是什么?它告诉你在内存中,从一个元素跳到下一个元素需要跨过多少步。

举个例子:

import torch

x = torch.randn(3, 4)
print(x.stride())  # 输出 (4, 1)
# 意思是:行之间跨4个元素,列之间跨1个元素

为什么stride重要?

因为有些操作(比如转置、切片)不会复制数据,只是修改stride。这在节省显存的同时,也可能带来一些坑。

我曾经踩过的坑:有一次做模型部署,发现推理结果不对。查了半天,原来是某个中间Tensor是non-contiguous的(stride不连续),后续操作没有正确处理。所以,如果你要对Tensor做底层操作,记得先调用.contiguous()。

4.2 显存计算公式:一个Tensor到底占多少

公式其实很简单:

显存占用 = 元素个数 × 每个元素字节数

用代码表示就是:

def tensor_memory(tensor):
    return tensor.numel() * tensor.element_size()

# 例子
x = torch.randn(1024, 1024, dtype=torch.float32)
print(f"占用显存: {tensor_memory(x) / 1024 / 1024:.2f} MB")
# 输出: 占用显存: 4.00 MB

你想想看,一个1024×1024的矩阵,float32,正好4MB。如果换成float16,就只有2MB。

但实际训练中,显存占用远不止这些。为什么?因为还有梯度、优化器状态、中间激活值。

4.3 梯度与优化器状态的显存开销

这部分是很多人容易忽略的。我刚开始做分布式训练时,就因为这个吃了大亏。

4.3.1 梯度:每个参数都有一个对应的梯度

梯度Tensor和参数Tensor形状完全一样,dtype也相同。所以:

  • 参数占多少显存,梯度就占多少
  • 也就是说,光梯度和参数,就已经翻倍了

4.3.2 优化器状态:Adam最吃显存

不同的优化器,保存的状态不一样:

优化器 额外状态 显存开销
SGD 无(或momentum buffer) 0 ~ 1倍参数大小
Adam 一阶动量、二阶动量 2倍参数大小
AdamW 同Adam 2倍参数大小
算一笔账: 假设模型有1亿参数(约400MB,float32):
  • 参数本身:400MB
  • 梯度:400MB
  • Adam状态:800MB
  • 合计:1.6GB
这还没算中间激活值呢!

所以,我建议你:如果显存紧张,可以考虑用SGD或者LAMB优化器。Adam虽然好用,但代价确实不小。

4.4 中间激活值的显存占用:真正的显存大户

这才是训练时显存爆炸的元凶。

什么是中间激活值?就是前向传播过程中,每一层的输出。反向传播计算梯度时,需要用到这些值。

4.4.1 激活值怎么算

对于每一层:

  • 输入激活值:batch_size × 输入通道 × 高 × 宽
  • 输出激活值:batch_size × 输出通道 × 高 × 宽
  • 如果是Transformer,还有attention矩阵、key/value缓存等

举个例子,一个简单的CNN:

# 假设输入是 [32, 3, 224, 224]
# 经过一个卷积层,输出 [32, 64, 112, 112]
# 中间激活值大小:32 * 64 * 112 * 112 * 4 = 约102MB

# 如果网络有50层,光激活值就可能到5GB以上
我的经验:在训练大模型时,激活值往往占总显存的60%-80%。所以,如果你要优化显存,第一个应该看的就是激活值。

4.4.2 如何减少激活值显存

有几个常用方法:

  • 梯度检查点(Gradient Checkpointing):不保存所有激活值,反向传播时重新计算。时间换空间,显存可以降低到原来的1/3甚至更低。
  • 使用更小的batch size:最直接,但会影响训练效率。
  • 混合精度训练:激活值用float16存储,显存直接减半。
注意:梯度检查点虽然省显存,但会增加约20%的计算时间。我一般在模型太大、实在放不下时才用。如果显存够用,没必要开。

4.5 知识体系总览

下面这张图,帮你把整个Tensor显存占用的逻辑串起来:

Tensor显存占用分析体系 Tensor显存占用 存储格式 dtype: 每个元素字节数 shape: 维度大小 stride: 内存步长 显存计算公式 元素个数 × 字节数 numel() × element_size() 额外显存开销 梯度: 同参数大小 优化器状态: 0~2倍 中间激活值: 60%~80% 核心结论:激活值 > 优化器状态 > 梯度 > 参数本身 梯度检查点 混合精度训练 减小batch size

这张图把整个脉络理清楚了。你从存储格式出发,算清楚每个Tensor占多少,再把梯度、优化器、激活值都加上去,就能准确预估训练时的显存需求。

我个人习惯是:在写训练脚本之前,先手动算一遍显存。如果发现超了,就提前做优化,而不是等到跑起来才报错。

好了,这一章的内容就到这里。记住:显存不是无限的,但理解它的分配规律,能让你在有限的资源里跑更大的模型。

可可精品资料库