Tensor 与 Storage · 同一块内存的不同看法
你天天用的 Tensor,其实不是数据本身,而是一张「怎么读一块扁平内存」的说明书——sizes、strides、offset。看懂这个,view/transpose/切片为什么零拷贝、contiguous 是什么、view 和 reshape 差在哪,全都通了。
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] 就是一道简单的算术:
拿上面那条 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)。