【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∑k1nik⋅sk(1) \mathrm{offset}(\mathbf{i}) o_0 \sum_{k1}^{n} i_k \cdot s_k \tag{1}offset(i)o0​k1∑n​ik​⋅sk​(1)「人话翻译第kkk维坐标每 1物理地址就前进sks_ksk​格再加上起始偏移o0o_0o0​就得到该元素的物理位置。」所谓「连续C-contiguous行主序」就是 strides 恰好等于「后面各维长度连乘」sk∏jk1ndj,sn1(2) s_k \prod_{jk1}^{n} d_j, \qquad s_n 1 \tag{2}sk​jk1∏n​dj​,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_offset0):# 多维坐标 → 物理偏移每维坐标乘以该维 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)acc1forkinrange(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) matchTrue [2] offset_from_index((1, 2, 3))23 torch value at idx23 (storage[23]23)3.2 转置为什么零拷贝这段证明transpose只是交换 shape 与 strides底层存储完全不动。# 核心实现implement.py 片段deftranspose_strides(shape,strides,dim0,dim1):# 交换两个轴shape 与 strides 对应互换数据原地不动shapelist(shape);strideslist(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) matchTrue torch.is_contiguousFalse my_is_contiguousFalse shares storage with original? True3.3 手写一个 StridedArray为了让你确信「框架底层就这么回事」这里用 stride 公式从零实现一个最小多维数组# 核心实现implement.py 片段StridedArray 类classStridedArray:def__init__(self,data,shape,strides,storage_offset0):self.storagelist(data)# 一维底层存储self.shapetuple(int(s)forsinshape)self.stridestuple(int(s)forsinstrides)self.storage_offsetint(storage_offset)def__getitem__(self,indices):# 所有读写都走 offset_from_index等价于框架底层寻址idxtuple(int(i)foriinindices)returnself.storage[offset_from_index(idx,self.strides,self.storage_offset)]def__setitem__(self,indices,value):idxtuple(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 片段ttorch.arange(24).reshape(2,3,4)# 连续张量ttt.transpose(0,2)# 非连续只改 stridetry:tt.view(-1)# 非连续不能零拷贝重排 → 报错exceptRuntimeErrorase:print(view 报错:,str(e)[:60])rttt.reshape(-1)# reshape 兜底拷贝一份print(reshape 拷贝了?,rt.storage().data_ptr()!tt.storage().data_ptr())cttt.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_contiguousTrue4. 实验与结果在2048×2048的 float32 张量上对比「零拷贝转置只改 stride」与「物化拷贝transpose contiguous」两种实现实测数据如下全部来自results/metrics.json实现方式耗时µs额外内存B数据正确性零拷贝 view只改 stride3.110与基准一致物化拷贝contiguous17878.711677721616 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寻址靠oo0∑kiksko o_0 \sum_k i_k s_koo0​∑k​ik​sk​。连续C-contiguous的 stride 是行主序「后积」即(d2d3,d3,1)(d_2 d_3, d_3, 1)(d2​d3​,d3​,1)。transpose/切片只换 stride零拷贝view只接受连续reshape兜底拷贝contiguous()强制连续。实测零拷贝转置比物化拷贝快约 5745 倍、省 16MB这就是 stride 协议的价值。论文出处PyTorch 官方文档《Tensor Attributes》stride / storage_offset 定义官方实现https://github.com/pytorch/pytorch下一篇预告《【1.2】Einstein 求和约定einsum》—— 用一个字符串统一表达所有张量收缩继续把底层算清楚。