外观
第8章 Element-Wise:逐元素算子
本章导读
前面几章已经测量过向量加法。现在我们保持
C[i] = A[i] + B[i]不变,研究另一件事:同样一批元素,怎样交给 GPU 执行,才有机会更快?从这个小问题出发,我们逐步观察独立输出、连续访问、线程重复工作和尾部处理。读完后,你应该能解释一个元素由谁计算,判断两个版本是否进行了公平比较,并根据真实结果说明一次改动带来了什么。第 4–7 章的程序、计时与 profiling 是本章基础;HIP 和 Triton 的语法可以按需查阅附录。
你可以先读共同的数学与动画,再选择一种语言:
阅读路线与前置知识
| 阅读路线 | 如何开始 |
|---|---|
| 第一次接触 GPU 核函数 | 先读 编程范式附录,再回到 8.1 的小数组 |
| 从 HIP 开始 | 阅读 8.1–8.3,在 实现节选择 HIP,最后阅读共同的实验和对照 |
| 从 Triton 开始 | 阅读 8.1–8.3,在 实现节选择 Triton,最后阅读共同的实验和对照 |
8.1 先认识 Element-Wise
8.1.1 不看名字,先看依赖关系
判断一个算子是不是逐元素,最稳妥的方法不是背算子表,而是观察一个输出依赖哪些输入。
Vector Add 的定义是:
想得到 C[3],只需要 A[3] 与 B[3]。A[0]、A[1] 或其他位置都不会影响它。因此不同输出位置之间没有数据依赖,可以彼此独立地计算。
8.1 语义层 · Element-Wise位置 i 独立计算 C[i] = A[i] + B[i]
位置 0:只读自己的两个输入位置 0 只读取 A[0]=2 与 B[0]=7,得到 C[0]=9,其他位置不受影响。
算法过程示意 · 数字定位到该步完成状态,可拖动进度或播放;动画速度不代表 GPU 耗时。
图 8.1 Vector Add 的逐元素依赖:当前位置只读取对应的两个输入位置。
如 图 8.1 所示,动画每一步只激活同一列中的 A[i]、B[i] 与 C[i],结尾的「并行俯瞰」把各列完成顺序打乱以凸显独立性。真实 GPU 会并行处理许多列;这里故意逐步播放,是为了让依赖关系更容易看清。本章全部动画共用同一种播放器:拖动进度条可擦洗到任意中间状态,点步骤标签可跳步,键盘 ←/→ 逐步、空格播放暂停。
同一模式还包括:
| 算子 | 单个输出的表达式 | Y[i] 依赖什么 |
|---|---|---|
| Add | Y[i] = A[i] + B[i] | A[i]、B[i] |
| ReLU | Y[i] = max(X[i], 0) | X[i] |
| Scale & Bias | Y[i] = alpha * X[i] + beta | X[i] 与两个标量 |
| Clamp | Y[i] = min(max(X[i], low), high) | X[i] 与上下界 |
它们的计算表达式不同,但数据划分方法相似:先把互不依赖的下标分出去,再处理对应元素。HIP 与 Triton 对“怎样分出去”给出了不同的源码视角。
8.1.2 先选择一条实现路线
同一个加法可以用两种视角描述。HIP 的核函数先描述一个线程:它得到一个下标,读取两个数并写回结果。Triton 的核函数先描述一块数据:它生成一组下标,对这一组位置执行加载、加法和写回。
如果你还不熟悉这些词,可以先读 附录 D:HIP 与 Triton 的编程范式。附录用同一个带尾部的数组解释 thread/block、program/tile 和 if/mask,并把语法对应到实际源码。读完再回到本章即可,不需要先学完两门语言。
后面的实现节提供 HIP / Triton 标签页。先选择熟悉的一种语言,沿着一个可运行的实现读下去;数学问题、正确性检查和最终对照是两条路线共用的。
8.1.3 什么不属于这一类
如果输出 Y[i] 需要一整段输入,事情就变了。例如 Sum Reduction 要把很多输入合成一个值,线程之间必须协作;Softmax 还要先求一行最大值与总和。它们分别是第 9 章 Reduction和第 10 章 Normalization的主角。
因此,“输入输出形状相同”不是逐元素的充分条件。真正重要的是输出位置之间能不能独立完成。
8.2 固定数学语义与正确性标准
8.2.1 先用 4 个元素手算
给定:
text
A = [2, -1, 4, 3]
B = [7, 3, -1, 2]逐位置相加:
text
C[0] = 2 + 7 = 9
C[1] = -1 + 3 = 2
C[2] = 4 + -1 = 3
C[3] = 3 + 2 = 5
C = [9, 2, 3, 5]这个例子已经包含完整语义。把长度从 4 换成几千万,数学没有变化,变化的是我们怎样把这些位置交给 GPU。
8.2.2 固定同一份裁判规则
本章配套程序让 HIP 与 Triton 使用相同规则:
| 项目 | 固定方式 |
|---|---|
| 数学语义 | C[i] = A[i] + B[i] |
| 数据类型 | 32 位浮点数(FP32) |
| 输入 | 用同一确定性整数公式生成,再转成 FP32 |
| 参考结果 | CPU 逐元素执行同一个 FP32 加法 |
| 边界 | 覆盖小于 wavefront、刚好等于 block、比 block 多 1、不能被 4 整除等长度 |
| 正确性 | 正式计时前检查一次,计时后再检查一次 |
| 计时范围 | 只计 GPU kernel,不含分配、输入生成与 Host-to-Device 拷贝 |
为什么需要检查奇怪的长度?因为真实输入不会永远刚好等于 block size 的整数倍。若 N=1027,最后一个 block 只有少数位置有效;没有边界保护的 kernel 会访问数组外部。
PyTorch 参考写法只有一行:
python
reference = input_a + input_b短不代表可以省略它。自定义 kernel 的第一个目标永远是与参考结果一致;一个错误但很快的 kernel 没有比较价值。
8.2.3 把“尾部”加入裁判规则
边界长度专门检查最后一组不完整的位置。例如 N=13、候选下标为 8–15 时,只有 8–12 有效:HIP 由各 thread 的标量 if 关闭越界下标,Triton 由 mask 逐位置关闭越界 load/store。这是在落实 @fig-hip-triton-paradigms 的同一条规则,不再引入第三种分工方式。
8.3 建立成本模型和瓶颈假设
8.3.1 真正忙的可能不是加法器
对一个 FP32 输出元素,最理想的逻辑工作量是:
| 动作 | 数量 | 逻辑字节 |
|---|---|---|
读取 A[i] | 1 个 FP32 | 4 Byte |
读取 B[i] | 1 个 FP32 | 4 Byte |
| 执行加法 | 1 次浮点加法 | 1 FLOP |
写回 C[i] | 1 个 FP32 | 4 Byte |
| 合计 | 12 Byte + 1 FLOP |
所以它的理想算术强度是:
这只是从算子语义推导出的逻辑下界,不是性能实测。缓存命中、未合并访问、对齐和实际内存事务都可能让物理流量不同。
从这个比例可以提出一个等待实验检验的假设:
Vector Add 每搬 12 Byte 只做 1 次加法,当前大 shape 可能更容易受显存带宽限制,而不是受浮点计算吞吐限制。
第 7 章已经解释怎样在 Roofline 上读工作点;这里不再重推硬件参考线,只保留与当前算子直接相关的假设:Vector Add 的逻辑算术强度很低,当前大 shape 更可能先受数据搬运限制。注意用词仍是“更可能”。后文会用 Radeon RX 9070 XT 上的 GPU event 时间和受控地址实验检验它。即使逻辑有效带宽较高,也只能说明结果与访存受限假设一致;没有物理流量计数器时,不能把逻辑字节直接当成显存事务。
8.3.2 有效带宽怎样算
本章统一用计时区间中的逻辑字节计算有效带宽:
其中 t 是一次 kernel 的 GPU event 时间。这个指标适合在相同语义、相同 shape、相同计时范围下比较版本,但它不等于内存控制器实际传输了多少字节。物理流量必须由可用的硬件计数器或更进一步的分析支持。
8.4 实现向量加法
选择下面的一条路线开始。两种实现完成相同的加法,输入与输出要求不变;切换标签可以对照线程下标与 tile 下标怎样表达同一件事。
8.4.1 HIP:先读标量线程的工作
HIP 篇更适合下面的读者:
- 想知道 block、thread、wavefront 最后怎样变成具体地址;
- 愿意读少量 C++,并希望控制 grid、加载类型与尾部路径;
- 后续想继续学习 LDS、Wave Shuffle、VGPR 等更底层机制;
- 遇到性能问题时,希望能从源码一路追到 kernel trace。
第一次读 HIP 不需要先记住所有硬件名词。本节只反复使用四个对象:
text
grid 里有多个 block
block 里有多个 thread
相邻 thread 以 wavefront 为硬件执行组
thread 最终访问全局内存中的地址用一组具体数字锚定:本章 N=16,777,216、blockDim.x=256,那么 grid 需要 65,536 个 block;每个 block 里的 256 个 thread 按 32 个一组编成 8 个 wavefront;每个 thread 用 blockIdx.x × 256 + threadIdx.x 算出自己负责的那一个地址。后文所有版本只是在改变“哪个 thread 访问哪个地址、一次访问多少字节”。
8.4.2 HIP v0:一个线程处理一个元素
最短的正确 kernel 是:
cpp
__global__ void vector_add_v0(const float* __restrict__ input_a,
const float* __restrict__ input_b,
float* __restrict__ output,
std::size_t size) {
const std::size_t index =
static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
if (index < size) {
output[index] = input_a[index] + input_b[index];
}
}先把索引公式拆开:
text
blockIdx.x * blockDim.x 当前 block 之前有多少线程
+ threadIdx.x 当前线程在 block 内的位置
= index 当前线程负责的全局元素下标例如 blockDim.x=256,第 2 个 block 中的第 3 个线程得到:
text
index = 2 × 256 + 3 = 515它只处理 C[515] = A[515] + B[515]。最后一个 block 可能越过 size,所以 if (index < size) 不能省略。
当相邻线程得到相邻 index 时,它们也会访问相邻的 FP32 地址。AMD 的 HIP 性能指南把这种排列称为 coalesced memory access(合并访存):硬件有机会把多个线程的请求组合成更少的内存事务。这里先把它当作地址结构事实;实际事务数与性能仍要测量。
8.4.3 HIP v1:怎样公平比较连续与跨步
一个常见教学错误是:连续版每线程处理 1 个元素,跨步版每线程处理 32 个元素,然后直接比较时间。此时改变的不只是地址顺序,还包括 grid、循环次数和每线程工作量,无法知道差异来自哪里。
本章改用一个受控实验。真实代码与动画缩略的比例如下,两边只改“同一轮内 lane 到地址的排列”,其余全部固定:
| 配置 | 真实 HIP 代码 | 图 8.2 动画 |
|---|---|---|
| 执行单元 | 1 个 wave32(32 个 lane) | 8 个 lane |
| 每 lane 处理 | 32 个元素 | 4 个元素 |
| 全部轮次覆盖 | 32 × 32 = 1024 元素 | 8 × 4 = 32 元素 |
| grid / block / 循环次数 / 逻辑字节 | 两边相同 | 两边相同 |
8.4 HIP · 合并访存 · 8-lane 教学缩略同样 8 个 lane,地址排法决定触及几组
左右滑动画布,查看完整过程
同一批元素,两种下标排法同样 8 个 lane、同样 32 个元素。唯一变量:每轮内 lane 到下标的排列——左边 index = round×8+lane(连续),右边 index = lane×4+round(跨步)。
算法过程示意 · 数字定位到该步完成状态,可拖动进度或播放;动画速度不代表 GPU 耗时。
图 8.2 合并访存受控对照的缩略动画:两边处理同一批元素,只改变每一轮的下标排列。图中 32B 地址组是帮助观察聚集度的教学分桶,不是实测硬件事务计数;连续顺序每轮触及 1 组,跨步顺序每轮触及 4 组。动画结尾给出 4 轮的累计落点对照,并以 9070 XT 实测逻辑有效带宽(hip-v1 两版,见 8.5.2)收束。
左侧连续版在第 round 轮使用:
text
index = base + round × wave_size + lane右侧跨步版只交换两个维度:
text
index = base + lane × rounds + round四轮动画结束后,两边都覆盖下标 0–31,没有重复也没有遗漏。连续顺序的累计组访问依次为 1 / 2 / 3 / 4,跨步顺序依次为 4 / 8 / 12 / 16。这仍是地址分组层面的教学计数;正式 HIP 代码保持相同的 grid、block、每 lane 循环次数、加法次数和逻辑字节,物理事务与性能由后续实测判断。
text
连续:base + round * 32 + lane
跨步:base + lane * 32 + round这个实验要验证的不是“跨步一定慢多少”,而是:
在当前 RX 9070 XT、当前 shape 与当前编译结果下,只改变 wavefront 内地址顺序,kernel 时间和 trace 是否出现可重复差异?
2026-07-19 最终 curated evidence 给出了可重复差异:连续版的三进程 median 是 0.369584 ms,跨步版是 2.567170 ms,跨步版用时约为连续版的 6.95×。两者的 kernel trace 都记录到相同的 524,288 个 work-item、workgroup size 256、VGPR 16、SGPR 128、LDS 0 Byte 和 scratch 0 Byte;grid、循环次数、算术与逻辑字节也相同。
因此,当前证据支持“wave32 同轮地址顺序显著影响这个 Vector Add”的判断。它仍没有直接数出物理显存事务,所以更严格的措辞是:受控地址变化与约 6.95× 时间差同时出现,并且现有 trace 资源字段没有提供其他差异。
8.4.4 HIP v2:Grid-Stride Loop 让线程重复工作
v0 启动足够多的线程,让每个线程只处理一个位置。另一种常见写法是限制 grid,让线程每隔整个 grid 的跨度继续处理下一个元素:
cpp
const std::size_t thread =
static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
const std::size_t grid_stride =
static_cast<std::size_t>(gridDim.x) * blockDim.x;
for (std::size_t index = thread; index < size; index += grid_stride) {
output[index] = input_a[index] + input_b[index];
}假设 grid 一共包含 1024 个线程:
text
线程 0:0, 1024, 2048, ...
线程 1:1, 1025, 2049, ...
线程 2:2, 1026, 2050, ...在同一轮循环中,相邻线程依旧访问相邻元素,因此 Grid-Stride 与“连续访存”并不冲突。
本章代码默认把 v2 的 grid 上限设为“设备 CU 数量 × 8”,并把最终 grid 写入 RESULT。这只是待验证的起点,不是通用最优值。减少 block 数可能降低调度开销,也可能让并行度不足;两种方向都要由实测回答。
8.4.5 HIP v3:float4 与尾部不是一回事
FP32 标量占 4 Byte,float4 把 4 个 FP32 组合成 16 Byte 类型。v3 先把完整的四元素组交给向量路径,再用标量处理最后 0–3 个元素。
8.4 HIP · float4 向量化四个 FP32 组成 float4,尾部单独处理
左右滑动画布,查看完整过程
先把 A 看成 18 个独立元素N=18 个 FP32,每个占 4 Byte——这是 v0/v1/v2 标量世界的做法。
算法过程示意 · 数字定位到该步完成状态,可拖动进度或播放;动画速度不代表 GPU 耗时。
图 8.3 N=18 时的 float4 源码分组与标量尾部。动画只画输入 A 的读取;输入 B 的读取与输出 C 的写回采用同样分组。该分组不等同于物理显存事务一定减少。
核心结构是:
cpp
const std::size_t vector_count = size / 4;
for (std::size_t vector_index = thread;
vector_index < vector_count;
vector_index += grid_stride) {
const float4 a = input_a4[vector_index];
const float4 b = input_b4[vector_index];
output4[vector_index] =
make_float4(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w);
}
// hipMalloc returns suitably aligned base pointers. The scalar loop keeps
// the final 0-3 values correct when size is not divisible by four.
for (std::size_t index = vector_count * 4 + thread;
index < size;
index += grid_stride) {
output[index] = input_a[index] + input_b[index];
}这里有三个容易混淆的事实:
hipMalloc返回的基地址满足本例向量类型的对齐要求,但从任意偏移地址强转成float4*未必安全。- 使用
float4只说明源码请求了向量类型,不自动证明最终指令数量或显存事务减少。 N % 4 != 0时,尾部路径是正确性要求,不是可选优化。
因此 v3 仍然要检查编译结果、边界输入、kernel trace 和时间。若它没有变快,这也是有效结果:说明“源码向量化必然提速”的假设在当前条件下没有成立。
8.4.6 HIP 路线当前能下什么结论
HIP ladder 已经把三个问题拆开:v1 只控制同一轮的地址顺序,v2 改变 grid 与每线程工作方式,v3 再引入源码向量类型和尾部路径。单看代码不能给它们排快慢;统一的正确性、benchmark 和 trace 数据放在 共同实验,负结果与边界在 适用边界 汇总。
完整实现位于 code/part2-kernels/chapter8/vector_add_hip.hip。
8.5 正确性、Benchmark 与 Profiling
本节只发布 code/part2-kernels/chapter8/evidence/ 中已经进入 curated evidence 的字段。实验基线是 AMD Radeon RX 9070 XT(gfx1201)+ ROCm 7.13.99004 + 原生 Ubuntu 24.04.4 LTS;输入为 N=16,777,216 个 FP32 元素。
8.5.1 正确性矩阵
发布行都经过独立进程汇总,correct 与 max_abs_error 直接来自 summary.csv。正式计时前,run_all.sh 还会检查小于 wavefront、block 边界、block 加一和不能被向量宽度整除的输入。
| Implementation | Runtime | Shape | Block | Grid | Correct | Max abs error |
|---|---|---|---|---|---|---|
hip-v0 | hip | 16777216 | 256 | 65536 | OK | 0.0 |
hip-v1-contiguous | hip | 16777216 | 256 | 2048 | OK | 0.0 |
hip-v1-strided | hip | 16777216 | 256 | 2048 | OK | 0.0 |
hip-v2 | hip | 16777216 | 256 | 256 | OK | 0.0 |
hip-v3 | hip | 16777216 | 256 | 256 | OK | 0.0 |
triton-t0 | triton | 16777216 | 256 | 65536 | OK | 0.0 |
triton-t1 | triton | 16777216 | 1024 | 16384 | OK | 0.0 |
8.5.2 Benchmark 口径与发布结果
每个进程先 warmup 10 次、正式计时 50 次;每个发布值先取进程内 median,再对 3 个独立进程的 median 取中位数。计时范围是 GPU event,不含分配、输入生成和 Host-to-Device 拷贝。
| Implementation | Median (ms) | Run range (ms) | Logical effective bandwidth (GB/s) | Run range (GB/s) |
|---|---|---|---|---|
hip-v0 | 0.336324 | 0.336224–0.337965 | 598.609044 | 595.703362–598.787113 |
hip-v1-contiguous | 0.369584 | 0.367805–0.370344 | 544.738374 | 543.620507–547.373904 |
hip-v1-strided | 2.567170 | 2.539349–2.581509 | 78.423556 | 77.987935–79.282759 |
hip-v2 | 0.343484 | 0.341984–0.345644 | 586.130918 | 582.468070–588.701781 |
hip-v3 | 0.345224 | 0.344864–0.347444 | 583.176683 | 579.450457–583.785476 |
triton-t0 | 0.336204 | 0.335084–0.336803 | 598.823604 | 597.756836–600.824263 |
triton-t1 | 0.338504 | 0.338404–0.338844 | 594.753950 | 594.157167–594.929706 |

图 8.5 Radeon RX 9070 XT 上的 Vector Add 逻辑有效带宽;柱长为三进程中位数,误差线为三进程范围。
图和表中的带宽都按 12 Byte × N / 时间 计算,是方便同语义版本比较的逻辑有效带宽。它不等于内存控制器实际传输的物理 GDDR6 流量。
8.5.3 Profiling 字段
profile_all.sh 为每个实现启动独立的 rocprofv3 kernel trace。下面逐格来自 profile_summary.csv;dispatch 数、grid、workgroup 与资源字段不从源码反推。这里的 Grid X 是 trace 报告的工作项范围,不是 block 或 Triton program 数;例如 HIP v0 的 16777216 / 256 = 65536 才是该次启动的 block 数。
| Implementation | Dispatches | Grid X | Workgroup X | LDS B | Scratch B | VGPR | Accum VGPR | SGPR |
|---|---|---|---|---|---|---|---|---|
hip-v0 | 6 | 16777216 | 256 | 0 | 0 | 8 | 0 | 128 |
hip-v1-contiguous | 6 | 524288 | 256 | 0 | 0 | 16 | 0 | 128 |
hip-v1-strided | 6 | 524288 | 256 | 0 | 0 | 16 | 0 | 128 |
hip-v2 | 6 | 65536 | 256 | 0 | 0 | 16 | 0 | 128 |
hip-v3 | 6 | 65536 | 256 | 0 | 0 | 16 | 0 | 128 |
triton-t0 | 6 | 8388608 | 128 | 0 | 0 | 8 | 0 | 128 |
triton-t1 | 6 | 2097152 | 128 | 0 | 0 | 24 | 0 | 128 |
8.6 HIP 与 Triton 对照
8.6.1 把两种语言放回同一张数据流
| 要回答的问题 | HIP 写法 | Triton 写法 |
|---|---|---|
| 源码中显式编号的并行实例 | blockIdx.x、threadIdx.x 选出当前 thread | tl.program_id(0) 选出当前 program |
| 一次分多少数据 | 通常从一个 thread 的标量工作开始 | 一个 program 的 tile |
| 生成下标 | 标量 index,或在线程循环中递增 | 张量 offsets |
| 保护尾部 | if (index < size) | mask = offsets < size |
| 读取 | 指针下标或显式向量类型 | tl.load(pointer + offsets, mask=...) |
| 计算 | C++ 标量/向量表达式 | tile 上的张量表达式 |
| 写回 | 指针下标 | tl.store(..., mask=...) |
| 主要显式参数 | grid、block、每线程工作、加载类型 | program grid、block size、num warps |
| 主要风险 | 越界、对齐、并行度、资源压力 | mask、tile 过大、program 映射、资源压力 |
HIP 的一个 thread 与 Triton 的一个 program 不是一一对应。更准确的迁移方法是先问三次:
text
1. 这一组输出下标是什么?
2. 它们读取哪些输入下标?
3. 尾部哪些位置必须关闭?只要这三件事对应起来,语言差异就不会遮住算子本身。
8.6.2 什么时候先选哪条路线
| 当前目标 | 更自然的起点 | 原因 |
|---|---|---|
| 第一次验证一个简单算子想法 | Triton | kernel 与 Host wrapper 较短,tile/mask 容易修改 |
| 精确控制 thread 到地址的排列 | HIP | grid、thread、指针与加载类型直接暴露 |
| 研究 Wave/LDS/VGPR 细节 | HIP | 更靠近 AMD 执行与资源模型 |
| 快速尝试多个 tile 参数 | Triton | meta-parameter 与 Python 驱动更集中 |
| 定位地址或 mask 错误 | 下标动画与完整输出校验,再结合 GPU trace | 两边都要回到实际地址证据 |
同一个算子可以先用 Triton 验证算法,再用 HIP 追底层细节;也可以从 HIP baseline 出发,完全不重写。路线选择服务于当前问题,不建立跨 shape、跨软件栈的语言排名。
8.6.3 怎样读这次对照
这次 HIP 与 Triton 对照使用相同输入公式、FP32 语义、shape、warmup、repeat 与 kernel-only 计时边界。当前快照中 triton-t0 与 hip-v0 的三进程范围重叠,中心值差约 0.00012 ms;不能据此外推另一种环境中的胜负。
更可靠的共同结论是:先保持地址连续和边界正确,再用目标 shape 实测 grid、tile 与向量类型;更少的 block/program 或更宽的源码类型都不自动等于更快。
8.7 负结果、适用边界与下一步
负结果不是删掉的草稿,而是下一轮实验的输入:
- 受控跨步版本的 median 是
2.567170 ms,逻辑有效带宽是78.423556 GB/s;它是本次最明确的负例,但仍没有直接测得物理显存事务。 hip-v2把 block 数从 65536 限制为 256,median 为0.343484 ms,没有超过hip-v0的0.336324 ms。减少 grid 不自动带来收益。hip-v3与hip-v2使用相同的 256 blocks,但float4版本 median 为0.345224 ms;源码向量类型没有在当前配置下提速。triton-t1把 program 数从 65536 减到 16384,median 为0.338504 ms,没有超过triton-t0的0.336204 ms;trace 同时显示 VGPR 从 8 变为 24。
这些结论只适用于 manifest 记录的硬件、软件、shape、block、warmup、repeat 与独立进程协议。下一步若要检验可迁移性,应先扩展 shape、dtype 或系统状态中的一个变量,并生成新的 manifest 与 curated evidence;不能把当前逻辑有效带宽解释成物理 GDDR6 流量。
8.8 复跑与练习
8.8.1 一键入口
在已经 clone 的仓库根目录执行以下命令,准备 Part 2 环境,再分别运行正确性与计时、profiling。当前安装的是 ROCm 10.0;上面的历史性能表仍对应其标注的 ROCm 7.13 采集环境,复跑时请记录自己的版本与结果:
bash
cd code/part2-kernels
uv sync
source ./activate-rocm.sh
bash chapter8/run_all.sh
bash chapter8/profile_all.sh可选的地址调试工具默认关闭,不影响正确性、benchmark 和 profiling 主流程。
run_all.sh 的顺序是:
text
采集环境
→ 编译 HIP
→ HIP/Triton 边界正确性
→ 主 benchmark
→ 3 次独立进程复跑
→ 汇总 CSV/JSONprofile_all.sh 单独运行,避免 profiler 开销混入 GPU event benchmark。实验完成后应得到:
text
code/part2-kernels/chapter8/
├── evidence/
│ ├── manifest.json
│ ├── profile_summary.csv
│ ├── summary.csv
│ └── summary.json
├── logs/
└── profiles/详细环境、参数、关键结果与证据路径见 code/part2-kernels/chapter8/EXPERIMENT.md。带宽图可以直接从汇总结果重画:
bash
python chapter8/plot_vector_add_ch8.py \
--summary chapter8/evidence/summary.csv \
--manifest chapter8/evidence/manifest.json \
--out ../../docs/part2-kernels/chapter8/images/vector-add-ch8-bandwidth.png8.8.2 从 Add 迁移到更多逐元素算子
Vector Add 的价值不在于加法本身,而在于它提供了一个可替换的模板。
把核心表达式改成 ReLU:
text
output[i] = max(input[i], 0)线程/program 的划分与尾部保护可以保持不变。改成 Scale & Bias:
text
output[i] = alpha * input[i] + beta仍然是逐元素,只是每个位置多做了乘法和加法。把 Add 与 ReLU 合在一个 kernel:
text
output[i] = max(input_a[i] + input_b[i], 0)如果分成两个 kernel,中间结果通常需要写回再读出;融合后可能减少这次中间读写。这里仍然只能提出假设,真正的收益要在第 12 章用完整计时验证。
8.8.3 练习
- 把
N改成1、31、32、33、1027,先预测 HIP 的if与 Triton 的 mask 分别关闭哪些位置,再运行正确性检查。 - 把 Triton t1 的
BLOCK_SIZE改为512,保持num_warps=4,记录 program 数量怎样变化;不要在计时前猜谁更快。 - 在 HIP 与 Triton 中都实现
Scale & Bias,继续使用同一输入生成、precheck、postcheck 与 event 计时框架。 - 给
float4版本传入从input + 1开始的偏移指针,先解释为什么对齐假设被破坏,再设计安全的头部/主体/尾部拆分;不要直接运行未对齐强转。
8.8.4 验收信号
完成本章时,不要求某个版本必须最快,但需要同时满足下面四项:
- HIP 与 Triton 的边界正确性检查都通过,发布行的
max_abs_error与 evidence 一致。 - 能解释 benchmark 的 shape、warmup、repeat、独立进程汇总和 kernel-only 计时范围。
- 能从 profile 表指出受控 HIP 对照固定了哪些资源字段,以及 Triton tile 变化伴随什么资源变化。
- 能明确写出至少一个负结果,并说明当前结论为什么不能外推到其他硬件、shape 或物理显存流量。
本章小结
- Element-Wise 的核心不是“公式简单”,而是输出位置之间没有依赖,可以独立划分。
- HIP 线程级范式让 kernel 正文从一个 thread 与标量下标出发;Triton 分块范式让正文从一个 program 与一块逻辑下标出发。
blockDim.x与BLOCK_SIZE不是对应参数,tile 位置也不固定对应硬件 lane。 - Vector Add 每个 FP32 输出至少对应两次逻辑读取、一次逻辑写回和一次加法;受控跨步实验让用时增加到连续版的约
6.95×,结果与数据搬运主导假设一致。 - HIP 从 thread 与标量地址出发,适合看清连续访问、Grid-Stride、向量类型和尾部。
- Triton 从 program 与 tile offsets 出发,用 mask 处理边界;下标动画用于理解访问位置,真实性能仍需要 GPU 实验。
- Grid-Stride 在当前配置下与 v0 接近;
float4没有提速;Triton 的 1024 元素 tile 也没有稳定超过 256 元素 tile。负结果同样决定下一轮该测什么。 - 遇到一个新的逐元素算子时,本章的判断仍然成立:先确认输出位置彼此独立 → 沿用同一套线程/program 划分与尾部保护 → 只替换核心表达式 → 用同一口径复测,不要凭源码表象预判快慢。
- 下一章会撤掉“每个输出彼此独立”这个前提:Reduction 需要许多线程合作得到一个结果,因此会第一次引入跨线程通信、LDS 与 Wave Shuffle。
延伸阅读
- AMD HIP 编程模型:thread、block、grid、wavefront 与 SIMT 执行关系。
- AMD HIP Performance Guidelines:线程映射、合并访存、对齐与内存吞吐建议。
- Triton 官方编程指南:Introduction:Blocked Program、Scalar Threads 与分块算法的官方定义。
- Triton 官方 Vector Addition 教程:kernel、Host wrapper、正确性与 benchmark 的官方最小例子。
- PyTorch HIP 语义:为什么 ROCm 构建继续使用
torch.cuda接口名。
