ALPHA教程正在持续完善,部分内容仍待补充。反馈问题 ↗
Skip to content

第10章 Normalization:归一化算子

本章导读

第 9 章把多个输入合成一个和。现在,我们希望把一行分数变成一行非负的权重,并让这些权重加起来等于 1。Softmax 会先取指数,再除以整行指数的和,因此每个位置都需要知道其他位置贡献了多少。

这把前两章的基本操作连了起来:逐元素计算、最大值归约、求和归约。我们用 [1000, 1001, 1002] 跟踪中间值,先理解如何避免数值溢出,再看这些步骤怎样在 GPU 上协作,以及中间结果是否需要写回全局内存。

可以选择一条阅读路线:

配套代码位于 code/part2-kernels/chapter10/。本章使用 FP32 输入输出,已有性能证据对应 Radeon RX 9070 XT、ROCm 7.13、原生 Ubuntu 24.04;只对 4096×1024 和已检查的边界输入作结论。启动与计时基础沿用第 8 章第 5 章

10.1 从一行分数到一行概率

本节用一行三个数明确 Softmax 的输入、输出和共享依赖。

假设输入是一行 [2, 1, 0]。先逐位置取自然指数,再除以这些指数的总和:

输入 xexp(x),约值除以总和 11.1074 后,约值
27.38910.6652
12.71830.2447
01.00000.0900

分数越大,得到的权重越大。未舍入输出的和是 1,所以我们可以将它们解释为一组概率。表中四位小数的和为 0.9999,只是显示舍入。

对形状为 R×C 的矩阵 X,本章沿每一行的列维度计算:

Yr,c=eXr,cj=0C1eXr,j,0r<R, 0c<C.

r 是行号,c 是列号。一个输出 Y[r,c] 的分子来自当前位置,分母却来自整行。因此同行的列需要协作;不同行互不依赖,可以并行处理。我们不会对整个矩阵只求一个共同分母。

10.2 平移输入,避免指数溢出

本节保持同一个计算目标,解决直接取指数的范围问题。

把输入改成 [1000, 1001, 1002]。FP32 最大有限值约为 3.4×10³⁸,而 exp(1000) 远远超过这个范围。若直接取指数,最后会落到 inf/inf,结果不是有效概率。负方向也有问题:[-1002,-1001,-1000] 的指数可能全部下溢为 0,分母也变成 0

解决方法来自一个等式。整行共同减去常数 a,分子与分母会同时乘上 e^{-a},比例保持不变:

excajexja=eaexceajexj=excjexj.

选择 a=max(x),最大的指数自变量变成 0。对于 [1000,1001,1002],我们得到:

步骤手算结果
最大值 m1002
平移 x−m[-2, -1, 0]
指数 p=exp(x−m)[0.1353, 0.3679, 1.0000]
分母 s=sum(p)1.5032
输出 y=p/s[0.0900, 0.2447, 0.6652]
SOFTMAX · 数值与依赖同一行的数怎样变成概率
两次归约共享标量,两次逐元素计算各自处理位置输入 x100010011002x − m???exp(x − m)???输出 y???m:等待最大值归约s:等待指数求和

左右滑动画布,查看完整过程

先别直接取指数一行是 1000、1001、1002。它们都超过 FP32 直接取指数的安全范围;我们先找一个保持结果不变的平移。

算法过程示意 · 数字定位到该步完成状态,可拖动进度或播放;动画速度不代表 GPU 耗时。

图 10.1 跟踪同一行三个数的最大值、稳定指数、分母和输出。每一步只依赖此前已经产生的值;图中的时间轴是教学步骤,数值保留四位小数。

图 10.1 中,最大值和分母都只有一个,但要供三个位置共同使用。这正是两次归约的作用。数学上平移后指数属于 (0,1];FP32 中很小的项仍可能下溢为 0,因此实现中的范围是 [0,1]。至少一个最大值对应的指数为 1,分母不会因为全部指数下溢而变成 0

由此得到本章所有实现共同采用的稳定形式:

mr=maxjXr,j,pr,c=eXr,cmr,sr=jpr,j,Yr,c=pr,c/sr.

本章输入约定为有限 FP32 值。如果输入本身含 NaN 或无穷,需要另行定义传播行为;减最大值不负责修复这样的输入。对极端正负有限值,浮点减法也可能溢出为负无穷,使很小的概率被舍为 0,不能据此宣称消除了全部数值误差。

10.3 中间结果需要放在哪里

本节把数学步骤连成数据通路,建立融合要解决的问题。

稳定 Softmax 由四个逻辑操作组成:

操作已学过的模式产生什么
max(x)Reduction整行共享的一个最大值
exp(x−m)Element-Wise每列一个指数
sum(p)Reduction整行共享的一个分母
p/sElement-Wise每列一个输出

四个操作不要求启动四个 kernel。我们可以把中间值写到全局数组,由后一个 kernel 接着计算;也可以让一组线程完成整行,在组内交换共享值。

图 10.2 上方通过全局中间数组连接三次 dispatch;下方把整行交给同一组执行单元。实际 HIP 融合版仍会重复读取输入,Triton 则在 program 内保留逻辑中间值。

主 shape 是 4096×1024,仅 exp_tmp 就需要 4096×1024×4 Byte=16 MiB。省掉它的一次完整写入和随后读取,按数组元素计算少了 32 MiB 的中间数据访问;这不等于 profiler 已测到相同数量的 GDDR6 流量,缓存和编译器还会影响实际访问。

融合还会改变行内并行方式、同步与寄存器需求。因此我们要同时问:少写了哪些数组,多做了哪些计算,又让多少线程参与同一行?

10.4 固定正确性和完整计时

本节说明后面的版本怎样使用同一份输入与检查标准。

项目实验约定
输入输出连续的 R×C FP32 矩阵,沿最后一维计算
输入构造确定性有限值,按行叠加 +1000−10000
参考HIP 使用 CPU 稳定 Softmax;Triton 使用 CPU torch.softmax(..., dim=1)
元素误差最大绝对误差不超过 2e-5,所有输出必须有限
行和误差每行和与 1 的差不超过 2e-5
检查时机计时前检查一次;计时后检查最后一次输出
event 区间baseline 包含全部 3 次 dispatch;融合版包含完整 1 次 dispatch
区间之外内存分配、CPU reference、拷贝、Triton 首次 JIT

两个 CPU reference 的累加路径不同,行和检查也分别使用 CPU FP64 累加与 PyTorch FP32 求和。它们使用相同容差,足以检查本章案例,但不是逐比特相同的裁判实现。

输入中的大正负平移覆盖了指数范围压力;不同的行还带有不同基础数值,因此现有脚本并未直接构造一对 xx+1000 来比较平移不变性。这个单独的关系测试放在练习里。

脚本包含 1×1、2×31、3×32、4×33、2×255、3×257 六组边界。列数决定循环和 mask 是否越界,行数决定 grid 是否遗漏输出。通过这些案例不代表任意长行、其他 dtype 或所有输入分布都已验证。

10.5 用 HIP 或 Triton 完成整行

本节分别观察显式线程协作与逻辑向量表达。公式和裁判保持一致,详细实现通过标签切换。

切换会保留各自的阅读位置

10.5.1 HIP:三个 kernel 串起稳定公式

softmax_hip.hip 中的 hip-baseline-3kernel 把中间步骤展开,适合先读清楚每个数组的用途。

第一个 kernel 中,一个 thread 负责一行,循环得到行最大值:

cpp
float maximum = kDeviceNegativeInfinity;
const std::size_t base = row * columns;
for (std::size_t column = 0; column < columns; ++column) {
    maximum = fmaxf(maximum, input[base + column]);
}
row_max[row] = maximum;

base 是当前行首下标。源码中的 kDeviceNegativeInfinity 实际取接近最小有限 FP32 的值,用于本章有限输入的最大值初值;名称并不表示它真是 IEEE 负无穷。

第二个 kernel 读取行最大值,逐列保存指数,同时累加分母:

cpp
float sum = 0.0F;
for (std::size_t column = 0; column < columns; ++column) {
    const float value = expf(input[base + column] - row_max[row]);
    exponentials[base + column] = value;
    sum += value;
}
row_sum[row] = sum;

第三个 kernel 回到一线程一元素,使用 index / columns 找到当前元素所属的行:

cpp
if (index < elements) {
    output[index] = exponentials[index] / row_sum[index / columns];
}

前两个阶段都让一个线程串行处理整行。在同一轮循环中,相邻线程处理相邻行,读取地址相隔 columns 个元素;这也与一组线程读取相邻列的映射不同。因此后面 baseline 与融合版的比较同时改变了 dispatch、中间数组和线程映射。

10.5.1 · HIP · baseline 三 kernel中间结果放在全局数组里的代价
X · 3 × 3 教学缩略xxxxxxxxxY · 与输入逐元素对应row_max(每行 1 个)m0m1m2exp_tmp(每元素 · 16 MiB)row_sum(每行 1 个)s0s1s2dispatch 1一线程一行写 row_maxdispatch 2读 X 与 row_max写 exp_tmp 与 row_sumdispatch 3读 exp_tmp 与 row_sum归一化,写 Yexp_tmp:32 MiB 逻辑访问三个 kernel 串成一条链:行最大值 → 指数与行和 → 归一化;连接它们的不是寄存器,是全局数组

左右滑动画布,查看完整过程

一条用全局数组串起来的链行最大值 → 指数与行和 → 归一化。三个 dispatch 之间传递的不是寄存器,而是 row_max、exp_tmp、row_sum 三个全局数组。

算法过程示意 · 数字定位到该步完成状态,可拖动进度或播放;动画速度不代表 GPU 耗时。

图 10.3 baseline 三个 kernel 用全局中间数组串接的数据流:dispatch 1 写 row_max,dispatch 2 物化 exp_tmp(16 MiB)与 row_sum,dispatch 3 归一化写回。图为 3 行 × 3 列教学缩略,逻辑访问量按「写一次、读一次」计算,不是硬件事务计数。

图 10.3 所示,三个 dispatch 之间传递的是全局数组:指数被完整写进 exp_tmp 再整读一遍。把这张图与 图 10.2 的下方路线对照,就能看到融合要省掉的是哪一段往返。

已准备 Part 2 环境后,可以单独运行两版 HIP:

bash
cd code/part2-kernels
source ./activate-rocm.sh
hipcc --offload-arch=gfx1201 -O3 -std=c++17 \
  chapter10/softmax_hip.hip -o /tmp/softmax_hip
/tmp/softmax_hip --version all --rows 4096 --cols 1024 \
  --block 256 --warmup 10 --repeat 50

10.5.2 一个 block 合作处理一行

hip-fused-block-lds 使用 blockIdx.x 作为行号。若 block=256,线程 t 读取列 t、t+256、t+512……。每一轮中,相邻线程访问相邻列。

每个线程先求自己的局部最大值,再借用第 9 章的 LDS 树合并:

cpp
shared[lane] = local_maximum;
__syncthreads();

for (unsigned int stride = blockDim.x / 2; stride > 0; stride /= 2) {
    if (lane < stride) {
        shared[lane] = fmaxf(shared[lane], shared[lane + stride]);
    }
    __syncthreads();
}

本文件把 threadIdx.x 命名为 lane,这里它是 block 内线程编号,不是 wavefront 内的 lane 编号。这个区别在 block=256 包含多个 wavefront 时很重要。

得到行最大值后,各线程重新遍历自己的列,累加稳定指数,再复用 LDS 求整行分母。先看最大值读出后的一道屏障:

cpp
const float maximum = shared[0];
// Every thread must read the maximum before LDS is reused for the sum.
__syncthreads();

最后一次 max 树归约屏障保证最大值已经写好;上面这道屏障还要保证所有线程都读完最大值,其他线程才能把 LDS 改作 sum 的空间。如果一个 wavefront 先覆盖 shared[0],另一个尚未读取最大值的 wavefront 就可能拿错数据。复用共享存储需要同时照顾写者和读者。

最后再遍历一次输入并写回:

cpp
output[base + column] =
    expf(input[base + column] - maximum) / denominator;

当前实现没有保存整行指数,因此源码中扫描输入三次,并在求和与输出阶段分别计算指数。它省去了全局 exp_tmp 和两次 launch,也付出了重复读取和重算的成本;不能描述为“输入只读一次”。

10.5.2 · HIP · 一 block 一行LDS 复用的时间线:max 树、读者屏障、sum 树
同一行 3 列(正文手算数据)· block=4 线程教学缩略100010011002无列t0local max·t1local max·t2local max·t3local max·LDSshared[0..3]···· __syncthreads():所有线程读完 maximum,LDS 才能改作 sum 空间 0.09000.24470.6652y = p / 1.5032lane c 负责第 c 列;第 4 个线程没有有效列,局部 max 停留在初值 −∞

左右滑动画布,查看完整过程

lane 沿列跨步,先求局部 maxblock 的 4 个线程沿列跨步:t0–t2 分别读到 1000、1001、1002;t3 没有有效列,局部 max 停留在初值 −∞——单位元参与归约但不改变结果。

算法过程示意 · 数字定位到该步完成状态,可拖动进度或播放;动画速度不代表 GPU 耗时。

图 10.4 hip-fused-block-lds 一个 block 处理一行的时间线:跨步局部 max → LDS max 树 → 读者屏障 → 复用 LDS 求 sum 树 → 重算指数写回。图为 block=4 线程、3 列的教学缩略(无有效列的线程演示单位元),行数据为正文手算值。

图 10.4 所示,LDS 在一次 kernel 里先后扮演两个角色:先当 max 树的空间,再当 sum 树的空间。中间那道读者屏障是关键——所有线程都读完 shared[0] 里的最大值之后,其他线程才能把它覆盖掉。

10.5.3 当前实现的范围

当前 HIP 实现使用整个 block 的 LDS 树,尚未实现 wavefront shuffle 收尾。硬件以 wave32 执行线程,并不自动把 LDS 算法变成 shuffle 算法。

树从 blockDim.x/2 逐轮减半,因此命令行要求 block 为 1–1024 之间的 2 的幂。列数没有这个要求;没有有效列的线程给 max 贡献最低初值,给 sum 贡献 0

block=256 时源码申请 256×4=1024 Byte 动态 LDS。这个申请量可以手算,但实际占用率和寄存器成本不能只看数组大小。如果保存指数来减少重算,需要同时确定每个线程要保存多少值、支持多长的行,以及资源增加后的性能。

10.6 先读测量,再解释融合收益

本节把不同实现放回共同的输入与计时区间,保留没有提速的配置。

下面保留 2026-07-19 的历史观察值。本轮代码检查发现 HIP 融合版在复用 LDS 前缺少一道读者屏障,当前源码已经补上;旧测试虽然通过,仍不能证明该同步缺口安全。表中 HIP 融合时间对应修复前源码,不能直接用作修复后的性能结论。

归档实验条件为:RX 9070 XT(gfx1201)、原生 Ubuntu 24.04.4、ROCm 7.13、PyTorch 2.11、Triton 3.6;4096×1024 FP32,warmup 10 次、repeat 50 次、3 个独立进程。表中时间是三个进程各自 median 的中位数。

实现完整 Softmax median(ms)三进程 median 范围(ms)
hip-baseline-3kernel0.7442280.743547–0.746188
hip-fused-block-lds0.1157810.114701–0.116281
triton-t0-compact0.0383610.037941–0.038841
triton-t1-wide0.0608010.060581–0.060841

RX 9070 XT 上四种行级 Softmax 实现的完整 GPU event 时间对比

图 10.5 比较时从输入与实现范围出发。扩大逻辑块并增加 warps 的 t1-wide 在当前 shape 上更慢,这个负结果与较快版本一起保留。

HIP 融合版的历史时间约为 baseline 的六分之一。但它同时减少了 launch、去掉了全局中间数组,并把单线程串行读行改成 block 协作;不能把全部收益归给“少两次 launch”。

Triton wide 版比 compact 版慢。两者数学相同,却分别使用 1024/42048/8 的逻辑块和 warps 配置。更多参与资源未必弥补额外逻辑工作与协作成本。现有 profile 中,两版的 VGPR 字段都是 40,所以也不能未经进一步证据就写成“wide 版因为寄存器数更高而变慢”。

把实现放在一起,可以明确它们各自承担的工作:

问题HIP baselineHIP LDS 融合Triton
行内分工归约阶段一线程一行一 block 一行一 program 一行
每次调用的主体 kernel 数311
指数中间结果写入全局数组不保存,输出时重算program 内逻辑值
max、sum 协作线程串行循环block 的 LDS 树tl.maxtl.sum
输入读取(源码层面)max 和 exp 阶段读取max、sum、输出阶段读取一次逻辑 load

program 内的逻辑值不等于每种配置都能完全驻留在寄存器。源码能告诉我们没有显式全局中间数组,实际是否发生溢出存储仍应检查生成代码与资源信息。

证据入口是 code/part2-kernels/chapter10/evidence/manifest.jsonsummary.csvprofile_summary.csv,对应源码 ef1722a6743bc0a9d6528d1fa938ad64976f0c05。历史记录保留原数值;代码修复后的结果需要另外实测,不能自动沿用这张表。

2026-09-11 补上 LDS 复用屏障后,两个 HIP 实现在 8 组边界形状和主形状 4096×1024 的 3 个独立进程中,计时前后校验均通过。该次运行的融合版三进程 median 为 0.113042 ms,范围为 0.101562–0.118622 ms;它属于单独的修正验证,未重跑 Triton 或采集新 trace,不能与上面的历史表拼成新排名。详细参数、源码哈希和逐条结果见同步修正记录

10.7 运行、筛选 trace 与检查边界

本节把一次完整复跑和逐条解释记录连起来。Part 2 环境准备好后,在项目根目录执行:

bash
cd code/part2-kernels
uv sync
source ./activate-rocm.sh
bash chapter10/run_all.sh

脚本先编译 HIP,依次运行六组边界 shape 的四种实现,再运行默认 4096×1024、warmup 10 次、repeat 50 次。先确认 correct=OK precheck=OK postcheck=OK,再比较相同 shape 的 median_ms

run_all.sh 负责正确性和计时。需要采集四种实现的 trace 时,在同一个 Part 2 环境中运行:

bash
bash chapter10/profile_all.sh

历史 profile 使用 warmup 0 次、repeat 5 次。加上预检一次,共 6 次逻辑调用,因此四种实现的主体 dispatch 数对应 18、6、6、6。后检只检查最后一次输出,不额外运行一次 Softmax。

读 trace 时可以先定位 kernel 名称:

  • baseline:row_max_serialrow_exp_sum_serialnormalize_rows
  • HIP 融合:softmax_fused_lds
  • Triton 两种配置:softmax_row_kernel

现有汇总的 grid_size_x 数值对应 trace 中的总工作项范围。例如 HIP 融合记录为 1,048,576,除以 workgroup 256,才得到 4096 个 block。不能直接将其解释为百万个 block。LDS 汇总为 0 也不能据此否定动态 LDS;使用某个资源字段前,要先确认它真正表示什么。

尚需扩展的测试包括:严格成对的平移输入、相同最大值、多种长行和其他 dtype。特别是 C=4097、FP16/BF16,只是后续实验方向,不能写成已经通过。

10.8 练习:预测数值与成本

  1. 将动画输入改成 [0,0,0],再改成 [1000,1000,1000]。先写下两个结果与中间最大值,再解释哪些量变了、哪些没变。
  2. 用逻辑宽度 4 处理三个负数 [-1002,-1001,-1000]。分别用 0 和负无穷填充第四格,追踪 max、指数与分母,说明错误填充可能怎样影响数值稳定性。
  3. 显式构造相同基础行的 xx+1000x−1000,比较稳定公式与直接指数公式。记录非有限结果,而不只是最终误差。
  4. 固定逻辑宽度,只改变 Triton num_warps;再固定 num_warps,只改变逻辑宽度。与同时改变两个参数相比,现在能更清楚地区分什么?
  5. 保持一 block 一行与相同列映射,为 HIP 增加 shuffle 收尾或局部指数保存中的一种改动。先补边界检查,再报告资源、完整时间和不适用的行长。
自检提示:相同概率不代表相同中间值

两组三个相同输入都得到 [1/3,1/3,1/3]。行最大值不同,但减最大值之后都变成 [0,0,0]

全负输入若用 0 填充,max 会被无效位置改成 0,这个补齐位置还会给分母贡献 exp(0)=1。即使最终不写第四格,它也已经参与了中间计算;真实三项的指数还可能全部下溢。用负无穷填充,真实最大值仍是 −1000,补齐位置的指数为 0

本章小结

Softmax 的一行共享最大值和分母,两个归约之间穿插逐元素计算。减去行最大值保持数学比例,并让至少一个稳定指数为 1,避免直接指数的主要范围问题。

融合可以减少全局中间数组和 dispatch,但不同实现也会改变线程分工、同步、重算和资源需求。判断收益时,要把这些变化分别说清楚;本章 wide 配置变慢的结果提醒我们,参数变大不是优化方向本身。

下一章讨论矩阵乘。那时我们仍然关心数据能否留在片上,但复用会从“同一行的多个阶段”扩展到“多个输出共同使用同一块输入”。

延伸阅读