LEARNING UNIT · 13
GPU 算子与稀疏计算
从算子融合、SIMD、双缓冲和网格步进延伸到广播索引、CSR 与稀疏矩阵向量乘。
- 已整理章节
- 10 节
- 单元来源
- 9 条视频
- 总时长
- 21:20
- 状态
- 已发布
- 学习位置
- 13 / 20
主题讲解 · 01:51
算子融合为何能加速逐元素乘加
学习目标
- 能区分算子融合、数据并行与硬件 FMA 指令。
- 能计算分离 kernel 与融合 kernel 的理想化内存流量。
- 能解释为什么逐元素乘加通常受内存带宽影响。
- 能说明 fusion 还可减少 kernel launch 与全局同步边界。
- 能判断 cache、广播、寄存器压力与数值语义对 fusion 的影响。
- 能避免把“大慢/小快”板书固定映射成某一种硬件。
前置与衔接
本课计算同 shape 张量的逐元素表达式:
每个输出元素满足
元素之间互不依赖,因此天然适合数据并行。
逐元素目标为 ;板书用“大而慢/小而快”抽象外部内存与片上存储,具体层级可能是 DRAM、HBM、cache、shared memory 或 register。
原视频 · 00:20 ↗这个抽象可能对应:
- CPU 的 DRAM、cache、register;
- GPU 的 HBM、L2、shared memory、register;
- 加速器的外部内存、片上 SRAM 与向量寄存器。
不同硬件不必拥有完全相同的层级和显式搬运指令。
核心讲解
1. 数据并行不是算子融合
视频在 00:34 先说明多个计算单元分担任务。
多个计算单元可分担互不依赖的元素区间;并行度改变任务分配,但每个元素仍要完成输入读取、乘加和结果写回。
原视频 · 00:40 ↗若有 P 个 worker,可把 N 个元素分成互不重叠的索引集合。
理想均匀负载下,每个 worker 处理约
个元素。
这是并行任务划分。
算子融合回答的是另一个问题:
两种优化可以同时存在,但概念不能混为一谈。
2. 未融合路径物化 temp
未融合实现分两步:
分离执行时,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 次元素写入。
总计:
3. 融合路径不暴露完整 temp
融合 kernel 对每个元素直接执行:
t 只需存在于寄存器或编译器管理的局部临时值中。
融合 kernel 在同一执行范围内读取 A、B、C,计算 后直接写 D,避免将完整 temp 暴露为持久全局中间张量。
原视频 · 01:40 ↗理想化流量是:
- 读取 A、B、C:3N 次元素读取;
- 写 D:N 次元素写入。
即
相对未融合路径,少了 T 的一次完整写入和一次完整读取。
相对融合路径,物化 temp 至少多出一次 N 元素写入和一次 N 元素读取;是否真正落到 HBM 仍取决于编译器、cache 与运行时。
原视频 · 01:20 ↗4. 用字节数比较
设每个元素占 s bytes。
不考虑 cache line、write allocate 与对齐,未融合流量约为
融合流量约为
节省
bytes,理想化总流量下降
这是流量比例,不等于运行时间必然下降三分之一。
5. 为什么逐元素乘加常是带宽受限
每个元素只有一次乘法和一次加法。
按常见 FLOP 口径,这是 2 FLOPs。
FP32 时,融合路径理想化需要 16 bytes:
算术强度为
未融合路径为
两者算术量几乎相同,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 是单条硬件指令执行
融合 kernel 可能被编译成 FMA,也可能仍使用分离的 multiply 与 add 指令。
反过来,一个 FMA 指令也不自动说明多个高层算子已消除全局 temp。
FMA 只有一次最终舍入,可能与先乘后加的两次舍入产生微小数值差异。
严格数值模式可能限制 contraction。
9. 什么时候 fusion 可能不划算
fusion 会扩大单个 kernel 的工作范围,可能带来:
- 更多寄存器,降低 occupancy;
- 寄存器 spill,反而增加内存访问;
- 更复杂的分支或广播索引;
- 破坏某个库算子的专用高性能实现;
- 重复计算公共中间量;
- 延长编译时间和代码尺寸;
- 不方便多个消费者复用 T。
此外,存在副作用、精确异常语义或必须同步的算子不能任意融合。
10. shape 与广播边界
板书默认 A、B、C、D 具有相同 shape,并按相同连续索引访问。
若存在 broadcasting,例如
融合仍可行,但 B 的索引、cache 复用和向量化方式会变化。
非连续 stride、转置 view 与动态 shape 也会改变性能模型。
跟练与练习
编者练习
计算 FP16 张量的理想化流量 设 N=10,000,000,A、B、C、D、T 都是 FP16。 忽略 cache、对齐和写分配,分别计算未融合与融合的总字节流量,以及节省量。
查看参考答案
FP16 每元素 2 bytes。
未融合:
融合:
节省:
这是算法层的理想化流量,不是 profiler 必然测到的 HBM bytes。
自检
若 T 还有另一个消费者,是否仍能完全删除 T?
不一定。
可选择复制计算、部分融合或保留 T;最优方案取决于 T 的计算成本、消费者数量和内存压力。
常见误区
误区 1:算子融合减少乘加 FLOPs
本例的数学乘法和加法数量没有减少。
主要减少的是中间量流量与调度边界。
误区 2:大慢一定是 HBM,小快一定是 SRAM
这是跨硬件的教学抽象,实际层级可能完全不同。
误区 3:融合后一定使用 FMA
是否 contraction 由 ISA、编译器和数值模式决定。
误区 4:融合越多越快
过度融合可能增加寄存器压力、spill、分支和代码尺寸。
误区 5:理想流量下降三分之一,时间也必降三分之一
运行时间还受 launch、带宽利用率、cache、occupancy 与其他瓶颈影响。
本课小结
- 未融合逐元素乘加会物化 T,并多出一次完整写入和读取。
- 理想化总元素事务从 降到 ,即字节总量从 降到 。
- 逐元素乘加算术强度低,fusion 常通过减少内存流量提升性能。
- fusion 还可减少 kernel launch、同步与临时张量管理。
- 算子融合不等于硬件 FMA,也不保证固定倍数加速。
- 最终收益必须结合 shape、广播、cache、寄存器压力与真实 profiler 判断。
主题讲解 · 01:10
SIMD 如何提高逐元素张量吞吐
学习目标
- 能解释 SIMD 中一条向量指令如何覆盖多个数据 lane。
- 能从寄存器位宽和元素位宽计算 lane 数。
- 能比较标量与向量主循环的地址步进和动态指令数。
- 能处理对齐、尾部、非连续 stride 与 aliasing 边界。
- 能说明 SIMD 理论宽度不等于固定加速倍数。
- 能区分 CPU SIMD 与 GPU SIMT 的编程和执行模型。
前置与衔接
本课使用最简单的逐元素加法:
默认 A、B、C:
- shape 相同;
- 元素类型为 FP32;
- 内存连续;
- 地址区间不存在破坏优化的重叠。
本地字幕在 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 ``
标量教学模型每轮分别 load 、,执行一次标量 add,再 store ,随后地址前进一个 FP32 元素。
原视频 · 00:10 ↗FP32 占 4 bytes,因此地址每轮前进
处理 N 个元素,主循环需要约 N 次标量 add 指令。
2. 标量不等于 CPU 一次只做一件事
标量 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 数为
视频在 00:29 采用 512-bit 示例。
对 FP32:
以 512-bit 向量寄存器为例,一次可容纳 个 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 ``
向量 load 可把 与对应 B 段载入向量寄存器;连续、对齐和可预测访问更利于高效实现。
原视频 · 00:40 ↗地址每次前进
视频在 00:53 说明 64-byte 步进。
5. 动态指令数为何下降
忽略尾部,标量主循环约执行:
- N 次 A load;
- N 次 B load;
- N 次 add;
- N 次 store。
向量宽 L 时约执行:
- 次向量 A load;
- 次向量 B load;
- 次向量 add;
- 次向量 store。
一条向量 add 对各 lane 独立相加,向量 store 一次写回一段结果;尾部不足完整向量时需标量余数或 mask。
原视频 · 00:50 ↗主循环动态指令数近似下降 L 倍。
但 load/store 的总字节数没有下降:
SIMD 改变每条指令覆盖的数据宽度,不会凭空减少必须读取的 A、B 与必须写出的 C。
6. 为什么速度不一定等于 16 倍
视频在 00:58 解释“标量和向量指令周期数相近,向量一次处理更多数据”。
这是有用的直觉,但不是跨 CPU 的固定事实。
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:
向量化可能需要 gather,吞吐通常低于连续 load。
8. 对齐与 unaligned load
现代 ISA 常支持未对齐向量 load。
“未对齐”不等于一定故障。
但跨 cache line 或页边界时,可能产生额外事务。
编译器可能:
- 生成 unaligned load;
- 用 peel loop 先走到对齐地址;
- 运行时检查后选择对齐版本;
- 对很短输入放弃向量化。
最优策略依赖 ISA 与微架构。
9. 尾部元素怎么处理
若
主循环处理 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。
- 每个向量可放多少个 FP32?
- 完整向量循环多少次?
- 尾部多少个元素?
查看参考答案
lane 数:
完整向量次数:
覆盖
个元素。
尾部:
这 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。
- 向量化把主循环动态算术和访存指令数约缩小到 。
- 总数据字节数通常不变,因此大张量加法仍可能受内存带宽限制。
- 对齐、尾部、stride、alias 与浮点语义决定编译器能否高效向量化。
- 理论向量宽度不是固定加速倍数,也不能直接类比 GPU SIMT。
主题讲解 · 02:30
双缓冲如何把数据搬运藏在计算之后
学习目标
- 能画出单缓冲的传入、计算、传出依赖。
- 能解释 ping-pong 双缓冲的所有权切换。
- 能推导理想流水线的 fill、steady state 与 drain 时间。
- 能说明异步 copy、独立执行资源与同步原语是重叠前提。
- 能判断双缓冲的额外片上存储、occupancy 与 tile 大小权衡。
- 能避免把 HBM/DRAM、SRAM 与 DMA 当成所有硬件的固定结构。
前置与衔接
设大张量 A、B 按 tile 分块,逐块计算
每个 tile 经历三个概念阶段:
- 输入搬运:把 放入可供计算使用的局部工作集;
- 计算:消费输入 tile 并产生 ;
- 输出搬运:把 提交到目标存储。
单缓冲为当前 A、B tile 各保留一个片上槽位,按传入、计算、传出的顺序处理;板书的 HBM/DRAM 与 SRAM 是教学抽象。
原视频 · 00:20 ↗具体硬件可能使用:
- CPU cache 与 software prefetch;
- GPU global memory、shared memory、register;
- 加速器 DMA 与片上 SRAM;
- 异步 copy pipeline 或硬件自动 cache。
板书名称不能直接替代目标平台文档。
核心讲解
1. 单缓冲为什么容易串行
设只有一组输入缓冲 。
当前 tile 的输入必须先准备好,计算才能开始。
计算没有读完缓冲前,下一 tile 不能覆盖它。
当前 tile 必须先完成 A、B 的输入搬运,计算才可消费对应缓冲;同一缓冲不能在仍被使用时覆盖。
原视频 · 00:40 ↗若平台没有其他重叠机制,一个 tile 的时序近似:
若只有一组槽位且没有其他重叠机制,tile 的输入、计算、输出在教学模型中串行,稳态耗时近似三阶段之和。
原视频 · 01:00 ↗2. 单缓冲总时间
设每 tile:
- 输入耗时 ;
- 计算耗时 ;
- 输出耗时 。
K 个 tile 完全串行时:
这个式子忽略 launch、barrier、尾 tile 与 cache 效果,只用于建立上限直觉。
3. 双缓冲的 ping-pong 结构
双缓冲为输入准备两套槽位:
计算消费第 0 套时,copy 可以把下一 tile 写入第 1 套。
下一轮交换角色。
双缓冲为 A、B 分别准备两组槽位;计算使用第 1 组时,可把下一 tile 预取到第 2 组。
原视频 · 01:20 ↗这种角色切换常称 ping-pong:
4. 第一次填充仍无法消失
在计算 tile 0 之前,必须先把 tile 0 放入某个缓冲。
所以流水线有 fill 阶段。
最后一个 tile 计算后,还要完成结果提交,形成 drain 阶段。
双缓冲改善的是中间稳态,不会消除首尾开销。
5. 稳态如何重叠
当 compute 使用 buffer 0 时:
- copy engine 可向 buffer 1 传入下一 tile;
- 若输出资源独立,还可传出前一 tile 的结果。
当硬件与运行时支持异步传输且资源独立时,下一 tile 的传入可与当前 tile 计算重叠;否则只是逻辑排程,并不会自动并行。
原视频 · 01:40 ↗理想情况下,三个资源互相独立,稳态每 tile 周期为
总时间近似
当 K 很大,平均时间趋近最慢阶段,而不是三阶段之和。
6. 三阶段不一定真的能完全并行
流水线稳态可同时处理不同 tile 的传入、计算与传出;实际周期由最慢资源路径主导,并受 copy engine 数量与同步约束。
原视频 · 02:00 ↗视频在 02:00 概括传入、计算、传出同时进行。
这需要额外前提:
- 输入 copy 与 compute 可异步执行;
- 输出 copy 有可用资源;
- 数据路径不争用同一带宽或执行单元;
- 有 event/barrier 保证生产者消费者顺序;
- 缓冲未被提前覆盖;
- tile 足够大,能摊薄同步开销。
若输入与输出共享一个 copy engine,稳态更可能受
约束,而不是三个时间单独取 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,、、。 假设三阶段资源完全独立,忽略额外同步开销。 计算单缓冲与理想双缓冲总时间。
查看参考答案
单缓冲:
稳态阶段:
理想双缓冲:
这是理想模型。若输入与输出共用 copy engine,需改用更保守的资源模型。
自检
若 、,增加更多计算流水级能否突破输入带宽上限?
不能。
稳态仍由输入路径主导,除非减少 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 拓扑必须结合目标硬件调优。
主题讲解 · 01:17
逐元素张量加法的优化路线图
学习目标
- 能建立逐元素加法的 baseline、shape 与复杂度模型。
- 能区分多核并行、访存优化、SIMD、异步流水线与循环展开。
- 能计算逐元素加法的最小理想化内存流量和算术强度。
- 能解释为什么增加核心数最终会撞上内存带宽墙。
- 能判断显式 staging、双缓冲和循环展开的适用条件。
- 能说明广播、stride、dtype 与目标硬件会改变最优实现。
前置与衔接
视频把“两数之和”类比为并行编程中的“两张量之和”。
这个类比只用于引出问题。
LeetCode Two Sum 是在数组中寻找满足目标和的两个索引,通常涉及哈希或搜索。
本课处理的是逐元素算子:
二者算法目标完全不同。
逐元素张量加法 的优化栈包括数据并行、访存、SIMD、异步流水线与循环展开;它与“两数之和”检索题不是同一问题。
原视频 · 00:00 ↗核心讲解
1. 先固定 baseline 假设
最简单模型假设:
- A、B、C shape 相同;
- 无 broadcasting;
- 三者按 row-major 连续存储;
- 元素类型相同;
- C 不与 A、B 发生危险 alias;
- 每个元素只做一次加法。
把任意维 shape 扁平化为 N 个元素:
标量 baseline:
``text for i in [0, N): C[i] = A[i] + B[i] ``
时间复杂度:
后续优化不会把必须生成 N 个输出的复杂度变成 ,主要改善常数与硬件利用率。
2. 第一层:跨 worker 数据并行
对同 shape、连续存储且无广播的教学模型,可把扁平索引 划成互不重叠区间交给多个 worker。
原视频 · 00:20 ↗对 P 个 worker,可做静态区间划分:
每个 worker 只写自己的 C 区间,因此没有输出数据竞争。
理想计算工作量约为
但整体仍需传输全部 A、B、C 数据。
3. 多核加速为什么会饱和
每个 FP32 输出元素至少需要:
- 读取 A:4 bytes;
- 读取 B:4 bytes;
- 写 C:4 bytes。
理想化总流量:
每元素只有一次加法,即 1 FLOP。
算术强度:
每个元素仅做一次加法,却至少读取 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 降至约
SIMD 可减少动态指令数、分支与地址更新开销。
但必须处理:
- 连续与 stride;
- alignment;
- 尾部 mask;
- aliasing;
- ISA dispatch;
- 内存带宽上限。
视频在 01:02 也提醒向量指令要考虑连续存储。
6. 第四层:异步 DMA 与双缓冲
双缓冲可以:
- 计算当前 tile;
- 同时预取下一 tile;
- 在资源允许时传出上一 tile。
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 会改变索引与流量
若
则 B 沿 M 广播。
逻辑元素仍是 MN,但 B 可能被 cache 多次复用。
若 stride=0 表示广播,向量代码可以 broadcast load,而不是读取 MN 个不同 B 元素。
非连续 view、类型转换与 mixed precision 也会改变事务数和 vector width。
10. 性能优化的正确顺序
一个稳健流程是:
- 固定正确性与 shape/stride 语义;
- 建立 bytes、FLOPs 与理论下界;
- 测量 baseline;
- 确认瓶颈是带宽、launch、计算还是调度;
- 针对瓶颈选择并行、向量化、融合或流水线;
- 用 profiler 验证实际 bytes、带宽与 occupancy;
- 覆盖小尺寸、尾部、广播与非连续输入。
跟练与练习
编者练习
估算带宽下界 N=100,000,000,A、B、C 为 FP32,假设没有额外读写,持续内存带宽为 300 GB/s。 估算仅由必要数据流量决定的理想时间下界。
查看参考答案
必要流量:
时间下界:
这是理想持续带宽模型,不含 launch、NUMA、cache write policy 与尾部。
快速判断
- 多核会减少总共要写出的 C 元素吗?不会。
- SIMD 会让复杂度从 变成 吗?不会。
- 双缓冲一定适合只有一次加法的 CPU 连续数组吗?不一定。
常见误区
误区 1:逐元素加法就是 LeetCode Two Sum
前者生成同 shape 输出,后者寻找索引对。
误区 2:核心数翻倍,速度永久翻倍
共享内存带宽会饱和,调度与 NUMA 也有成本。
误区 3:数据一定要显式搬到 SRAM 才能算
具体由硬件和编程模型决定;CPU cache 常由硬件管理。
误区 4:SIMD 与异步流水线收益可以直接相乘
它们可能争用同一瓶颈,必须看新的 roofline 限制。
误区 5:循环展开越大越好
过度展开会增加寄存器与代码尺寸压力。
本课小结
- 逐元素加法在同 shape 连续模型下是 streaming kernel。
- FP32 每元素至少约 12 bytes 数据流量、1 FLOP,常受带宽限制。
- 多核负责分配索引,SIMD 降低动态指令数,二者不减少必要 bytes。
- 连续访问、合并事务和避免中间量通常比显式 staging 更关键。
- 异步 DMA/双缓冲需要独立资源,且计算太短时未必能隐藏 copy。
- 循环展开可降低控制开销,但要平衡寄存器、代码尺寸与编译器能力。
主题讲解 · 02:08
网格步进循环如何分配 GPU 索引
学习目标
- 能写出一维 CUDA 风格的 grid-stride loop。
- 能区分连续范围划分与网格步进划分。
- 能计算线程起始索引、总 stride 与单线程迭代次数。
- 能解释为什么单线程跨步不等于一个 warp 的访存不连续。
- 能判断网格步进何时可能改善位置相关的负载不均。
- 能说明 coalescing、分支发散、同步与片上存储的适用边界。
前置与衔接
上一课从逐元素加法出发,讨论了多 worker、SIMD、异步搬运和循环展开。
本课继续追问更具体的问题:
视频在 00:04 对比两种任务划分:
- range partition:每个 worker 获得一个连续区间;
- grid-stride:每个线程从自己的全局索引出发,按整个 grid 的线程数跨步。
范围划分给每个 worker 一个连续区间;网格步进让 worker 从全局索引出发,以总 worker 数为 stride 循环处理多个元素。
原视频 · 00:00 ↗这里的 worker 可用于讲解 CPU 线程、GPU 线程或抽象计算单元。
但本课公式采用 CUDA 风格的一维 GPU grid。
不能把抽象 worker 与真实硬件核心一一对应。
核心讲解
1. 先固定问题与 shape
考虑长度为 N 的一维连续张量:
多维连续张量也可先展平:
若张量非连续、带 broadcasting 或每个元素对应多个坐标,就还需要 stride-aware 的地址计算。
本课先只讨论逻辑索引如何分配,不把 layout 问题混进来。
2. 连续范围划分
假设有 G 个 worker,编号为 。
一种静态范围划分是:
worker g 依次处理:
范围划分的优点是单个 worker 的局部连续性直观。
若所有元素计算成本相同,每个区间长度也近似相等,它通常已经有不错的静态均衡。
3. 连续区间何时会失衡
若每个元素的计算成本为 ,worker g 的工作量是:
整个并行阶段的时间近似由最慢 worker 决定:
视频从 00:49 构造“前半段难、后半段容易”的例子。
若高代价元素集中在某段,连续范围划分可能让少数 worker 承担多数工作;这不是所有逐元素 kernel 都会发生的固定事实。
原视频 · 00:20 ↗如果高代价元素集中在同一连续区域,某些 worker 会很慢,另一些会提前完成。
范围划分中的 worker 在自己的区间内逐 tile 前进;板书的小快存储只表示可能的局部工作集,并非 grid-stride 必须显式搬运到 SRAM。
原视频 · 00:40 ↗视频在 01:03 用“同步等待”解释尾部效应。
当不同区间工作量不同且后续存在同步边界时,先完成的 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]); } ```
起始索引是线程的全局线性编号:
整个 grid 的逻辑线程数是:
线程处理的索引序列是:
5. 覆盖性与复杂度
任意索引 i 都可唯一写成:
其中
它由起始编号为 r 的线程在第 q 轮处理。
因此,只要边界条件正确,网格步进既不遗漏也不重复逻辑索引。
线程 r 的迭代次数是:
每线程约处理 个元素。
总工作量仍是 ,不是因为“跨步”就减少了要计算的元素数。
6. 为什么可能改善位置相关的负载不均
网格步进等价于按余数类做 round-robin 式静态划分:
若高代价元素在索引空间中形成较宽的连续区域,多个线程可能各取到其中一部分。
网格步进把同一 worker 的连续迭代分散到索引空间各处,可能把位置相关的重工作更均匀地摊到不同 worker。
原视频 · 01:20 ↗视频在 01:40 用“每个计算单元都分到一些难题和简单题”说明这个直觉。
但这不是通用的动态负载均衡算法。
它仍然是静态映射。
以下情况不保证改善:
- 成本模式刚好与 G 同周期;
- 少数异常重元素仍集中落到同一余数类;
- 差异来自 block 级资源,而非元素位置;
- 每元素成本本来完全一致。
若负载高度不规则,可能需要 work queue、persistent kernel、任务窃取或其他动态调度。
7. 单线程跨步不等于 warp 访存不连续
最容易误解的地方是访问连续性。
单个线程在相邻迭代中访问:
它自己的地址确实跨得很远。
但 GPU 合并访存通常看同一条指令下一个 warp 内线程访问的地址集合。
在第 q 轮,相邻线程 r、r+1 访问:
这些逻辑索引仍相邻。
单个 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. 分支发散是另一条轴
若 包含数据相关分支,不同线程可能执行不同路径。
在 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:
实现应根据 N 的取值范围选择足够宽的无符号或有符号索引类型。
还要保证:
- stride 非零;
- 不发生溢出;
- 每轮都有 边界检查;
- 多维映射中的 shape 与 stride 使用一致单位。
对超大 N,可写成在加法前判断剩余范围,或使用经证明不会溢出的 64-bit 逻辑。
12. 范围划分也能合并访存
不能把两种方案简化为:
- range 一定访存差;
- grid-stride 一定访存好。
如果范围划分把连续索引映射给相邻 GPU 线程,同样可以 coalesce。
如果错误地让一个线程独占超长连续区间、而同一 warp 的其他线程跳到远处,才可能破坏事务模式。
关键是“同一时刻相邻 lane 访问什么”,不是算法名称。
跟练与练习
编者练习
手算 grid-stride 索引 设 N=23,启动 G=8 个逻辑线程。 列出线程 3 和线程 7 分别处理的索引,并给出线程 3 的迭代次数。
查看参考答案
线程 3 的索引为:
线程 7 的索引为:
线程 3 的迭代次数:
所有线程的索引集合两两不重叠,合起来覆盖 。
进一步思考
若代价模式为“所有偶数索引很重、奇数索引很轻”,且 G=8,grid-stride 会自动把重任务平均给所有线程吗?
不会。
因为 G 为偶数,偶数起始线程之后始终访问偶数索引,奇数线程始终访问奇数索引。
这是“静态 round-robin 不保证任意负载均衡”的反例。
常见误区
误区 1:网格步进会降低总复杂度
它只改变索引分配,总体仍处理 N 个元素,复杂度仍是 。
误区 2:单线程地址跨步,所以访存一定不合并
coalescing 主要看同一 warp 同一指令的地址集合。
误区 3:网格步进一定比连续范围更均衡
只有成本与索引位置的结构恰好适合交错时才可能改善。
误区 4:网格步进等同于动态任务队列
它是由起始索引和固定 stride 决定的静态映射。
误区 5:视频中的同步就是 kernel 内任意 block barrier
板书表达的是尾部等待;真实同步能力由编程模型和 kernel 边界决定。
误区 6:grid-stride 必须配合 shared memory
索引映射与存储 staging 是两件独立的事。
本课小结
- grid-stride loop 让线程从全局编号出发,以 grid 总线程数为 stride。
- 余数类分解证明它能不重不漏地覆盖 。
- 总工作量仍为 ,每线程约执行 轮。
- 它可能摊平位置相关的连续重区间,但不是通用动态负载均衡。
- 单线程跨步与 warp 同轮连续访问可以同时成立,coalescing 要按 warp 判断。
- 分支发散、同步边界、片上 staging 和索引宽度需要独立分析。
- 所有性能结论都依赖 shape、layout、dtype、对齐、warp 与目标 GPU 架构。
主题讲解 · 03:47
张量广播如何落到线性索引
学习目标
- 能按尾轴对齐判断两个 shape 是否可广播。
- 能计算广播后的输出 shape。
- 能把输出线性索引还原为多维坐标。
- 能把输出坐标映射回两个输入的坐标与物理 offset。
- 能解释广播维为何总取输入坐标 0。
- 能区分逻辑扩展、stride-0 view 与真实内存复制。
前置与衔接
视频在 00:02 提出问题:两个 shape 不同的张量,在底层线性存储中如何完成广播相加?
示例是:
广播后:
A=(2,1) 与 B=(3,1,4) 先按尾轴对齐为 (1,2,1) 和 (3,1,4),兼容后得到输出 shape (3,2,4)。
原视频 · 00:00 ↗视频用“从右向左读,有变就复制,否则就提取”建立直觉。
实现层更准确的说法是:
本课先假设 row-major 连续输入,再推广到任意 stride。
核心讲解
1. shape 必须从尾轴对齐
A 的 rank 为 2,B 的 rank 为 3。
给 A 左侧补一个大小 1 的前导轴:
于是对齐为:
左侧补 1 只用于统一索引推导,不需要改变数据。
2. 每个对齐轴的兼容条件
对轴 k,输入大小分别为 与 。
可广播当且仅当:
视频在 00:57 做兼容检查。
对本例:
三对都兼容。
在普通正尺寸例子中,输出轴大小取两者较大值:
所以视频在 01:09 得到:
如果某一轴既不相等、也没有一方为 1,就应报 shape error,不能靠截断或循环取模补救。
3. row-major 线性化
以 row-major 连续布局为例,shape (3,2,4) 的最后一维变化最快;逻辑多维索引最终换算为一维地址。
原视频 · 01:00 ↗对 row-major 连续张量
逻辑坐标 的线性 offset 是:
也就是:
范围为:
“线性存储”不意味着张量只有一维语义。
shape 与 strides 保存了从逻辑坐标到物理地址的规则。
4. 从输出线性索引恢复多维坐标
给定输出 offset t:
去掉第 0 轴贡献:
再得到:
这就是整数除法与取模形式的 unravel index。
真实 kernel 可能用增量计数、预计算 magic number 或合并循环降低除法成本,但语义相同。
5. 输出坐标如何映射到 A
对齐后的 A shape 是:
输出坐标是:
A 的第 0、2 轴大小都是 1,所以输入坐标必须固定为 0:
输入维度为 1、输出维度扩展时,多个输出位置都映射到该输入轴的坐标 0;图中的复制是逻辑复用,不要求真实复制内存。
原视频 · 01:40 ↗原始 A shape 为 ,row-major strides 可写为:
因此:
不同 、 的输出都可能读取同一个 A 元素。
这就是板书“复制”的真实索引含义。
6. 输出坐标如何映射到 B
B shape 是:
它的第 1 轴大小为 1,因此:
row-major strides 为:
所以:
最终每个输出元素执行:
视频从 02:41 开始演示 B 的对应过程。
7. 用统一规则写成公式
对任意对齐后的输入轴大小 与输出坐标 :
输入物理 offset 为:
这里 是输入真实 stride。
从尾轴向前看:相等维度保留对应坐标,输入为 1 的维度把坐标钳到 0;这比把数据真的复制多份更接近 kernel 实现。
原视频 · 02:20 ↗这个规则比“真的复制 D 份、E 份”更适合实现。
8. stride-0 view
另一种等价表达是先构造 expanded view。
把广播轴的有效 stride 设为 0:
随后直接用输出坐标:
得到与前面完全相同的地址。
stride 0 表示沿该轴移动时物理地址不变。
9. 通常不需要物化复制
视频在 03:38 强调“复制不用真的复制”。
广播通常通过 stride-0 或等价的索引计算形成视图语义,算子读取时复用原元素;是否物化由具体算子和后端决定。
原视频 · 03:20 ↗常见实现有两类:
- kernel 直接根据输出坐标计算两个输入 offset;
- 先建立 stride-0 view,再由通用 iterator 生成地址。
但“广播永远零拷贝”也不严谨。
以下操作可能物化:
- 显式调用 contiguous/copy;
- 后端不支持某种 stride;
- 后续算子要求连续内存;
- 编译器为融合或布局转换选择临时缓冲。
输出 C 本身通常仍需分配,除非继续被融合且没有可观察的中间结果。
10. 任意 rank 都是同一规则
视频从 03:04 推广到更多维度。
一般 rank 的广播仍逐个尾轴应用同一规则:相等维保留索引,广播维取 0,缺失前导维可视为大小 1。
原视频 · 03:00 ↗无论 rank 多高,流程都不变:
- 左侧补 1 对齐 rank;
- 逐尾轴验证兼容性;
- 计算输出 shape;
- 遍历输出坐标;
- 输入为 1 的轴取坐标 0;
- 用真实 stride 计算 offset。
复杂的是索引 bookkeeping,不是生成一个真正的高维立方体。
11. 非连续输入的边界
若输入来自 transpose、slice 或其他 view,它可能不是 row-major 连续。
此时不能用 shape 自动推导连续 strides。
仍应使用:
但 必须取 view 的真实 strides。
负 stride、storage offset、aliasing 与设备后端限制也可能影响实现。
广播规则决定逻辑坐标,layout 决定物理地址;两层不能混为一谈。
跟练与练习
编者练习
从输出线性索引追到两个输入 仍取 A shape 、B shape 、C shape 。 当输出线性 offset 时,求输出坐标、A offset 与 B offset。
查看参考答案
先反解输出坐标:
所以输出坐标为:
A 映射到 ,因此:
B 映射到 ,因此:
最终计算 。
快速判断
- A shape 与 B shape 能广播吗?不能,第一个对齐轴 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。
- 示例 与 广播为 。
- 输出线性 offset 先还原为多维坐标,再映射到输入坐标。
- 输入大小为 1 的轴总取坐标 0,相等轴保留输出坐标。
- 物理地址由 base、映射后坐标和真实 strides 共同决定。
- stride-0 view 能表达逻辑扩展,但后端或连续化操作仍可能物化。
- shape 语义与 layout 语义要分层分析。
主题讲解 · 02:53
同一段算子代码如何被多个 CPU 线程执行
学习目标
- 能解释多个线程为何能执行同一份机器代码却处理不同数据。
- 能用 thread id 计算互不重叠的半开区间。
- 能区分代码段、地址空间、栈、寄存器上下文和 TLS。
- 能说明“每线程独享寄存器”只是程序语义,不是永久占用物理寄存器。
- 能判断并发、并行、上下文切换与核心数之间的关系。
- 能发现尾部越界、数据竞争、false sharing 与整数绝对值边界。
前置与衔接
视频在 00:02 提问:多个线程执行同一段算子代码时发生了什么?
它选择 CPU 线程和逐元素绝对值作为极简模型:
多个 CPU 线程共享同一算子代码,但各自获得不同线程 id,并据此计算不同的 start/end;图中每线程 1000 元素是教学常量。
原视频 · 00:00 ↗这一模型抓住了核心:
- 代码相同;
- thread id 不同;
- 由 id 派生的数据范围不同;
- 每个线程有自己的执行状态。
但真实 CPU runtime、线程池、调度器和编译器会增加更多层次。
核心讲解
1. 极简分块代码
设固定 block size 为:
线程逻辑编号为 id。
安全版本伪代码:
```cpp start = id * B; end = min(start + B, N);
for (i = start; i < end; ++i) { output[i] = abs(input[i]); } ```
线程 id 经分块公式映射到半开区间 [start,end),实际实现还应以 min(end,N) 处理不足整块的尾部。
原视频 · 00:40 ↗视频板书直接使用 。
只有 N 恰好覆盖所有整块、或输入预先 padding 时才不会越界。
通用实现必须 clamp 到 N。
2. 为什么同一代码会做不同工作
函数体本身没有为线程 0、1、2、3 各写一份。
差异来自运行时状态:
因此:
每个线程把自己的 id 代入同一条指令,得到不同 start/end。
这属于 single program, multiple data 的基本思想。
“像执行了不同代码”只是观察结果不同;机器指令通常来自同一代码段。
3. 4000 元素、4 线程示例
视频从 00:58 假设:
区间分别是:
这些半开区间不重叠,合起来恰好覆盖 。
4. 线程 2 如何实例化局部状态
视频从 01:18 单独观察线程 2。
在线程 2 的示例中,id=2、start=2000、end=3000,因此同一循环体处理 input/output 的第三段。
原视频 · 01:20 ↗它看到:
因此 loop body 处理:
同一个变量名 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 进入寄存器视角。
从程序语义看,每个线程拥有独立的 PC 与寄存器上下文;它们是可调度状态,不表示物理寄存器被某线程永久独占。
原视频 · 01:40 ↗程序语义上,线程 1 可具有:
线程 2 同时可具有:
但不能据此认为每个软件线程永久占据一整套物理寄存器。
当线程运行时,值驻留在当前核心的 architectural/physical register resources 中。
上下文切换时,必要状态由 OS 保存并恢复。
编译器还可能把变量 spill 到栈,或完全消除某个变量。
8. PC 相同也不代表锁步执行
视频在 01:55 强调共享代码段。
两个线程可以暂时指向同一条机器指令。
它们不必同步前进:
- 一个线程可能被抢占;
- 一个线程可能 cache miss;
- 一个线程可能运行在另一个核心;
- 核心不足时,它们可能分时运行;
- 分支和异常也会让 PC 路径不同。
并发表示执行时间区间重叠。
并行表示某一时刻确实在多个执行资源上同时运行。
线程数大于核心数时,不能假设所有线程物理并行。
9. 每线程 stack 与局部数组
普通自动存储期局部数组通常位于该线程的栈帧;编译器也可能把标量或小对象放入寄存器或优化掉。
原视频 · 02:20 ↗概念代码:
``cpp float buf[1000] = {}; buf[0] = id; ``
线程 1 的 buf 与线程 2 的 buf 属于不同调用栈帧。
所以:
相同局部代码 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。
真实算子必须采用框架定义的溢出/饱和/异常语义,不能只凭数学 推断机器行为。
14. 更通用的均匀分块
若希望 P 个线程覆盖任意 N,可定义:
这样每块大小最多相差 1,不要求固定 1000。
真实 runtime 还可能动态切小任务,以改善负载均衡。
固定静态分块适合每元素成本近似相同的情况。
跟练与练习
编者练习
补全尾部边界 设 N=4100,固定 ,线程 id=4 和 id=5 分别处理什么区间?
查看参考答案
线程 4:
所以处理:
线程 5:
它没有有效工作,应直接返回或得到空区间。
若照板书无条件访问到 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 契约。
主题讲解 · 03:46
用前缀和读懂稀疏矩阵 CSR
学习目标
- 能说出 COO 与 CSR 各自保存的三个数组。
- 能从每行非零计数构造 CSR 的 row_ptr。
- 能用 row_ptr 相邻差恢复每行 nnz。
- 能从 row_ptr 定位某一行在 values/col_idx 中的半开区间。
- 能解释 CSR 为什么无需为每个非零项保存 row_idx。
- 能区分按行分段、行内列排序与 canonical 稀疏格式。
前置与衔接
视频在 00:02 提出问题:如何用前缀和理解 CSR?
示例稀疏矩阵是:
它有:
nnz 表示格式中存储的非零条目数。
若格式允许显式存储数值 0 或重复坐标,stored entries 与数学上的非零位置数可能不完全相同。
本课先采用无重复、无显式零的标准示例。
核心讲解
1. 定位一个稀疏条目需要三项信息
视频从 00:22 说明:一个稀疏条目至少需要知道:
- value;
- row index;
- column index。
一个非零项由 value、row index、column index 三项定位;示例矩阵共有 5 个显式非零项。
原视频 · 00:20 ↗例如:
表示:
这里使用 0-based index。
只有 value 不知道放在哪里;只有坐标也不知道条目取值。
2. COO 直接保存三元组
视频在 00:34 引入 COO。
对示例矩阵,一种按行排列的 COO 是:
对任意位置 k,三数组共同组成:
三个数组必须同步重排。
不能只交换 values 而保持坐标不动。
COO 便于增量收集条目,但每个条目都显式重复保存行号。
3. CSR 先统计每行有多少条目
视频从 01:00 引入每行非零计数。
对示例:
示例各行非零数为 row_count=[2,1,2];它是构造 CSR row_ptr 的中间统计量,不是标准 CSR 必存数组。
原视频 · 01:00 ↗含义是:
- 第 0 行有 2 项;
- 第 1 行有 1 项;
- 第 2 行有 2 项。
视频在 01:11 读出 。
row_count 是构造时的中间数组。
标准 CSR 通常不需要同时保存它,因为 row_ptr 已能通过差分恢复。
4. exclusive prefix sum 构造 row_ptr
对:
定义:
这就是带前导 0 的 exclusive prefix sum。
对 row_count=[2,1,2] 做带前导 0 的 exclusive prefix sum,得到 row_ptr=[0,2,3,5],末项等于 nnz=5。
原视频 · 01:40 ↗本例:
长度是:
最后一个值满足:
视频在 01:44 解释末项 5。
5. row_ptr 的区间语义
第 r 行的条目位于:
本例:
这三个区间正好覆盖:
row_ptr 保存的是每一行在扁平 entries 数组中的边界。
6. 相邻差恢复 row_count
视频从 01:54 用差分解释可逆性。
第 r 行非零数由 row_ptr[r+1]-row_ptr[r] 得到;该行在 values/col_idx 中占半开区间 [row_ptr[r],row_ptr[r+1])。
原视频 · 02:00 ↗例如第 1 行:
row_ptr 中的 3 表示前两行一共占用 3 个 entries。
它既是第 2 行的起点,也是前两行累计 nnz。
7. CSR 保存哪三个数组
CSR 通常保存:
CSR 保留 values 与 col_idx,用长度 rows+1 的 row_ptr 代替 COO 中逐项 row_idx,从而压缩重复行号。
原视频 · 02:40 ↗两种格式都有三个主数组,但数组长度不同:
- COO row_idx 长度为 nnz;
- CSR row_ptr 长度为 M+1。
当 时,CSR 的行信息通常更紧凑。
8. 为什么 CSR 仍能恢复每个条目的行号
对任意 k,只需找到唯一的 r,使:
就知道第 k 个 entry 属于第 r 行。
然后:
因此条目的 value、row、column 信息仍完备。
9. 空行如何表示
若某行没有 stored entry:
于是:
也就是 row_ptr 出现重复值。
例如:
表示第 1 行区间为:
即空区间。
重复 row_ptr 不是错误,恰好是空行的合法表示。
10. COO 可重排,CSR 必须按行分段
视频从 03:32 比较顺序要求。
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:
- 初始化 ;
- 遍历 row_idx,对对应行计数;
- 对 row_count 做 prefix sum 得到 row_ptr;
- 为每行维护当前写入 cursor;
- 把 values 与 col_idx scatter 到对应行分段;
- 如有需要,行内按 col 排序并合并重复项。
串行复杂度约为:
外加可选排序成本。
并行构造还要处理 histogram 与 scatter 的冲突,可能使用 atomics、分块计数或 segmented prefix sum。
12. 前缀和为什么适合并行
prefix sum 不只是数学定义。
它是 GPU/并行算法中的基础 primitive。典型并行 scan 用树形 upsweep/downsweep 或分层 block scan,把串行依赖重组成 的并行深度,并保持 级总工作量。
具体 work efficiency、同步与内存访问取决于实现。
不能只用并行深度替代实际运行时间。
13. CSR 的存储量
设 value 占 bytes,index 占 bytes。
CSR 主数组约占:
COO 约占:
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_ptr,并写出第 1 行与第 3 行的 entry 区间。
查看参考答案
带前导 0 做累积:
第 1 行:
第 3 行:
所以第 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 做差可恢复每行 nnz,末项等于总 nnz。
- CSR 用长度 M+1 的 row_ptr 替代长度 nnz 的 row_idx。
- CSR 要求 entries 按行分段,行内是否排序是额外格式约定。
- 前缀和解决行边界构造,但稀疏计算仍有负载不均和不规则访存问题。
主题讲解 · 01:58
稀疏矩阵向量乘如何跳过隐式零
学习目标
- 能写出稠密矩阵-向量乘与 COO SpMV 的循环。
- 能解释 values、row_idx、col_idx 如何驱动一次乘加。
- 能计算 SpMV 的 与 工作量差异。
- 能区分隐式零、显式存储零和重复坐标。
- 能说明 COO 并行累加为什么可能需要 atomic 或 segmented reduction。
- 能判断索引流量、不规则 gather 与行负载不均的性能边界。
前置与衔接
上一课用前缀和构造 CSR。
本课回到更直观的 COO,回答视频在 00:00 提出的问题:
SpMV 是:
其中:
是稀疏矩阵,
是稠密向量。
SpMV 计算 y=Ax;COO 只保存 values、row_idx、col_idx 三个长度为 nnz 的数组,而非展开整个稠密矩阵。
原视频 · 00:00 ↗视频在 00:07 定义 SpMV。
本课使用无重复坐标、无显式存储零的 COO 示例,再讨论这些边界。
核心讲解
1. 数学定义
对每一行 i:
示例:
因此:
数学式中,零项对结果没有贡献。
关键是存储格式能否让程序根本不枚举这些位置。
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 ``
无论 是否为 0,循环都遍历全部 个坐标。
朴素稠密矩阵-向量乘按 M×N 位置计算,即便 A[i,j]=0 也会执行对应乘加;稀疏格式通过不存这些隐式零来跳过它们。
原视频 · 00:20 ↗工作量约为:
这里“会计算零”是算法层的说法。
编译器一般不能预先知道任意运行时 dense A 中哪些位置为零。
3. COO 只枚举 stored entries
视频从 00:26 使用 COO 举例。
示例 COO:
三个数组长度都是 ;第 k 个 entry 是 。
没有出现在数组中的坐标被解释为隐式零。
4. 单个 COO 条目如何定位
视频在 00:47 选择数值 3。
COO 中 values[k]=3、row_idx[k]=1、col_idx[k]=1 共同表示 A[1,1]=3;三个数组必须按同一 k 对齐。
原视频 · 00:40 ↗对相应 k:
所以它表示:
三个数组必须按同一个 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] ``
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。
对应:
列索引先选择:
对条目 A[2,0]=4,列索引 0 选择 x[0],形成乘积 4x[0];这种 x 访问在一般稀疏模式下可能不连续。
原视频 · 01:20 ↗视频在 01:21 得到 。
形成乘积:
行索引再决定输出:
同一条目的行索引 2 决定把 4x[0] 累加到 y[2];并行 COO 若多个条目写同一 y 行,需要原子操作或分段归约。
原视频 · 01:40 ↗最终执行:
7. “跳过 0”究竟是什么意思
COO 循环的 k 只取:
它不生成所有 坐标。
因此,未存储坐标对应的隐式零没有:
- A value load;
- multiply;
- output accumulate。
8. 稀疏复杂度
串行 COO SpMV 的主要循环工作量:
若计入输出清零:
稠密 baseline 是:
加速空间取决于稀疏度:
当 density 很低时,可大幅减少算术。
当 时,稀疏格式还要读取索引,可能比 dense 更慢。
9. 显式存储的 0 不会自动跳过
如果 COO 中存在 ,循环仍会读取 row、col、value,通常也会执行乘加。
“跳过零”只对未存储的隐式零成立。
若要删除显式零,需要 prune/canonicalize 步骤。
是否可删除还要考虑 NaN/Inf、symbolic sparsity、autograd、固定 pattern 与数值阈值语义。
不能把接近 0 的小值擅自当成精确 0 删除。
10. 重复坐标会自然累加
COO 可包含多个 k 指向同一 。
例如 与 在 SpMV 中共同贡献 。
因此 COO 循环的 += 很重要。
有些库要求先 coalesce 重复项,有些算子允许运行时累加。
浮点加法不满足严格结合律,重排或并行合并可能产生末位差异。
11. 并行 COO 的写冲突
若一个线程处理一个 k,不同条目可能具有相同 row_idx,于是多个线程同时更新同一个 。
普通 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 限制。
因此实际速度不会简单等于:
14. shape 与合法性检查
正确实现至少要保证 、,且三个 COO 数组长度相同。x 长度应为 N、y 长度应为 M;index dtype 要能表示维度与 nnz,设备与 accumulation dtype 也须匹配算子契约。
跟练与练习
编者练习
手算 COO SpMV 给定: 输出 y 有 3 行。求 y。
查看参考答案
先初始化 。三次更新为:
最终 。
第 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 遍历 个坐标,COO SpMV 只遍历 nnz 个 stored entries。
- 每个 entry 执行 。
- 未存储坐标对应的隐式零被跳过,显式存储零不会自动跳过。
- 串行工作量约为 ,收益取决于 density。
- COO 可含重复坐标,使用
+=累积;并行写同一 y 需要同步或分段归约。 - CSR 可按行组织归约,但仍有行长度负载不均问题。
- 稀疏 SpMV 常受索引流量和不规则访存限制,算术减少不等于同比加速。
单元综合
从字节账本到稀疏索引:GPU 算子优化的统一分析方法
单元能力目标
完成本单元后,应能从“工作、数据、索引、并行、同步”五本账分析一个张量算子,而不是把某项技术直接等同于固定加速倍数。
具体需要做到:
- 估算逐元素算子的 FLOPs、必要字节与临时张量流量;
- 区分 fusion、FMA、SIMD、SIMT、多核和双缓冲的作用层次;
- 用 grid-stride loop 证明 GPU 索引覆盖;
- 把广播 shape 映射到多维坐标、stride 和线性地址;
- 区分 CPU 线程的共享代码、私有上下文与共享地址空间;
- 从 COO 构造 CSR 的
row_ptr; - 写出 COO/CSR SpMV 的真实工作量与并行写冲突;
- 用 profiler 验证带宽、指令、occupancy 与负载不均假设。
概念连接
1. 优化前先判断瓶颈类别
对逐元素加法
FP32 每个元素至少需要:
- 读取 :4 bytes;
- 读取 :4 bytes;
- 写入 :4 bytes;
- 执行 1 次浮点加法。
理想算术强度约为
这个数很低,大张量通常更容易受内存带宽限制,而不是受峰值 FLOPs 限制。
所以优化顺序应先看内存流量与访问模式,再看算术指令数。
2. 算子融合主要消灭中间流量
未融合的逐元素乘加:
若每个张量有 个元素、元素大小为 ,理想化内存事务为:
- 第一算子读 、写 :;
- 第二算子读 、写 :;
- 总计 。
融合为
后,只需读 、写 ,约为
fusion 还可减少 kernel launch、同步与临时内存管理。
3. fusion 不等于 FMA
fusion 是计算图或 kernel 级的算子合并。
FMA 是硬件指令级的一次乘加,语义近似
一个 fused kernel 不一定使用 FMA;一个使用 FMA 的 kernel 也不一定消除了跨算子的中间张量。
两者可以同时存在,但优化对象不同。
4. SIMD 减少动态指令,不减少必要 bytes
若向量宽度为 ,一条 SIMD 指令可并行处理 个 lane。
主循环的动态算术和访存指令数可近似降为标量循环的 。
例如 512-bit 向量寄存器可容纳 16 个 FP32 元素,连续地址步进为 64 bytes。
但处理同一个大张量仍需读写相同数量的数据。
若已经受内存带宽限制,增加理论 lane 数不保证线性加速。
还需处理对齐、尾部、stride、alias 与编译器向量化条件。
5. CPU 多线程与 SIMD 是两个并行层次
多线程把索引区间分给不同核心或硬件线程。
SIMD 在每个线程内部一次处理多个连续元素。
常见组合是:
- 线程 负责半开区间 ;
- 区间内部使用向量主循环;
- 最后处理不足一个向量宽度的 tail。
线程共享同一段算子代码,但具有逻辑上独立的 PC、寄存器上下文、stack 与 TLS。
它们共享进程地址空间,所以写入重叠区域时仍需同步。
6. 正确分块必须处理边界
若每线程块大小为 ,线程编号为 :
半开区间避免重叠,min 防止最后一块越界。
不同线程写 disjoint output 可避免数据竞争,但仍需考虑:
- 启动与 join;
- false sharing;
- NUMA;
- 小任务调度开销;
- 浮点归约次序。
7. grid-stride loop 用余数类覆盖索引
GPU 中全局线程编号为
网格总线程数为
线程处理
每个索引 都可唯一写成
所以它由起点为 的线程处理,覆盖不重不漏。
这是一种静态索引分配,不是通用动态负载均衡。
8. 跨步循环与 coalescing 不矛盾
单个线程相邻轮次相差 ,看起来跨步很大。
但同一个 warp 在同一轮中,线程起点通常连续:
因此是否合并访存应按 warp 同轮地址判断,而不是只看单线程的 stride。
分支发散、非连续 layout 或位置相关工作量仍可能破坏性能。
9. 双缓冲隐藏 latency,不减少总工作
单缓冲循环可能串行执行:
双缓冲让 compute 使用 buffer A 时,producer 同时填充 buffer B,下一轮交换。
理想稳态周期由最慢独立阶段决定:
但还需付出 pipeline fill/drain。
双缓冲不减少 bytes 或 FLOPs,而是使用更多片上空间换取 latency overlap。
10. 完全重叠有硬件与资源前提
要让 copy 与 compute 真正并行,需要:
- 异步 copy 能力;
- 独立 copy/compute 资源或可重叠流水线;
- 正确同步,避免读未完成或覆写仍在使用的数据;
- 足够带宽;
- tile 足够大以摊薄调度;
- 片上容量与寄存器压力不把 occupancy 压得过低。
所以双缓冲是需要 profiler 验证的调度策略,不是自动加速开关。
11. 广播先处理逻辑 shape
两个张量广播时从尾轴对齐。
每个对齐轴必须:
- 大小相等;或
- 至少一方大小为 1。
例如 shape
与
对齐后广播为
逻辑广播不一定立即物化重复数据。
12. 输出坐标怎样映射回输入地址
对输出多维坐标 ,输入某轴大小为 1 时,对应输入坐标固定为 0;大小相等时保留输出坐标。
物理地址由
计算。
广播 view 可以把扩展轴表示为 stride 0,但后端或 .contiguous() 仍可能物化数据。
所以 shape 语义与 layout/stride 语义必须分开分析。
13. 稀疏计算先改变表示
稠密矩阵保存 个位置,包括大量零。
COO 只保存 stored entries:
它直接表达稀疏坐标,构造简单,但每个条目都需保存行列索引。
14. CSR 用前缀和压缩行索引
先统计每行非零数:
再做 exclusive prefix sum:
第 行存储区间为
末项等于 ,空行会产生相邻重复指针。
CSR 只强制按行分段;行内列索引是否排序是额外约定。
15. SpMV 只跳过未存储的隐式零
COO SpMV 对每个 stored entry 执行:
工作量由 决定,而不是 。
但显式存储的零仍会被访问;重复坐标也需要用 += 累积。
并行 COO 中,多个条目可能写同一个 ,需要 atomic 或分段归约。
CSR 可按行组织归约,却会遇到行长度不均导致的负载不平衡。
16. 稀疏 FLOPs 下降不等于同比加速
稀疏格式额外读取索引,并产生不规则访问。
SpMV 常受以下因素限制:
col_idx与row_ptr流量;- 对 的间接访存与 cache miss;
- 行长度负载不均;
- 并行写冲突;
- 低算术强度。
因此应同时比较 density、索引位宽、格式转换成本和目标硬件 kernel。
对比与决策
1. 五种优化分别改变什么
- fusion:减少中间张量流量和 kernel 边界。
- SIMD:减少 CPU 动态指令数。
- 多线程 / SIMT:增加并行工作者数量。
- 双缓冲:重叠搬运与计算,隐藏 latency。
- 稀疏格式:跳过隐式零,但引入索引和不规则访问。
它们可以组合,但不能相互替代。
2. 逐元素算子的优先路线
- 保证连续、合并访问并避免无谓物化。
- 融合相邻逐元素操作,减少临时张量。
- 使用多核、SIMD 或 GPU 网格覆盖索引。
- 只有 copy 能与足够长的计算重叠时再考虑显式双缓冲。
- 用 profiler 检查带宽、launch、寄存器、occupancy 与 tail。
3. 何时值得转稀疏
稀疏度必须足以抵消:
- 索引存储;
- 格式转换;
- 间接访存;
- 负载不均;
- 稀疏 kernel 吞吐差距。
只看到很多零还不够,还要确认它们是未存储的结构化隐式零,而不是 dense 张量里的数值零。
综合训练
编者练习
一个 FP32 逐元素表达式先计算 ,再计算 。忽略 cache,比较未融合与融合后的理想字节流量,并说明为何加速比不必等于 。
查看参考答案
未融合每元素读 、写 ,再读 、写 ,共 6 次元素事务,即 24 bytes;融合读 、写 ,共 4 次,即 16 bytes。理想流量比为 1.5,但真实加速还受 cache、launch、指令、寄存器压力、shape、广播、带宽饱和与编译器生成代码影响,所以不能直接断言固定 1.5 倍。
编者练习 2
给定 gridDim.x=80、blockDim.x=256、,写出全局线程 17 处理的索引,并说明为何不会与线程 18 重复。
查看参考答案
总线程数 。线程 17 处理 ,下一项 61457 已越界。线程 18 处理余数为 18 的索引;两个序列模 的余数不同,因此不重复。任意 都有唯一余数 ,所以整体覆盖不重不漏。
编者练习 3
矩阵三行的非零数为 。构造 CSR 的 row_ptr,写出每行区间,并说明空行怎样表示。
查看参考答案
exclusive prefix sum 得到 row_ptr=[0,2,2,5]。第 0 行区间为 ,第 1 行为 ,第 2 行为 。空行由相邻两个相同指针表示,不需要额外哨兵。末项 5 等于总 。
进入下一单元前
- 已能为逐元素算子列出 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 语义。