张量根本不是多维数组:一个 C++ 程序员把 PyTorch 张量拆到底
2026/7/8 10:22:49 网站建设 项目流程

给一个写了十年 C++ 的人看torch.randn(4096, 4096),他脑子里大概率浮现的是float**,或者std::vector<std::vector<float>>。两个都错。

来做个实验。建一个 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; // dtype:f32 / 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 的张量最多四维,nenb各是一个长度为 4 的数组。注意data的类型是void*——它就是一个指针,指向一段连成一条线的字节。所谓"四维张量",在这一层是一根一维的内存条。

PyTorch 的TensorImpl字段更多(要扛 autograd、设备分发、量化),但骨架一模一样:一个Storage(引用计数的一维字节缓冲)+sizes(shape)+strides(步长)+storage_offset(起始偏移)+dtype+device。我把这套东西统称张量的**元数据五元组**:(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-major,C 的规矩)存储意味着:最后一维变化最快,相邻元素在内存里挨着。元素步长是这么递推的——最后一维 stride 是 1 个元素,往前每维等于"后一维的 stride 乘后一维的大小":

纯文本

stride[2] = 1 // 最后一维,相邻元素挨着 stride[1] = stride[2]*ne[2] = 1*4 = 4 stride[0] = stride[1]*ne[1] = 4*3 = 12

ggml 用的是字节步长而不是元素步长,对 C++ 程序员反而更诚实——因为datavoid*,跳到下一个元素本来就得按字节加。它的递推公式直接写在结构体注释里,再在建张量时落地成代码:

C

// ggml dbe9c0c8, ggml/src/ggml.c:1794(ggml_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*4=16, nb[2]=16*3=48字节。换算回元素就是 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的内存,后两行交换nenb的前两项,完事。没有循环,没有分配,没有 memcpy。data指针自始至终指向a的那块内存。一个 64 MiB 的矩阵转置,改的就是这2×8=16个字节的元数据。这就是开头"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

注意第一行:transposepermute都从ggml_view_tensor开始。这个函数才是"免费变形"的共同地基——它造一个新的ggml_tensor头,但让它的data指向源张量的内存:

C

// ggml dbe9c0c8, ggml/src/ggml.c:1751(ggml_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 和逐元素操作"。文档还列了一长串"返回视图"的算子:切片索引、transposetpermuteexpandnarrowselectsqueezeunsqueezediagonalview本身。**这些操作你随便连用十个,内存里的数据一个字节都不会动。** 它们改的全是说明书。

我的判断很明确:在热点路径上,凡是能用 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 诚实——它**只做视图,做不了就报错**。这就是那个经典翻车现场:

Python

x = 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 tensor's 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)

0

O(1)

交换 16 字节元数据

x.permute(...)/ 切片 /x.view(...)

0

O(1)

同上,改说明书

y.contiguous()(y 已转置非连续)

读 64 MiB + 写 64 MiB ≈128 MiB

O(n)

逐元素按新顺序搬一遍

y.reshape(-1)(y 非连续)

同上 ≈128 MiB

O(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 的expand(broadcasting 的显式版本)就是干这个的:它返回一个 view,把被扩展维的 stride 置 0。

来算这笔账。(1024, 1) + (1, 1024)广播成(1024, 1024):如果老老实实把两个操作数都物化到(1024,1024),每个要1024×1024×4 = 4 MiB,两个 8 MiB。stride=0 的实现——**额外 0 字节**,两个操作数各自只占原来的 4 KiB。这就是为什么你能在一个 8GB 的卡上对超大张量做广播加法而不爆显存。

ggml 的实现路子稍有不同,但更直白地暴露了"广播 = 索引重映射"这件事。它不用 stride=0,而是在 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 == 1),i0x % 1恒等于 0——和 stride=0 殊途同归,源指针在这维永远停在第 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 或相等"那套来。

这里埋着一个第七章会引爆的雷,先点一句:对 stride=0 的广播张量做 in-place 写入是危险的。因为多个逻辑下标指向同一个物理地址,你以为在写 1024 个不同的格子,其实在反复践踏同一个格子。PyTorch 对expand出来的张量直接禁止 in-place 写就是这个原因。记住这个伏笔。


六、dtype 提升与 .to(device):两种"隐式转换"的真实代价

说明书五元组里还剩两栏没拆:dtypedevice。它们各自藏着一种"你没写、但框架替你做了"的隐式转换,代价一个在精度、一个在带宽。

先说 dtype 提升。int_tensor + 1.5该是什么类型?float16 + float32呢?PyTorch 用一套类别格点(category lattice)来定,类别从低到高是:**bool < 整型 < 浮点 < 复数**。官方文档给的规则,我浓缩成三条:

  1. 跨类别,高类别赢。long + float → float32(整型让位给浮点),float + complex → complex

  2. 同类别,更宽的赢。float32 + float64 → float64int8 + int32 → int32

  3. 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])给你 float32,torch.arange(10)给你 int64。验收三个例子:

Python

(torch.ones(3, dtype=torch.int32) + 5).dtype # torch.int32 (规则3:标量不抬类别) torch.add(torch.ones(3, dtype=torch.long), torch.ones(3, dtype=torch.float32)).dtype # torch.float32(规则1:跨类别浮点赢) (torch.ones(3, dtype=torch.float32) + torch.ones(3, dtype=torch.float64)).dtype # torch.float64(规则2:同类别更宽赢)

这套规则的工程陷阱在混合精度训练里:你精心把模型权重设成 f16 省显存,结果某处f16_tensor + f32_scalar,整个结果被悄悄抬成 f32,省显存的初衷当场落空。反方向也有坑——f16 累加大量数值会溢出/精度塌缩,这时你**需要**显式.float()把它抬上去再算。dtype 提升不是越自动越好,它是一个你必须心里有数的隐式开销。dtype 决定的字节宽是一切账本的基数:f32=4 字节、f16/bf16=2、i8=1、f64=8——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_blocking=True。** 锁页内存(pinned)让 DMA 引擎能在不阻塞 CPU 的情况下直接传输,non_blocking=True让拷贝和计算重叠。边界是:只有 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:2024(ggml_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),我们第三章见过它,它复用adata指针。非 in-place 走ggml_dup_tensor,分配新内存。所以 in-place 省的就是那一次分配 + 拷贝。ggml 里几乎每个算子都有这么一对孪生:ggml_sub_inplaceggml_mul_inplaceggml_div_inplace……全部靠ggml_view_tensor复用内存实现。in-place 不是什么特殊指令,就是"结果别名输入"。

省内存是好事,撕裂别名就是代价。三个真实的翻车现场,从轻到重:

现场一:改 view 顺手改了 base。这是最朴素的别名 bug:

Python

a = 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 的硬性禁止:

Python

w = torch.randn(3, requires_grad=True) 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 兜底:

Python

x = torch.randn(5, requires_grad=True) y = x.sigmoid() # 反向时,sigmoid 的梯度公式要用到 y 本身(σ'=σ(1-σ)) y.add_(1) # in-place 改了 y,version 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出来的、stride=0 的广播张量做 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

改说明书(stride=0)

0 字节

禁止对它 in-place 写

reshape

看情况

连续时 0;非连续时全量拷贝

静默拷贝,是性能玄学高发区

contiguous

搬内存(若非连续)

读 N + 写 N

明码标价的收费站,主动调、只调一次

clone

搬内存

全量拷贝

要的就是独立副本时用它

dtype 提升 /.float()

搬内存(新 dtype 缓冲)

全量拷贝

混合精度里悄悄吃显存/丢精度

.to(device)

搬内存(跨芯片)

跨 PCIe,比设备内贵两个数量级

别放循环里;大批量配 pin + non_blocking

add_/+=等 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。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询