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 知识体系:张量与显存的核心逻辑

下面这张图,是我自己总结的张量与显存的关系。你看完应该能一目了然:

张量与显存:核心逻辑图 GPU显存(物理存储) Storage A data_ptr: 0x7f...00 Storage B data_ptr: 0x7f...80 Tensor层(视图/元数据) Tensor A shape: (4, 4) stride: (4, 1) offset: 0 → Storage A Tensor B (视图) shape: (2, 4) stride: (4, 1) offset: 4 → Storage A Tensor C (独立) shape: (3, 3) stride: (3, 1) offset: 0 → Storage B Storage(实际数据) Tensor(视图/元数据)

这张图想表达的核心思想是:多个Tensor可以指向同一个Storage。Tensor A和Tensor B共享Storage A,但它们的shape和offset不同。Tensor C则独占Storage B。

你想想看,如果不懂这个机制,你可能会以为创建了3个Tensor就占了3份显存。但实际上,可能只占了2份甚至1份。

3.7 实战建议:如何利用视图优化显存

基于上面的知识,我给出几条实战建议:

  1. 优先使用视图操作:能用.view()就别用.reshape(),能用切片就别用.clone()。
  2. 警惕非连续张量:非连续张量在参与某些运算(如矩阵乘法)时会触发隐式拷贝。可以用.is_contiguous()检查。
  3. 善用.detach():如果你只需要数据不需要梯度,.detach()比.clone()省显存得多。
  4. 监控Storage大小:用torch.cuda.memory_allocated()配合storage.size(),能精确知道每块数据占了多少显存。

一个小技巧:我在做模型推理优化时,经常把输入张量先.contiguous()一下,确保后续所有操作都不会触发隐式拷贝。虽然多了一次拷贝,但整体性能反而提升了——因为避免了多次小拷贝。

好了,关于张量与显存的存储机制,就讲到这里。记住一句话:Tensor是皮,Storage是骨。理解了这层关系,你就能真正掌控显存的使用。