【1.1】Strided Tensor 内存布局(Strided Tensor Memory Layout)
本文位置:第 1 章 → 1.1
前置阅读:无(本专栏开篇)|代码仓库:code/ch01_math/0101_strided_tensor/
读完本文,你能:用stride公式解释任意张量的内存寻址,并彻底避开view/reshape/contiguous的常见坑。
0. 开篇比喻
想象你住在一个「一维」的长条形公寓里,所有房间一字排开,只有「门牌号」这一个坐标。但你的朋友偏要按「楼栋 + 楼层 + 房间号」这个三维坐标来找你。于是物业准备了一张换算表:每往下一栋走要跳过多少个门、每往上一层要跳过多少扇门、每往旁边挪一间只挪 1 格——这三组「跳格数」就是stride。
关键来了:这张换算表可以随便改,而房间里的家具根本不用搬。把「楼栋」和「房间号」两列对调一下(转置),你只是换了一张换算表,物理上一个人都没搬家。绝大多数初学者踩的坑,都源于误以为「换了个视角看数据」就等于「数据真的被重新排列了」。本文就把这张换算表彻底讲透。
1. 为什么需要它
深度学习框架里的张量看起来是多维的,但 GPU 显存 / 内存永远是一维的连续地址。从逻辑多维坐标到物理一维地址,中间必须有一层「寻址协议」,它就是shape + strides + storage_offset。这层协议直接决定了三件事:
- 零拷贝是否可能:
transpose、切片、view之所以快,是因为它们只改 strides、不搬数据;而reshape在非连续情况下必须真的拷贝一遍。 - 性能数量级差异:本文实测,
transpose只改 stride 耗时 3.1µs、额外内存 0B;而物化拷贝耗时 17.9ms、额外 16MB——差距超过5700 倍。 - 报错的根源:
RuntimeError: view size is not compatible...这类让新手抓狂的错,本质就是「非连续张量不能零拷贝重排」。
| 痛点 | 方案 |
|---|---|
| 以为转置/切片会真的重新排列数据,导致重复拷贝 | 理解 stride 只改「换算表」,数据原地不动 |
view()突然报错,不知道为什么 | 记住:view要求连续;非连续用reshape(兜底拷贝)或先contiguous() |
不知道何时该手动contiguous() | 掌握is_contiguous()/stride()自检 |
2. 核心原理
张量的寻址公式是整件事的「宪法」,一切都从它出发:
offset(i)=o0+∑k=1nik⋅sk(1) \mathrm{offset}(\mathbf{i}) = o_0 + \sum_{k=1}^{n} i_k \cdot s_k \tag{1}offset(i)=o0+k=1∑nik⋅sk(1)
「人话翻译:第kkk维坐标每 +1,物理地址就前进sks_ksk格;再加上起始偏移o0o_0o0,就得到该元素的物理位置。」
所谓「连续(C-contiguous,行主序)」就是 strides 恰好等于「后面各维长度连乘」:
sk=∏j=k+1ndj,sn=1(2) s_k = \prod_{j=k+1}^{n} d_j, \qquad s_n = 1 \tag{2}sk=j=k+1∏ndj,sn=1(2)
「人话翻译:最后一维的 stride 恒为 1,往前每一维的 stride 是它身后所有维度长度的乘积——这正是torch.arange(n).reshape(d_1,\dots,d_n)的默认布局。」
以形状(2,3,4)(2,3,4)(2,3,4)为例,标准 strides 是(12,4,1)(12,4,1)(12,4,1);对它transpose(0,2)变成形状(4,3,2)(4,3,2)(4,3,2)、strides(1,4,12)(1,4,12)(1,4,12)——数据一行没动,只是把 0 号维和 2 号维的 stride 对调了。这就是为什么转置是零拷贝的。
3. 从零实现
下面全部代码取自本算法目录下真实运行的implement.py与run.py,可直接复现。
3.1 寻址公式与连续判定
这段实现了 stride 算术的两个地基函数:坐标→偏移、以及「行主序标准 stride」的生成。
# 核心实现:implement.py 片段defoffset_from_index(indices,strides,storage_offset=0):# 多维坐标 → 物理偏移:每维坐标乘以该维 stride 再求和returnint(storage_offset)+sum(int(i)*int(s)fori,sinzip(indices,strides))defc_contiguous_strides(shape):# 行主序标准 stride:最后一维 =1,向前每维 = 后续各维长度之积shape=[int(s)forsinshape]strides=[1]*len(shape)acc=1forkinrange(len(shape)-1,-1,-1):strides[k]=acc# 第 k 维 stride = 身后各维长度的乘积acc*=shape[k]returntuple(strides)defis_c_contiguous(shape,strides):# 连续判定:实际 stride 是否等于行主序标准值returntuple(strides)==c_contiguous_strides(shape)运行输出(摘自results/output.log):
[1] contiguous tensor shape=(2, 3, 4) strides=(12, 4, 1) my_c_contiguous_strides=(12, 4, 1) match=True [2] offset_from_index((1, 2, 3))=23 torch value at idx=23 (storage[23]=23)3.2 转置为什么零拷贝
这段证明transpose只是交换 shape 与 strides,底层存储完全不动。
# 核心实现:implement.py 片段deftranspose_strides(shape,strides,dim0,dim1):# 交换两个轴:shape 与 strides 对应互换,数据原地不动shape=list(shape);strides=list(strides)shape[dim0],shape[dim1]=shape[dim1],shape[dim0]strides[dim0],strides[dim1]=strides[dim1],strides[dim0]returntuple(shape),tuple(strides)defcan_view(shape,strides):# reshape 能否零拷贝:仅当输入连续returnis_c_contiguous(shape,strides)运行输出:
[4] after transpose: shape=(4, 3, 2) strides=(1, 4, 12) my shape=(4, 3, 2) my strides=(1, 4, 12) match=True torch.is_contiguous=False my_is_contiguous=False shares storage with original? True3.3 手写一个 StridedArray
为了让你确信「框架底层就这么回事」,这里用 stride 公式从零实现一个最小多维数组:
# 核心实现:implement.py 片段(StridedArray 类)classStridedArray:def__init__(self,data,shape,strides,storage_offset=0):self.storage=list(data)# 一维底层存储self.shape=tuple(int(s)forsinshape)self.strides=tuple(int(s)forsinstrides)self.storage_offset=int(storage_offset)def__getitem__(self,indices):# 所有读写都走 offset_from_index,等价于框架底层寻址idx=tuple(int(i)foriinindices)returnself.storage[offset_from_index(idx,self.strides,self.storage_offset)]def__setitem__(self,indices,value):idx=tuple(int(i)foriinindices)self.storage[offset_from_index(idx,self.strides,self.storage_offset)]=valuedefis_contiguous(self):returnis_c_contiguous(self.shape,self.strides)运行输出:
[3] StridedArray: shape=(2, 3), strides=(3, 1), storage=[0, 1, 2, 3, 4, 5] a[1,2]=5 sa[1,2]=53.4 view / reshape / contiguous 的坑
这段用 PyTorch 现场演示「非连续上view报错、reshape兜底拷贝」的经典陷阱:
# 演示入口:run.py 片段t=torch.arange(24).reshape(2,3,4)# 连续张量tt=t.transpose(0,2)# 非连续(只改 stride)try:tt.view(-1)# 非连续不能零拷贝重排 → 报错exceptRuntimeErrorase:print("view 报错:",str(e)[:60])rt=tt.reshape(-1)# reshape 兜底:拷贝一份print("reshape 拷贝了?",rt.storage().data_ptr()!=tt.storage().data_ptr())ct=tt.contiguous()# 显式拷贝成连续print("contiguous strides:",tuple(ct.stride()))运行输出:
[5] tt.view(-1) -> RuntimeError (非连续不可 view) tt.reshape(-1) ok, data_ptr changed? True (拷贝) tt.contiguous() strides=(6, 2, 1) is_contiguous=True4. 实验与结果
在2048×2048的 float32 张量上,对比「零拷贝转置(只改 stride)」与「物化拷贝(transpose + contiguous)」两种实现,实测数据如下(全部来自results/metrics.json):
| 实现方式 | 耗时(µs) | 额外内存(B) | 数据正确性 |
|---|---|---|---|
| 零拷贝 view(只改 stride) | 3.11 | 0 | 与基准一致 |
| 物化拷贝(contiguous) | 17878.71 | 16777216(16 MB) | 与基准一致 |
三组对比维度:耗时、额外内存、正确性。结论一目了然——同样得到一个转置结果,零拷贝路径快约 5745 倍、且不占任何额外内存。这也解释了为什么框架会不遗余力地让transpose、切片、view保持零拷贝语义。
转置前后物理内存偏移的对比图(逻辑坐标变了,物理地址不变):
5. 踩坑指南
view报错:非连续张量(如transpose/permute之后)直接view()会抛RuntimeError: view size is not compatible...。解法:改用reshape()(内部自动拷贝兜底),或先.contiguous()。代价是后者真的会复制一份数据。- 「数据变了没?」用 data_ptr 判断要小心:
tensor.data_ptr()返回的是该张量视图首元素的地址(含storage_offset),切片后的row.data_ptr()不等于基张量。要判断是否共享底层存储,应比较.storage().data_ptr(),并配合.storage_offset()看偏移。 - 误以为
reshape总是零拷贝:只有输入连续时reshape才等价于view;非连续时它会静默拷贝。对超大张量,一次「无意的 reshape 拷贝」可能直接把显存打爆,务必先is_contiguous()自检。 as_strided是「危险但强大」的终极武器:它能人为构造任意 stride,从而用极小内存表达「广播」「重叠」等布局;但 stride 设置错误会造成内存越界读到脏数据,生产代码慎用。
6. 总结与延伸
- 要点卡片:
- 张量 = 一维 storage +
shape/strides/storage_offset,寻址靠o=o0+∑kiksko = o_0 + \sum_k i_k s_ko=o0+∑kiksk。 - 连续(C-contiguous)的 stride 是行主序「后积」,即(d2d3,d3,1)(d_2 d_3, d_3, 1)(d2d3,d3,1)。
transpose/切片只换 stride,零拷贝;view只接受连续;reshape兜底拷贝;contiguous()强制连续。- 实测零拷贝转置比物化拷贝快约 5745 倍、省 16MB,这就是 stride 协议的价值。
- 张量 = 一维 storage +
- 论文出处:PyTorch 官方文档《Tensor Attributes》(stride / storage_offset 定义)|官方实现:https://github.com/pytorch/pytorch
- 下一篇预告:《【1.2】Einstein 求和约定(einsum)》—— 用一个字符串统一表达所有张量收缩,继续把底层算清楚。