Tensor 与 Storage · 同一块内存的不同看法

你天天用的 Tensor,其实不是数据本身,而是一张「怎么读一块扁平内存」的说明书——sizes、strides、offset。看懂这个,view/transpose/切片为什么零拷贝、contiguous 是什么、view 和 reshape 差在哪,全都通了。

作者 David更新于 第 1 篇(共 9 篇)

Tensor 与 Storage · 同一块内存的不同看法

漫游从最底下的地基开始。你天天 torch.tensor(...),但一个 Tensor 到底是什么? 答案有点反直觉,而它是理解后面一切的钥匙:

Tensor 不是数据本身。Tensor 是一张「怎么读一块扁平内存」的说明书。真正的数据,躺在它下面的 Storage 里。

Storage:一条没有形状的数

先看底下那层。Storage(存储) 就是一维、连续的一块内存——一长条挨着排的数字,没有任何形状信息。它只知道「这里有 N 个 float,一个挨一个」,不知道自己是 2×3 还是 3×2,也不知道哪是行哪是列。

Storage(6 个 float):  [ 1.0  2.0  3.0  4.0  5.0  6.0 ]

就这样,一条扁平的数。形状?顺序?怎么切?——这些都不在 Storage 里,而在上面的 Tensor 说明书里。

Tensor:读这条数的「说明书」

一个 Tensor = 一个 Storage + 一小撮元数据(metadata):

  • sizes(形状):比如 (2, 3)——「把这条数当成 2 行 3 列来读」。
  • strides(步长):每个维度「走一步」要在 Storage 里跳几个元素。这是最关键、也最容易被忽略的一个。
  • storage_offset(偏移):从 Storage 的第几个元素开始读。
  • dtype、device:数据类型、在 CPU 还是哪张 GPU 上。

有了这张说明书,读 tensor[i, j] 就是一道简单的算术:

tensor[i,j]=Storage[ offset+i⋅stride[0]+j⋅stride[1] ]\texttt{tensor[i,j]} = \texttt{Storage}[\ \text{offset} + i \cdot \text{stride}[0] + j \cdot \text{stride}[1]\ ]

拿上面那条 6 个数举例,配一张 sizes=(2,3)、strides=(3,1)、offset=0 的说明书,就读成:

              列 j→
          storage[ 0+i*3+j*1 ]
行 i↓   [ 1.0  2.0  3.0 ]      ← i=0: storage[0],[1],[2]
        [ 4.0  5.0  6.0 ]      ← i=1: storage[3],[4],[5]

stride=(3,1) 的意思:行走一步跳 3 个(跨过一整行)、列走一步跳 1 个(挨着的下一个)。这就是所谓的「行主序(row-major)/ C 连续」布局。

魔法揭穿:view / transpose / 切片,全是「改说明书」

一旦你接受「Tensor 只是说明书」,一堆看着神奇的操作立刻变得平平无奇——它们根本不动数据,只是造一张新说明书,指向同一个 Storage。所以它们零拷贝、极快:

  • 转置 x.t() / transpose:把两个维度的 stride 对调一下就完事。同一条数,strides=(3,1) 改成 (1,3),读出来就「转置」了——一个字节都没搬。
  • 切片 x[1:] / x[::2]:改 offset 和 sizes(隔行取还会改 stride),照样指着原 Storage。
  • view / reshape:换个 sizes,重新解释同一条数。
  • 广播 expand:把某个维度的 stride 设成 0——「这一维走一步,内存不动」,于是一份数据被「假装」重复了很多份,还是零拷贝。

这解释了一个新手常踩的坑:b = a.t() 之后改 b,a 也变了。 因为 b 和 a 共享同一个 Storage,b 只是 a 的另一张说明书。想要独立的一份,得 .clone() 真正拷贝底层数据。「谁和谁共享内存」,看的就是它们是不是指向同一个 Storage。

contiguous:说明书和内存「对不对得齐」

既然 transpose 只是换 stride 不搬数据,就带来一个问题:转置后,元素在内存里的物理顺序,和你「按行读」的逻辑顺序对不上了。

  • 一个 tensor 叫连续(contiguous),当它的 strides 正好是「紧凑打包」的标准布局(逻辑顺序 = 物理顺序)。
  • transpose 之后,strides 变成 (1,3),不再紧凑——它非连续了。

很多底层内核(尤其涉及 view 的)要求输入是连续的,否则那道 offset + i*stride 的寻址假设会崩。这时就得 .contiguous():它真正拷贝一份数据,按紧凑布局重排,让说明书和内存重新对齐。

还记得 nanoGPT 注意力那章最后那行 y.transpose(1, 2).contiguous().view(B, T, C) 吗?现在你彻底懂它了:transpose 换了 stride(非连续),而紧接着的 view 要求连续,所以中间必须 .contiguous() 先把内存排整齐,view 才不会报错。当时说「view 前得先把内存排整齐」,底下就是这套 stride 机制。

这也顺带讲清了 view 和 reshape 的区别:view 只肯「改说明书」,所以要求输入连续,否则报错;reshape 更随和——能改说明书就改(零拷贝),不能就替你 .contiguous() 拷一份。想要「零拷贝或报错」的确定性用 view,想要「反正给我搞定」用 reshape。

源码在哪

想去源码里印证:

  • 这张「说明书」在 C++ 里叫 TensorImpl(c10/core/TensorImpl.h)——里面就存着 sizes、strides、storage_offset、dtype、device,以及一个指向 StorageImpl 的指针。
  • 底层那条扁平数是 StorageImpl(c10/core/StorageImpl.h),它裹着一个 DataPtr 指向真正的内存。
  • 你在 Python 里拿到的 torch.Tensor,就是对一个 C++ TensorImpl 的包装。

c10 是 PyTorch 的核心库(Core + Tensor...大致这个意思),Tensor 的地基就浇在这儿。

地基打好了,该看「调用」怎么走

「Tensor 是说明书、Storage 是数据」——记住这一条,后面全都顺。现在数据结构清楚了,下一站我们跟着一次真实的调用往下走:你写下 a + b,这个 + 是怎么从 Python 一路找到「该在 CPU 还是 CUDA 上、该不该记梯度」的正确内核的?答案是 PyTorch 的中枢——dispatcher。👉 一次 a + b 的旅程:dispatcher 分发器

参考文献

  • [1] Edward Z. Yang. “PyTorch internals.” 2019. http://blog.ezyang.com/2019/05/pytorch-internals/ —— Tensor / Storage / strides 的权威讲解,本章主要依据。
  • [2] Paszke et al. “PyTorch: An Imperative Style, High-Performance Deep Learning Library.” NeurIPS 2019. arXiv:1912.01703. —— PyTorch 整体设计。
  • [3] PyTorch 文档:“Tensor Views.” https://pytorch.org/docs/stable/tensor_view.html —— 哪些操作是零拷贝视图。
  • 源码:c10/core/TensorImpl.h、c10/core/StorageImpl.h(pytorch/pytorch)。

本页目录