LLM WIKI · 课程精读

LEARNING UNIT · 13

GPU 算子与稀疏计算

从算子融合、SIMD、双缓冲和网格步进延伸到广播索引、CSR 与稀疏矩阵向量乘。

已整理章节
10 节
单元来源
9 条视频
总时长
21:20
状态
已发布
学习位置
13 / 20
01

主题讲解 · 01:51

算子融合为何能加速逐元素乘加

学习目标

  • 能区分算子融合、数据并行与硬件 FMA 指令。
  • 能计算分离 kernel 与融合 kernel 的理想化内存流量。
  • 能解释为什么逐元素乘加通常受内存带宽影响。
  • 能说明 fusion 还可减少 kernel launch 与全局同步边界。
  • 能判断 cache、广播、寄存器压力与数值语义对 fusion 的影响。
  • 能避免把“大慢/小快”板书固定映射成某一种硬件。

前置与衔接

本课计算同 shape 张量的逐元素表达式:

D=AB+C.D=A\odot B+C.

每个输出元素满足

Di=AiBi+Ci.D_i=A_iB_i+C_i.

元素之间互不依赖,因此天然适合数据并行。

视频在 00:07 给出这个目标,在 00:14 用“大而慢/小而快”描述存储层次。

图 1

逐元素目标为 D=AB+CD=A\odot B+C;板书用“大而慢/小而快”抽象外部内存与片上存储,具体层级可能是 DRAM、HBM、cache、shared memory 或 register。

原视频 · 00:20 ↗

这个抽象可能对应:

  • CPU 的 DRAM、cache、register;
  • GPU 的 HBM、L2、shared memory、register;
  • 加速器的外部内存、片上 SRAM 与向量寄存器。

不同硬件不必拥有完全相同的层级和显式搬运指令。

核心讲解

1. 数据并行不是算子融合

视频在 00:34 先说明多个计算单元分担任务。

图 2

多个计算单元可分担互不依赖的元素区间;并行度改变任务分配,但每个元素仍要完成输入读取、乘加和结果写回。

原视频 · 00:40 ↗

若有 P 个 worker,可把 N 个元素分成互不重叠的索引集合。

理想均匀负载下,每个 worker 处理约

NP\left\lceil\frac{N}{P}\right\rceil

个元素。

这是并行任务划分。

算子融合回答的是另一个问题:

两种优化可以同时存在,但概念不能混为一谈。

2. 未融合路径物化 temp

未融合实现分两步:

T=AB,T=A\odot B,
D=T+C.D=T+C.

视频从 01:01 开始描述两个函数,在 01:08 进入乘法 kernel。

图 3

分离执行时,mul kernel 产生临时张量 temp 并写回外部内存,add kernel 随后重新读取 temp 与 C。

原视频 · 01:00 ↗

若两个 kernel 之间存在全局可见边界,第一步通常需要让 T 可被第二步读取。

理想化地按元素统计:

  • 乘法 kernel 读取 A、B:2N 次元素读取;
  • 乘法 kernel 写 T:N 次元素写入;
  • 加法 kernel 读取 T、C:2N 次元素读取;
  • 加法 kernel 写 D:N 次元素写入。

总计:

4N reads+2N writes.4N\text{ reads}+2N\text{ writes}.

3. 融合路径不暴露完整 temp

融合 kernel 对每个元素直接执行:

t=AiBi,t=A_iB_i,
Di=t+Ci.D_i=t+C_i.

t 只需存在于寄存器或编译器管理的局部临时值中。

图 4

融合 kernel 在同一执行范围内读取 A、B、C,计算 AB+CA\odot B+C 后直接写 D,避免将完整 temp 暴露为持久全局中间张量。

原视频 · 01:40 ↗

视频从 01:31 转入融合路径,在 01:44 说明一次完成乘加。

理想化流量是:

  • 读取 A、B、C:3N 次元素读取;
  • 写 D:N 次元素写入。

3N reads+N writes.3N\text{ reads}+N\text{ writes}.

相对未融合路径,少了 T 的一次完整写入和一次完整读取。

图 5

相对融合路径,物化 temp 至少多出一次 N 元素写入和一次 N 元素读取;是否真正落到 HBM 仍取决于编译器、cache 与运行时。

原视频 · 01:20 ↗

4. 用字节数比较

设每个元素占 s bytes。

不考虑 cache line、write allocate 与对齐,未融合流量约为

Bunfused=6Ns.B_{unfused}=6Ns.

融合流量约为

Bfused=4Ns.B_{fused}=4Ns.

节省

2Ns2Ns

bytes,理想化总流量下降

6Ns4Ns6Ns=13.\frac{6Ns-4Ns}{6Ns}=\frac13.

这是流量比例,不等于运行时间必然下降三分之一。

5. 为什么逐元素乘加常是带宽受限

每个元素只有一次乘法和一次加法。

按常见 FLOP 口径,这是 2 FLOPs。

FP32 时,融合路径理想化需要 16 bytes:

3×4 B read+1×4 B write.3\times4\text{ B read}+1\times4\text{ B write}.

算术强度为

Ifused=216=0.125 FLOP/B.I_{fused}=\frac{2}{16}=0.125\text{ FLOP/B}.

未融合路径为

Iunfused=2240.0833 FLOP/B.I_{unfused}=\frac{2}{24}\approx0.0833\text{ FLOP/B}.

两者算术量几乎相同,fusion 主要提升有效算术强度并减少数据搬运。

6. fusion 还减少哪些开销

除了 temp 流量,融合通常还可减少:

  • 一次 kernel launch;
  • 两个 kernel 之间的全局完成边界;
  • temp 张量分配与生命周期管理;
  • 调度器与框架的算子开销。

小张量上,launch 开销可能比 HBM 流量更显著。

大张量上,带宽与中间量写回通常更重要。

视频在 01:47 把收益概括为“搬运减少”。

7. fusion 不保证所有数据都只从 HBM 读一次

板书把每次算子边界画成外部内存往返,是为了突出最坏的全局中间量成本。

真实执行可能不同:

  • T 可能命中 cache;
  • 编译器可能已经自动融合;
  • 框架可能使用 lazy evaluation;
  • 写回可能触发 write allocate;
  • 多个消费者可能需要保留 T;
  • 内存系统可能进行预取与合并事务。

因此应通过 profiler 检查真实 global memory traffic,而不是把板书箭头直接当成 HBM 事务数。

8. fusion 与 FMA 指令不是同一个概念

算子融合是计算图或 kernel 边界优化。

FMA 是单条硬件指令执行

a×b+c.a\times b+c.

融合 kernel 可能被编译成 FMA,也可能仍使用分离的 multiply 与 add 指令。

反过来,一个 FMA 指令也不自动说明多个高层算子已消除全局 temp。

FMA 只有一次最终舍入,可能与先乘后加的两次舍入产生微小数值差异。

严格数值模式可能限制 contraction。

9. 什么时候 fusion 可能不划算

fusion 会扩大单个 kernel 的工作范围,可能带来:

  • 更多寄存器,降低 occupancy;
  • 寄存器 spill,反而增加内存访问;
  • 更复杂的分支或广播索引;
  • 破坏某个库算子的专用高性能实现;
  • 重复计算公共中间量;
  • 延长编译时间和代码尺寸;
  • 不方便多个消费者复用 T。

此外,存在副作用、精确异常语义或必须同步的算子不能任意融合。

10. shape 与广播边界

板书默认 A、B、C、D 具有相同 shape,并按相同连续索引访问。

若存在 broadcasting,例如

ARM×N,BRN,A\in\mathbb{R}^{M\times N}, \quad B\in\mathbb{R}^{N},

融合仍可行,但 B 的索引、cache 复用和向量化方式会变化。

非连续 stride、转置 view 与动态 shape 也会改变性能模型。

跟练与练习

编者练习

计算 FP16 张量的理想化流量 设 N=10,000,000,A、B、C、D、T 都是 FP16。 忽略 cache、对齐和写分配,分别计算未融合与融合的总字节流量,以及节省量。

查看参考答案

FP16 每元素 2 bytes。
未融合:
6Ns=6×107×2=120,000,000 bytes.6Ns=6\times10^7\times2 =120{,}000{,}000\text{ bytes}.
融合:
4Ns=4×107×2=80,000,000 bytes.4Ns=4\times10^7\times2 =80{,}000{,}000\text{ bytes}.
节省:
40,000,000 bytes.40{,}000{,}000\text{ bytes}.
这是算法层的理想化流量,不是 profiler 必然测到的 HBM bytes。

自检

若 T 还有另一个消费者,是否仍能完全删除 T?

不一定。

可选择复制计算、部分融合或保留 T;最优方案取决于 T 的计算成本、消费者数量和内存压力。

常见误区

误区 1:算子融合减少乘加 FLOPs

本例的数学乘法和加法数量没有减少。

主要减少的是中间量流量与调度边界。

误区 2:大慢一定是 HBM,小快一定是 SRAM

这是跨硬件的教学抽象,实际层级可能完全不同。

误区 3:融合后一定使用 FMA

是否 contraction 由 ISA、编译器和数值模式决定。

误区 4:融合越多越快

过度融合可能增加寄存器压力、spill、分支和代码尺寸。

误区 5:理想流量下降三分之一,时间也必降三分之一

运行时间还受 launch、带宽利用率、cache、occupancy 与其他瓶颈影响。

本课小结

  • 未融合逐元素乘加会物化 T,并多出一次完整写入和读取。
  • 理想化总元素事务从 6N6N 降到 4N4N,即字节总量从 6Ns6Ns 降到 4Ns4Ns
  • 逐元素乘加算术强度低,fusion 常通过减少内存流量提升性能。
  • fusion 还可减少 kernel launch、同步与临时张量管理。
  • 算子融合不等于硬件 FMA,也不保证固定倍数加速。
  • 最终收益必须结合 shape、广播、cache、寄存器压力与真实 profiler 判断。
02

主题讲解 · 01:10

SIMD 如何提高逐元素张量吞吐

学习目标

  • 能解释 SIMD 中一条向量指令如何覆盖多个数据 lane。
  • 能从寄存器位宽和元素位宽计算 lane 数。
  • 能比较标量与向量主循环的地址步进和动态指令数。
  • 能处理对齐、尾部、非连续 stride 与 aliasing 边界。
  • 能说明 SIMD 理论宽度不等于固定加速倍数。
  • 能区分 CPU SIMD 与 GPU SIMT 的编程和执行模型。

前置与衔接

本课使用最简单的逐元素加法:

Ci=Ai+Bi.C_i=A_i+B_i.

默认 A、B、C:

  • shape 相同;
  • 元素类型为 FP32;
  • 内存连续;
  • 地址区间不存在破坏优化的重叠。

视频在 00:04 进入两个张量相加,在 00:07 先讲标量版。

本地字幕在 68 秒后出现重复尾句,课程内容不采用该无效重复。

核心讲解

1. 标量主循环一次处理一个元素

标量教学伪指令可写为:

``text load_scalar A[i] -> r0 load_scalar B[i] -> r1 add_scalar r0, r1 -> r2 store_scalar r2 -> C[i] i += 1 ``

图 1

标量教学模型每轮分别 load AiA_iBiB_i,执行一次标量 add,再 store CiC_i,随后地址前进一个 FP32 元素。

原视频 · 00:10 ↗

视频在 00:10 描述循环,在 00:11 进入 A 的标量 load。

FP32 占 4 bytes,因此地址每轮前进

4 bytes.4\text{ bytes}.

处理 N 个元素,主循环需要约 N 次标量 add 指令。

2. 标量不等于 CPU 一次只做一件事

图 2

标量 add 每条指令只产生一个元素结果;真实 CPU 仍可能借助乱序执行、多发射与自动向量化提升吞吐。

原视频 · 00:20 ↗

单条标量 add 只产生一个元素结果。

但现代 CPU 还可能具备:

  • 多发射;
  • 乱序执行;
  • 多个 load/store pipeline;
  • 多个标量或向量算术端口;
  • hardware prefetch;
  • 多核和 SMT。

所以“标量版”只是向量化比较的主循环模型,不表示整个处理器严格串行。

3. SIMD 的 lane 数如何计算

SIMD 是 Single Instruction, Multiple Data。

一条向量算术指令对多个 lane 执行同一运算。

若向量寄存器宽 W bits,元素宽 E bits,理论 lane 数为

L=WE.L=\frac{W}{E}.

视频在 00:29 采用 512-bit 示例。

对 FP32:

L=51232=16.L=\frac{512}{32}=16.
图 3

以 512-bit 向量寄存器为例,一次可容纳 512/32=16512/32=16 个 FP32;这是 AVX-512 风格示例,不代表所有 ISA 都有同样宽度。

原视频 · 00:30 ↗

这是 AVX-512 风格教学例子。

并非所有 CPU 支持 512-bit 向量:

  • SSE 常见 128-bit;
  • AVX/AVX2 常见 256-bit;
  • ARM NEON 常见 128-bit;
  • ARM SVE 是可变向量长度;
  • 某些 ISA 会把宽指令拆成多个微操作。

4. 向量主循环一次推进 L 个元素

512-bit、FP32 的概念伪指令:

``text load_vector A[i:i+16] -> v0 load_vector B[i:i+16] -> v1 add_vector v0, v1 -> v2 store_vector v2 -> C[i:i+16] i += 16 ``

图 4

向量 load 可把 Ai,,Ai+15A_i,\ldots,A_{i+15} 与对应 B 段载入向量寄存器;连续、对齐和可预测访问更利于高效实现。

原视频 · 00:40 ↗

视频从 00:35 说明一次读取 16 个数,在 00:38 给出 AiA_iAi+15A_{i+15}

地址每次前进

16×4=64 bytes.16\times4=64\text{ bytes}.

视频在 00:53 说明 64-byte 步进。

5. 动态指令数为何下降

忽略尾部,标量主循环约执行:

  • N 次 A load;
  • N 次 B load;
  • N 次 add;
  • N 次 store。

向量宽 L 时约执行:

  • N/LN/L 次向量 A load;
  • N/LN/L 次向量 B load;
  • N/LN/L 次向量 add;
  • N/LN/L 次向量 store。
图 5

一条向量 add 对各 lane 独立相加,向量 store 一次写回一段结果;尾部不足完整向量时需标量余数或 mask。

原视频 · 00:50 ↗

主循环动态指令数近似下降 L 倍。

但 load/store 的总字节数没有下降:

2Ns bytes read+Ns bytes write.2Ns\text{ bytes read}+Ns\text{ bytes write}.

SIMD 改变每条指令覆盖的数据宽度,不会凭空减少必须读取的 A、B 与必须写出的 C。

6. 为什么速度不一定等于 16 倍

视频在 00:58 解释“标量和向量指令周期数相近,向量一次处理更多数据”。

这是有用的直觉,但不是跨 CPU 的固定事实。

图 6

SIMD 降低主循环的动态指令数,但速度不必等于向量宽度:load/store 吞吐、内存带宽、微操作拆分与降频都可能成为瓶颈。

原视频 · 01:00 ↗

实际速度受以下上限约束:

  • 向量 add 的 latency 与 throughput;
  • load/store port 数量;
  • L1/L2/LLC 与 DRAM 带宽;
  • 取指、解码和微操作 cache;
  • 宽向量指令引起的频率变化;
  • 数据对齐、页边界与 cache line;
  • 线程数与 NUMA 放置。

对大张量加法,算术强度很低,常见瓶颈是内存带宽。

即使向量 ALU 还能更快,内存也可能喂不满。

7. 连续存储为何重要

视频板书强调“数据连续、周期数相近”。

连续访问有利于:

  • 一条 vector load/store 覆盖整段;
  • cache line 充分利用;
  • hardware prefetch;
  • 避免 gather/scatter;
  • 编译器证明安全向量化。

若张量是转置 view 或大 stride:

xi=base+istride,x_i=base+i\cdot stride,

向量化可能需要 gather,吞吐通常低于连续 load。

8. 对齐与 unaligned load

现代 ISA 常支持未对齐向量 load。

“未对齐”不等于一定故障。

但跨 cache line 或页边界时,可能产生额外事务。

编译器可能:

  • 生成 unaligned load;
  • 用 peel loop 先走到对齐地址;
  • 运行时检查后选择对齐版本;
  • 对很短输入放弃向量化。

最优策略依赖 ISA 与微架构。

9. 尾部元素怎么处理

N=qL+r,0r<L,N=qL+r, \qquad 0\le r<L,

主循环处理 q 个完整向量。

剩余 r 个元素可用:

  • 标量 cleanup loop;
  • masked vector load/store;
  • predicate register;
  • padding,但必须保证越界安全。

不能直接越界读取或写入。

10. 自动向量化需要编译器证明什么

编译器通常要确认:

  • 循环迭代间无真实数据依赖;
  • A、B、C 不以危险方式 alias;
  • 操作满足所选浮点语义;
  • trip count 或 remainder 可处理;
  • 访问模式可高效向量化。

语言中的 restrict、alignment hint 或高层 tensor 编译器的 shape/stride 信息可能帮助证明。

错误 hint 会导致未定义行为或错误结果。

11. SIMD 与 GPU SIMT 的区别

CPU SIMD 通常是一条向量指令显式操作一个向量寄存器。

GPU 常以 SIMT 暴露线程模型:多个线程按 warp/wavefront 成组发射。

两者都利用数据并行,但:

  • 编程抽象不同;
  • 分支发散处理不同;
  • 寄存器与线程映射不同;
  • load coalescing 规则不同。

不能把 512-bit CPU 向量寄存器直接等同于 GPU warp。

跟练与练习

编者练习

计算 AVX2 FP32 主循环次数 假设 AVX2 向量宽 256 bits,N=1003。

  1. 每个向量可放多少个 FP32?
  2. 完整向量循环多少次?
  3. 尾部多少个元素?
查看参考答案

lane 数:
L=25632=8.L=\frac{256}{32}=8.
完整向量次数:
q=10038=125.q=\left\lfloor\frac{1003}{8}\right\rfloor=125.
覆盖
125×8=1000125\times8=1000
个元素。
尾部:
r=10031000=3.r=1003-1000=3.
这 3 个元素可由 masked vector 或标量 cleanup 处理。

快速判断

  • 向量化会减少加法结果数量吗?不会。
  • 向量化会减少必须传输的数组总字节吗?一般不会。
  • 512-bit FP64 有 16 个 lane 吗?没有,只有 8 个。

常见误区

误区 1:SIMD 宽度是 16,所以一定快 16 倍

内存带宽、load/store 吞吐、微操作拆分与频率会压低收益。

误区 2:所有机器都支持 512-bit

向量宽度由 ISA 与硬件决定,代码还要处理 dispatch 或 fallback。

误区 3:未对齐就不能向量化

许多 ISA 支持未对齐访问,只是成本可能变化。

误区 4:非连续张量与连续张量一样

stride、gather/scatter 与 cache line 利用会显著影响吞吐。

误区 5:CPU SIMD 就是 GPU warp

两者都利用数据并行,但执行模型、索引与内存规则不同。

本课小结

  • SIMD 用一条向量指令对多个 lane 执行相同操作。
  • 512-bit、FP32 的教学示例有 16 个 lane,地址步进 64 bytes。
  • 向量化把主循环动态算术和访存指令数约缩小到 1/L1/L
  • 总数据字节数通常不变,因此大张量加法仍可能受内存带宽限制。
  • 对齐、尾部、stride、alias 与浮点语义决定编译器能否高效向量化。
  • 理论向量宽度不是固定加速倍数,也不能直接类比 GPU SIMT。
03

主题讲解 · 02:30

双缓冲如何把数据搬运藏在计算之后

学习目标

  • 能画出单缓冲的传入、计算、传出依赖。
  • 能解释 ping-pong 双缓冲的所有权切换。
  • 能推导理想流水线的 fill、steady state 与 drain 时间。
  • 能说明异步 copy、独立执行资源与同步原语是重叠前提。
  • 能判断双缓冲的额外片上存储、occupancy 与 tile 大小权衡。
  • 能避免把 HBM/DRAM、SRAM 与 DMA 当成所有硬件的固定结构。

前置与衔接

设大张量 A、B 按 tile 分块,逐块计算

Ct=f(At,Bt).C_t=f(A_t,B_t).

每个 tile 经历三个概念阶段:

  1. 输入搬运:把 At,BtA_t,B_t 放入可供计算使用的局部工作集;
  2. 计算:消费输入 tile 并产生 CtC_t
  3. 输出搬运:把 CtC_t 提交到目标存储。

视频在 00:08 用“大而慢/小而快”建立教学模型,在 00:27 进入单缓冲。

图 1

单缓冲为当前 A、B tile 各保留一个片上槽位,按传入、计算、传出的顺序处理;板书的 HBM/DRAM 与 SRAM 是教学抽象。

原视频 · 00:20 ↗

具体硬件可能使用:

  • CPU cache 与 software prefetch;
  • GPU global memory、shared memory、register;
  • 加速器 DMA 与片上 SRAM;
  • 异步 copy pipeline 或硬件自动 cache。

板书名称不能直接替代目标平台文档。

核心讲解

1. 单缓冲为什么容易串行

设只有一组输入缓冲 A0,B0A_0,B_0

当前 tile 的输入必须先准备好,计算才能开始。

计算没有读完缓冲前,下一 tile 不能覆盖它。

图 2

当前 tile 必须先完成 A、B 的输入搬运,计算才可消费对应缓冲;同一缓冲不能在仍被使用时覆盖。

原视频 · 00:40 ↗

视频在 00:42 开始搬入,在 00:49 进入计算与写回。

若平台没有其他重叠机制,一个 tile 的时序近似:

loadtcomputetstoret.\text{load}_t \rightarrow \text{compute}_t \rightarrow \text{store}_t.
图 3

若只有一组槽位且没有其他重叠机制,tile 的输入、计算、输出在教学模型中串行,稳态耗时近似三阶段之和。

原视频 · 01:00 ↗

视频在 00:53 总结传入、计算、传出三步,并在 00:59 说明它们有依赖。

2. 单缓冲总时间

设每 tile:

  • 输入耗时 TinT_{in}
  • 计算耗时 TcT_c
  • 输出耗时 ToutT_{out}

K 个 tile 完全串行时:

TsingleK(Tin+Tc+Tout).T_{single} \approx K(T_{in}+T_c+T_{out}).

这个式子忽略 launch、barrier、尾 tile 与 cache 效果,只用于建立上限直觉。

3. 双缓冲的 ping-pong 结构

双缓冲为输入准备两套槽位:

(A0,B0),(A1,B1).(A_0,B_0), \qquad (A_1,B_1).

计算消费第 0 套时,copy 可以把下一 tile 写入第 1 套。

下一轮交换角色。

图 4

双缓冲为 A、B 分别准备两组槽位;计算使用第 1 组时,可把下一 tile 预取到第 2 组。

原视频 · 01:20 ↗

视频在 01:08 引入第二个缓冲,在 01:12 明确 A1、A2 两组。

这种角色切换常称 ping-pong:

read_buf=tmod2,read\_buf=t\bmod2,
write_buf=(t+1)mod2.write\_buf=(t+1)\bmod2.

4. 第一次填充仍无法消失

在计算 tile 0 之前,必须先把 tile 0 放入某个缓冲。

所以流水线有 fill 阶段。

最后一个 tile 计算后,还要完成结果提交,形成 drain 阶段。

双缓冲改善的是中间稳态,不会消除首尾开销。

5. 稳态如何重叠

当 compute 使用 buffer 0 时:

  • copy engine 可向 buffer 1 传入下一 tile;
  • 若输出资源独立,还可传出前一 tile 的结果。
图 5

当硬件与运行时支持异步传输且资源独立时,下一 tile 的传入可与当前 tile 计算重叠;否则只是逻辑排程,并不会自动并行。

原视频 · 01:40 ↗

视频从 01:33 进入异步计算与传入,在 01:40 说明下一 tile 写入另一缓冲。

理想情况下,三个资源互相独立,稳态每 tile 周期为

Tstage=max(Tin,Tc,Tout).T_{stage}=\max(T_{in},T_c,T_{out}).

总时间近似

TdoubleTin+Tc+Tout+(K1)Tstage.T_{double} \approx T_{in}+T_c+T_{out} +(K-1)T_{stage}.

当 K 很大,平均时间趋近最慢阶段,而不是三阶段之和。

6. 三阶段不一定真的能完全并行

图 6

流水线稳态可同时处理不同 tile 的传入、计算与传出;实际周期由最慢资源路径主导,并受 copy engine 数量与同步约束。

原视频 · 02:00 ↗

视频在 02:00 概括传入、计算、传出同时进行。

这需要额外前提:

  • 输入 copy 与 compute 可异步执行;
  • 输出 copy 有可用资源;
  • 数据路径不争用同一带宽或执行单元;
  • 有 event/barrier 保证生产者消费者顺序;
  • 缓冲未被提前覆盖;
  • tile 足够大,能摊薄同步开销。

若输入与输出共享一个 copy engine,稳态更可能受

max(Tc,Tin+Tout)\max(T_c,T_{in}+T_{out})

约束,而不是三个时间单独取 max。

若它们争用同一外部内存带宽,重叠也可能只改变排程,不增加总可用带宽。

7. 正确性依赖缓冲所有权

每套缓冲至少有三种状态:

  • 空闲,可由 producer 写入;
  • 就绪,可由 compute 读取;
  • 使用中,禁止覆盖。

典型顺序:

``text async_copy(next, write_buf) compute(current, read_buf) wait_copy(write_buf) swap(read_buf, write_buf) ``

真实 API 还可能需要:

  • commit group;
  • wait group;
  • block/warp barrier;
  • memory fence;
  • stream/event 依赖。

缺少同步会造成读取未完成数据或覆盖仍在使用的 tile。

8. 双缓冲不会减少数据总量

K 个 tile 仍要:

  • 读取同样的输入 bytes;
  • 执行同样的数学运算;
  • 写出同样的输出 bytes。

双缓冲的主要作用是隐藏 latency,让 copy 与 compute 时间重叠。

它不是压缩,也不是算子融合。

若 kernel 已被内存带宽完全饱和,增加重叠未必提高吞吐。

9. 额外缓冲会消耗片上资源

若单个输入 tile 总大小为 B bytes,单缓冲需约 B,双缓冲需约 2B。

这可能导致:

  • shared memory 占用翻倍;
  • 每个 SM 同驻 block 数下降;
  • register pressure 上升;
  • occupancy 降低;
  • 可选 tile 变小。

更高 occupancy 与更深 pipeline 之间存在权衡。

双缓冲不是“无成本加速”。

10. tile 大小如何影响收益

tile 太小:

  • barrier/event 开销占比高;
  • copy 事务不充分;
  • 计算太短,难以覆盖传输 latency。

tile 太大:

  • 片上缓冲不足;
  • occupancy 下降;
  • 尾部浪费增加;
  • 单阶段时间失衡。

通常要针对 shape、数据类型与硬件 autotune。

11. 双缓冲与 cache 的关系

在 cache-coherent CPU 上,程序可能不显式把数据搬到 SRAM。

software prefetch、硬件预取和 cache replacement 已在做部分“提前准备”。

显式双缓冲更常见于:

  • scratchpad/SRAM 由软件管理的加速器;
  • GPU shared memory tile;
  • DMA 驱动的流水线;
  • IO、网络或磁盘批处理。

概念相同,实现原语不同。

跟练与练习

编者练习

估算理想流水线时间 有 K=10 个 tile,Tin=3μsT_{in}=3\,\mu sTc=5μsT_c=5\,\mu sTout=2μsT_{out}=2\,\mu s。 假设三阶段资源完全独立,忽略额外同步开销。 计算单缓冲与理想双缓冲总时间。

查看参考答案

单缓冲:
Tsingle=10(3+5+2)=100μs.T_{single}=10(3+5+2)=100\,\mu s.
稳态阶段:
Tstage=max(3,5,2)=5μs.T_{stage}=\max(3,5,2)=5\,\mu s.
理想双缓冲:
Tdouble=3+5+2+(101)×5=55μs.T_{double} =3+5+2+(10-1)\times5 =55\,\mu s.
这是理想模型。若输入与输出共用 copy engine,需改用更保守的资源模型。

自检

Tc=1μsT_c=1\,\mu sTin=8μsT_{in}=8\,\mu s,增加更多计算流水级能否突破输入带宽上限?

不能。

稳态仍由输入路径主导,除非减少 bytes 或提高传输带宽。

常见误区

误区 1:开两个数组就自动异步

还需要异步 copy 能力、独立资源与正确依赖。

误区 2:传入、计算、传出总能三者完全并行

copy engine 数量、共享带宽和同步可能限制重叠。

误区 3:双缓冲减少总访存字节

它主要隐藏 latency,不减少输入输出的必要 bytes。

误区 4:缓冲越多越快

更深缓冲消耗片上存储,可能降低 occupancy 并增加同步复杂度。

误区 5:所有平台都显式使用 HBM 与 SRAM

这是板书抽象;CPU cache 与不同加速器有各自机制。

本课小结

  • 单缓冲容易形成输入、计算、输出串行链。
  • 双缓冲让 compute 读取一组槽位时,producer 填充另一组。
  • 理想稳态周期由最慢独立阶段主导,并包含 fill/drain 开销。
  • 完全重叠依赖异步 copy、独立资源、足够带宽与正确同步。
  • 双缓冲不减少 bytes 或 FLOPs,而是用更多片上空间隐藏 latency。
  • tile 大小、缓冲深度、occupancy 和 copy-engine 拓扑必须结合目标硬件调优。
04

主题讲解 · 01:17

逐元素张量加法的优化路线图

学习目标

  • 能建立逐元素加法的 baseline、shape 与复杂度模型。
  • 能区分多核并行、访存优化、SIMD、异步流水线与循环展开。
  • 能计算逐元素加法的最小理想化内存流量和算术强度。
  • 能解释为什么增加核心数最终会撞上内存带宽墙。
  • 能判断显式 staging、双缓冲和循环展开的适用条件。
  • 能说明广播、stride、dtype 与目标硬件会改变最优实现。

前置与衔接

视频把“两数之和”类比为并行编程中的“两张量之和”。

这个类比只用于引出问题。

LeetCode Two Sum 是在数组中寻找满足目标和的两个索引,通常涉及哈希或搜索。

本课处理的是逐元素算子:

Ci=Ai+Bi.C_i=A_i+B_i.

二者算法目标完全不同。

视频在 00:00 提出问题,在 00:08 做上述类比。

图 1

逐元素张量加法 C=A+BC=A+B 的优化栈包括数据并行、访存、SIMD、异步流水线与循环展开;它与“两数之和”检索题不是同一问题。

原视频 · 00:00 ↗

核心讲解

1. 先固定 baseline 假设

最简单模型假设:

  • A、B、C shape 相同;
  • 无 broadcasting;
  • 三者按 row-major 连续存储;
  • 元素类型相同;
  • C 不与 A、B 发生危险 alias;
  • 每个元素只做一次加法。

把任意维 shape 扁平化为 N 个元素:

N=k=1rdk.N=\prod_{k=1}^{r}d_k.

标量 baseline:

``text for i in [0, N): C[i] = A[i] + B[i] ``

时间复杂度:

O(N).O(N).

后续优化不会把必须生成 N 个输出的复杂度变成 o(N)o(N),主要改善常数与硬件利用率。

2. 第一层:跨 worker 数据并行

视频在 00:20 指出现实系统有多个计算核心,在 00:25 开始任务划分。

图 2

对同 shape、连续存储且无广播的教学模型,可把扁平索引 [0,N)[0,N) 划成互不重叠区间交给多个 worker。

原视频 · 00:20 ↗

对 P 个 worker,可做静态区间划分:

startp=pNP,start_p=\left\lfloor\frac{pN}{P}\right\rfloor,
endp=(p+1)NP.end_p=\left\lfloor\frac{(p+1)N}{P}\right\rfloor.

每个 worker 只写自己的 C 区间,因此没有输出数据竞争。

理想计算工作量约为

O(N/P).O(N/P).

但整体仍需传输全部 A、B、C 数据。

3. 多核加速为什么会饱和

每个 FP32 输出元素至少需要:

  • 读取 A:4 bytes;
  • 读取 B:4 bytes;
  • 写 C:4 bytes。

理想化总流量:

12N bytes.12N\text{ bytes}.

每元素只有一次加法,即 1 FLOP。

算术强度:

I=1120.0833 FLOP/B.I=\frac{1}{12}\approx0.0833\text{ FLOP/B}.
图 3

每个元素仅做一次加法,却至少读取 A、B 并写 C;因此大张量加法常受内存带宽而非浮点加法吞吐限制。

原视频 · 00:40 ↗

所以增加 worker 后:

  • 开始阶段吞吐可能近似线性提高;
  • 接近共享内存带宽上限后,更多核心只会争用带宽;
  • NUMA、cache coherence 与线程调度还会增加成本。

4. 第二层:让访存模式适合硬件

视频从 00:34 进入存储设备快慢差异。

对于没有数据复用的 streaming add,关键通常是:

  • 连续访问;
  • cache line 充分利用;
  • GPU 上相邻线程访问相邻地址;
  • 合理对齐;
  • 避免不必要的中间张量;
  • 合理的 NUMA first-touch 与线程绑定。

板书画“搬到小而快存储”是通用抽象。

但简单加法没有 tile 内复用,显式先复制到 scratchpad 再计算未必有利。

若 staging 本身增加一次额外 copy,可能比直接 streaming 更慢。

5. 第三层:SIMD 向量化

视频在 00:48 转入向量指令。

向量宽 L 时,一条 vector add 产生 L 个元素结果。

主循环迭代数从 N 降至约

NL.\left\lceil\frac{N}{L}\right\rceil.

SIMD 可减少动态指令数、分支与地址更新开销。

但必须处理:

  • 连续与 stride;
  • alignment;
  • 尾部 mask;
  • aliasing;
  • ISA dispatch;
  • 内存带宽上限。

视频在 01:02 也提醒向量指令要考虑连续存储。

6. 第四层:异步 DMA 与双缓冲

视频在 01:04 进入异步,在 01:06 给出 DMA 示例。

双缓冲可以:

  • 计算当前 tile;
  • 同时预取下一 tile;
  • 在资源允许时传出上一 tile。
图 4

SIMD、异步 DMA/双缓冲与循环展开可减少指令或隐藏延迟,但收益取决于连续性、传输引擎、tile 复用、寄存器压力和编译器。

原视频 · 01:00 ↗

但逐元素加法计算极少。

如果 copy 时间远大于 add 时间,短计算阶段无法完全隐藏传输。

异步重叠还需要:

  • 独立 copy/compute 能力;
  • 足够片上缓冲;
  • 正确 barrier/event;
  • tile 足够大;
  • 不因双缓冲降低 occupancy 太多。

7. 第五层:循环展开

视频在 01:12 提到循环展开。

展开因子 U 可让一次 loop body 处理 U 个标量或 U 个向量:

``text for i += U * L: vector_add block 0 vector_add block 1 ... ``

潜在收益:

  • 减少 loop branch 与地址更新;
  • 暴露 instruction-level parallelism;
  • 隐藏部分 latency。

潜在代价:

  • 代码尺寸增大;
  • register pressure 增加;
  • instruction cache 压力;
  • 尾部处理更复杂。

现代编译器常会自动展开,手工展开不一定更好。

8. 这些优化不是简单相加

一个高性能实现可能同时使用:

  • 多线程或多 block;
  • SIMD/SIMT;
  • 连续合并访存;
  • 合适展开;
  • 针对目标硬件的调度。

但瓶颈会迁移。

例如 SIMD 提高算术吞吐后,kernel 更快撞到 DRAM/HBM 带宽上限。

因此不能把“多核倍数 × SIMD 宽度 × 双缓冲倍数”直接相乘。

9. broadcasting 会改变索引与流量

ARM×N,BRN,A\in\mathbb{R}^{M\times N}, \quad B\in\mathbb{R}^{N},

则 B 沿 M 广播。

逻辑元素仍是 MN,但 B 可能被 cache 多次复用。

若 stride=0 表示广播,向量代码可以 broadcast load,而不是读取 MN 个不同 B 元素。

非连续 view、类型转换与 mixed precision 也会改变事务数和 vector width。

10. 性能优化的正确顺序

一个稳健流程是:

  1. 固定正确性与 shape/stride 语义;
  2. 建立 bytes、FLOPs 与理论下界;
  3. 测量 baseline;
  4. 确认瓶颈是带宽、launch、计算还是调度;
  5. 针对瓶颈选择并行、向量化、融合或流水线;
  6. 用 profiler 验证实际 bytes、带宽与 occupancy;
  7. 覆盖小尺寸、尾部、广播与非连续输入。

跟练与练习

编者练习

估算带宽下界 N=100,000,000,A、B、C 为 FP32,假设没有额外读写,持续内存带宽为 300 GB/s。 估算仅由必要数据流量决定的理想时间下界。

查看参考答案

必要流量:
12N=1.2×109 bytes=1.2 GB.12N=1.2\times10^9\text{ bytes}=1.2\text{ GB}.
时间下界:
T1.2 GB300 GB/s=0.004 s=4 ms.T\ge\frac{1.2\text{ GB}}{300\text{ GB/s}} =0.004\text{ s}=4\text{ ms}.
这是理想持续带宽模型,不含 launch、NUMA、cache write policy 与尾部。

快速判断

  • 多核会减少总共要写出的 C 元素吗?不会。
  • SIMD 会让复杂度从 O(N)O(N) 变成 O(1)O(1) 吗?不会。
  • 双缓冲一定适合只有一次加法的 CPU 连续数组吗?不一定。

常见误区

误区 1:逐元素加法就是 LeetCode Two Sum

前者生成同 shape 输出,后者寻找索引对。

误区 2:核心数翻倍,速度永久翻倍

共享内存带宽会饱和,调度与 NUMA 也有成本。

误区 3:数据一定要显式搬到 SRAM 才能算

具体由硬件和编程模型决定;CPU cache 常由硬件管理。

误区 4:SIMD 与异步流水线收益可以直接相乘

它们可能争用同一瓶颈,必须看新的 roofline 限制。

误区 5:循环展开越大越好

过度展开会增加寄存器与代码尺寸压力。

本课小结

  • 逐元素加法在同 shape 连续模型下是 O(N)O(N) streaming kernel。
  • FP32 每元素至少约 12 bytes 数据流量、1 FLOP,常受带宽限制。
  • 多核负责分配索引,SIMD 降低动态指令数,二者不减少必要 bytes。
  • 连续访问、合并事务和避免中间量通常比显式 staging 更关键。
  • 异步 DMA/双缓冲需要独立资源,且计算太短时未必能隐藏 copy。
  • 循环展开可降低控制开销,但要平衡寄存器、代码尺寸与编译器能力。
05

主题讲解 · 02:08

网格步进循环如何分配 GPU 索引

学习目标

  • 能写出一维 CUDA 风格的 grid-stride loop。
  • 能区分连续范围划分与网格步进划分。
  • 能计算线程起始索引、总 stride 与单线程迭代次数。
  • 能解释为什么单线程跨步不等于一个 warp 的访存不连续。
  • 能判断网格步进何时可能改善位置相关的负载不均。
  • 能说明 coalescing、分支发散、同步与片上存储的适用边界。

前置与衔接

上一课从逐元素加法出发,讨论了多 worker、SIMD、异步搬运和循环展开。

本课继续追问更具体的问题:

视频在 00:04 对比两种任务划分:

  • range partition:每个 worker 获得一个连续区间;
  • grid-stride:每个线程从自己的全局索引出发,按整个 grid 的线程数跨步。
图 1

范围划分给每个 worker 一个连续区间;网格步进让 worker 从全局索引出发,以总 worker 数为 stride 循环处理多个元素。

原视频 · 00:00 ↗

这里的 worker 可用于讲解 CPU 线程、GPU 线程或抽象计算单元。

但本课公式采用 CUDA 风格的一维 GPU grid。

不能把抽象 worker 与真实硬件核心一一对应。

核心讲解

1. 先固定问题与 shape

考虑长度为 N 的一维连续张量:

yi=f(xi),0i<N.y_i=f(x_i),\qquad 0\le i<N.

多维连续张量也可先展平:

N=k=1rdk.N=\prod_{k=1}^{r}d_k.

若张量非连续、带 broadcasting 或每个元素对应多个坐标,就还需要 stride-aware 的地址计算。

本课先只讨论逻辑索引如何分配,不把 layout 问题混进来。

2. 连续范围划分

假设有 G 个 worker,编号为 g[0,G)g\in[0,G)

一种静态范围划分是:

startg=gNG,start_g=\left\lfloor\frac{gN}{G}\right\rfloor,
endg=(g+1)NG.end_g=\left\lfloor\frac{(g+1)N}{G}\right\rfloor.

worker g 依次处理:

startg,startg+1,,endg1.start_g,start_g+1,\ldots,end_g-1.

视频在 00:11 展示这种连续切分,在 00:17 以四个计算单元为例。

范围划分的优点是单个 worker 的局部连续性直观。

若所有元素计算成本相同,每个区间长度也近似相等,它通常已经有不错的静态均衡。

3. 连续区间何时会失衡

若每个元素的计算成本为 cic_i,worker g 的工作量是:

Wg=i=startgendg1ci.W_g=\sum_{i=start_g}^{end_g-1}c_i.

整个并行阶段的时间近似由最慢 worker 决定:

TmaxgWg.T\propto\max_g W_g.

视频从 00:49 构造“前半段难、后半段容易”的例子。

图 2

若高代价元素集中在某段,连续范围划分可能让少数 worker 承担多数工作;这不是所有逐元素 kernel 都会发生的固定事实。

原视频 · 00:20 ↗

如果高代价元素集中在同一连续区域,某些 worker 会很慢,另一些会提前完成。

图 3

范围划分中的 worker 在自己的区间内逐 tile 前进;板书的小快存储只表示可能的局部工作集,并非 grid-stride 必须显式搬运到 SRAM。

原视频 · 00:40 ↗

视频在 01:03 用“同步等待”解释尾部效应。

图 4

当不同区间工作量不同且后续存在同步边界时,先完成的 worker 会等待慢区间,尾部执行时间由最慢者决定。

原视频 · 01:00 ↗

这里的等待是阶段结束、kernel 结束或后续同步边界上的抽象。

普通 CUDA kernel 内不存在任意 block 之间可直接使用的全局 barrier。

4. 网格步进循环的标准形式

一维 CUDA 风格伪代码如下:

```cpp std::uint64_t i = blockIdx.x blockDim.x + threadIdx.x; std::uint64_t stride = blockDim.x gridDim.x;

for (; i < N; i += stride) { y[i] = f(x[i]); } ```

起始索引是线程的全局线性编号:

i0=blockIdx.xblockDim.x+threadIdx.x.i_0=blockIdx.x\cdot blockDim.x+threadIdx.x.

整个 grid 的逻辑线程数是:

G=gridDim.xblockDim.x.G=gridDim.x\cdot blockDim.x.

线程处理的索引序列是:

i0, i0+G, i0+2G,<N.i_0,\ i_0+G,\ i_0+2G,\ldots<N.

视频在 01:15 引入网格步进,在 01:22 强调同一 worker 得到的是非连续工作。

5. 覆盖性与复杂度

任意索引 i 都可唯一写成:

i=qG+r,i=qG+r,

其中

0r<G.0\le r<G.

它由起始编号为 r 的线程在第 q 轮处理。

因此,只要边界条件正确,网格步进既不遗漏也不重复逻辑索引。

线程 r 的迭代次数是:

Kr={N1rG+1,r<N,0,rN.K_r= \begin{cases} \left\lfloor\dfrac{N-1-r}{G}\right\rfloor+1,&r<N,\\ 0,&r\ge N. \end{cases}

每线程约处理 N/G\lceil N/G\rceil 个元素。

总工作量仍是 O(N)O(N),不是因为“跨步”就减少了要计算的元素数。

6. 为什么可能改善位置相关的负载不均

网格步进等价于按余数类做 round-robin 式静态划分:

Wrgrid=q:r+qG<Ncr+qG.W_r^{grid}=\sum_{q:r+qG<N}c_{r+qG}.

若高代价元素在索引空间中形成较宽的连续区域,多个线程可能各取到其中一部分。

图 5

网格步进把同一 worker 的连续迭代分散到索引空间各处,可能把位置相关的重工作更均匀地摊到不同 worker。

原视频 · 01:20 ↗

视频在 01:40 用“每个计算单元都分到一些难题和简单题”说明这个直觉。

但这不是通用的动态负载均衡算法。

它仍然是静态映射。

以下情况不保证改善:

  • 成本模式刚好与 G 同周期;
  • 少数异常重元素仍集中落到同一余数类;
  • 差异来自 block 级资源,而非元素位置;
  • 每元素成本本来完全一致。

若负载高度不规则,可能需要 work queue、persistent kernel、任务窃取或其他动态调度。

7. 单线程跨步不等于 warp 访存不连续

最容易误解的地方是访问连续性。

单个线程在相邻迭代中访问:

i0i0+G.i_0\rightarrow i_0+G.

它自己的地址确实跨得很远。

但 GPU 合并访存通常看同一条指令下一个 warp 内线程访问的地址集合。

在第 q 轮,相邻线程 r、r+1 访问:

qG+r,qG+r,
qG+r+1.qG+r+1.

这些逻辑索引仍相邻。

图 6

单个 worker 相邻迭代地址跨 stride,但同一轮中相邻线程仍访问相邻元素,因此 GPU 上可保持 coalesced 访问;具体事务由 warp、对齐和数据类型决定。

原视频 · 01:40 ↗

视频在 02:01 给出“整体仍连续”的直觉。

更准确地说:

  • 单线程跨迭代不连续;
  • 同一 warp 同一迭代可连续;
  • 是否形成最少内存事务,还取决于元素大小、warp 宽度、对齐、有效线程 mask 与架构事务粒度。

所以不能只看一个线程的轨迹判断 coalescing。

8. grid stride 的首要工程价值

即使每元素成本相同,grid-stride loop 仍有实际价值:

  • grid 可以小于 N;
  • 线程可复用,避免要求“一个元素一个线程”;
  • 可以把 block 数限制在适合设备驻留和调度的范围;
  • 同一 kernel 能处理不同 N;
  • 调试时可用较小 grid 重放同一索引逻辑。

它不意味着物理线程会被创建 N 次。

同一个逻辑线程会执行多轮 loop body。

9. 分支发散是另一条轴

f(xi)f(x_i) 包含数据相关分支,不同线程可能执行不同路径。

在 SIMT 模型中,同一 warp 内的路径分歧会产生 divergence。

网格步进把不同位置交给多个线程,可能改变每轮的成本分布。

但它不会自动消除 warp 内分支发散。

甚至在某些数据布局下,连续范围能把相同分支聚到一起,而 round-robin 会把不同分支混在同一 warp。

负载均衡与分支一致性要分别用 profiler 验证。

10. 板书中的“大慢存储”和“小快存储”

视频在 00:26 用大而慢、小而快的存储层级解释 tile。

这是教学模型,不指定某个固定硬件部件。

在不同平台上,它可能对应:

  • GPU global memory 与 shared memory;
  • cache hierarchy;
  • accelerator external memory 与 scratchpad;
  • 软件管理的 DMA tile。

grid-stride 只规定索引循环。

它本身不要求把数据显式搬到 shared memory,也不保证产生数据复用。

对只有一次读写的简单逐元素 kernel,额外 staging 可能没有收益。

11. 索引宽度与边界检查

当 N 很大时,以下乘法可能超过 32-bit:

blockIdx.xblockDim.x.blockIdx.x\cdot blockDim.x.

实现应根据 N 的取值范围选择足够宽的无符号或有符号索引类型。

还要保证:

  • stride 非零;
  • i+stridei+stride 不发生溢出;
  • 每轮都有 i<Ni<N 边界检查;
  • 多维映射中的 shape 与 stride 使用一致单位。

对超大 N,可写成在加法前判断剩余范围,或使用经证明不会溢出的 64-bit 逻辑。

12. 范围划分也能合并访存

不能把两种方案简化为:

  • range 一定访存差;
  • grid-stride 一定访存好。

如果范围划分把连续索引映射给相邻 GPU 线程,同样可以 coalesce。

如果错误地让一个线程独占超长连续区间、而同一 warp 的其他线程跳到远处,才可能破坏事务模式。

关键是“同一时刻相邻 lane 访问什么”,不是算法名称。

跟练与练习

编者练习

手算 grid-stride 索引 设 N=23,启动 G=8 个逻辑线程。 列出线程 3 和线程 7 分别处理的索引,并给出线程 3 的迭代次数。

查看参考答案

线程 3 的索引为:
3,11,19.3,11,19.
线程 7 的索引为:
7,15.7,15.
线程 3 的迭代次数:
K3=23138+1=3.K_3=\left\lfloor\frac{23-1-3}{8}\right\rfloor+1=3.
所有线程的索引集合两两不重叠,合起来覆盖 [0,23)[0,23)

进一步思考

若代价模式为“所有偶数索引很重、奇数索引很轻”,且 G=8,grid-stride 会自动把重任务平均给所有线程吗?

不会。

因为 G 为偶数,偶数起始线程之后始终访问偶数索引,奇数线程始终访问奇数索引。

这是“静态 round-robin 不保证任意负载均衡”的反例。

常见误区

误区 1:网格步进会降低总复杂度

它只改变索引分配,总体仍处理 N 个元素,复杂度仍是 O(N)O(N)

误区 2:单线程地址跨步,所以访存一定不合并

coalescing 主要看同一 warp 同一指令的地址集合。

误区 3:网格步进一定比连续范围更均衡

只有成本与索引位置的结构恰好适合交错时才可能改善。

误区 4:网格步进等同于动态任务队列

它是由起始索引和固定 stride 决定的静态映射。

误区 5:视频中的同步就是 kernel 内任意 block barrier

板书表达的是尾部等待;真实同步能力由编程模型和 kernel 边界决定。

误区 6:grid-stride 必须配合 shared memory

索引映射与存储 staging 是两件独立的事。

本课小结

  • grid-stride loop 让线程从全局编号出发,以 grid 总线程数为 stride。
  • 余数类分解证明它能不重不漏地覆盖 [0,N)[0,N)
  • 总工作量仍为 O(N)O(N),每线程约执行 N/G\lceil N/G\rceil 轮。
  • 它可能摊平位置相关的连续重区间,但不是通用动态负载均衡。
  • 单线程跨步与 warp 同轮连续访问可以同时成立,coalescing 要按 warp 判断。
  • 分支发散、同步边界、片上 staging 和索引宽度需要独立分析。
  • 所有性能结论都依赖 shape、layout、dtype、对齐、warp 与目标 GPU 架构。
06

主题讲解 · 03:47

张量广播如何落到线性索引

学习目标

  • 能按尾轴对齐判断两个 shape 是否可广播。
  • 能计算广播后的输出 shape。
  • 能把输出线性索引还原为多维坐标。
  • 能把输出坐标映射回两个输入的坐标与物理 offset。
  • 能解释广播维为何总取输入坐标 0。
  • 能区分逻辑扩展、stride-0 view 与真实内存复制。

前置与衔接

视频在 00:02 提出问题:两个 shape 不同的张量,在底层线性存储中如何完成广播相加?

示例是:

AR2×1,A\in\mathbb{R}^{2\times1},
BR3×1×4.B\in\mathbb{R}^{3\times1\times4}.

广播后:

C=A+BR3×2×4.C=A+B\in\mathbb{R}^{3\times2\times4}.
图 1

A=(2,1) 与 B=(3,1,4) 先按尾轴对齐为 (1,2,1) 和 (3,1,4),兼容后得到输出 shape (3,2,4)。

原视频 · 00:00 ↗

视频用“从右向左读,有变就复制,否则就提取”建立直觉。

实现层更准确的说法是:

本课先假设 row-major 连续输入,再推广到任意 stride。

核心讲解

1. shape 必须从尾轴对齐

视频从 00:31 解释输出 shape,在 00:38 给出 A 与 B。

A 的 rank 为 2,B 的 rank 为 3。

给 A 左侧补一个大小 1 的前导轴:

(2,1)(1,2,1).(2,1)\rightarrow(1,2,1).

于是对齐为:

A:(1,2,1),B:(3,1,4).\begin{aligned} A'&:(1,2,1),\\ B&:(3,1,4). \end{aligned}

左侧补 1 只用于统一索引推导,不需要改变数据。

2. 每个对齐轴的兼容条件

对轴 k,输入大小分别为 aka_kbkb_k

可广播当且仅当:

ak=bkak=1bk=1.a_k=b_k \quad\text{或}\quad a_k=1 \quad\text{或}\quad b_k=1.

视频在 00:57 做兼容检查。

对本例:

(1,3), (2,1), (1,4)(1,3),\ (2,1),\ (1,4)

三对都兼容。

在普通正尺寸例子中,输出轴大小取两者较大值:

ok=max(ak,bk).o_k=\max(a_k,b_k).

所以视频在 01:09 得到:

O=(3,2,4).O=(3,2,4).

如果某一轴既不相等、也没有一方为 1,就应报 shape error,不能靠截断或循环取模补救。

3. row-major 线性化

视频从 01:27 说明默认连续存储,并在 01:35 用 shape (3,2,4)(3,2,4) 画出一维长条。

图 2

以 row-major 连续布局为例,shape (3,2,4) 的最后一维变化最快;逻辑多维索引最终换算为一维地址。

原视频 · 01:00 ↗

对 row-major 连续张量

CR3×2×4,C\in\mathbb{R}^{3\times2\times4},

逻辑坐标 (i0,i1,i2)(i_0,i_1,i_2) 的线性 offset 是:

t=i0(24)+i1(4)+i2.t=i_0(2\cdot4)+i_1(4)+i_2.

也就是:

t=8i0+4i1+i2.t=8i_0+4i_1+i_2.

范围为:

0t<24.0\le t<24.

“线性存储”不意味着张量只有一维语义。

shape 与 strides 保存了从逻辑坐标到物理地址的规则。

4. 从输出线性索引恢复多维坐标

给定输出 offset t:

i0=t8.i_0=\left\lfloor\frac{t}{8}\right\rfloor.

去掉第 0 轴贡献:

r=tmod8.r=t\bmod 8.

再得到:

i1=r4,i_1=\left\lfloor\frac{r}{4}\right\rfloor,
i2=rmod4.i_2=r\bmod4.

这就是整数除法与取模形式的 unravel index。

真实 kernel 可能用增量计数、预计算 magic number 或合并循环降低除法成本,但语义相同。

5. 输出坐标如何映射到 A

对齐后的 A shape 是:

A=(1,2,1).A'=(1,2,1).

输出坐标是:

(i0,i1,i2).(i_0,i_1,i_2).

A 的第 0、2 轴大小都是 1,所以输入坐标必须固定为 0:

idxA=(0,i1,0).idx_A=(0,i_1,0).
图 3

输入维度为 1、输出维度扩展时,多个输出位置都映射到该输入轴的坐标 0;图中的复制是逻辑复用,不要求真实复制内存。

原视频 · 01:40 ↗

原始 A shape 为 (2,1)(2,1),row-major strides 可写为:

sA=(1,1).s_A=(1,1).

因此:

offsetA=i1.offset_A=i_1.

不同 i0i_0i2i_2 的输出都可能读取同一个 A 元素。

这就是板书“复制”的真实索引含义。

6. 输出坐标如何映射到 B

B shape 是:

B=(3,1,4).B=(3,1,4).

它的第 1 轴大小为 1,因此:

idxB=(i0,0,i2).idx_B=(i_0,0,i_2).

row-major strides 为:

sB=(4,4,1).s_B=(4,4,1).

所以:

offsetB=4i0+i2.offset_B=4i_0+i_2.

最终每个输出元素执行:

C[t]=A[offsetA]+B[offsetB].C[t]=A[offset_A]+B[offset_B].

视频从 02:41 开始演示 B 的对应过程。

7. 用统一规则写成公式

对任意对齐后的输入轴大小 dkd_k 与输出坐标 iki_k

jk={0,dk=1,ik,dk=ok.j_k= \begin{cases} 0,&d_k=1,\\ i_k,&d_k=o_k. \end{cases}

输入物理 offset 为:

offset=base+kjksk.offset=base+\sum_k j_k s_k.

这里 sks_k 是输入真实 stride。

图 4

从尾轴向前看:相等维度保留对应坐标,输入为 1 的维度把坐标钳到 0;这比把数据真的复制多份更接近 kernel 实现。

原视频 · 02:20 ↗

这个规则比“真的复制 D 份、E 份”更适合实现。

8. stride-0 view

另一种等价表达是先构造 expanded view。

把广播轴的有效 stride 设为 0:

sA=(0,1,0),s_{A'}=(0,1,0),
sB=(4,0,1).s_{B'}=(4,0,1).

随后直接用输出坐标:

offsetA=0i0+1i1+0i2,offset_A=0\cdot i_0+1\cdot i_1+0\cdot i_2,
offsetB=4i0+0i1+i2.offset_B=4i_0+0\cdot i_1+i_2.

得到与前面完全相同的地址。

stride 0 表示沿该轴移动时物理地址不变。

9. 通常不需要物化复制

视频在 03:38 强调“复制不用真的复制”。

图 5

广播通常通过 stride-0 或等价的索引计算形成视图语义,算子读取时复用原元素;是否物化由具体算子和后端决定。

原视频 · 03:20 ↗

常见实现有两类:

  • kernel 直接根据输出坐标计算两个输入 offset;
  • 先建立 stride-0 view,再由通用 iterator 生成地址。

但“广播永远零拷贝”也不严谨。

以下操作可能物化:

  • 显式调用 contiguous/copy;
  • 后端不支持某种 stride;
  • 后续算子要求连续内存;
  • 编译器为融合或布局转换选择临时缓冲。

输出 C 本身通常仍需分配,除非继续被融合且没有可观察的中间结果。

10. 任意 rank 都是同一规则

视频从 03:04 推广到更多维度。

图 6

一般 rank 的广播仍逐个尾轴应用同一规则:相等维保留索引,广播维取 0,缺失前导维可视为大小 1。

原视频 · 03:00 ↗

无论 rank 多高,流程都不变:

  1. 左侧补 1 对齐 rank;
  2. 逐尾轴验证兼容性;
  3. 计算输出 shape;
  4. 遍历输出坐标;
  5. 输入为 1 的轴取坐标 0;
  6. 用真实 stride 计算 offset。

复杂的是索引 bookkeeping,不是生成一个真正的高维立方体。

11. 非连续输入的边界

若输入来自 transpose、slice 或其他 view,它可能不是 row-major 连续。

此时不能用 shape 自动推导连续 strides。

仍应使用:

offset=base+kjksk,offset=base+\sum_k j_k s_k,

sks_k 必须取 view 的真实 strides。

负 stride、storage offset、aliasing 与设备后端限制也可能影响实现。

广播规则决定逻辑坐标,layout 决定物理地址;两层不能混为一谈。

跟练与练习

编者练习

从输出线性索引追到两个输入 仍取 A shape (2,1)(2,1)、B shape (3,1,4)(3,1,4)、C shape (3,2,4)(3,2,4)。 当输出线性 offset t=19t=19 时,求输出坐标、A offset 与 B offset。

查看参考答案

先反解输出坐标:
i0=19/8=2.i_0=\lfloor19/8\rfloor=2.
r=19mod8=3.r=19\bmod8=3.
i1=3/4=0,i2=3.i_1=\lfloor3/4\rfloor=0, \qquad i_2=3.
所以输出坐标为:
(2,0,3).(2,0,3).
A 映射到 (0,0,0)(0,0,0),因此:
offsetA=i1=0.offset_A=i_1=0.
B 映射到 (2,0,3)(2,0,3),因此:
offsetB=42+3=11.offset_B=4\cdot2+3=11.
最终计算 C[19]=A[0]+B[11]C[19]=A[0]+B[11]

快速判断

  • A shape (2,3)(2,3) 与 B shape (4,3)(4,3) 能广播吗?不能,第一个对齐轴 2 与 4 不兼容。
  • 广播轴的输入坐标会对输出坐标取模吗?不会,输入大小为 1 时固定取 0。
  • stride-0 view 可安全原地写入吗?通常需谨慎,因为多个逻辑位置可能 alias 同一存储位置。

常见误区

误区 1:低维向高维对齐是从左边开始比较

广播从尾轴对齐,缺失的前导轴视为 1。

误区 2:输出线性 offset 可直接用于两个输入

输入 shape 与 strides 不同,必须先做坐标映射。

误区 3:广播就是把输入真实复制很多份

常见实现是索引映射或 stride 0,不物化重复数据。

误区 4:所有张量都按 row-major 连续存储

transpose、slice 与 expand 都可能产生非连续 view。

误区 5:只要元素总数相同就能广播

广播检查逐轴兼容性,不比较总元素数。

本课小结

  • 广播先从尾轴对齐 shape,轴大小必须相等或至少一方为 1。
  • 示例 (2,1)(2,1)(3,1,4)(3,1,4) 广播为 (3,2,4)(3,2,4)
  • 输出线性 offset 先还原为多维坐标,再映射到输入坐标。
  • 输入大小为 1 的轴总取坐标 0,相等轴保留输出坐标。
  • 物理地址由 base、映射后坐标和真实 strides 共同决定。
  • stride-0 view 能表达逻辑扩展,但后端或连续化操作仍可能物化。
  • shape 语义与 layout 语义要分层分析。
07

主题讲解 · 02:53

同一段算子代码如何被多个 CPU 线程执行

学习目标

  • 能解释多个线程为何能执行同一份机器代码却处理不同数据。
  • 能用 thread id 计算互不重叠的半开区间。
  • 能区分代码段、地址空间、栈、寄存器上下文和 TLS。
  • 能说明“每线程独享寄存器”只是程序语义,不是永久占用物理寄存器。
  • 能判断并发、并行、上下文切换与核心数之间的关系。
  • 能发现尾部越界、数据竞争、false sharing 与整数绝对值边界。

前置与衔接

视频在 00:02 提问:多个线程执行同一段算子代码时发生了什么?

它选择 CPU 线程和逐元素绝对值作为极简模型:

outputi=inputi.output_i=|input_i|.

视频在 00:18 引入算子,在 00:21 设定每线程处理 1000 个元素。

图 1

多个 CPU 线程共享同一算子代码,但各自获得不同线程 id,并据此计算不同的 start/end;图中每线程 1000 元素是教学常量。

原视频 · 00:00 ↗

这一模型抓住了核心:

  • 代码相同;
  • thread id 不同;
  • 由 id 派生的数据范围不同;
  • 每个线程有自己的执行状态。

但真实 CPU runtime、线程池、调度器和编译器会增加更多层次。

核心讲解

1. 极简分块代码

设固定 block size 为:

B=1000.B=1000.

线程逻辑编号为 id。

安全版本伪代码:

```cpp start = id * B; end = min(start + B, N);

for (i = start; i < end; ++i) { output[i] = abs(input[i]); } ```

视频在 00:35 获取 id,在 00:39 计算 start,在 00:46 计算 end。

图 2

线程 id 经分块公式映射到半开区间 [start,end),实际实现还应以 min(end,N) 处理不足整块的尾部。

原视频 · 00:40 ↗

视频板书直接使用 end=start+1000end=start+1000

只有 N 恰好覆盖所有整块、或输入预先 padding 时才不会越界。

通用实现必须 clamp 到 N。

2. 为什么同一代码会做不同工作

函数体本身没有为线程 0、1、2、3 各写一份。

差异来自运行时状态:

id0id1id2id3.id_0\ne id_1\ne id_2\ne id_3.

因此:

startid=idB.start_{id}=id\cdot B.

每个线程把自己的 id 代入同一条指令,得到不同 start/end。

这属于 single program, multiple data 的基本思想。

“像执行了不同代码”只是观察结果不同;机器指令通常来自同一代码段。

3. 4000 元素、4 线程示例

视频从 00:58 假设:

N=4000,P=4,B=1000.N=4000, \qquad P=4, \qquad B=1000.

区间分别是:

T0:[0,1000),T_0:[0,1000),
T1:[1000,2000),T_1:[1000,2000),
T2:[2000,3000),T_2:[2000,3000),
T3:[3000,4000).T_3:[3000,4000).

这些半开区间不重叠,合起来恰好覆盖 [0,4000)[0,4000)

4. 线程 2 如何实例化局部状态

视频从 01:18 单独观察线程 2。

图 3

在线程 2 的示例中,id=2、start=2000、end=3000,因此同一循环体处理 input/output 的第三段。

原视频 · 01:20 ↗

它看到:

id=2,id=2,
start=21000=2000,start=2\cdot1000=2000,
end=3000.end=3000.

因此 loop body 处理:

i=2000,2001,,2999.i=2000,2001,\ldots,2999.

同一个变量名 start 在不同线程中可同时拥有不同值,因为它属于各自的调用与执行上下文。

5. thread id 不一定来自连续 OS 编号

板书中的 get_thread_id() 是抽象接口。

真实环境可能使用:

  • 线程池内部 worker index;
  • parallel-for 直接传入 range;
  • OpenMP thread number;
  • task runtime 的任务编号;
  • 操作系统线程标识符。

OS thread id 未必是从 0 开始的稠密整数,也不应直接乘 1000 做数组分块。

高层 runtime 通常显式提供 worker index 或 range。

6. 共享的是代码,不是所有状态

同一进程的 CPU 线程通常共享:

  • 虚拟地址空间;
  • 代码段;
  • heap;
  • global/static 对象;
  • 打开的文件与部分进程资源。

每个线程通常拥有自己的:

  • 程序计数器语义;
  • architectural register context;
  • stack;
  • thread-local storage;
  • 调度状态。

“共享地址空间”意味着一个线程拿到合法指针时,理论上可以访问另一个线程栈中的对象。

因此“栈私有”主要描述每线程独立分配、调用约定与生命周期,而不是独立进程式的地址隔离。

7. 寄存器上下文的准确含义

视频从 01:38 进入寄存器视角。

图 4

从程序语义看,每个线程拥有独立的 PC 与寄存器上下文;它们是可调度状态,不表示物理寄存器被某线程永久独占。

原视频 · 01:40 ↗

程序语义上,线程 1 可具有:

PC=0x400,R0=1,R1=1000.PC=0x400, \quad R_0=1, \quad R_1=1000.

线程 2 同时可具有:

PC=0x400,R0=2,R1=2000.PC=0x400, \quad R_0=2, \quad R_1=2000.

但不能据此认为每个软件线程永久占据一整套物理寄存器。

当线程运行时,值驻留在当前核心的 architectural/physical register resources 中。

上下文切换时,必要状态由 OS 保存并恢复。

编译器还可能把变量 spill 到栈,或完全消除某个变量。

8. PC 相同也不代表锁步执行

视频在 01:55 强调共享代码段。

两个线程可以暂时指向同一条机器指令。

它们不必同步前进:

  • 一个线程可能被抢占;
  • 一个线程可能 cache miss;
  • 一个线程可能运行在另一个核心;
  • 核心不足时,它们可能分时运行;
  • 分支和异常也会让 PC 路径不同。

并发表示执行时间区间重叠。

并行表示某一时刻确实在多个执行资源上同时运行。

线程数大于核心数时,不能假设所有线程物理并行。

9. 每线程 stack 与局部数组

视频从 02:09 进入栈视角,并在 02:22 定义局部数组 buf

图 5

普通自动存储期局部数组通常位于该线程的栈帧;编译器也可能把标量或小对象放入寄存器或优化掉。

原视频 · 02:20 ↗

概念代码:

``cpp float buf[1000] = {}; buf[0] = id; ``

线程 1 的 buf 与线程 2 的 buf 属于不同调用栈帧。

所以:

buf1[0]=1,buf_1[0]=1,
buf2[0]=2.buf_2[0]=2.
图 6

相同局部代码 buf[0]=id 在不同线程栈中产生不同值,体现栈私有;代码段、进程地址空间中的堆和全局对象通常仍由线程共享。

原视频 · 02:40 ↗

视频从 02:34 对比这两个值。

10. “局部变量在栈上”也不是绝对硬件事实

语言语义通常只规定作用域、生命周期与可观察行为。

编译器可把局部变量:

  • 放入寄存器;
  • spill 到栈;
  • 标量替换数组;
  • 完全优化掉;
  • 在逃逸后改由其他存储承载。

大数组还可能造成 stack overflow。

高性能算子常使用 heap、arena、thread-local scratch buffer 或 runtime workspace,而非在函数栈上放巨型临时张量。

11. 为什么示例没有数据竞争

若满足:

  • input 只读;
  • 每个 output[i] 只有一个线程写;
  • 分块区间不重叠;
  • 所有线程结束后再消费完整 output;

则逐元素绝对值没有 output data race。

但仍需正确的 join/barrier 建立 happens-before。

若区间计算错误造成重叠,或共享计数器无同步,就会产生 race。

12. false sharing 与 cache line

不同线程写不同元素不等于永远没有共享缓存成本。

若两个线程写的元素落在同一 cache line,可能发生 false sharing。

连续大块只在边界附近可能共享少量 cache line,通常比细粒度交错写更友好。

具体影响取决于:

  • cache line 大小;
  • dtype;
  • block 边界对齐;
  • cache coherence;
  • NUMA placement。

13. 绝对值算子的数值边界

对浮点数,要考虑:

  • NaN;
  • negative zero;
  • dtype 与向量指令语义。

对有符号整数,最小值的正数可能无法用同 dtype 表示。

例如 32-bit two's complement 的 INT_MIN 没有对应的正 int

真实算子必须采用框架定义的溢出/饱和/异常语义,不能只凭数学 x|x| 推断机器行为。

14. 更通用的均匀分块

若希望 P 个线程覆盖任意 N,可定义:

startp=pNP,start_p=\left\lfloor\frac{pN}{P}\right\rfloor,
endp=(p+1)NP.end_p=\left\lfloor\frac{(p+1)N}{P}\right\rfloor.

这样每块大小最多相差 1,不要求固定 1000。

真实 runtime 还可能动态切小任务,以改善负载均衡。

固定静态分块适合每元素成本近似相同的情况。

跟练与练习

编者练习

补全尾部边界 设 N=4100,固定 B=1000B=1000,线程 id=4 和 id=5 分别处理什么区间?

查看参考答案

线程 4:
start=41000=4000,start=4\cdot1000=4000,
end=min(5000,4100)=4100.end=\min(5000,4100)=4100.
所以处理:
[4000,4100).[4000,4100).
线程 5:
start=5000>N.start=5000>N.
它没有有效工作,应直接返回或得到空区间。
若照板书无条件访问到 6000,就会越界。

快速判断

  • 两个线程共享同一代码段,局部变量会自动相同吗?不会。
  • 每个软件线程永久占有一套物理寄存器吗?不会。
  • 写不同 output 元素就完全没有 cache coherence 成本吗?不一定。

常见误区

误区 1:同一段代码只能被一个线程执行

代码可重入时,多个线程可持有各自状态执行同一机器指令。

误区 2:OS thread id 一定是 0、1、2、3

板书 id 是 runtime 提供的稠密 worker index 抽象。

误区 3:线程的所有内存都彼此隔离

同一进程线程共享地址空间、heap 与 globals。

误区 4:局部数组一定原样存在于栈内存

编译器可寄存器化、标量替换或消除它。

误区 5:线程数等于同时运行的 CPU 核心数

调度、SMT 与超额订阅都会打破一一对应。

本课小结

  • 多线程共享算子代码,通过不同 id 和私有状态处理不同数据区间。
  • 分块应使用半开区间并 clamp 到 N,不能假设总长度整除 1000。
  • 线程逻辑上具有独立 PC、register context、stack 与 TLS。
  • 物理寄存器由核心与调度复用,局部变量位置也由编译器决定。
  • 线程共享进程地址空间,因此 heap/global 访问需要同步。
  • disjoint output 可避免数据竞争,但仍要考虑 join、false sharing 与数值边界。
  • 视频代码是 CPU 教学模型,不是某个线程库的完整 API 契约。
08

主题讲解 · 03:46

用前缀和读懂稀疏矩阵 CSR

学习目标

  • 能说出 COO 与 CSR 各自保存的三个数组。
  • 能从每行非零计数构造 CSR 的 row_ptr。
  • 能用 row_ptr 相邻差恢复每行 nnz。
  • 能从 row_ptr 定位某一行在 values/col_idx 中的半开区间。
  • 能解释 CSR 为什么无需为每个非零项保存 row_idx。
  • 能区分按行分段、行内列排序与 canonical 稀疏格式。

前置与衔接

视频在 00:02 提出问题:如何用前缀和理解 CSR?

示例稀疏矩阵是:

A=[102030405].A= \begin{bmatrix} 1&0&2\\ 0&3&0\\ 4&0&5 \end{bmatrix}.

它有:

M=3,N=3,nnz=5.M=3, \qquad N=3, \qquad nnz=5.

nnz 表示格式中存储的非零条目数。

若格式允许显式存储数值 0 或重复坐标,stored entries 与数学上的非零位置数可能不完全相同。

本课先采用无重复、无显式零的标准示例。

核心讲解

1. 定位一个稀疏条目需要三项信息

视频从 00:22 说明:一个稀疏条目至少需要知道:

  • value;
  • row index;
  • column index。
图 1

一个非零项由 value、row index、column index 三项定位;示例矩阵共有 5 个显式非零项。

原视频 · 00:20 ↗

例如:

value=3,row=1,col=1value=3, \quad row=1, \quad col=1

表示:

A1,1=3.A_{1,1}=3.

这里使用 0-based index。

只有 value 不知道放在哪里;只有坐标也不知道条目取值。

2. COO 直接保存三元组

视频在 00:34 引入 COO。

对示例矩阵,一种按行排列的 COO 是:

values=[1,2,3,4,5],values=[1,2,3,4,5],
row_idx=[0,0,1,2,2],row\_idx=[0,0,1,2,2],
col_idx=[0,2,1,0,2].col\_idx=[0,2,1,0,2].

对任意位置 k,三数组共同组成:

(row_idx[k],col_idx[k],values[k]).(row\_idx[k],col\_idx[k],values[k]).

三个数组必须同步重排。

不能只交换 values 而保持坐标不动。

COO 便于增量收集条目,但每个条目都显式重复保存行号。

3. CSR 先统计每行有多少条目

视频从 01:00 引入每行非零计数。

对示例:

row_count=[2,1,2].row\_count=[2,1,2].
图 2

示例各行非零数为 row_count=[2,1,2];它是构造 CSR row_ptr 的中间统计量,不是标准 CSR 必存数组。

原视频 · 01:00 ↗

含义是:

  • 第 0 行有 2 项;
  • 第 1 行有 1 项;
  • 第 2 行有 2 项。

视频在 01:11 读出 [2,1,2][2,1,2]

row_count 是构造时的中间数组。

标准 CSR 通常不需要同时保存它,因为 row_ptr 已能通过差分恢复。

4. exclusive prefix sum 构造 row_ptr

对:

c=[c0,c1,,cM1],c=[c_0,c_1,\ldots,c_{M-1}],

定义:

row_ptr[0]=0,row\_ptr[0]=0,
row_ptr[r+1]=row_ptr[r]+cr.row\_ptr[r+1]=row\_ptr[r]+c_r.

这就是带前导 0 的 exclusive prefix sum。

视频在 01:27 开始求前缀和,在 01:33 命名 row_ptr。

图 3

对 row_count=[2,1,2] 做带前导 0 的 exclusive prefix sum,得到 row_ptr=[0,2,3,5],末项等于 nnz=5。

原视频 · 01:40 ↗

本例:

row_ptr=[0,2,3,5].row\_ptr=[0,2,3,5].

长度是:

M+1=4.M+1=4.

最后一个值满足:

row_ptr[M]=nnz=5.row\_ptr[M]=nnz=5.

视频在 01:44 解释末项 5。

5. row_ptr 的区间语义

第 r 行的条目位于:

k[row_ptr[r],row_ptr[r+1]).k\in[row\_ptr[r],row\_ptr[r+1]).

本例:

row 0:[0,2),row\ 0:[0,2),
row 1:[2,3),row\ 1:[2,3),
row 2:[3,5).row\ 2:[3,5).

这三个区间正好覆盖:

[0,nnz).[0,nnz).

row_ptr 保存的是每一行在扁平 entries 数组中的边界。

6. 相邻差恢复 row_count

视频从 01:54 用差分解释可逆性。

row_count[r]=row_ptr[r+1]row_ptr[r].row\_count[r] =row\_ptr[r+1]-row\_ptr[r].
图 4

第 r 行非零数由 row_ptr[r+1]-row_ptr[r] 得到;该行在 values/col_idx 中占半开区间 [row_ptr[r],row_ptr[r+1])。

原视频 · 02:00 ↗

例如第 1 行:

row_ptr[2]row_ptr[1]=32=1.row\_ptr[2]-row\_ptr[1]=3-2=1.

row_ptr 中的 3 表示前两行一共占用 3 个 entries。

它既是第 2 行的起点,也是前两行累计 nnz。

7. CSR 保存哪三个数组

CSR 通常保存:

values=[1,2,3,4,5],values=[1,2,3,4,5],
col_idx=[0,2,1,0,2],col\_idx=[0,2,1,0,2],
row_ptr=[0,2,3,5].row\_ptr=[0,2,3,5].
图 5

CSR 保留 values 与 col_idx,用长度 rows+1 的 row_ptr 代替 COO 中逐项 row_idx,从而压缩重复行号。

原视频 · 02:40 ↗

视频从 02:23 对比 CSR 与 COO,并在 02:31 说明替换关系。

两种格式都有三个主数组,但数组长度不同:

  • COO row_idx 长度为 nnz;
  • CSR row_ptr 长度为 M+1。

nnzMnnz\gg M 时,CSR 的行信息通常更紧凑。

8. 为什么 CSR 仍能恢复每个条目的行号

对任意 k,只需找到唯一的 r,使:

row_ptr[r]k<row_ptr[r+1].row\_ptr[r]\le k<row\_ptr[r+1].

就知道第 k 个 entry 属于第 r 行。

然后:

col=col_idx[k],col=col\_idx[k],
value=values[k].value=values[k].

因此条目的 value、row、column 信息仍完备。

视频从 02:42 做反向还原,在 03:11 说明行号已经确定。

9. 空行如何表示

若某行没有 stored entry:

row_count[r]=0.row\_count[r]=0.

于是:

row_ptr[r+1]=row_ptr[r].row\_ptr[r+1]=row\_ptr[r].

也就是 row_ptr 出现重复值。

例如:

row_ptr=[0,2,2,5]row\_ptr=[0,2,2,5]

表示第 1 行区间为:

[2,2),[2,2),

即空区间。

重复 row_ptr 不是错误,恰好是空行的合法表示。

10. COO 可重排,CSR 必须按行分段

视频从 03:32 比较顺序要求。

图 6

COO 三元组可整体重排;CSR 必须让每行条目落在 row_ptr 指定分段内,但行内 col_idx 不一定排序,除非格式或算子要求 canonical order。

原视频 · 03:20 ↗

准确边界是:

  • COO 三元组可整体重排;
  • CSR 中某行的所有 entries 必须位于该行 row_ptr 分段;
  • 行与行的分段顺序由 row_ptr 固定;
  • 同一行内部的 col_idx 不一定必须升序。

很多库定义 sorted/canonical CSR,要求行内列索引升序并合并重复坐标。

但基础 CSR 表示本身不必天然满足这些附加规范。

所以板书“CSR 必须行优先”应理解为按行成段,而不是每行列号必定有序。

11. 从 COO 构造 CSR 的算法

给定 M 行、nnz 个 COO entries:

  1. 初始化 row_count[M]=0row\_count[M]=0
  2. 遍历 row_idx,对对应行计数;
  3. 对 row_count 做 prefix sum 得到 row_ptr;
  4. 为每行维护当前写入 cursor;
  5. 把 values 与 col_idx scatter 到对应行分段;
  6. 如有需要,行内按 col 排序并合并重复项。

串行复杂度约为:

O(M+nnz)O(M+nnz)

外加可选排序成本。

并行构造还要处理 histogram 与 scatter 的冲突,可能使用 atomics、分块计数或 segmented prefix sum。

12. 前缀和为什么适合并行

prefix sum 不只是数学定义。

它是 GPU/并行算法中的基础 primitive。典型并行 scan 用树形 upsweep/downsweep 或分层 block scan,把串行依赖重组成 O(logM)O(\log M) 的并行深度,并保持 O(M)O(M) 级总工作量。

具体 work efficiency、同步与内存访问取决于实现。

不能只用并行深度替代实际运行时间。

13. CSR 的存储量

设 value 占 svs_v bytes,index 占 sis_i bytes。

CSR 主数组约占:

nnzsv+nnzsi+(M+1)si.nnz\cdot s_v +nnz\cdot s_i +(M+1)\cdot s_i.

COO 约占:

nnzsv+2nnzsi.nnz\cdot s_v+2nnz\cdot s_i.

CSR 相比 COO 用 row_ptr 替代逐项 row_idx。

是否更省内存取决于 M 与 nnz 的关系、index width 和格式元数据。

14. CSR 的计算优势与局限

CSR 很适合逐行运算,例如 SpMV:

``text for row in [0, M): sum = 0 for k in [row_ptr[row], row_ptr[row+1]): sum += values[k] * x[col_idx[k]] y[row] = sum ``

每行边界可 O(1) 获取。

但性能仍受:

  • 每行 nnz 不均衡;
  • x 的不规则 gather;
  • 短行并行粒度;
  • index bytes;
  • cache 与 memory bandwidth;
  • 行内排序状态。

CSR 解决存储与定位,不自动解决所有稀疏计算瓶颈。

跟练与练习

编者练习

从 row_count 构造 row_ptr 设一个 5 行矩阵的: row_count=[0,3,1,0,2].row\_count=[0,3,1,0,2]. 求 row_ptr,并写出第 1 行与第 3 行的 entry 区间。

查看参考答案

带前导 0 做累积:
row_ptr=[0,0,3,4,4,6].row\_ptr=[0,0,3,4,4,6].
第 1 行:
[row_ptr[1],row_ptr[2])=[0,3).[row\_ptr[1],row\_ptr[2])=[0,3).
第 3 行:
[row_ptr[3],row_ptr[4])=[4,4).[row\_ptr[3],row\_ptr[4])=[4,4).
所以第 3 行为空行。
末项 6 等于总 stored entries 数。

快速判断

  • row_ptr 长度等于 nnz 吗?不,等于行数加 1。
  • row_ptr 可出现重复值吗?可以,表示空行。
  • CSR 同一行的 col_idx 一定升序吗?基础格式不保证,canonical 规范可能要求。

常见误区

误区 1:row_ptr[r] 是第 r 行非零数

它是第 r 行在 entries 数组中的起始 offset。

误区 2:row_ptr 最后一项是列数

最后一项是 nnz。

误区 3:COO 可以任意单独重排一个数组

只能把三元组作为整体重排。

误区 4:CSR 天然去除了重复坐标和显式零

是否合并/清理取决于构造与 canonicalization。

误区 5:CSR 一定比所有稀疏格式更快

性能取决于行长度分布、算子、硬件与访存模式。

本课小结

  • COO 用 values、row_idx、col_idx 直接保存每个条目的三元组。
  • CSR 先统计 row_count,再用 exclusive prefix sum 得到 row_ptr。
  • 第 r 行对应区间 [row_ptr[r],row_ptr[r+1])[row\_ptr[r],row\_ptr[r+1])
  • 相邻 row_ptr 做差可恢复每行 nnz,末项等于总 nnz。
  • CSR 用长度 M+1 的 row_ptr 替代长度 nnz 的 row_idx。
  • CSR 要求 entries 按行分段,行内是否排序是额外格式约定。
  • 前缀和解决行边界构造,但稀疏计算仍有负载不均和不规则访存问题。
09

主题讲解 · 01:58

稀疏矩阵向量乘如何跳过隐式零

学习目标

  • 能写出稠密矩阵-向量乘与 COO SpMV 的循环。
  • 能解释 values、row_idx、col_idx 如何驱动一次乘加。
  • 能计算 SpMV 的 O(MN)O(MN)O(nnz)O(nnz) 工作量差异。
  • 能区分隐式零、显式存储零和重复坐标。
  • 能说明 COO 并行累加为什么可能需要 atomic 或 segmented reduction。
  • 能判断索引流量、不规则 gather 与行负载不均的性能边界。

前置与衔接

上一课用前缀和构造 CSR。

本课回到更直观的 COO,回答视频在 00:00 提出的问题:

SpMV 是:

y=Ax,y=Ax,

其中:

ARM×NA\in\mathbb{R}^{M\times N}

是稀疏矩阵,

xRN,yRMx\in\mathbb{R}^{N}, \qquad y\in\mathbb{R}^{M}

是稠密向量。

图 1

SpMV 计算 y=Ax;COO 只保存 values、row_idx、col_idx 三个长度为 nnz 的数组,而非展开整个稠密矩阵。

原视频 · 00:00 ↗

视频在 00:07 定义 SpMV。

本课使用无重复坐标、无显式存储零的 COO 示例,再讨论这些边界。

核心讲解

1. 数学定义

对每一行 i:

yi=j=0N1Aijxj.y_i=\sum_{j=0}^{N-1}A_{ij}x_j.

示例:

A=[102030405].A= \begin{bmatrix} 1&0&2\\ 0&3&0\\ 4&0&5 \end{bmatrix}.

因此:

y0=x0+2x2,y_0=x_0+2x_2,
y1=3x1,y_1=3x_1,
y2=4x0+5x2.y_2=4x_0+5x_2.

数学式中,零项对结果没有贡献。

关键是存储格式能否让程序根本不枚举这些位置。

2. 稠密循环为什么会访问零位置

朴素 dense matvec:

``text for i in [0, M): sum = 0 for j in [0, N): sum += A[i,j] * x[j] y[i] = sum ``

无论 AijA_{ij} 是否为 0,循环都遍历全部 M×NM\times N 个坐标。

图 2

朴素稠密矩阵-向量乘按 M×N 位置计算,即便 A[i,j]=0 也会执行对应乘加;稀疏格式通过不存这些隐式零来跳过它们。

原视频 · 00:20 ↗

视频从 00:11 说明传统矩阵-向量乘,在 00:16 强调遍历矩阵。

工作量约为:

O(MN).O(MN).

这里“会计算零”是算法层的说法。

编译器一般不能预先知道任意运行时 dense A 中哪些位置为零。

3. COO 只枚举 stored entries

视频从 00:26 使用 COO 举例。

示例 COO:

values=[1,2,3,4,5],row_idx=[0,0,1,2,2],col_idx=[0,2,1,0,2].\begin{aligned} values&=[1,2,3,4,5],\\ row\_idx&=[0,0,1,2,2],\\ col\_idx&=[0,2,1,0,2]. \end{aligned}

三个数组长度都是 nnz=5nnz=5;第 k 个 entry 是 (row_idx[k],col_idx[k],values[k])(row\_idx[k],col\_idx[k],values[k])

没有出现在数组中的坐标被解释为隐式零。

4. 单个 COO 条目如何定位

视频在 00:47 选择数值 3。

图 3

COO 中 values[k]=3、row_idx[k]=1、col_idx[k]=1 共同表示 A[1,1]=3;三个数组必须按同一 k 对齐。

原视频 · 00:40 ↗

对相应 k:

values[k]=3,row_idx[k]=1,col_idx[k]=1.values[k]=3,\quad row\_idx[k]=1,\quad col\_idx[k]=1.

所以它表示:

A1,1=3.A_{1,1}=3.

三个数组必须按同一个 k 对齐。

如果只重排 values,坐标语义就会损坏。

5. COO SpMV 的核心循环

先把输出初始化为 0:

``text y[0:M] = 0 ``

再遍历 stored entries:

``text for k in [0, nnz): i = row_idx[k] j = col_idx[k] y[i] += values[k] * x[j] ``

图 4

SpMV 遍历 k=0…nnz-1,以 col_idx[k] 读取 x,以 row_idx[k] 选择 y,并用 values[k] 完成一次乘加。

原视频 · 01:00 ↗

视频从 01:05 开始遍历三个数组。

每个 stored entry 恰好贡献一次乘加。

6. 以条目 A[2,0]=4 为例

视频在 01:11 取到数值 4。

对应:

value=4,row=2,col=0.value=4, \quad row=2, \quad col=0.

列索引先选择:

x[col]=x0.x[col]=x_0.
图 5

对条目 A[2,0]=4,列索引 0 选择 x[0],形成乘积 4x[0];这种 x 访问在一般稀疏模式下可能不连续。

原视频 · 01:20 ↗

视频在 01:21 得到 x0x_0

形成乘积:

4x0.4x_0.

行索引再决定输出:

y[row]=y2.y[row]=y_2.
图 6

同一条目的行索引 2 决定把 4x[0] 累加到 y[2];并行 COO 若多个条目写同一 y 行,需要原子操作或分段归约。

原视频 · 01:40 ↗

视频在 01:30 进入累加,在 01:33 指向 y2y_2

最终执行:

y2+=4x0.y_2\mathrel{+}=4x_0.

7. “跳过 0”究竟是什么意思

COO 循环的 k 只取:

0,1,,nnz1.0,1,\ldots,nnz-1.

它不生成所有 (i,j)(i,j) 坐标。

因此,未存储坐标对应的隐式零没有:

  • A value load;
  • multiply;
  • output accumulate。

视频从 01:42 总结零被跳过,在 01:46 用只有一两个条目的极稀疏情况强化直觉。

8. 稀疏复杂度

串行 COO SpMV 的主要循环工作量:

O(nnz).O(nnz).

若计入输出清零:

O(M+nnz).O(M+nnz).

稠密 baseline 是:

O(MN).O(MN).

加速空间取决于稀疏度:

density=nnzMN.density=\frac{nnz}{MN}.

当 density 很低时,可大幅减少算术。

nnzMNnnz\approx MN 时,稀疏格式还要读取索引,可能比 dense 更慢。

9. 显式存储的 0 不会自动跳过

如果 COO 中存在 values[k]=0values[k]=0,循环仍会读取 row、col、value,通常也会执行乘加。

“跳过零”只对未存储的隐式零成立。

若要删除显式零,需要 prune/canonicalize 步骤。

是否可删除还要考虑 NaN/Inf、symbolic sparsity、autograd、固定 pattern 与数值阈值语义。

不能把接近 0 的小值擅自当成精确 0 删除。

10. 重复坐标会自然累加

COO 可包含多个 k 指向同一 (i,j)(i,j)

例如 (i,j,2)(i,j,2)(i,j,3)(i,j,3) 在 SpMV 中共同贡献 2xj+3xj=5xj2x_j+3x_j=5x_j

因此 COO 循环的 += 很重要。

有些库要求先 coalesce 重复项,有些算子允许运行时累加。

浮点加法不满足严格结合律,重排或并行合并可能产生末位差异。

11. 并行 COO 的写冲突

若一个线程处理一个 k,不同条目可能具有相同 row_idx,于是多个线程同时更新同一个 yiy_i

普通 load-add-store 会 data race。

常见方案包括:

  • atomic add;
  • 按行或按 segment 做 reduction;
  • sort/coalesce 后再归约;
  • 转为 CSR,由一个 warp/block/线程组负责一行;
  • 使用私有 partial sums 后合并。

atomic 保证更新不丢失,但会有竞争成本。

浮点 atomic 的完成顺序变化也可能造成非确定的末位差异。

12. CSR SpMV 为什么常见

CSR 直接给出每行区间:

``text for i in [0, M): sum = 0 for k in [row_ptr[i], row_ptr[i+1]): sum += values[k] * x[col_idx[k]] y[i] = sum ``

若每行只由一个执行单元或一个协作组负责,可先局部归约再写一次 y,避免 COO 式逐 entry 原子写。

但 CSR 也会遇到每行 nnz 差异造成的负载不均。

长行、短行、power-law 行长度通常需要不同调度策略。

13. 算术少不等于一定快很多

每个 COO entry 至少需要读取:

  • value;
  • row index;
  • column index;
  • x 的一个元素;
  • 并更新 y。

x 由 col_idx 做 gather,地址可能不规则。

y 由 row_idx 做 scatter/accumulate,也可能竞争。

SpMV 的算术强度通常很低,容易受 memory bandwidth、cache miss 与 index traffic 限制。

因此实际速度不会简单等于:

MNnnz.\frac{MN}{nnz}.

14. shape 与合法性检查

正确实现至少要保证 0row_idx[k]<M0\le row\_idx[k]<M0col_idx[k]<N0\le col\_idx[k]<N,且三个 COO 数组长度相同。x 长度应为 N、y 长度应为 M;index dtype 要能表示维度与 nnz,设备与 accumulation dtype 也须匹配算子契约。

跟练与练习

编者练习

手算 COO SpMV 给定: values=[2,1,4],row_idx=[0,0,2],col_idx=[1,3,0],x=[5,6,7,8].\begin{aligned} values&=[2,-1,4],\\ row\_idx&=[0,0,2],\\ col\_idx&=[1,3,0],\\ x&=[5,6,7,8]. \end{aligned} 输出 y 有 3 行。求 y。

查看参考答案

先初始化 y=[0,0,0]y=[0,0,0]。三次更新为:
y0+=2x1=12,y0+=x3=8,y2+=4x0=20.\begin{aligned} y_0&\mathrel{+}=2x_1=12,\\ y_0&\mathrel{+}=-x_3=-8,\\ y_2&\mathrel{+}=4x_0=20. \end{aligned}
最终 y=[4,0,20]y=[4,0,20]
第 1 行没有 stored entry,因此保持初始化的 0。

快速判断

  • COO values 中存了一个 0,循环会自动不读它吗?不会。
  • 每个 k 并行就一定可以普通写 y[row[k]] += ... 吗?不可以,可能写冲突。
  • 稀疏矩阵越接近 dense,稀疏格式越一定快吗?不是。

常见误区

误区 1:SpMV 会先扫描 dense A 再判断是否为 0

真正节省工作的来源是稀疏格式只枚举 stored entries。

误区 2:nnz 一定等于数学非零坐标数

显式零和重复坐标会让 stored entries 与数学 support 不完全一致。

误区 3:col_idx 决定写到哪个 y

col_idx 读取 x,row_idx 选择 y。

误区 4:COO 并行不需要归约

同一行的多个条目会写同一 y 元素。

误区 5:减少乘法数就会按同倍数加速

索引流量、gather、scatter、atomic 与带宽可能主导性能。

本课小结

  • dense matvec 遍历 M×NM\times N 个坐标,COO SpMV 只遍历 nnz 个 stored entries。
  • 每个 entry 执行 y[rowk]+=valuekx[colk]y[row_k]\mathrel{+}=value_k\,x[col_k]
  • 未存储坐标对应的隐式零被跳过,显式存储零不会自动跳过。
  • 串行工作量约为 O(M+nnz)O(M+nnz),收益取决于 density。
  • COO 可含重复坐标,使用 += 累积;并行写同一 y 需要同步或分段归约。
  • CSR 可按行组织归约,但仍有行长度负载不均问题。
  • 稀疏 SpMV 常受索引流量和不规则访存限制,算术减少不等于同比加速。
10

单元综合

从字节账本到稀疏索引:GPU 算子优化的统一分析方法

单元能力目标

完成本单元后,应能从“工作、数据、索引、并行、同步”五本账分析一个张量算子,而不是把某项技术直接等同于固定加速倍数。

具体需要做到:

  • 估算逐元素算子的 FLOPs、必要字节与临时张量流量;
  • 区分 fusion、FMA、SIMD、SIMT、多核和双缓冲的作用层次;
  • 用 grid-stride loop 证明 GPU 索引覆盖;
  • 把广播 shape 映射到多维坐标、stride 和线性地址;
  • 区分 CPU 线程的共享代码、私有上下文与共享地址空间;
  • 从 COO 构造 CSR 的 row_ptr
  • 写出 COO/CSR SpMV 的真实工作量与并行写冲突;
  • 用 profiler 验证带宽、指令、occupancy 与负载不均假设。

概念连接

1. 优化前先判断瓶颈类别

对逐元素加法

Ci=Ai+Bi,C_i=A_i+B_i,

FP32 每个元素至少需要:

  • 读取 AiA_i:4 bytes;
  • 读取 BiB_i:4 bytes;
  • 写入 CiC_i:4 bytes;
  • 执行 1 次浮点加法。

理想算术强度约为

1 FLOP12 bytes.\frac{1\ \text{FLOP}}{12\ \text{bytes}}.

这个数很低,大张量通常更容易受内存带宽限制,而不是受峰值 FLOPs 限制。

所以优化顺序应先看内存流量与访问模式,再看算术指令数。

2. 算子融合主要消灭中间流量

未融合的逐元素乘加:

T=AB,T=A\odot B,
Y=T+C.Y=T+C.

若每个张量有 NN 个元素、元素大小为 ss,理想化内存事务为:

  • 第一算子读 A,BA,B、写 TT3Ns3Ns
  • 第二算子读 T,CT,C、写 YY3Ns3Ns
  • 总计 6Ns6Ns

融合为

Y=AB+CY=A\odot B+C

后,只需读 A,B,CA,B,C、写 YY,约为

4Ns.4Ns.

fusion 还可减少 kernel launch、同步与临时内存管理。

3. fusion 不等于 FMA

fusion 是计算图或 kernel 级的算子合并。

FMA 是硬件指令级的一次乘加,语义近似

a×b+c.a\times b+c.

一个 fused kernel 不一定使用 FMA;一个使用 FMA 的 kernel 也不一定消除了跨算子的中间张量。

两者可以同时存在,但优化对象不同。

4. SIMD 减少动态指令,不减少必要 bytes

若向量宽度为 LL,一条 SIMD 指令可并行处理 LL 个 lane。

主循环的动态算术和访存指令数可近似降为标量循环的 1/L1/L

例如 512-bit 向量寄存器可容纳 16 个 FP32 元素,连续地址步进为 64 bytes。

但处理同一个大张量仍需读写相同数量的数据。

若已经受内存带宽限制,增加理论 lane 数不保证线性加速。

还需处理对齐、尾部、stride、alias 与编译器向量化条件。

5. CPU 多线程与 SIMD 是两个并行层次

多线程把索引区间分给不同核心或硬件线程。

SIMD 在每个线程内部一次处理多个连续元素。

常见组合是:

  1. 线程 tt 负责半开区间 [startt,endt)[start_t,end_t)
  2. 区间内部使用向量主循环;
  3. 最后处理不足一个向量宽度的 tail。

线程共享同一段算子代码,但具有逻辑上独立的 PC、寄存器上下文、stack 与 TLS。

它们共享进程地址空间,所以写入重叠区域时仍需同步。

6. 正确分块必须处理边界

若每线程块大小为 BB,线程编号为 tt

startt=tB,start_t=tB,
endt=min((t+1)B,N).end_t=\min((t+1)B,N).

半开区间避免重叠,min 防止最后一块越界。

不同线程写 disjoint output 可避免数据竞争,但仍需考虑:

  • 启动与 join;
  • false sharing;
  • NUMA;
  • 小任务调度开销;
  • 浮点归约次序。

7. grid-stride loop 用余数类覆盖索引

GPU 中全局线程编号为

i0=blockIdx.xblockDim.x+threadIdx.x.i_0 = blockIdx.x\cdot blockDim.x +threadIdx.x.

网格总线程数为

G=gridDim.xblockDim.x.G=gridDim.x\cdot blockDim.x.

线程处理

i=i0,i0+G,i0+2G,<N.i=i_0,i_0+G,i_0+2G,\ldots<N.

每个索引 ii 都可唯一写成

i=qG+r,0r<G,i=qG+r, \qquad 0\le r<G,

所以它由起点为 rr 的线程处理,覆盖不重不漏。

这是一种静态索引分配,不是通用动态负载均衡。

8. 跨步循环与 coalescing 不矛盾

单个线程相邻轮次相差 GG,看起来跨步很大。

但同一个 warp 在同一轮中,线程起点通常连续:

i,i+1,i+2,.i,i+1,i+2,\ldots.

因此是否合并访存应按 warp 同轮地址判断,而不是只看单线程的 stride。

分支发散、非连续 layout 或位置相关工作量仍可能破坏性能。

9. 双缓冲隐藏 latency,不减少总工作

单缓冲循环可能串行执行:

copycomputecopycompute.copy\rightarrow compute\rightarrow copy\rightarrow compute.

双缓冲让 compute 使用 buffer A 时,producer 同时填充 buffer B,下一轮交换。

理想稳态周期由最慢独立阶段决定:

Tsteadymax(Tcopy,Tcompute).T_{steady}\approx\max(T_{copy},T_{compute}).

但还需付出 pipeline fill/drain。

双缓冲不减少 bytes 或 FLOPs,而是使用更多片上空间换取 latency overlap。

10. 完全重叠有硬件与资源前提

要让 copy 与 compute 真正并行,需要:

  • 异步 copy 能力;
  • 独立 copy/compute 资源或可重叠流水线;
  • 正确同步,避免读未完成或覆写仍在使用的数据;
  • 足够带宽;
  • tile 足够大以摊薄调度;
  • 片上容量与寄存器压力不把 occupancy 压得过低。

所以双缓冲是需要 profiler 验证的调度策略,不是自动加速开关。

11. 广播先处理逻辑 shape

两个张量广播时从尾轴对齐。

每个对齐轴必须:

  • 大小相等;或
  • 至少一方大小为 1。

例如 shape

(2,1)(2,1)

(3,1,4)(3,1,4)

对齐后广播为

(3,2,4).(3,2,4).

逻辑广播不一定立即物化重复数据。

12. 输出坐标怎样映射回输入地址

对输出多维坐标 oo,输入某轴大小为 1 时,对应输入坐标固定为 0;大小相等时保留输出坐标。

物理地址由

offset=base+didstridedoffset = base+ \sum_d i_d\cdot stride_d

计算。

广播 view 可以把扩展轴表示为 stride 0,但后端或 .contiguous() 仍可能物化数据。

所以 shape 语义与 layout/stride 语义必须分开分析。

13. 稀疏计算先改变表示

稠密矩阵保存 M×NM\times N 个位置,包括大量零。

COO 只保存 stored entries:

(rowk,colk,valuek),k=0,,nnz1.(row_k,col_k,value_k), \qquad k=0,\ldots,nnz-1.

它直接表达稀疏坐标,构造简单,但每个条目都需保存行列索引。

14. CSR 用前缀和压缩行索引

先统计每行非零数:

row_count[r].row\_count[r].

再做 exclusive prefix sum:

row_ptr[0]=0,row\_ptr[0]=0,
row_ptr[r+1]=row_ptr[r]+row_count[r].row\_ptr[r+1] = row\_ptr[r]+row\_count[r].

rr 行存储区间为

[row_ptr[r],row_ptr[r+1]).[row\_ptr[r],row\_ptr[r+1]).

末项等于 nnznnz,空行会产生相邻重复指针。

CSR 只强制按行分段;行内列索引是否排序是额外约定。

15. SpMV 只跳过未存储的隐式零

COO SpMV 对每个 stored entry 执行:

y[rowk]+=valuekx[colk].y[row_k] \mathrel{+}= value_k\,x[col_k].

工作量由 nnznnz 决定,而不是 MNMN

但显式存储的零仍会被访问;重复坐标也需要用 += 累积。

并行 COO 中,多个条目可能写同一个 y[row]y[row],需要 atomic 或分段归约。

CSR 可按行组织归约,却会遇到行长度不均导致的负载不平衡。

16. 稀疏 FLOPs 下降不等于同比加速

稀疏格式额外读取索引,并产生不规则访问。

SpMV 常受以下因素限制:

  • col_idxrow_ptr 流量;
  • xx 的间接访存与 cache miss;
  • 行长度负载不均;
  • 并行写冲突;
  • 低算术强度。

因此应同时比较 density、索引位宽、格式转换成本和目标硬件 kernel。

对比与决策

1. 五种优化分别改变什么

  • fusion:减少中间张量流量和 kernel 边界。
  • SIMD:减少 CPU 动态指令数。
  • 多线程 / SIMT:增加并行工作者数量。
  • 双缓冲:重叠搬运与计算,隐藏 latency。
  • 稀疏格式:跳过隐式零,但引入索引和不规则访问。

它们可以组合,但不能相互替代。

2. 逐元素算子的优先路线

  1. 保证连续、合并访问并避免无谓物化。
  2. 融合相邻逐元素操作,减少临时张量。
  3. 使用多核、SIMD 或 GPU 网格覆盖索引。
  4. 只有 copy 能与足够长的计算重叠时再考虑显式双缓冲。
  5. 用 profiler 检查带宽、launch、寄存器、occupancy 与 tail。

3. 何时值得转稀疏

稀疏度必须足以抵消:

  • 索引存储;
  • 格式转换;
  • 间接访存;
  • 负载不均;
  • 稀疏 kernel 吞吐差距。

只看到很多零还不够,还要确认它们是未存储的结构化隐式零,而不是 dense 张量里的数值零。

综合训练

编者练习

一个 FP32 逐元素表达式先计算 T=ABT=A\odot B,再计算 Y=T+CY=T+C。忽略 cache,比较未融合与融合后的理想字节流量,并说明为何加速比不必等于 6/46/4

查看参考答案

未融合每元素读 A,BA,B、写 TT,再读 T,CT,C、写 YY,共 6 次元素事务,即 24 bytes;融合读 A,B,CA,B,C、写 YY,共 4 次,即 16 bytes。理想流量比为 1.5,但真实加速还受 cache、launch、指令、寄存器压力、shape、广播、带宽饱和与编译器生成代码影响,所以不能直接断言固定 1.5 倍。

编者练习 2

给定 gridDim.x=80blockDim.x=256N=50000N=50000,写出全局线程 17 处理的索引,并说明为何不会与线程 18 重复。

查看参考答案

总线程数 G=80×256=20480G=80\times256=20480。线程 17 处理 17,20497,4097717,20497,40977,下一项 61457 已越界。线程 18 处理余数为 18 的索引;两个序列模 GG 的余数不同,因此不重复。任意 i<Ni<N 都有唯一余数 imodGi\bmod G,所以整体覆盖不重不漏。

编者练习 3

矩阵三行的非零数为 [2,0,3][2,0,3]。构造 CSR 的 row_ptr,写出每行区间,并说明空行怎样表示。

查看参考答案

exclusive prefix sum 得到 row_ptr=[0,2,2,5]。第 0 行区间为 [0,2)[0,2),第 1 行为 [2,2)[2,2),第 2 行为 [2,5)[2,5)。空行由相邻两个相同指针表示,不需要额外哨兵。末项 5 等于总 nnznnz

进入下一单元前

  • 已能为逐元素算子列出 FLOPs、必要 bytes 和临时张量流量。
  • 已能区分 fusion、FMA、SIMD、多线程、SIMT 与双缓冲。
  • 已能证明 grid-stride loop 覆盖,并按 warp 判断 coalescing。
  • 已能从广播输出坐标映射到输入坐标、stride 与地址。
  • 已能从 row counts 构造 CSR,并写出 COO/CSR SpMV。
  • 若仍把 SIMD 宽度当作固定加速倍数,回看 P38、P40 的带宽账本。
  • 若仍把双缓冲当作减少 bytes,回看 P39 的 fill/steady/drain 与资源前提。
  • 若仍把数值零自动当作稀疏跳过,回看 P44、P45 的 stored entry 语义。