给一个写了十年 C 的人看torch.randn(4096, 4096)他脑子里大概率浮现的是float**或者std::vectorstd::vectorfloat。两个都错。来做个实验。建一个 64 MiB 的方阵转置它然后问这次转置往内存里搬了多少字节直觉答案是 64 MiB——你不是把行变成了列吗真实答案是0 字节。转置一个 40 亿元素的张量和转置一个 1 元素的张量开销完全一样都是常数时间。这不是 PyTorch 的魔法是一个朴素到有点扫兴的数据结构事实。我手头有一份 llama.cpp 的源码ggml 后端commitdbe9c0c8里面判断一个张量是不是转置过的函数整个函数体是这样的C// ggml dbe9c0c8, ggml/src/ggml.c:1443 bool ggml_is_transposed(const struct ggml_tensor * tensor) { return tensor-nb[0] tensor-nb[1]; }一个布尔比较。转置在这一层不是一个动作是一个状态——第 0 维的步长比第 1 维大就叫转置了。没有循环没有 memcpy没有数据。这篇文章干一件事把张量这个被 Python API 包得严严实实的黑箱当成一个 C struct 拆开拆到你能对任意一行tensor.permute(...).reshape(...).to(cuda)当场报出这步搬了几个字节、那步会不会偷偷拷贝、哪步可能让另一个变量跟着一起变。读者对象是懂指针、懂内存布局、懂值语义和引用语义的人——你已经具备拆开它的全部工具只是没人把盖子掀给你看过。读完你会带走五样东西把任意张量拆成 storage 元数据并手算任意索引落到第几个字节判断view/reshape/permute/contiguous里谁零拷贝、谁必拷贝、谁看情况推导 broadcasting 和 dtype 提升的规则并预测结果算清.to(device)一次过 PCIe 的真实代价以及——这条最值钱——预防 in-place 操作因为共享内存而引发的别名 bug 和 autograd 报错。一、把张量拆开它其实是一块一维内存 一张说明书先别看 PyTorch。如果让你这个 C 程序员从零设计一个能表示任意维度数组的类型你会怎么设计你不会真的用float**——那是指针的指针每行一次额外解引用缓存不友好还得为每行单独 malloc。你会做的是申请**一整块连续内存**装所有元素然后用一组整数记下它逻辑上是几维、每维多大、怎么从下标算到内存偏移。恭喜你刚刚重新发明了张量。它就是这么实现的。来看 ggml 的定义C// ggml dbe9c0c8, ggml/include/ggml.h:666 struct ggml_tensor { enum ggml_type type; // dtypef32 / f16 / i8 ... struct ggml_backend_buffer * buffer; int64_t ne[GGML_MAX_DIMS]; // number of elements即 shape size_t nb[GGML_MAX_DIMS]; // stride in bytes每维的字节步长 // ... struct ggml_tensor * view_src; // 这块内存是借谁的视图机制 size_t view_offs; // 借的偏移量 void * data; // 指向那块一维内存的裸指针 char name[GGML_MAX_NAME]; };把噪音去掉核心就四样type元素类型、ne形状、nb步长、data指向连续内存的裸指针。GGML_MAX_DIMS是 4所以 ggml 的张量最多四维ne和nb各是一个长度为 4 的数组。注意data的类型是void*——它就是一个指针指向一段连成一条线的字节。所谓四维张量在这一层是一根一维的内存条。PyTorch 的TensorImpl字段更多要扛 autograd、设备分发、量化但骨架一模一样一个Storage引用计数的一维字节缓冲sizesshapestrides步长storage_offset起始偏移dtypedevice。我把这套东西统称张量的**元数据五元组**(shape, stride, offset, dtype, device)。data 是货元数据是说明书。这套设计不是 PyTorch 拍脑袋定的。2017 年出现的 DLPack——一个让 PyTorch、TVM、MXNet 之间零拷贝交换张量的标准——把一个张量精确定义成DLTensor{ data, device, ndim, dtype, shape, strides, byte_offset }。各家框架内部实现千差万别但要互相递张量时大家同意的最小公约数就是这一个元组。这反过来证明了**张量 一块内存 一组元数据不是某个框架的实现细节是整个领域的共识。**这里有个对 C 程序员特别重要的推论shape 和 stride 是两组正交的量。它们都长度为 4都描述维度但记的是完全不同的事——shape 说逻辑上每维有几个元素stride 说沿这一维走一步指针要跳多少字节。ggml 把这件事摆得很明白它有两个独立的比较函数C// ggml dbe9c0c8, ggml/src/ggml.c:1525, 1535 bool ggml_are_same_shape(const struct ggml_tensor * t0, const struct ggml_tensor * t1) { return (t0-ne[0]t1-ne[0]) (t0-ne[1]t1-ne[1]) (t0-ne[2]t1-ne[2]) (t0-ne[3]t1-ne[3]); } bool ggml_are_same_stride(const struct ggml_tensor * t0, const struct ggml_tensor * t1) { return (t0-nb[0]t1-nb[0]) (t0-nb[1]t1-nb[1]) (t0-nb[2]t1-nb[2]) (t0-nb[3]t1-nb[3]); }两个张量可以 shape 相同而 stride 不同一个连续、一个转置过也可以 stride 相同而 shape 不同。把它们当成两件事后面所有的为什么这个操作不拷贝、那个必须拷贝才讲得清。一句话记住这一章张量的维度是说明书里的数字不是内存里的结构。二、stride 就是指针算术而且它会撒谎说明书里最关键的一栏是 stride。它直接决定了逻辑下标怎么变成内存地址而这个翻译过程就是你每天在 C 里写的指针算术。先把连续布局的 stride 算出来。一个形状(2, 3, 4)的 f32 张量行优先row-majorC 的规矩存储意味着最后一维变化最快相邻元素在内存里挨着。元素步长是这么递推的——最后一维 stride 是 1 个元素往前每维等于后一维的 stride 乘后一维的大小纯文本stride[2] 1 // 最后一维相邻元素挨着 stride[1] stride[2]*ne[2] 1*4 4 stride[0] stride[1]*ne[1] 4*3 12ggml 用的是字节步长而不是元素步长对 C 程序员反而更诚实——因为data是void*跳到下一个元素本来就得按字节加。它的递推公式直接写在结构体注释里再在建张量时落地成代码C// ggml dbe9c0c8, ggml/src/ggml.c:1794ggml_new_tensor_impl 内 result-nb[0] ggml_type_size(type); // 4 (f32) result-nb[1] result-nb[0]*(result-ne[0]/ggml_blck_size(type)); for (int i 2; i GGML_MAX_DIMS; i) { result-nb[i] result-nb[i - 1]*result-ne[i - 1]; }ggml_blck_size对非量化类型恒为 1先当它不存在量化类型才用到第四章再说。对一个 f32 的(2,3,4)注意 ggml 把维度顺序倒过来存——ne[0]是最末维——所以nb[0]4, nb[1]4*416, nb[2]16*348字节。换算回元素就是 1、4、12和上面手算的完全一致。有了 stride索引公式就是全文最核心的一行数学。逻辑下标(i0, i1, ..., in)对应的元素地址是纯文本addr base Σ idx[k] · stride[k] 元素口径 (char*)data Σ idx[k] · nb[k] 字节口径ggml 实际这么算不信翻开 ggml 的 CPU kernel每一个逐元素算子的核心就是这行指针算术一个字都不多C// ggml dbe9c0c8, ggml/src/ggml-cpu/ops.cpp:139 const src_t * src0_ptr (const src_t *) ((char *) src0-data i00*nb00 i01*nb01 i02*nb02 i03*nb03);(char*)data拿到字节基址加上每维下标乘以该维字节步长强转回元素指针。这就是张量索引的全部真相——它从来不是什么多维寻址硬件指令就是一次char*加法。你在大学里写a[i*cols j]手动展开二维数组时干的事和这里一模一样只是维度数变成了运行时变量。现在手算一遍验收。(2,3,4)f32 连续张量nb元素口径是(12, 4, 1)取元素[1, 2, 3]纯文本偏移 1*12 2*4 3*1 12 8 3 23 个元素 92 字节第 23 个 float第 92 个字节。一次乘加落定。讲到这你可能已经看出那句stride 会撒谎是什么意思了。索引公式里地址完全由 stride 决定而 stride 只是说明书里的几个整数——我完全可以给同一块内存配一组不同的 stride让它变成一个不同的张量。内存一个字节没动但读出来的形状变了。行优先和列优先的分裂就是这么来的。同一个数学上的矩阵A[i][j]C 的行优先让stride (cols, 1)Fortran 的列优先让stride (1, rows)。这两套规矩从 1957 年的 Fortran列优先和 1972 年的 C行优先就固定下来吵了半个世纪。而**转置本质上就是在行优先的内存里假装它是列优先的**——shape 交换一下stride 跟着交换一下数据原地不动。下一章我们就看 ggml 怎么用三行代码完成这个假装。三、免费的变形view / transpose / permute 一个字节都不搬把开头那个ggml_is_transposed的悬念收掉。它判断转置只看nb[0] nb[1]是因为**转置压根没碰数据只是把 shape 和 stride 各交换了一下**于是第 0 维的步长变得比第 1 维大——这个反常的步长大小关系就是转置过的全部痕迹。看实现C// ggml dbe9c0c8, ggml/src/ggml.c:3833 struct ggml_tensor * ggml_transpose(struct ggml_context * ctx, struct ggml_tensor * a) { struct ggml_tensor * result ggml_view_tensor(ctx, a); // 复用 a 的内存 result-ne[0] a-ne[1]; result-ne[1] a-ne[0]; // 交换 shape result-nb[0] a-nb[1]; result-nb[1] a-nb[0]; // 交换 stride result-op GGML_OP_TRANSPOSE; result-src[0] a; return result; }逐行验收第一行借来a的内存后两行交换ne和nb的前两项完事。没有循环没有分配没有 memcpy。data指针自始至终指向a的那块内存。一个 64 MiB 的矩阵转置改的就是这2×816个字节的元数据。这就是开头0 字节的来历。permute任意维度重排是同一个套路的推广只是把交换两维换成按一张排列表重排所有维C// ggml dbe9c0c8, ggml/src/ggml.c:3777节选 struct ggml_tensor * result ggml_view_tensor(ctx, a); // 同样复用内存 int ne[GGML_MAX_DIMS], nb[GGML_MAX_DIMS]; ne[axis0]a-ne[0]; ne[axis1]a-ne[1]; ne[axis2]a-ne[2]; ne[axis3]a-ne[3]; nb[axis0]a-nb[0]; nb[axis1]a-nb[1]; nb[axis2]a-nb[2]; nb[axis3]a-nb[3]; // 再把 ne[]/nb[] 写回 result注意第一行transpose和permute都从ggml_view_tensor开始。这个函数才是免费变形的共同地基——它造一个新的ggml_tensor头但让它的data指向源张量的内存C// ggml dbe9c0c8, ggml/src/ggml.c:1751ggml_new_tensor_impl 内view 分支 void * data view_src ! NULL ? view_src-data : NULL; if (data ! NULL) { data (char *) data view_offs; // 借内存 偏移 } // ... view_src NULL 时才会真正 obj_alloc_size data_size 去分配一个视图就是借内存的张量。它在view_src里记下自己借的是谁、在view_offs里记下从哪个字节开始借——这两个字段我们在第一章的结构体里见过现在它们派上用场了。新张量有自己独立的说明书可以有不同的 shape/stride/offset但共用同一份货。PyTorch 把这套机制叫 view官方文档说得很直白视图张量与它的 base 共享底层 data……支持 view 是为了避免显式拷贝从而做到快速、省内存的 reshape、slicing 和逐元素操作。文档还列了一长串返回视图的算子切片索引、transpose、t、permute、expand、narrow、select、squeeze、unsqueeze、diagonal、view本身。**这些操作你随便连用十个内存里的数据一个字节都不会动。** 它们改的全是说明书。我的判断很明确在热点路径上凡是能用 view 类操作表达的形状调整都不要用会拷贝的方式去写。边界条件是——只要后续 kernel 能接受非连续输入view 就是白嫖一旦后续 kernel 要求连续下一章的主角你省下的拷贝会以隐式contiguous的形式加倍还回来。所以问题从来不是view 快不快而是我这个 view 喂给的下一个算子吃不吃非连续输入。四、成本分水岭contiguous、reshape与那次你没看见的拷贝到这里张量操作的两类已经分得很清楚了上一章那些只改说明书的免费这一章这些要重排内存的收费。分水岭就是一个词——contiguous连续。先给连续一个不含糊的定义一个张量是连续的当且仅当它的 stride 恰好等于由当前 shape 按行优先推出来的那组 stride——也就是第二章那个递推公式的结果。换句话说元素在内存里紧挨着、没有空洞、最后一维 stride 等于元素大小、没有哪两维的步长是反的。ggml 把这个判定写成一个逐维核对的循环C// ggml dbe9c0c8, ggml/src/ggml.c:1447 static bool ggml_is_contiguous_n(const struct ggml_tensor * tensor, int n) { size_t next_nb ggml_type_size(tensor-type); if (tensor-ne[0] ! ggml_blck_size(tensor-type) tensor-nb[0] ! next_nb) return false; // 最后一维步长必须等于元素大小 next_nb * tensor-ne[0]/ggml_blck_size(tensor-type); for (int i 1; i GGML_MAX_DIMS; i) { if (i n) { if (tensor-ne[i] ! 1 tensor-nb[i] ! next_nb) return false; // 每维实际步长必须等于推出来的步长 next_nb * tensor-ne[i]; } else { next_nb tensor-ne[i]*tensor-nb[i]; } } return true; }它就是把第二章的递推next_nb一维一维往上乘拿应该是多少和实际是多少比对。一处对不上就不连续。ggml_blck_size在这里现身了——对量化类型元素是一个 block连续性要按 block 大小算这是市面文章基本不会提的细节。普通 f32 张量 block size 是 1退化成上面说的朴素定义。转置过的张量nb[0] nb[1]第一项就挂所以它不连续——这和ggml_is_transposed是同一个事实的两面。为什么连续这么重要因为有一大类操作只在连续时才能零拷贝reshape是头号代表。reshape 要把(2,12)变成(4,6)、把矩阵摊平成一维它的可行前提是新形状能在现有这块内存上、用一组合法 stride 表达出来。当内存连续时元素排布就是一条干净的线怎么切分维度都行改说明书即可可一旦内存有空洞非连续新形状根本没法用任何 stride 在这块乱序内存上描述于是只剩一条路——把数据拷到一块新的连续内存里再说。ggml 干脆把这个前提变成一道铁门。它的reshape第一行就是断言C// ggml dbe9c0c8, ggml/src/ggml.c:3589 struct ggml_tensor * ggml_reshape(struct ggml_context * ctx, struct ggml_tensor * a, struct ggml_tensor * b) { GGML_ASSERT(ggml_is_contiguous(a)); // 不连续直接挂 GGML_ASSERT(ggml_nelements(a) ggml_nelements(b)); // 元素总数必须不变 struct ggml_tensor * result ggml_new_tensor_impl(ctx, a-type, GGML_MAX_DIMS, b-ne, a, 0); // 复用 a 内存 result-op GGML_OP_RESHAPE; result-src[0] a; return result; }ggml 的选择是reshape 永远是纯视图所以它拒绝给非连续张量 reshape——你想 reshape先自己变连续。这是一种把代价显式化的设计要拷贝你自己调ggml_cont我不替你偷偷做。PyTorch 走的是另一条路也是更多人踩坑的那条。它的reshape文档原文是reshape、reshape_as、flatten **可能返回视图也可能返回新张量调用方不应依赖它是不是视图**。翻译成大白话——**reshape 能 view 就 view连续时免费不能 view 就在背后给你拷一份非连续时按字节收费而它不告诉你这次走了哪条路。** 这个不告诉你就是无数性能玄学问题的根。一个在循环里反复x.transpose(0,1).reshape(-1)的代码如果x非连续每次迭代都在全量复制而代码表面上一行拷贝都看不到。反过来view比 reshape 诚实——它**只做视图做不了就报错**。这就是那个经典翻车现场Pythonx torch.randn(4096, 4096) # 连续 y x.transpose(0, 1) # 0 拷贝但 y 非连续nb[0] nb[1] y.view(-1) # RuntimeError: # view size is not compatible with input tensors size and stride # (at least one dimension spans across two contiguous subspaces). # Use .reshape(...) instead. y.contiguous().view(-1) # ✅ 先花 64 MiB 拷成连续再 0 拷贝 view y.reshape(-1) # ✅ 等价reshape 替你判断 必要时拷贝contiguous()就是那个明码标价的收费站。文档说它已经连续就返回自身否则拷贝出一个新的连续张量。来算这笔账用我们的标尺张量(4096, 4096)f32 4096×4096×4 67,108,864字节 64 MiB操作搬运字节复杂度说明x.transpose(0,1)0O(1)交换 16 字节元数据x.permute(...)/ 切片 /x.view(...)0O(1)同上改说明书y.contiguous()y 已转置非连续读 64 MiB 写 64 MiB ≈128 MiBO(n)逐元素按新顺序搬一遍y.reshape(-1)y 非连续同上 ≈128 MiBO(n)静默触发 contiguous最值钱的认知在这张表里transpose 不慢慢的是它之后第一个要求连续输入的算子。很多人观测到加了个 transpose 性能就崩了归罪于转置——冤枉它了。转置本身 0 字节是它产出的非连续张量喂给了一个reshape或一个要求连续的 matmul kernel触发了那次 128 MiB 的隐式搬运。诊断这类问题盯的不是 transpose是它下游第一个吃不下非连续输入的消费者。我的明确推荐带边界**如果一个非连续张量只被 view 类操作和支持非连续的逐元素算子消费就别contiguous()省下这 128 MiB如果它马上要喂给 reshape、要喂给只接受连续输入的 GEMM/卷积 kernel就主动、显式地contiguous()一次**别让它在循环里被隐式触发 N 次。把那次拷贝从看不见、可能重复变成看得见、只发生一次——这是连续这件事上唯一重要的纪律。五、broadcasting把 stride 设成 0 制造的复制幻觉你写a ba是(1024, 1024)b是(1024,)。两个形状不一样加法却成立b仿佛被复制了 1024 行去匹配a。这就是 broadcasting。问题来了那 1024 份b的拷贝在内存里真的存在吗不存在。又是 stride 在撒谎。先把规则讲清楚。NumPy/PyTorch 的广播对齐分两步把两个 shape 右对齐末维对末维然后逐维检查——要么相等要么其中一个是 1否则报错高位缺的维度按 1 补齐。(1024,1024)和(1024,)右对齐后b补成(1, 1024)逐维看1024 vs 1其一为 1过b这维要广播、1024 vs 1024相等过。结果形状(1024, 1024)。关键在广播那一维的实现。被广播的维度逻辑上从 1 变成了 1024但内存里只有 1 份数据。怎么让 1024 个不同的逻辑下标都读到这同一份数据把这一维的 stride 设成 0。回到第二章的索引公式纯文本addr base Σ idx[k] · stride[k]如果第 k 维stride[k] 0那么不管idx[k]是 0 还是 1023这一项idx[k]·0永远是 0指针在这一维上**原地踏步**。于是 1024 个逻辑行全部映射到同一块物理内存——复制的效果0 字节的成本。PyTorch 的expandbroadcasting 的显式版本就是干这个的它返回一个 view把被扩展维的 stride 置 0。来算这笔账。(1024, 1) (1, 1024)广播成(1024, 1024)如果老老实实把两个操作数都物化到(1024,1024)每个要1024×1024×4 4 MiB两个 8 MiB。stride0 的实现——**额外 0 字节**两个操作数各自只占原来的 4 KiB。这就是为什么你能在一个 8GB 的卡上对超大张量做广播加法而不爆显存。ggml 的实现路子稍有不同但更直白地暴露了广播 索引重映射这件事。它不用 stride0而是在 kernel 里对源坐标取模C// ggml dbe9c0c8, ggml/src/ggml-cpu/binary-ops.cpp:85 const int64_t i13 i03 % ne13; // 输出第3维坐标 → 源第3维坐标 const int64_t i12 i02 % ne12; const int64_t i11 i01 % ne11; // 然后用 i1x 去算 src1 的地址src1-data i13*nb13 i12*nb12 i11*nb11当src1在某维只有 1 个元素ne1x 1i0x % 1恒等于 0——和 stride0 殊途同归源指针在这维永远停在第 0 个。无论 stride 置零还是坐标取模**重复都只发生在地址计算里从不发生在内存里。** 这是同一个真相的两种写法。ggml 判断能不能广播的函数也值得一看它的判据比 NumPy 更宽C// ggml dbe9c0c8, ggml/src/ggml.c:1550 bool ggml_can_repeat(const struct ggml_tensor * t0, const struct ggml_tensor * t1) { return ggml_is_empty(t0) ? ggml_is_empty(t1) : (t1-ne[0]%t0-ne[0] 0) (t1-ne[1]%t0-ne[1] 0) (t1-ne[2]%t0-ne[2] 0) (t1-ne[3]%t0-ne[3] 0); }它要求t1每维是t0对应维的整数倍而不是 NumPy 那种必须是 1 或相等。这是 ggml 服务于神经网络计算的工程取舍知道就行写 PyTorch 时仍按1 或相等那套来。这里埋着一个第七章会引爆的雷先点一句对 stride0 的广播张量做 in-place 写入是危险的。因为多个逻辑下标指向同一个物理地址你以为在写 1024 个不同的格子其实在反复践踏同一个格子。PyTorch 对expand出来的张量直接禁止 in-place 写就是这个原因。记住这个伏笔。六、dtype 提升与 .to(device)两种隐式转换的真实代价说明书五元组里还剩两栏没拆dtype和device。它们各自藏着一种你没写、但框架替你做了的隐式转换代价一个在精度、一个在带宽。先说 dtype 提升。int_tensor 1.5该是什么类型float16 float32呢PyTorch 用一套类别格点category lattice来定类别从低到高是**bool 整型 浮点 复数**。官方文档给的规则我浓缩成三条跨类别高类别赢。long float → float32整型让位给浮点float complex → complex。同类别更宽的赢。float32 float64 → float64int8 int32 → int32。Python 标量不抬张量的类别只在同类别内保证够装。这条最反直觉int32_tensor 5仍是int32——尽管 Python 的5在 PyTorch 里默认按int64算它也不会把张量从 int32 抬到 int64。但int_tensor 1.5会变浮点因为1.5是更高类别浮点的标量。默认值也要记牢Python 浮点标量按torch.get_default_dtype()默认 float32整型标量按 int64所以torch.tensor([1.0])给你 float32torch.arange(10)给你 int64。验收三个例子Python(torch.ones(3, dtypetorch.int32) 5).dtype # torch.int32 规则3标量不抬类别 torch.add(torch.ones(3, dtypetorch.long), torch.ones(3, dtypetorch.float32)).dtype # torch.float32规则1跨类别浮点赢 (torch.ones(3, dtypetorch.float32) torch.ones(3, dtypetorch.float64)).dtype # torch.float64规则2同类别更宽赢这套规则的工程陷阱在混合精度训练里你精心把模型权重设成 f16 省显存结果某处f16_tensor f32_scalar整个结果被悄悄抬成 f32省显存的初衷当场落空。反方向也有坑——f16 累加大量数值会溢出/精度塌缩这时你**需要**显式.float()把它抬上去再算。dtype 提升不是越自动越好它是一个你必须心里有数的隐式开销。dtype 决定的字节宽是一切账本的基数f324 字节、f16/bf162、i81、f648——ggml 的类型表里就是一串sizeof(float)、sizeof(int8_t)。再说device这是整篇文章里最贵的操作没有之一。.to(cuda)不改 shape、不改 stride、不改 dtype它干的是把那块一维内存**整个搬到另一颗芯片的内存上**。我们的标尺张量 64 MiB过一次 PCIe Gen4 x16纯文本PCIe Gen4 x16 理论带宽 ≈ 32 GB/s实测可用 ≈ 25 GB/s 64 MiB ÷ 25 GB/s ≈ 2.6 ms ← 一次 Host→Device 拷贝2.6 毫秒。对比一下同样这 64 MiB如果已经在 H100 的显存里、要在显存内部挪一下HBM3 带宽 ~3 TB/s只要64 MiB ÷ 3 TB/s ≈ 22 µs。**跨设备搬运比设备内搬运贵两个数量级。** 这个数量级差就是所有.to(device)要慎用建议背后的硬件事实。由此给两条带边界的明确推荐常量别在循环里搬。见过太多训练代码把一个固定的 mask / 位置编码.to(device)写在for batch内部每步白交 2.6 ms 的 PCIe 过路费。把它提到循环外搬一次结束。**大批量小数据搬运上pin_memory()non_blockingTrue。** 锁页内存pinned让 DMA 引擎能在不阻塞 CPU 的情况下直接传输non_blockingTrue让拷贝和计算重叠。边界是只有 pinned 内存配non_blocking才真异步普通可分页内存上non_blocking基本是空头支票。dtype 和 device 的共同点是它们都是说明书里不起眼的一栏却各自挂着一类隐式成本——一类悄悄改变你的精度和显存一类悄悄吃掉你的毫秒。把它们当成显式决策而不是默认行为。七、in-place 与 aliasing原地操作如何撕裂别名最后这一章是整篇最值钱的因为它讲的坑最隐蔽而且根因正是前面所有章节铺垫的那个事实——视图共享 storage。C 程序员对别名aliasing不陌生两个指针指向同一块内存通过一个改了另一个看到的值也变。张量的 view 就是张量级别的别名而 in-place 操作PyTorch 里带下划线的add_、mul_或者则是触发别名 bug 的扳机。先看 in-place 在源码层到底是什么。ggml 的加法有 in-place 和非 in-place 两个版本区别浓缩在一行三目运算里C// ggml dbe9c0c8, ggml/src/ggml.c:2024ggml_add_impl static struct ggml_tensor * ggml_add_impl(struct ggml_context * ctx, struct ggml_tensor * a, struct ggml_tensor * b, bool inplace) { GGML_ASSERT(ggml_can_repeat(b, a)); struct ggml_tensor * result inplace ? ggml_view_tensor(ctx, a) // in-place结果是 a 的视图共享内存 : ggml_dup_tensor(ctx, a); // 非 in-place复制一份新内存 result-op GGML_OP_ADD; result-src[0] a; result-src[1] b; return result; }看明白了吗in-place 的本质就是让结果张量成为输入的一个视图——ggml_view_tensor(ctx, a)我们第三章见过它它复用a的data指针。非 in-place 走ggml_dup_tensor分配新内存。所以 in-place 省的就是那一次分配 拷贝。ggml 里几乎每个算子都有这么一对孪生ggml_sub_inplace、ggml_mul_inplace、ggml_div_inplace……全部靠ggml_view_tensor复用内存实现。in-place 不是什么特殊指令就是结果别名输入。省内存是好事撕裂别名就是代价。三个真实的翻车现场从轻到重现场一改 view 顺手改了 base。这是最朴素的别名 bugPythona torch.zeros(4, 4) b a[0] # b 是 a 第 0 行的视图0 拷贝共享内存 b 1 # in-place 改 b print(a[0]) # tensor([1., 1., 1., 1.]) ← a 跟着变了b a[0]是切片第三章说过它返回视图。b 1是 in-place直接写共享的那块内存于是a的第 0 行也变了。如果你以为b是个独立副本这里就是个潜伏的 bug。C 里你不会对int* b a[0]; *b 1然后惊讶a[0]变了——张量切片是一回事只是语法把指针藏起来了。想要独立副本得显式b a[0].clone()。现场二对需要梯度的叶子张量做 in-place。这是 autograd 的硬性禁止Pythonw torch.randn(3, requires_gradTrue) w - 0.1 * w.grad # RuntimeError: a leaf Variable that requires # grad is being used in an in-place operation.w是叶子节点leaf计算图的输入autograd 需要它保持稳定来反向传播。正确写法是用torch.no_grad()包起来或者干脆交给 optimizer——这也是为什么optimizer.step()内部更新参数是包在no_grad里的。现场三in-place 改掉了反向传播要用的中间值。这是最隐蔽的PyTorch 用 version counter 兜底Pythonx torch.randn(5, requires_gradTrue) y x.sigmoid() # 反向时sigmoid 的梯度公式要用到 y 本身σσ(1-σ) y.add_(1) # in-place 改了 yversion counter 自增 y.sum().backward() # RuntimeError: one of the variables needed for # gradient computation has been modified by an # inplace operation ...PyTorch 的机制是这样的官方 autograd 文档每个张量都有一个 version counter任何 in-place 操作都会让它自增当一个算子为反向保存某个张量时会连它当时的版本号一起存下来反向真正执行时比对当前版本号和保存时的版本号一旦发现被人 in-place 改过版本号变大立即报错。这个设计的价值在于——文档原话——如果你用了 in-place 操作又没看到任何报错那你可以确信算出来的梯度是对的。它把一类静默的错误梯度变成了一个吵闹的崩溃。文档还点出 in-place 在 autograd 下被严格限制的两个根本原因① in-place 可能覆盖掉求梯度所必需的值现场三② 每个 in-place 操作都要求重写计算图而当多个张量通过索引、转置共享同一块 storage 时这件事会变得很棘手所以 autograd 干脆在这些情况下报错。两条都指向同一个源头共享 storage。那 in-place 到底该不该用我的判断带明确边界推理、no_grad 上下文、手写不走 autograd 的 kernel比如你在 ggml 这层写算子——放心用 in-place省下的分配和拷贝是实打实的没有反向图要维护。训练里、autograd 计算图上——默认不用 in-place除非你非常确定这个张量不参与任何反向、且没有别的 view 指着它。PyTorch 文档自己都说autograd 激进的缓冲区释放和复用已经很高效了in-place 很少能真正省下多少内存——在常规训练里in-place 的收益小、风险大不划算。**永远不要对expand出来的、stride0 的广播张量做 in-place 写**第五章埋的雷。多个逻辑下标映射到同一物理地址in-place 写入会让它们互相践踏结果完全不可预测——所以 PyTorch 直接禁止这种写法。一句话收束这章in-place 的危险从来不是覆盖了数据这么简单是它让结果别名了输入而你周围可能还有别的视图、别的计算图节点正盯着同一块内存。八、把张量当 struct 看绕了一大圈回到开头那句话张量是一块一维内存加一张说明书。现在这张说明书的五栏你都拆过了——shape 说逻辑形状stride 说指针怎么跳offset 说从哪开始dtype 说每个元素几个字节device 说货在哪颗芯片上。所有看起来高深的操作落到这层都是在改这几个数或者在搬那块内存。把它压成一张你下次写代码能直接用的判断清单你写的操作改说明书还是搬内存代价要警惕什么transpose/permute/t改说明书0 字节O(1)产出非连续张量下游可能触发隐式拷贝切片 /view/squeeze/unsqueeze改说明书0 字节O(1)是视图改它会改 base别名expand/ broadcasting改说明书stride00 字节禁止对它 in-place 写reshape看情况连续时 0非连续时全量拷贝静默拷贝是性能玄学高发区contiguous搬内存若非连续读 N 写 N明码标价的收费站主动调、只调一次clone搬内存全量拷贝要的就是独立副本时用它dtype 提升 /.float()搬内存新 dtype 缓冲全量拷贝混合精度里悄悄吃显存/丢精度.to(device)搬内存跨芯片跨 PCIe比设备内贵两个数量级别放循环里大批量配 pin non_blockingadd_/等 in-place改说明书结果别名输入省一次分配autograd 下慎用version counter 会抓你这张表的左半边改说明书随便连用不要钱右半边搬内存每一次都按字节计费——把任意一行 PyTorch 代码在脑子里翻译成这步落在左边还是右边你对它内存行为的判断就八九不离十了。最后给一眼历史因为它能解释为什么这套设计是稳的而不是 PyTorch 的一时之选。1957 年 Fortran 选了列优先、1972 年 C 选了行优先同一个矩阵两套 stride的分裂从语言层就埋下了。1960 年代 APL 把多维数组当成一等公民立下了形状 索引的数组编程范式。2006 年 NumPy 把(data, shape, strides, dtype)四元组确立为 strided 数组的事实标准2011 年 Torch7 已经是 storage 加 (size, stride, offset) 的分离结构PyTorch 2016 年原样继承。2017 年 DLPack 用一个统一的元数据元组让各框架零拷贝交换张量2020 年 PyTorch 又用 channels_last 证明了 shape 和 stride 解耦的工程价值——同一个 NCHW 逻辑形状换一组物理 stride就能给卷积选更优的内存布局。**七十年间从语言到框架到跨框架标准所有人都收敛到了同一个答案张量 元数据 一块内存。** 你刚才拆的不是 PyTorch 的实现细节是整个数组计算领域的公理。留一个开放问题给你既然 shape 和 stride 能解耦到 channels_last 这种程度那么一块内存能被多少种合法的 (shape, stride) 说明书解读本身就是一个值得琢磨的设计空间——稀疏张量、量化 block、ragged/nested tensor全都是在这张说明书上做文章。下次你再import torch希望你看到的不再是一个多维数组的黑箱而是一个你能亲手拆开、亲手算清每一笔内存账的 C struct。
张量根本不是多维数组:一个 C++ 程序员把 PyTorch 张量拆到底
发布时间:2026/7/8 10:23:08
给一个写了十年 C 的人看torch.randn(4096, 4096)他脑子里大概率浮现的是float**或者std::vectorstd::vectorfloat。两个都错。来做个实验。建一个 64 MiB 的方阵转置它然后问这次转置往内存里搬了多少字节直觉答案是 64 MiB——你不是把行变成了列吗真实答案是0 字节。转置一个 40 亿元素的张量和转置一个 1 元素的张量开销完全一样都是常数时间。这不是 PyTorch 的魔法是一个朴素到有点扫兴的数据结构事实。我手头有一份 llama.cpp 的源码ggml 后端commitdbe9c0c8里面判断一个张量是不是转置过的函数整个函数体是这样的C// ggml dbe9c0c8, ggml/src/ggml.c:1443 bool ggml_is_transposed(const struct ggml_tensor * tensor) { return tensor-nb[0] tensor-nb[1]; }一个布尔比较。转置在这一层不是一个动作是一个状态——第 0 维的步长比第 1 维大就叫转置了。没有循环没有 memcpy没有数据。这篇文章干一件事把张量这个被 Python API 包得严严实实的黑箱当成一个 C struct 拆开拆到你能对任意一行tensor.permute(...).reshape(...).to(cuda)当场报出这步搬了几个字节、那步会不会偷偷拷贝、哪步可能让另一个变量跟着一起变。读者对象是懂指针、懂内存布局、懂值语义和引用语义的人——你已经具备拆开它的全部工具只是没人把盖子掀给你看过。读完你会带走五样东西把任意张量拆成 storage 元数据并手算任意索引落到第几个字节判断view/reshape/permute/contiguous里谁零拷贝、谁必拷贝、谁看情况推导 broadcasting 和 dtype 提升的规则并预测结果算清.to(device)一次过 PCIe 的真实代价以及——这条最值钱——预防 in-place 操作因为共享内存而引发的别名 bug 和 autograd 报错。一、把张量拆开它其实是一块一维内存 一张说明书先别看 PyTorch。如果让你这个 C 程序员从零设计一个能表示任意维度数组的类型你会怎么设计你不会真的用float**——那是指针的指针每行一次额外解引用缓存不友好还得为每行单独 malloc。你会做的是申请**一整块连续内存**装所有元素然后用一组整数记下它逻辑上是几维、每维多大、怎么从下标算到内存偏移。恭喜你刚刚重新发明了张量。它就是这么实现的。来看 ggml 的定义C// ggml dbe9c0c8, ggml/include/ggml.h:666 struct ggml_tensor { enum ggml_type type; // dtypef32 / f16 / i8 ... struct ggml_backend_buffer * buffer; int64_t ne[GGML_MAX_DIMS]; // number of elements即 shape size_t nb[GGML_MAX_DIMS]; // stride in bytes每维的字节步长 // ... struct ggml_tensor * view_src; // 这块内存是借谁的视图机制 size_t view_offs; // 借的偏移量 void * data; // 指向那块一维内存的裸指针 char name[GGML_MAX_NAME]; };把噪音去掉核心就四样type元素类型、ne形状、nb步长、data指向连续内存的裸指针。GGML_MAX_DIMS是 4所以 ggml 的张量最多四维ne和nb各是一个长度为 4 的数组。注意data的类型是void*——它就是一个指针指向一段连成一条线的字节。所谓四维张量在这一层是一根一维的内存条。PyTorch 的TensorImpl字段更多要扛 autograd、设备分发、量化但骨架一模一样一个Storage引用计数的一维字节缓冲sizesshapestrides步长storage_offset起始偏移dtypedevice。我把这套东西统称张量的**元数据五元组**(shape, stride, offset, dtype, device)。data 是货元数据是说明书。这套设计不是 PyTorch 拍脑袋定的。2017 年出现的 DLPack——一个让 PyTorch、TVM、MXNet 之间零拷贝交换张量的标准——把一个张量精确定义成DLTensor{ data, device, ndim, dtype, shape, strides, byte_offset }。各家框架内部实现千差万别但要互相递张量时大家同意的最小公约数就是这一个元组。这反过来证明了**张量 一块内存 一组元数据不是某个框架的实现细节是整个领域的共识。**这里有个对 C 程序员特别重要的推论shape 和 stride 是两组正交的量。它们都长度为 4都描述维度但记的是完全不同的事——shape 说逻辑上每维有几个元素stride 说沿这一维走一步指针要跳多少字节。ggml 把这件事摆得很明白它有两个独立的比较函数C// ggml dbe9c0c8, ggml/src/ggml.c:1525, 1535 bool ggml_are_same_shape(const struct ggml_tensor * t0, const struct ggml_tensor * t1) { return (t0-ne[0]t1-ne[0]) (t0-ne[1]t1-ne[1]) (t0-ne[2]t1-ne[2]) (t0-ne[3]t1-ne[3]); } bool ggml_are_same_stride(const struct ggml_tensor * t0, const struct ggml_tensor * t1) { return (t0-nb[0]t1-nb[0]) (t0-nb[1]t1-nb[1]) (t0-nb[2]t1-nb[2]) (t0-nb[3]t1-nb[3]); }两个张量可以 shape 相同而 stride 不同一个连续、一个转置过也可以 stride 相同而 shape 不同。把它们当成两件事后面所有的为什么这个操作不拷贝、那个必须拷贝才讲得清。一句话记住这一章张量的维度是说明书里的数字不是内存里的结构。二、stride 就是指针算术而且它会撒谎说明书里最关键的一栏是 stride。它直接决定了逻辑下标怎么变成内存地址而这个翻译过程就是你每天在 C 里写的指针算术。先把连续布局的 stride 算出来。一个形状(2, 3, 4)的 f32 张量行优先row-majorC 的规矩存储意味着最后一维变化最快相邻元素在内存里挨着。元素步长是这么递推的——最后一维 stride 是 1 个元素往前每维等于后一维的 stride 乘后一维的大小纯文本stride[2] 1 // 最后一维相邻元素挨着 stride[1] stride[2]*ne[2] 1*4 4 stride[0] stride[1]*ne[1] 4*3 12ggml 用的是字节步长而不是元素步长对 C 程序员反而更诚实——因为data是void*跳到下一个元素本来就得按字节加。它的递推公式直接写在结构体注释里再在建张量时落地成代码C// ggml dbe9c0c8, ggml/src/ggml.c:1794ggml_new_tensor_impl 内 result-nb[0] ggml_type_size(type); // 4 (f32) result-nb[1] result-nb[0]*(result-ne[0]/ggml_blck_size(type)); for (int i 2; i GGML_MAX_DIMS; i) { result-nb[i] result-nb[i - 1]*result-ne[i - 1]; }ggml_blck_size对非量化类型恒为 1先当它不存在量化类型才用到第四章再说。对一个 f32 的(2,3,4)注意 ggml 把维度顺序倒过来存——ne[0]是最末维——所以nb[0]4, nb[1]4*416, nb[2]16*348字节。换算回元素就是 1、4、12和上面手算的完全一致。有了 stride索引公式就是全文最核心的一行数学。逻辑下标(i0, i1, ..., in)对应的元素地址是纯文本addr base Σ idx[k] · stride[k] 元素口径 (char*)data Σ idx[k] · nb[k] 字节口径ggml 实际这么算不信翻开 ggml 的 CPU kernel每一个逐元素算子的核心就是这行指针算术一个字都不多C// ggml dbe9c0c8, ggml/src/ggml-cpu/ops.cpp:139 const src_t * src0_ptr (const src_t *) ((char *) src0-data i00*nb00 i01*nb01 i02*nb02 i03*nb03);(char*)data拿到字节基址加上每维下标乘以该维字节步长强转回元素指针。这就是张量索引的全部真相——它从来不是什么多维寻址硬件指令就是一次char*加法。你在大学里写a[i*cols j]手动展开二维数组时干的事和这里一模一样只是维度数变成了运行时变量。现在手算一遍验收。(2,3,4)f32 连续张量nb元素口径是(12, 4, 1)取元素[1, 2, 3]纯文本偏移 1*12 2*4 3*1 12 8 3 23 个元素 92 字节第 23 个 float第 92 个字节。一次乘加落定。讲到这你可能已经看出那句stride 会撒谎是什么意思了。索引公式里地址完全由 stride 决定而 stride 只是说明书里的几个整数——我完全可以给同一块内存配一组不同的 stride让它变成一个不同的张量。内存一个字节没动但读出来的形状变了。行优先和列优先的分裂就是这么来的。同一个数学上的矩阵A[i][j]C 的行优先让stride (cols, 1)Fortran 的列优先让stride (1, rows)。这两套规矩从 1957 年的 Fortran列优先和 1972 年的 C行优先就固定下来吵了半个世纪。而**转置本质上就是在行优先的内存里假装它是列优先的**——shape 交换一下stride 跟着交换一下数据原地不动。下一章我们就看 ggml 怎么用三行代码完成这个假装。三、免费的变形view / transpose / permute 一个字节都不搬把开头那个ggml_is_transposed的悬念收掉。它判断转置只看nb[0] nb[1]是因为**转置压根没碰数据只是把 shape 和 stride 各交换了一下**于是第 0 维的步长变得比第 1 维大——这个反常的步长大小关系就是转置过的全部痕迹。看实现C// ggml dbe9c0c8, ggml/src/ggml.c:3833 struct ggml_tensor * ggml_transpose(struct ggml_context * ctx, struct ggml_tensor * a) { struct ggml_tensor * result ggml_view_tensor(ctx, a); // 复用 a 的内存 result-ne[0] a-ne[1]; result-ne[1] a-ne[0]; // 交换 shape result-nb[0] a-nb[1]; result-nb[1] a-nb[0]; // 交换 stride result-op GGML_OP_TRANSPOSE; result-src[0] a; return result; }逐行验收第一行借来a的内存后两行交换ne和nb的前两项完事。没有循环没有分配没有 memcpy。data指针自始至终指向a的那块内存。一个 64 MiB 的矩阵转置改的就是这2×816个字节的元数据。这就是开头0 字节的来历。permute任意维度重排是同一个套路的推广只是把交换两维换成按一张排列表重排所有维C// ggml dbe9c0c8, ggml/src/ggml.c:3777节选 struct ggml_tensor * result ggml_view_tensor(ctx, a); // 同样复用内存 int ne[GGML_MAX_DIMS], nb[GGML_MAX_DIMS]; ne[axis0]a-ne[0]; ne[axis1]a-ne[1]; ne[axis2]a-ne[2]; ne[axis3]a-ne[3]; nb[axis0]a-nb[0]; nb[axis1]a-nb[1]; nb[axis2]a-nb[2]; nb[axis3]a-nb[3]; // 再把 ne[]/nb[] 写回 result注意第一行transpose和permute都从ggml_view_tensor开始。这个函数才是免费变形的共同地基——它造一个新的ggml_tensor头但让它的data指向源张量的内存C// ggml dbe9c0c8, ggml/src/ggml.c:1751ggml_new_tensor_impl 内view 分支 void * data view_src ! NULL ? view_src-data : NULL; if (data ! NULL) { data (char *) data view_offs; // 借内存 偏移 } // ... view_src NULL 时才会真正 obj_alloc_size data_size 去分配一个视图就是借内存的张量。它在view_src里记下自己借的是谁、在view_offs里记下从哪个字节开始借——这两个字段我们在第一章的结构体里见过现在它们派上用场了。新张量有自己独立的说明书可以有不同的 shape/stride/offset但共用同一份货。PyTorch 把这套机制叫 view官方文档说得很直白视图张量与它的 base 共享底层 data……支持 view 是为了避免显式拷贝从而做到快速、省内存的 reshape、slicing 和逐元素操作。文档还列了一长串返回视图的算子切片索引、transpose、t、permute、expand、narrow、select、squeeze、unsqueeze、diagonal、view本身。**这些操作你随便连用十个内存里的数据一个字节都不会动。** 它们改的全是说明书。我的判断很明确在热点路径上凡是能用 view 类操作表达的形状调整都不要用会拷贝的方式去写。边界条件是——只要后续 kernel 能接受非连续输入view 就是白嫖一旦后续 kernel 要求连续下一章的主角你省下的拷贝会以隐式contiguous的形式加倍还回来。所以问题从来不是view 快不快而是我这个 view 喂给的下一个算子吃不吃非连续输入。四、成本分水岭contiguous、reshape与那次你没看见的拷贝到这里张量操作的两类已经分得很清楚了上一章那些只改说明书的免费这一章这些要重排内存的收费。分水岭就是一个词——contiguous连续。先给连续一个不含糊的定义一个张量是连续的当且仅当它的 stride 恰好等于由当前 shape 按行优先推出来的那组 stride——也就是第二章那个递推公式的结果。换句话说元素在内存里紧挨着、没有空洞、最后一维 stride 等于元素大小、没有哪两维的步长是反的。ggml 把这个判定写成一个逐维核对的循环C// ggml dbe9c0c8, ggml/src/ggml.c:1447 static bool ggml_is_contiguous_n(const struct ggml_tensor * tensor, int n) { size_t next_nb ggml_type_size(tensor-type); if (tensor-ne[0] ! ggml_blck_size(tensor-type) tensor-nb[0] ! next_nb) return false; // 最后一维步长必须等于元素大小 next_nb * tensor-ne[0]/ggml_blck_size(tensor-type); for (int i 1; i GGML_MAX_DIMS; i) { if (i n) { if (tensor-ne[i] ! 1 tensor-nb[i] ! next_nb) return false; // 每维实际步长必须等于推出来的步长 next_nb * tensor-ne[i]; } else { next_nb tensor-ne[i]*tensor-nb[i]; } } return true; }它就是把第二章的递推next_nb一维一维往上乘拿应该是多少和实际是多少比对。一处对不上就不连续。ggml_blck_size在这里现身了——对量化类型元素是一个 block连续性要按 block 大小算这是市面文章基本不会提的细节。普通 f32 张量 block size 是 1退化成上面说的朴素定义。转置过的张量nb[0] nb[1]第一项就挂所以它不连续——这和ggml_is_transposed是同一个事实的两面。为什么连续这么重要因为有一大类操作只在连续时才能零拷贝reshape是头号代表。reshape 要把(2,12)变成(4,6)、把矩阵摊平成一维它的可行前提是新形状能在现有这块内存上、用一组合法 stride 表达出来。当内存连续时元素排布就是一条干净的线怎么切分维度都行改说明书即可可一旦内存有空洞非连续新形状根本没法用任何 stride 在这块乱序内存上描述于是只剩一条路——把数据拷到一块新的连续内存里再说。ggml 干脆把这个前提变成一道铁门。它的reshape第一行就是断言C// ggml dbe9c0c8, ggml/src/ggml.c:3589 struct ggml_tensor * ggml_reshape(struct ggml_context * ctx, struct ggml_tensor * a, struct ggml_tensor * b) { GGML_ASSERT(ggml_is_contiguous(a)); // 不连续直接挂 GGML_ASSERT(ggml_nelements(a) ggml_nelements(b)); // 元素总数必须不变 struct ggml_tensor * result ggml_new_tensor_impl(ctx, a-type, GGML_MAX_DIMS, b-ne, a, 0); // 复用 a 内存 result-op GGML_OP_RESHAPE; result-src[0] a; return result; }ggml 的选择是reshape 永远是纯视图所以它拒绝给非连续张量 reshape——你想 reshape先自己变连续。这是一种把代价显式化的设计要拷贝你自己调ggml_cont我不替你偷偷做。PyTorch 走的是另一条路也是更多人踩坑的那条。它的reshape文档原文是reshape、reshape_as、flatten **可能返回视图也可能返回新张量调用方不应依赖它是不是视图**。翻译成大白话——**reshape 能 view 就 view连续时免费不能 view 就在背后给你拷一份非连续时按字节收费而它不告诉你这次走了哪条路。** 这个不告诉你就是无数性能玄学问题的根。一个在循环里反复x.transpose(0,1).reshape(-1)的代码如果x非连续每次迭代都在全量复制而代码表面上一行拷贝都看不到。反过来view比 reshape 诚实——它**只做视图做不了就报错**。这就是那个经典翻车现场Pythonx torch.randn(4096, 4096) # 连续 y x.transpose(0, 1) # 0 拷贝但 y 非连续nb[0] nb[1] y.view(-1) # RuntimeError: # view size is not compatible with input tensors size and stride # (at least one dimension spans across two contiguous subspaces). # Use .reshape(...) instead. y.contiguous().view(-1) # ✅ 先花 64 MiB 拷成连续再 0 拷贝 view y.reshape(-1) # ✅ 等价reshape 替你判断 必要时拷贝contiguous()就是那个明码标价的收费站。文档说它已经连续就返回自身否则拷贝出一个新的连续张量。来算这笔账用我们的标尺张量(4096, 4096)f32 4096×4096×4 67,108,864字节 64 MiB操作搬运字节复杂度说明x.transpose(0,1)0O(1)交换 16 字节元数据x.permute(...)/ 切片 /x.view(...)0O(1)同上改说明书y.contiguous()y 已转置非连续读 64 MiB 写 64 MiB ≈128 MiBO(n)逐元素按新顺序搬一遍y.reshape(-1)y 非连续同上 ≈128 MiBO(n)静默触发 contiguous最值钱的认知在这张表里transpose 不慢慢的是它之后第一个要求连续输入的算子。很多人观测到加了个 transpose 性能就崩了归罪于转置——冤枉它了。转置本身 0 字节是它产出的非连续张量喂给了一个reshape或一个要求连续的 matmul kernel触发了那次 128 MiB 的隐式搬运。诊断这类问题盯的不是 transpose是它下游第一个吃不下非连续输入的消费者。我的明确推荐带边界**如果一个非连续张量只被 view 类操作和支持非连续的逐元素算子消费就别contiguous()省下这 128 MiB如果它马上要喂给 reshape、要喂给只接受连续输入的 GEMM/卷积 kernel就主动、显式地contiguous()一次**别让它在循环里被隐式触发 N 次。把那次拷贝从看不见、可能重复变成看得见、只发生一次——这是连续这件事上唯一重要的纪律。五、broadcasting把 stride 设成 0 制造的复制幻觉你写a ba是(1024, 1024)b是(1024,)。两个形状不一样加法却成立b仿佛被复制了 1024 行去匹配a。这就是 broadcasting。问题来了那 1024 份b的拷贝在内存里真的存在吗不存在。又是 stride 在撒谎。先把规则讲清楚。NumPy/PyTorch 的广播对齐分两步把两个 shape 右对齐末维对末维然后逐维检查——要么相等要么其中一个是 1否则报错高位缺的维度按 1 补齐。(1024,1024)和(1024,)右对齐后b补成(1, 1024)逐维看1024 vs 1其一为 1过b这维要广播、1024 vs 1024相等过。结果形状(1024, 1024)。关键在广播那一维的实现。被广播的维度逻辑上从 1 变成了 1024但内存里只有 1 份数据。怎么让 1024 个不同的逻辑下标都读到这同一份数据把这一维的 stride 设成 0。回到第二章的索引公式纯文本addr base Σ idx[k] · stride[k]如果第 k 维stride[k] 0那么不管idx[k]是 0 还是 1023这一项idx[k]·0永远是 0指针在这一维上**原地踏步**。于是 1024 个逻辑行全部映射到同一块物理内存——复制的效果0 字节的成本。PyTorch 的expandbroadcasting 的显式版本就是干这个的它返回一个 view把被扩展维的 stride 置 0。来算这笔账。(1024, 1) (1, 1024)广播成(1024, 1024)如果老老实实把两个操作数都物化到(1024,1024)每个要1024×1024×4 4 MiB两个 8 MiB。stride0 的实现——**额外 0 字节**两个操作数各自只占原来的 4 KiB。这就是为什么你能在一个 8GB 的卡上对超大张量做广播加法而不爆显存。ggml 的实现路子稍有不同但更直白地暴露了广播 索引重映射这件事。它不用 stride0而是在 kernel 里对源坐标取模C// ggml dbe9c0c8, ggml/src/ggml-cpu/binary-ops.cpp:85 const int64_t i13 i03 % ne13; // 输出第3维坐标 → 源第3维坐标 const int64_t i12 i02 % ne12; const int64_t i11 i01 % ne11; // 然后用 i1x 去算 src1 的地址src1-data i13*nb13 i12*nb12 i11*nb11当src1在某维只有 1 个元素ne1x 1i0x % 1恒等于 0——和 stride0 殊途同归源指针在这维永远停在第 0 个。无论 stride 置零还是坐标取模**重复都只发生在地址计算里从不发生在内存里。** 这是同一个真相的两种写法。ggml 判断能不能广播的函数也值得一看它的判据比 NumPy 更宽C// ggml dbe9c0c8, ggml/src/ggml.c:1550 bool ggml_can_repeat(const struct ggml_tensor * t0, const struct ggml_tensor * t1) { return ggml_is_empty(t0) ? ggml_is_empty(t1) : (t1-ne[0]%t0-ne[0] 0) (t1-ne[1]%t0-ne[1] 0) (t1-ne[2]%t0-ne[2] 0) (t1-ne[3]%t0-ne[3] 0); }它要求t1每维是t0对应维的整数倍而不是 NumPy 那种必须是 1 或相等。这是 ggml 服务于神经网络计算的工程取舍知道就行写 PyTorch 时仍按1 或相等那套来。这里埋着一个第七章会引爆的雷先点一句对 stride0 的广播张量做 in-place 写入是危险的。因为多个逻辑下标指向同一个物理地址你以为在写 1024 个不同的格子其实在反复践踏同一个格子。PyTorch 对expand出来的张量直接禁止 in-place 写就是这个原因。记住这个伏笔。六、dtype 提升与 .to(device)两种隐式转换的真实代价说明书五元组里还剩两栏没拆dtype和device。它们各自藏着一种你没写、但框架替你做了的隐式转换代价一个在精度、一个在带宽。先说 dtype 提升。int_tensor 1.5该是什么类型float16 float32呢PyTorch 用一套类别格点category lattice来定类别从低到高是**bool 整型 浮点 复数**。官方文档给的规则我浓缩成三条跨类别高类别赢。long float → float32整型让位给浮点float complex → complex。同类别更宽的赢。float32 float64 → float64int8 int32 → int32。Python 标量不抬张量的类别只在同类别内保证够装。这条最反直觉int32_tensor 5仍是int32——尽管 Python 的5在 PyTorch 里默认按int64算它也不会把张量从 int32 抬到 int64。但int_tensor 1.5会变浮点因为1.5是更高类别浮点的标量。默认值也要记牢Python 浮点标量按torch.get_default_dtype()默认 float32整型标量按 int64所以torch.tensor([1.0])给你 float32torch.arange(10)给你 int64。验收三个例子Python(torch.ones(3, dtypetorch.int32) 5).dtype # torch.int32 规则3标量不抬类别 torch.add(torch.ones(3, dtypetorch.long), torch.ones(3, dtypetorch.float32)).dtype # torch.float32规则1跨类别浮点赢 (torch.ones(3, dtypetorch.float32) torch.ones(3, dtypetorch.float64)).dtype # torch.float64规则2同类别更宽赢这套规则的工程陷阱在混合精度训练里你精心把模型权重设成 f16 省显存结果某处f16_tensor f32_scalar整个结果被悄悄抬成 f32省显存的初衷当场落空。反方向也有坑——f16 累加大量数值会溢出/精度塌缩这时你**需要**显式.float()把它抬上去再算。dtype 提升不是越自动越好它是一个你必须心里有数的隐式开销。dtype 决定的字节宽是一切账本的基数f324 字节、f16/bf162、i81、f648——ggml 的类型表里就是一串sizeof(float)、sizeof(int8_t)。再说device这是整篇文章里最贵的操作没有之一。.to(cuda)不改 shape、不改 stride、不改 dtype它干的是把那块一维内存**整个搬到另一颗芯片的内存上**。我们的标尺张量 64 MiB过一次 PCIe Gen4 x16纯文本PCIe Gen4 x16 理论带宽 ≈ 32 GB/s实测可用 ≈ 25 GB/s 64 MiB ÷ 25 GB/s ≈ 2.6 ms ← 一次 Host→Device 拷贝2.6 毫秒。对比一下同样这 64 MiB如果已经在 H100 的显存里、要在显存内部挪一下HBM3 带宽 ~3 TB/s只要64 MiB ÷ 3 TB/s ≈ 22 µs。**跨设备搬运比设备内搬运贵两个数量级。** 这个数量级差就是所有.to(device)要慎用建议背后的硬件事实。由此给两条带边界的明确推荐常量别在循环里搬。见过太多训练代码把一个固定的 mask / 位置编码.to(device)写在for batch内部每步白交 2.6 ms 的 PCIe 过路费。把它提到循环外搬一次结束。**大批量小数据搬运上pin_memory()non_blockingTrue。** 锁页内存pinned让 DMA 引擎能在不阻塞 CPU 的情况下直接传输non_blockingTrue让拷贝和计算重叠。边界是只有 pinned 内存配non_blocking才真异步普通可分页内存上non_blocking基本是空头支票。dtype 和 device 的共同点是它们都是说明书里不起眼的一栏却各自挂着一类隐式成本——一类悄悄改变你的精度和显存一类悄悄吃掉你的毫秒。把它们当成显式决策而不是默认行为。七、in-place 与 aliasing原地操作如何撕裂别名最后这一章是整篇最值钱的因为它讲的坑最隐蔽而且根因正是前面所有章节铺垫的那个事实——视图共享 storage。C 程序员对别名aliasing不陌生两个指针指向同一块内存通过一个改了另一个看到的值也变。张量的 view 就是张量级别的别名而 in-place 操作PyTorch 里带下划线的add_、mul_或者则是触发别名 bug 的扳机。先看 in-place 在源码层到底是什么。ggml 的加法有 in-place 和非 in-place 两个版本区别浓缩在一行三目运算里C// ggml dbe9c0c8, ggml/src/ggml.c:2024ggml_add_impl static struct ggml_tensor * ggml_add_impl(struct ggml_context * ctx, struct ggml_tensor * a, struct ggml_tensor * b, bool inplace) { GGML_ASSERT(ggml_can_repeat(b, a)); struct ggml_tensor * result inplace ? ggml_view_tensor(ctx, a) // in-place结果是 a 的视图共享内存 : ggml_dup_tensor(ctx, a); // 非 in-place复制一份新内存 result-op GGML_OP_ADD; result-src[0] a; result-src[1] b; return result; }看明白了吗in-place 的本质就是让结果张量成为输入的一个视图——ggml_view_tensor(ctx, a)我们第三章见过它它复用a的data指针。非 in-place 走ggml_dup_tensor分配新内存。所以 in-place 省的就是那一次分配 拷贝。ggml 里几乎每个算子都有这么一对孪生ggml_sub_inplace、ggml_mul_inplace、ggml_div_inplace……全部靠ggml_view_tensor复用内存实现。in-place 不是什么特殊指令就是结果别名输入。省内存是好事撕裂别名就是代价。三个真实的翻车现场从轻到重现场一改 view 顺手改了 base。这是最朴素的别名 bugPythona torch.zeros(4, 4) b a[0] # b 是 a 第 0 行的视图0 拷贝共享内存 b 1 # in-place 改 b print(a[0]) # tensor([1., 1., 1., 1.]) ← a 跟着变了b a[0]是切片第三章说过它返回视图。b 1是 in-place直接写共享的那块内存于是a的第 0 行也变了。如果你以为b是个独立副本这里就是个潜伏的 bug。C 里你不会对int* b a[0]; *b 1然后惊讶a[0]变了——张量切片是一回事只是语法把指针藏起来了。想要独立副本得显式b a[0].clone()。现场二对需要梯度的叶子张量做 in-place。这是 autograd 的硬性禁止Pythonw torch.randn(3, requires_gradTrue) w - 0.1 * w.grad # RuntimeError: a leaf Variable that requires # grad is being used in an in-place operation.w是叶子节点leaf计算图的输入autograd 需要它保持稳定来反向传播。正确写法是用torch.no_grad()包起来或者干脆交给 optimizer——这也是为什么optimizer.step()内部更新参数是包在no_grad里的。现场三in-place 改掉了反向传播要用的中间值。这是最隐蔽的PyTorch 用 version counter 兜底Pythonx torch.randn(5, requires_gradTrue) y x.sigmoid() # 反向时sigmoid 的梯度公式要用到 y 本身σσ(1-σ) y.add_(1) # in-place 改了 yversion counter 自增 y.sum().backward() # RuntimeError: one of the variables needed for # gradient computation has been modified by an # inplace operation ...PyTorch 的机制是这样的官方 autograd 文档每个张量都有一个 version counter任何 in-place 操作都会让它自增当一个算子为反向保存某个张量时会连它当时的版本号一起存下来反向真正执行时比对当前版本号和保存时的版本号一旦发现被人 in-place 改过版本号变大立即报错。这个设计的价值在于——文档原话——如果你用了 in-place 操作又没看到任何报错那你可以确信算出来的梯度是对的。它把一类静默的错误梯度变成了一个吵闹的崩溃。文档还点出 in-place 在 autograd 下被严格限制的两个根本原因① in-place 可能覆盖掉求梯度所必需的值现场三② 每个 in-place 操作都要求重写计算图而当多个张量通过索引、转置共享同一块 storage 时这件事会变得很棘手所以 autograd 干脆在这些情况下报错。两条都指向同一个源头共享 storage。那 in-place 到底该不该用我的判断带明确边界推理、no_grad 上下文、手写不走 autograd 的 kernel比如你在 ggml 这层写算子——放心用 in-place省下的分配和拷贝是实打实的没有反向图要维护。训练里、autograd 计算图上——默认不用 in-place除非你非常确定这个张量不参与任何反向、且没有别的 view 指着它。PyTorch 文档自己都说autograd 激进的缓冲区释放和复用已经很高效了in-place 很少能真正省下多少内存——在常规训练里in-place 的收益小、风险大不划算。**永远不要对expand出来的、stride0 的广播张量做 in-place 写**第五章埋的雷。多个逻辑下标映射到同一物理地址in-place 写入会让它们互相践踏结果完全不可预测——所以 PyTorch 直接禁止这种写法。一句话收束这章in-place 的危险从来不是覆盖了数据这么简单是它让结果别名了输入而你周围可能还有别的视图、别的计算图节点正盯着同一块内存。八、把张量当 struct 看绕了一大圈回到开头那句话张量是一块一维内存加一张说明书。现在这张说明书的五栏你都拆过了——shape 说逻辑形状stride 说指针怎么跳offset 说从哪开始dtype 说每个元素几个字节device 说货在哪颗芯片上。所有看起来高深的操作落到这层都是在改这几个数或者在搬那块内存。把它压成一张你下次写代码能直接用的判断清单你写的操作改说明书还是搬内存代价要警惕什么transpose/permute/t改说明书0 字节O(1)产出非连续张量下游可能触发隐式拷贝切片 /view/squeeze/unsqueeze改说明书0 字节O(1)是视图改它会改 base别名expand/ broadcasting改说明书stride00 字节禁止对它 in-place 写reshape看情况连续时 0非连续时全量拷贝静默拷贝是性能玄学高发区contiguous搬内存若非连续读 N 写 N明码标价的收费站主动调、只调一次clone搬内存全量拷贝要的就是独立副本时用它dtype 提升 /.float()搬内存新 dtype 缓冲全量拷贝混合精度里悄悄吃显存/丢精度.to(device)搬内存跨芯片跨 PCIe比设备内贵两个数量级别放循环里大批量配 pin non_blockingadd_/等 in-place改说明书结果别名输入省一次分配autograd 下慎用version counter 会抓你这张表的左半边改说明书随便连用不要钱右半边搬内存每一次都按字节计费——把任意一行 PyTorch 代码在脑子里翻译成这步落在左边还是右边你对它内存行为的判断就八九不离十了。最后给一眼历史因为它能解释为什么这套设计是稳的而不是 PyTorch 的一时之选。1957 年 Fortran 选了列优先、1972 年 C 选了行优先同一个矩阵两套 stride的分裂从语言层就埋下了。1960 年代 APL 把多维数组当成一等公民立下了形状 索引的数组编程范式。2006 年 NumPy 把(data, shape, strides, dtype)四元组确立为 strided 数组的事实标准2011 年 Torch7 已经是 storage 加 (size, stride, offset) 的分离结构PyTorch 2016 年原样继承。2017 年 DLPack 用一个统一的元数据元组让各框架零拷贝交换张量2020 年 PyTorch 又用 channels_last 证明了 shape 和 stride 解耦的工程价值——同一个 NCHW 逻辑形状换一组物理 stride就能给卷积选更优的内存布局。**七十年间从语言到框架到跨框架标准所有人都收敛到了同一个答案张量 元数据 一块内存。** 你刚才拆的不是 PyTorch 的实现细节是整个数组计算领域的公理。留一个开放问题给你既然 shape 和 stride 能解耦到 channels_last 这种程度那么一块内存能被多少种合法的 (shape, stride) 说明书解读本身就是一个值得琢磨的设计空间——稀疏张量、量化 block、ragged/nested tensor全都是在这张说明书上做文章。下次你再import torch希望你看到的不再是一个多维数组的黑箱而是一个你能亲手拆开、亲手算清每一笔内存账的 C struct。