硬件与张量 入门

张量就是一个指针、一个形状和一组步长,铺在只有一维的内存上面。GPU 挑剔的所有事情 —— 连续性、合并访存、分块,以及它希望你的维度取成什么形状 —— 全都从这一个事实推出来。本页每一个数字都由旁边那张图算出来,所以你可以把它拖到任何状态,结论依然成立。

01

张量 = 一个指针、一个形状、一组步长

内存只有一维。深度学习里的每一个矩形,都是铺在一条线上的算术。

当你写下 A[2, 1],机器并不会去找「第 2 行」。根本没有第 2 行。有的只是一段缓冲区开头的指针、一个说明你约定怎么读它的形状,以及每个轴上说明该跨多远的步长。沿着缓冲区拖动滑块,看看它对应矩形里的哪一格:

第 0 格装的是 A[0, 0]

注意这条遍历路径是一条直线。缓冲区自始至终都按格子顺序排列;折叠的是矩形,每四格折一行。第 1 行从开始,原因只有一个:它前面有四列。

「前面有四列」就是整个下标公式。行步长是 4,因为一行有四个元素;列步长是 1,因为列与列相邻;偏移量就是乘完再加的结果。设定 i 和 j,在那条线上读出这个和:

A[0, 0] 落在第 0 格

看青色的括号量出 i × 4,再由琥珀色的那个加上 j。这就是 offset = i·s₀ + j·s₁ 的全部,而且可以推广:k 阶张量有 k 个步长,偏移量就是下标和步长的点积。 PyTorch 存的就是这三样 —— storage()、stride()、storage_offset() —— 下面每一个操作改的都是其中之一。

没有谁规定步长必须是 (4, 1)。NumPy 和 PyTorch 默认如此; Fortran、MATLAB 以及 BLAS 的每一个后代都默认 (1, 3)。在两者之间切换,看视图的第一行落在哪四格上:

步长为 4 和 1

因为行优先把同一行的各列摆在一起,所以一行在缓冲区里是一段连续区间,列则是跨步区间;列优先则反过来。这不是口味问题。这就是torch.matmul 碰到转置操作数时有时会先复制的原因,也是列优先的 cuBLAS 通常被刻意喂进转置后的 B 的原因。

地址里还差一样东西:一格有多宽。步长数的是元素,硬件数的是字节, dtype 就是这两者之间的汇率。走一遍 2026 年的模型真正在用的三种宽度,看同样这十二个元素怎么缩水:

fp32 把 700 亿参数装进 260.8 GiB

注意缓冲区变短了,形状却纹丝不动:三行都是 (3, 4),变的只有 element_size()。把它乘上真实的参数量,这就不再是细节 —— 700 亿权重在 fp32 下是 260.8 GiB,bf16 下 130.4 GiB,,而一张 H100 只带 80 GB。

02

视图不过是步长算术

转置、切片、广播。它们都不碰一个字节 —— 直到其中一个不得不碰,而那次复制不会提前打招呼。

一旦地址变成了公式,你写下的大部分操作改的就是公式而不是数据。转置什么都不搬:它只是交换形状里的两项和步长元组里的两项。在 A 和 Aᵀ 之间切换,看存储纹丝不动:

步长现在是 4 和 1,而且什么都没复制

注意那些连线。在 A 下,视图按阅读顺序直直落下;在 Aᵀ 下它们互相交织,因为按转置自己的顺序读,每一步都要跳四格。字节完全相同,走法却不同 —— 这个差别就是本页后半部分全部的性能故事。不管 A 有多大,A.t() 都是 O(1)。

切片是同一个把戏再加两个旋钮。从某一列开始每隔 step 列保留一列,相当于给偏移量加上 start × s₁,再把 s₁ 乘以 step。拖动两个滑块,看被保留的列落在那条线的哪些位置:

偏移 0,列步长 1

因为两处改动都只是算术,A[:, 1::2] 生成起来毫无代价 —— 而且它和 A 共享存储,往切片里写就是在改 A。这种别名是免费视图的代价,一整类 bug 就住在这里:一个你以为是副本的切片,以及一个悄悄透过它写下去的循环。

第三种改动是怪的那个。步长为零,就让一行存储替任意多行作答,因为所有下标都映射到同一个地址。把行数往上调,看存储拒绝跟着长大:

从 4 格存储里读出 1 行

看那些连线怎么汇聚到一起。把一个偏置广播到 4096 行,代价是四格而不是 16384 格 —— 这就是在(4096, 768) 的激活上写 x + b 时,b 不需要额外分配的原因。步长是 0,内存只有一行,剩下的 4096 次重读由缓存兜住,几乎免费。

那么视图什么时候不再免费?恰好是它强加给缓冲区的遍历不再每步 +1 的时候。这就是连续的定义,也是 .view() 在答应重新解释形状之前检查的条件。把步长调大,直到这条遍历路径断掉:

连续,所以 view 返回视图

step = 1 时遍历是十二次 +1,.view(-1) 免费。到了,它出现了空洞,.view() 直接报错,而 .reshape() 会不声不响地分配并复制。麻烦就在这份安静:reshape 是宽容的那个,所以大家都伸手去拿它,于是一次 O(1) 的调用变成了一次 O(n) 的分配,而且是在一个要跑一百万次的循环里。写成x.contiguous().view(-1),复制就发生在你放它的地方。

03

一次遍历真正的代价

内存系统不卖你要的那几个字节。它卖的是32 字节的扇区,而且没有更小的规格。

到目前为止,跨步遍历的代价只是一次乘法。硬件不同意,因为这世上没有哪个内存系统会只送四个字节。 NVIDIA GPU 处理一次全局内存请求的粒度是32 字节的扇区。沿着缓冲区拖动滑块,要一个 fp32,看看实际送到的是什么:

元素 0 住在扇区 0

注意顺带被搬来的那七个元素。它们跨过了内存总线,落进了 L2,然后被扔掉。单看这一次只是个趣闻 —— 如果你下一个要的是元素 1,那个扇区已经在手里了。只有当你要的东西彼此都不相邻时,代价才会显形。

这就把机器的另一半牵进来了。GPU 不会只发一条 load;它按 warp 发 —— 32 条 lane,一条指令,32 个地址。地址连续时,整个 warp 只要四个扇区。地址跨步时,可能要 32 个。把步长拉开,看扇区怎么翻番:

步长 1 时,warp 取回 4 个扇区,只用上其中 100%

看 lane 怎么散开,扇区怎么在它们身后一格格填满。步长为 1 时, warp 要 128 字节,硬件搬 128 字节。到了,它还是只要 128 字节,硬件却搬了 1024 —— 每条 lane 独占一个扇区,八分之七的流量被丢弃。这就是合并访存,也是所有受内存限制的 kernel 上最大的一根杠杆。

这个关系是精确的,值得画成一条曲线而不是只看两个姿势。在每条 lane 都独占一个扇区之前,效率就是 1/步长;到那之后就没什么可再丢的了 —— 拖动同一根步长滑块,在曲线上把它读出来:

步长 1 用上流量的 100%

因为效率是一条双曲线,第一次翻倍是最贵的:步长从 1 到 2 就吃掉一半带宽,而从 4 到 8 只再吃掉八分之一。在 H100 上,这是 3.35 TB/s 的实得带宽与有效带宽之间的差距,而算术一模一样。kernel 什么都没变 —— 变的只是它索要的顺序。

所以同一个求和写成两种写法,就是两个不同的程序。对行优先矩阵沿行求和读的是连续地址;沿列求和则每个扇区只用一个元素。切换方向,看扇区怎么亮起来:

从 1 个扇区里读了 8 个元素

因为这个矩阵的一列是八个相隔 32 字节的元素,读它要触到全部八个扇区,却只用上它们携带的 256 字节里的 32 字节。行版本每八个元素只触一个扇区。同样的循环、同样的 FLOP、八倍的流量 —— 而在 4096 × 4096 上,列版本还会每次都在 L2 里落空。

04

矩阵乘法其实是一张内存排班表

算术几乎是免费的。把A 的行和 B 的列送到算术单元面前,才是全部的工程难题。

矩阵乘法是 Transformer 里计算最密集的操作,可只要写得朴素,它依然受内存限制。每一个输出元素都是A 的一行与B 的一列的点积。在输出上拖动,看一个元素到底要读些什么:

C[0, 0]. 拖动选择输出元素;方向键一次移动一格,Home 回到 C[0, 0]
C[0, 0] 要 6 次乘加

注意旁边那个输出还要再读一次A 的同一行。完全不复用地写下去,一个 n × n 的矩阵乘做 2n³ 次 FLOP,却要读 2n³ 个操作数 —— 每个 2 字节,也就是每字节 0.5 FLOP。而 H100 的算术单元要吃到 295 才不闲着。

这个比值有个名字:算术强度,即 FLOP 数除以从 DRAM 搬运的字节数。抬高它的办法是复用。如果一个线程块持有输出的一个 T × T 分块,再把 A 和 B 对应的条带流过它,那么每个操作数只被读 n/T 次,而不是 n 次。把分块边长拉大,在曲线上读出强度:

边长 1 的分块每字节买到 0.5 FLOP

两条轴都是对数轴,而且都写明了,所以这里的直线是幂律而不是恒定斜率。 bf16 下强度就是 T/2 —— 边长每翻一倍,流量就减半。要够到拐点,也就是算术终于成为瓶颈的那个位置,需要 T = 591,远超一个 SM 装得下的量。为什么 128 就够用,我们马上会回来说。

拐点是机器的属性,不是 kernel 的属性:用峰值算力除以峰值带宽,就得到那个分界强度 —— 在它之下天花板是内存,在它之上天花板是张量核。滑动强度,看你正压在哪一层天花板底下:

每字节 0.5 FLOP 时天花板是 1.7 TFLOP/s

每字节 295 FLOP 以下,你走在屋顶的斜坡上,吞吐就是带宽 × 强度,跟这块芯片上有多少张量核毫无关系。朴素矩阵乘在处封顶在 1.7 TFLOP/s,而这块硬件标称 989(H100 SXM5,NVIDIA 2023 年数据表)。softmax、LayerNorm 和所有逐元素算子,则长期住在那条斜坡上。

把输出画成一块块的,复用就一目了然了。每一块读A 的一条横带和B 的一条竖带,于是整个矩阵乘把每个操作数读 n/T 次。改变边长,看 DRAM 流量:

边长 128 的分块搬运 2.0 GiB

看流量怎么随每次翻倍而减半:T = 128 时 2.0 GiB, 256 时 1.0 GiB,512 时 512 MiB —— 而三个矩阵本身只装得下 96 MiB。这中间的落差由 L2 兜住。H100 有 50 MB 的 L2,所以真实的 128 分块 kernel 量出来的数字,比这个模型预测的更接近理想值。

分块也不能一直长下去,那堵墙是共享内存。两块 T × T的操作数分块,每个元素 2 字节,再做双缓冲让下一次加载和当前计算重叠,一共是 8T² 字节,而一个 SM 能分配的是 228 KiB。把边长推过这堵墙:

边长 128 的分块放得进共享内存

T = 128 时一对分块是 128 KiB,装得下,还给累加器留了余地。到了就是 288 KiB,kernel 根本启动不了 —— 是启动时刻的报错,不是一个跑得慢的 kernel。真正装得下的最大方形分块是 168,而 cuBLAS 挑 128,因为 2 的幂能整除张量核的 MMA 形状,还能给累加器留下寄存器。

05

GPU 为什么挑形状

任何一个不是某个数整数倍的维度,都是你付了钱又扔掉的算术。

分块宽 128,而硬件算不了半块。宽 512 列的输出正好被盖住;宽 520 列就意味着这次启动算了 640 列,再扔掉 120 列。把输出加宽,看填充怎么冒出来:

N = 512 时,最后一个分块有 0% 是填充

注意这份浪费并不和你越过边界多远成正比:在处,最后一个分块有 94% 是填充,而 N = 632 时只有 6%。这叫分块量化,也是生产环境里 Transformer 的各个维度都是 64 或 128 的倍数的原因 —— GPT-2 的 50257 词表被补到 50304,头维度取 64,FFN 宽度取模型宽度的四倍。

在它之上还有第二层量化,粒度更粗。分块是发给 SM 的,而 H100 有 132 个 SM。 132 个分块正好是一波;133 个就是两波,第二波只占住一个 SM,另外 131 个闲着。让分块数跨过一次波的边界:

132 个分块让机器忙碌 100%

看那道悬崖。只多出一个分块的活,占用率就从 100% 掉到,而且要到 264 个分块才恢复。这叫波量化,也是为什么你测的形状和你测的 kernel 一样重要。它随着波数增加而变小 —— 到第八波时,同样多出一个分块只损失 12%,而不是一半。

最后一个形状问题来自数据而不是 kernel。序列长短不一,而张量是矩形,于是一个批次被填充到它最长的那条,之后再由掩码把多出来的部分扔掉。把这一批按长度分桶,看被浪费的算术怎么掉下来:

分成 1 个桶时,55% 的计算是填充

只用一个桶时,这一批 55% 的 FLOP 算的是没人要的 token。按长度排序再,这个数字降到 12%,而且一行 kernel 都不用改。这就是按长度分组的批处理;也正是变长注意力干脆丢掉矩形、改用 cu_seqlens 偏移数组的原因 —— 一个不规则张量,说到底还是一组步长。

06

该记住的东西

一条不变式,三个常数。

下标 (i, j) 的元素就是 base + (offset + i·s₀ + j·s₁) × itemsize 处的字节;不复制的操作都只在改这四个数。三个常数:32 字节扇区、宽 128 的分块、132 个 SM。