3、张量与显存:PyTorch张量存储机制、Tensor的data_ptr与storage、张量视图与显存共享
好,咱们今天聊点硬核的。张量在PyTorch里到底是怎么存进显存的?
很多人写代码,模型一跑就OOM,然后疯狂调batch size。其实很多时候,问题出在你对张量的存储机制不够了解。说白了,你都不知道显存里那点空间是怎么被占用的,怎么优化?
我个人习惯,在排查显存问题时,第一件事就是看张量的data_ptr和storage。这两个东西,是理解显存占用的钥匙。
3.1 PyTorch张量的存储结构:Storage与Tensor的分离
先讲一个核心概念:Tensor = 视图 + Storage。
什么意思呢?PyTorch里的张量,其实是一个“壳子”,它本身不存数据。真正存数据的地方,是一个叫Storage的对象。Tensor只保存了:
- 指向Storage的指针
- 形状(shape)
- 步长(stride)
- 偏移量(offset)
你想想看,这设计多巧妙。同一个Storage,可以被多个Tensor引用。每个Tensor只是“看”数据的方式不同而已。
核心结论:显存占用 = Storage的大小,而不是Tensor的数量。
我在项目中遇到过一位同事,他创建了10个不同形状的张量,以为占了10份显存。结果一查,全是同一个Storage的不同视图。嗯,这种情况其实很常见。
3.2 data_ptr:找到数据在显存中的真实地址
data_ptr返回的是张量数据在显存中的起始地址。这个地址有什么用?
- 判断两个张量是否共享同一块显存
- 调试显存泄漏时,追踪数据去向
- 做底层算子优化时,直接操作指针
import torch
a = torch.tensor([1, 2, 3, 4])
b = a[1:3] # 切片操作
print(f"a的data_ptr: {a.data_ptr()}")
print(f"b的data_ptr: {b.data_ptr()}")
print(f"偏移量: {(b.data_ptr() - a.data_ptr()) / a.element_size()} 个元素")
输出结果会告诉你,b的地址比a多了8个字节(2个int64)。这说明b确实和a共享同一块Storage,只是偏移了2个元素。
我的习惯:每次做张量切片或转置后,我都会打印一下data_ptr,确认是否真的产生了新拷贝。这能帮你避免很多无谓的显存浪费。
3.3 Storage:显存分配的最小单元
Storage是PyTorch里真正管理显存的对象。你可以通过.storage()方法拿到它。
print(a.storage())
print(b.storage()) # 和a是同一个Storage
你会发现,即使b只用了2个元素,它的Storage还是包含了4个元素。这就是为什么切片操作不占额外显存——它只是“借用”了原来的Storage。
但要注意,.clone()和.contiguous()会创建新的Storage。我刚开始学的时候,就踩过这个坑:
c = b.contiguous() # 这会创建新的Storage
print(f"c的data_ptr: {c.data_ptr()}")
print(f"c的storage大小: {c.storage().size()}")
c的Storage大小变成了2,不再是4。这意味着它真的在显存里新开辟了一块空间。
我曾经踩过的坑:有一次训练大模型,显存一直涨。查了半天,发现是某个操作里用了.transpose()之后又调了.contiguous(),导致每轮迭代都复制了一份数据。改成.permute()加.reshape()后,显存直接降了30%。
3.4 张量视图:零拷贝的数据操作
视图(View)是PyTorch最优雅的设计之一。它让你可以“重新解释”同一块数据,而不需要移动任何字节。
常见的视图操作包括:
.view():改变形状.transpose():交换维度.permute():重排维度[start:end]:切片.unsqueeze()/.squeeze():增删维度
这些操作都不会创建新的Storage。它们只是修改了Tensor的元数据(shape、stride、offset)。
x = torch.randn(4, 4)
y = x.view(16) # 视图,共享Storage
z = x.t() # 转置,也是视图
print(f"x的storage id: {id(x.storage())}")
print(f"y的storage id: {id(y.storage())}")
print(f"z的storage id: {id(z.storage())}")
三个id完全一样。这就是显存共享的魔力。
3.5 显存共享的陷阱:什么时候会触发拷贝?
视图虽好,但有些操作会“打破”共享,强制创建新Storage。我总结了几种常见情况:
| 操作 | 是否共享Storage | 说明 |
|---|---|---|
| .view() | 是 | 要求数据连续 |
| .reshape() | 可能不共享 | 数据不连续时会拷贝 |
| .transpose() | 是 | 但数据变得不连续 |
| .contiguous() | 否 | 强制创建连续存储 |
| .clone() | 否 | 深度拷贝 |
| .detach() | 是 | 只断开计算图,不拷贝数据 |
避坑指南:我曾经在写数据加载器时,用了.reshape()来处理非连续张量。结果每个batch都触发了一次显存拷贝,训练速度直接腰斩。后来改成.view()加手动保证连续性,问题就解决了。
3.6 知识体系:张量与显存的核心逻辑
下面这张图,是我自己总结的张量与显存的关系。你看完应该能一目了然:
这张图想表达的核心思想是:多个Tensor可以指向同一个Storage。Tensor A和Tensor B共享Storage A,但它们的shape和offset不同。Tensor C则独占Storage B。
你想想看,如果不懂这个机制,你可能会以为创建了3个Tensor就占了3份显存。但实际上,可能只占了2份甚至1份。
3.7 实战建议:如何利用视图优化显存
基于上面的知识,我给出几条实战建议:
- 优先使用视图操作:能用
.view()就别用.reshape(),能用切片就别用.clone()。 - 警惕非连续张量:非连续张量在参与某些运算(如矩阵乘法)时会触发隐式拷贝。可以用
.is_contiguous()检查。 - 善用
.detach():如果你只需要数据不需要梯度,.detach()比.clone()省显存得多。 - 监控Storage大小:用
torch.cuda.memory_allocated()配合storage.size(),能精确知道每块数据占了多少显存。
一个小技巧:我在做模型推理优化时,经常把输入张量先.contiguous()一下,确保后续所有操作都不会触发隐式拷贝。虽然多了一次拷贝,但整体性能反而提升了——因为避免了多次小拷贝。
好了,关于张量与显存的存储机制,就讲到这里。记住一句话:Tensor是皮,Storage是骨。理解了这层关系,你就能真正掌控显存的使用。