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

第12章 Fusion:融合算子 ​

本章导读 ​

前两章已经能分别完成矩阵乘和 Softmax。把它们接起来后,前一步的输出就成了后一步的输入:先算完、写入数组,再由下一步读走,是最直接的实现。

如果后一步只需要中间结果的一部分,能否在这部分还可用时继续计算,省去完整中间数组?本章用 Attention 研究这个问题。先用三个 key 手算一行输出,再让已经算过的贡献随着新 key 到来继续累积,最后用完整路径的计时判断这项改动是否值得。

配套代码在 code/part2-kernels/chapter12/。环境沿用 Radeon RX 9070 XT(gfx1201)+ ROCm 10.0 + 原生 Ubuntu 24.04。动画展示算法中的依赖和手算过程,不表示 GPU 的真实执行速度。

12.1 Attention 的一行加权和 ​

本章计算单组 Q、K、V 的 FP32 前向 Attention,不包含 batch、多 head 或 causal mask。序列长度为 S,每个向量有 D 个分量,三个输入的形状都是 [S,D]。

固定第 i 行 query,先与每行 key 做点积得到分数,再对这一行分数做 Softmax,最后用这些权重合并 value:

sij=Qi⋅KjD,pij=esij∑k=0S−1esik,Oi=∑j=0S−1pijVj.

一个 pij 是标量,Vj 是向量;它们相乘后仍有 D 个分量。把所有 key 对应的贡献加起来,得到这一行输出 Oi。因此,计算某个 query 行需要遍历全部 key/value,但不需要其他 query 行的结果。

先假设某个 query 得到三个分数 [2,1,4],对应的 value 是 [1,0]、[0,2]、[3,1]。沿用第 10 章的稳定 Softmax,先减去最大值 4:

key平移后的指数,约值归一化权重,约值对输出的贡献,约值
0e−2=0.13530.1142[0.1142, 0]
1e−3=0.04980.0420[0, 0.0840]
210.8438[2.5314, 0.8438]

未舍入指数的总和约为 1.1851,输出约为 [2.6456,0.9278]。后面始终跟踪同一组数据,观察怎样在不保存全部分数的情况下得到这个结果。这个例子用于手算,不是性能实验输入。

先在 图 12.1 中逐项观察:一个概率分别乘对应 V 的两个分量,得到两个贡献,再各自加入输出的累计值。概率和 V 的原值始终保留,只有三个 key 都处理完后才写出输出。

ATTENTION · 一行手算一个概率怎样贡献两个输出分量
key分数概率 pV · 两个分量02待计算[1, 0]11待计算[0, 2]24待计算[3, 1]同一概率,分别乘 V 的两个分量分量 0p × V= 待计算累计分量 00分量 1p × V= 待计算累计分量 10O = 尚未写出输入保留;先累计,再写出向量key分数概率 pV · 两个分量02待计算[1, 0]11待计算[0, 2]24待计算[3, 1]同一概率,分别乘 V 的两个分量分量 0p × V= 待计算累计分量 00分量 1p × V= 待计算累计分量 10O = 尚未写出输入保留;先累计,再写出向量

固定一个 query 的三个 key三个 score 是 2、1、4;每个 V 都有两个分量。先保留这些输入,输出还未写出。

数学步骤示意;数字不插值,动画速度不表示 GPU 耗时。

图 12.1 同一个 query 的三个权重分别作用于三条 V。可在每个 key 的步骤停留,核对两个乘积和两个累计分量;动画的播放时长不表示计算耗时。

12.2 中间矩阵与物化成本 ​

对所有 query 行重复上述计算,就会得到分数矩阵 Scores[S,S] 和概率矩阵 P[S,S]。把一个中间结果真正保存成数组,称为物化(materialize):

text
Q、K → Scores [S,S] → P [S,S] → 与 V 合并 → O [S,D]

两条路线都先建立三个 kernel 的物化基线。分数、Softmax、加权和各自完成一段工作,通过全局数组传递结果。每段职责清楚,也便于定位错误。

图 12.2 三个阶段通过 Scores 与 P 交接。仍固定上例的一个 query:分数需要全部 K,输出需要完整概率行和全部 V。每个 query 都按此规则得到自己的输出行;箭头只表示数组依赖。

图 12.2 中,两块 FP32 中间数组合计占用 2S2×4 Byte。对主形状 S=128,D=64,它们的容量合计为 128 KiB。只按每块数组完整写一次、完整读一次计,已有 256 KiB 的逻辑访问。这是按数组大小计算的最低账目,不是测得的带宽或流量。

具体源码可能重复读取:例如 HIP 的加权和 kernel 由一个线程负责一个输出分量,同一行概率会被该行的多个输出线程分别读取;行 Softmax 也会多次扫描 Scores。缓存还会影响这些访问最终到达哪一级存储。因此,不能拿 16S² Byte 直接代替本程序的全部读取量,再用总时间反推显存带宽。

需要检验的假设是:省去完整 Scores/P 的中间读写,能否降低一次 Attention 的总时间? 为此必须改变计算过程,让尚未读完 key 时也能保留正确的历史贡献。

12.3 在线 Softmax 与历史贡献 ​

先只处理第一个分数 2。以它作为当前最大值,指数就是 1,加权 value 是 [1,0]。第二个分数 1 小于当前最大值,加入 e−1 和 e−1[0,2] 即可。

第三个分数变成 4。之前两个指数以 2 为基准,现在必须一起换成以 4 为基准:旧结果都乘 e2−4,再加入新的 [3,1]。不能只重缩放分母而保留原来的分子。

ATTENTION · 一行手算新最大值到来后,保留并更新历史贡献
key 0:score=2,V=[1, 0]固定同一个 query,输入保持不变本轮开始的状态m−∞l0a[0]0a[1]0当前保留的状态m−∞l0a[0]0a[1]0初始化m = −∞,l = 0,a = [0, 0]m 决定当前指数使用哪个基准O = 最后再做 a / l显示四位小数,计算不提前舍入key 0:score=2,V=[1, 0]固定同一个 query,输入保持不变本轮开始的状态m−∞l0a[0]0a[1]0当前保留的状态m−∞l0a[0]0a[1]0初始化m = −∞,l = 0,a = [0, 0]m 决定当前指数使用哪个基准O = 最后再做 a / l显示四位小数,计算不提前舍入

为同一行保留三个状态m 是最大值,l 是指数和,a 有两个加权累计分量。初始为 −∞、0、[0,0]。

数学步骤示意;数字不插值,动画速度不表示 GPU 耗时。

图 12.3 固定同一个 query,依次加入三个 key 的贡献。新最大值从 2 变为 4 时,分别停在“保留旧值”“重缩放”“加入新项”三个步骤,核对旧分母与两个旧累计是否同乘 exp(−2)。最后再做除法;显示的小数只用于阅读,后续计算使用未舍入的值。

把 图 12.3 中持续保留的值记为三个状态:

状态当前含义需要保存多少数据
m已处理分数的最大值一个标量
l以 m 为基准的指数和一个标量
a以同一基准累计的加权 value一个 D 维向量

初始状态为 m=−∞,l=0,a=0。读到分数 sj 后,令

m′=max(m,sj),α=em−m′,β=esj−m′.

新的分母和分子分别为

l′=αl+β,a′=αa+βVj.

最后输出 a/l。重缩放来自恒等式 es−m′=es−mem−m′,它让所有历史项仍采用同一尺度。第一个 key 有限时,α=e−∞=0,初始状态不会贡献任何权重。

这称为在线(online)计算:随着新数据到来更新已有状态。也可以一次处理一个 key tile,先求 tile 内的最大值、指数和与加权贡献,再按同样的关系合入历史。两种组织在数学上等价;FP32 的归约顺序不同,最后几位不要求逐位相同。

12.4 正确性参考与计时范围 ​

两条路线都使用连续的 FP32 Q/K/V[S,D],输出为 FP32 O[S,D]。性能实验输入按行号、分量号和固定 seed 生成;所有值都是整数除以二的幂,因此 HIP 与 Triton 得到相同的输入,不依赖两种随机库恰好生成相同序列。

检查项目本章约定
主形状与输入S=128,D=64,input=dyadic,seed=20260920
正确性参考CPU 用 FP64 完成 QK、稳定 Softmax、PV,最后转为 FP32
逐元素误差$
有限值任意输出为 NaN 或无穷立即失败
检查时机第一次输出预填 NaN,计时前检查完整输出;计时后再检查最后一次完整输出
GPU 算术两路线保持 FP32;本章 Triton 不使用 tl.dot 或混合精度矩阵路径

六个配置分别检查 dyadic 输入的 1×1、17×31、33×65、65×64,以及 rising-max 输入的 65×64、129×64,共 36 项检查通过。形状顺序为 S×D;其中 33/65/129 检查 key 尾部,31/65 检查向量分量尾部。

专用输入 rising-max 让后一个 key 的分数高于前一个,迫使状态在多个 key、多个 tile 之间反复重缩放。它用于发现“只有最大值不变时才算对”的错误;不与默认输入的性能样本混在一起。单 key 检查还有一个直接答案:权重为 1,输出应等于这一行 value。

GPU event 计完整前向路径:物化版本包含三个 kernel,在线版本包含一个 kernel。分配、输入拷贝、CPU 参考、正确性检查及 Triton 首次编译在稳态计时之外。正式计时不启用 profiler,诊断另行采集。

每配置运行 3 个独立进程,每进程预热 10 次、采样 50 次,每个 event 区间后同步。图的中心值是三个进程中位数的中位数,范围线是它们的最小—最大值,不是置信区间。主形状与两组附加形状共 54 个进程、2700 个样本;扫描后另外运行 18 个进程、900 个样本作独立确认。

两个 Host 当前都为 Scores/P 分配空间,即使只运行在线版本也保留这项分配。输出中的 intermediate_allocated_bytes 记录这两块缓冲区容量,intermediate_used_bytes 则在在线版本中为 0。后一个字段表示 kernel 不使用它们,既不是访存计数器,也不能证明整个进程的显存分配峰值已下降。

12.5 HIP 与 Triton 优化路线 ​

先在一种语言内建立物化基线,再对照在线实现。两条路线分别回答自己的优化问题,不把彼此当作必须战胜的初版。

环境与结果目录。 以下命令在 GPU 机器的仓库根目录开始执行,后续保持在 code/part2-kernels/。沿用本篇 uv.lock,不要删除锁文件:

bash
cd code/part2-kernels &&
uv sync --locked &&
source ./activate-rocm.sh

为本章新建一个独立结果目录,保存打印出来的路径:

bash
mkdir -p chapter12/results &&
RUN=$(mktemp -d chapter12/results/walkthrough-XXXXXX) &&
mkdir "$RUN/build" &&
printf '本轮结果目录:%s\n' "$RUN"

换终端后,重新执行环境准备,再恢复之前打印的目录;提示处只粘贴路径,不含 RUN=:

bash
read -r -p '粘贴上次输出的结果目录:' RUN &&
test -n "$RUN" && test -d "$RUN/build" &&
printf '继续使用结果目录:%s\n' "$RUN"

需要重测或改动源码时,新建 RUN,重新编译 HIP;恢复旧目录只用于继续未修改的实验。后续命令会拒绝写入已有样本。

每条命令先校验,再完成一个进程的 10 次预热与 50 次计时;折叠输出对应第 1 个进程。图使用三个独立进程的统计,不是一次终端输出。 手工复测时同时把样本名 p1 与 --process 1 改为 p2/2、p3/3,每遍覆盖同组候选并轮换起始项。章末批量入口会自动完成这一步、汇总并画本轮图,不要求先运行 profiler。

先判断完整结果与完整时间。HIP 在线版本出现明显退步,才进一步追问设备上的哪个阶段变长;Triton 的采用与 tile 选择先由重复测量判断,不要求两条路线走相同的工具流程。

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

12.5.1 HIP 基线:分阶段保存中间结果 ​

完整源码:code/part2-kernels/chapter12/attention_hip.hip
cpp
#include <hip/hip_runtime.h>

#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstdint>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <numeric>
#include <string>
#include <vector>

#define HIP_CHECK(call)                                                        \
    do {                                                                       \
        hipError_t error = (call);                                             \
        if (error != hipSuccess) {                                             \
            std::cerr << "HIP error: " << hipGetErrorString(error) << '\n';   \
            std::exit(1);                                                      \
        }                                                                      \
    } while (0)

__global__ void scores_kernel(const float* q, const float* k, float* scores,
                              int seq, int dim, float scale) {
    int index = blockIdx.x * blockDim.x + threadIdx.x;
    if (index >= seq * seq) return;
    int row = index / seq;
    int col = index % seq;
    float value = 0.0f;
    for (int d = 0; d < dim; ++d) {
        value += q[row * dim + d] * k[col * dim + d];
    }
    scores[index] = value * scale;
}

__global__ void softmax_rows_kernel(const float* scores, float* probabilities,
                                    int seq) {
    extern __shared__ float shared[];
    int row = blockIdx.x;
    int tid = threadIdx.x;
    float local_max = -INFINITY;
    for (int col = tid; col < seq; col += blockDim.x) {
        local_max = fmaxf(local_max, scores[row * seq + col]);
    }
    shared[tid] = local_max;
    __syncthreads();
    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) shared[tid] = fmaxf(shared[tid], shared[tid + stride]);
        __syncthreads();
    }
    float row_max = shared[0];
    // Finish every read of the maximum before reusing LDS for local sums.
    __syncthreads();
    float local_sum = 0.0f;
    for (int col = tid; col < seq; col += blockDim.x) {
        local_sum += expf(scores[row * seq + col] - row_max);
    }
    shared[tid] = local_sum;
    __syncthreads();
    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) shared[tid] += shared[tid + stride];
        __syncthreads();
    }
    float denominator = shared[0];
    for (int col = tid; col < seq; col += blockDim.x) {
        probabilities[row * seq + col] =
            expf(scores[row * seq + col] - row_max) / denominator;
    }
}

__global__ void probability_value_kernel(const float* probabilities,
                                         const float* value, float* output,
                                         int seq, int dim) {
    int index = blockIdx.x * blockDim.x + threadIdx.x;
    if (index >= seq * dim) return;
    int row = index / dim;
    int d = index % dim;
    float result = 0.0f;
    for (int col = 0; col < seq; ++col) {
        result += probabilities[row * seq + col] * value[col * dim + d];
    }
    output[index] = result;
}

__global__ void online_attention_kernel(const float* q, const float* k,
                                        const float* value, float* output,
                                        int seq, int dim, float scale) {
    extern __shared__ float shared[];
    float* reduction = shared;
    float* numerator = reduction + blockDim.x;
    float* state = numerator + dim;  // m, l, alpha, beta
    int row = blockIdx.x;
    int tid = threadIdx.x;

    for (int d = tid; d < dim; d += blockDim.x) numerator[d] = 0.0f;
    if (tid == 0) {
        state[0] = -INFINITY;
        state[1] = 0.0f;
    }
    __syncthreads();

    for (int col = 0; col < seq; ++col) {
        float partial = 0.0f;
        for (int d = tid; d < dim; d += blockDim.x) {
            partial += q[row * dim + d] * k[col * dim + d];
        }
        reduction[tid] = partial;
        __syncthreads();
        for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
            if (tid < stride) reduction[tid] += reduction[tid + stride];
            __syncthreads();
        }
        if (tid == 0) {
            float score = reduction[0] * scale;
            float old_max = state[0];
            float new_max = fmaxf(old_max, score);
            float alpha = expf(old_max - new_max);
            float beta = expf(score - new_max);
            state[0] = new_max;
            state[1] = state[1] * alpha + beta;
            state[2] = alpha;
            state[3] = beta;
        }
        __syncthreads();
        for (int d = tid; d < dim; d += blockDim.x) {
            numerator[d] = numerator[d] * state[2] +
                           state[3] * value[col * dim + d];
        }
        __syncthreads();
    }
    for (int d = tid; d < dim; d += blockDim.x) {
        output[row * dim + d] = numerator[d] / state[1];
    }
}

// Both runtimes use this element-wise contract against a CPU FP64 reference
// rounded once to FP32. NaN/Inf always fails, independently of the error value.
constexpr double kAtol = 2.0e-5;
constexpr double kRtol = 2.0e-4;

struct Options {
    std::string version = "all";
    std::string input = "dyadic";
    std::string samples;
    std::string config_id;
    int seq = 128;
    int dim = 64;
    int block = 256;
    int warmup = 10;
    int repeat = 50;
    std::uint64_t seed = 20260920;
    int process = 1;
};

[[noreturn]] void usage_error(const std::string& message) {
    std::cerr << message << '\n';
    std::exit(2);
}

std::uint64_t unsigned_integer(const std::string& text) {
    if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos)
        usage_error("expected a non-negative integer: " + text);
    try {
        return std::stoull(text);
    } catch (const std::exception&) {
        usage_error("integer out of range: " + text);
    }
}

Options parse_options(int argc, char** argv) {
    Options options;
    for (int i = 1; i < argc; ++i) {
        const std::string name = argv[i];
        if (name == "--help") {
            std::cout << "--version all|materialized|online --seq S --dim D "
                         "--block THREADS --input dyadic|rising-max --seed UINT32 "
                         "--warmup N --repeat N --config-id ID --process N "
                         "--samples FILE\n";
            std::exit(0);
        }
        if (i + 1 >= argc) usage_error("missing value for " + name);
        const std::string value = argv[++i];
        if (name == "--version") options.version = value;
        else if (name == "--input") options.input = value;
        else if (name == "--samples") options.samples = value;
        else if (name == "--config-id") options.config_id = value;
        else if (name == "--seed") options.seed = unsigned_integer(value);
        else {
            const auto number = unsigned_integer(value);
            if (number > static_cast<std::uint64_t>(std::numeric_limits<int>::max()))
                usage_error("integer too large for " + name);
            const int integer = static_cast<int>(number);
            if (name == "--seq") options.seq = integer;
            else if (name == "--dim") options.dim = integer;
            else if (name == "--block") options.block = integer;
            else if (name == "--warmup") options.warmup = integer;
            else if (name == "--repeat") options.repeat = integer;
            else if (name == "--process") options.process = integer;
            else usage_error("unknown option: " + name);
        }
    }
    const bool valid_version = options.version == "all" ||
                              options.version == "materialized" ||
                              options.version == "online";
    const bool power_of_two = options.block > 0 &&
                             (options.block & (options.block - 1)) == 0;
    if (!valid_version || options.seq <= 0 || options.seq > 4096 ||
        options.dim <= 0 || options.dim > 256 || !power_of_two ||
        options.block > 1024 || options.repeat <= 0 || options.process <= 0 ||
        options.seed > 0xFFFFFFFFULL ||
        (options.input != "dyadic" && options.input != "rising-max"))
        usage_error("invalid arguments (1 <= seq <= 4096, 1 <= dim <= 256)");
    if (options.config_id.find_first_not_of(
            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.") != std::string::npos)
        usage_error("invalid config-id");
    if (!options.samples.empty() &&
        (options.version == "all" || options.config_id.empty()))
        usage_error("--samples requires one version and a --config-id");
    return options;
}

struct Inputs {
    std::vector<float> q;
    std::vector<float> k;
    std::vector<float> value;
};

Inputs make_input(const Options& options) {
    const std::size_t size = static_cast<std::size_t>(options.seq) * options.dim;
    Inputs input{std::vector<float>(size), std::vector<float>(size),
                 std::vector<float>(size)};
    for (int row = 0; row < options.seq; ++row) {
        for (int d = 0; d < options.dim; ++d) {
            const std::size_t index = static_cast<std::size_t>(row) * options.dim + d;
            const auto r = static_cast<std::uint64_t>(row);
            const auto column = static_cast<std::uint64_t>(d);
            input.q[index] = (static_cast<int>((17 * r + 13 * column + options.seed) % 65) - 32) / 64.0f;
            input.k[index] = (static_cast<int>((29 * r + 7 * column + options.seed + 11) % 65) - 32) / 64.0f;
            input.value[index] = (static_cast<int>((11 * r + 19 * column + options.seed + 23) % 65) - 32) / 32.0f;
            if (options.input == "rising-max") {
                // Every later key has a larger score, including across tiles.
                input.q[index] = d == 0 ? 8.0f : 0.0f;
                input.k[index] = d == 0 ? (row - options.seq / 2) / 8.0f : 0.0f;
            }
        }
    }
    return input;
}

std::vector<float> reference_attention(const Inputs& input, int seq, int dim) {
    std::vector<float> output(static_cast<std::size_t>(seq) * dim);
    std::vector<double> weights(seq);
    const double scale = 1.0 / std::sqrt(static_cast<double>(dim));
    for (int row = 0; row < seq; ++row) {
        double maximum = -std::numeric_limits<double>::infinity();
        for (int col = 0; col < seq; ++col) {
            double score = 0.0;
            for (int d = 0; d < dim; ++d)
                score += static_cast<double>(input.q[row * dim + d]) * input.k[col * dim + d];
            weights[col] = score * scale;
            maximum = std::max(maximum, weights[col]);
        }
        double denominator = 0.0;
        for (double& weight : weights) {
            weight = std::exp(weight - maximum);
            denominator += weight;
        }
        for (int d = 0; d < dim; ++d) {
            double result = 0.0;
            for (int col = 0; col < seq; ++col)
                result += (weights[col] / denominator) * input.value[col * dim + d];
            output[row * dim + d] = static_cast<float>(result);
        }
    }
    return output;
}

struct Validation {
    bool correct = true;
    bool finite = true;
    double max_absolute_error = 0.0;
    double max_tolerance_ratio = 0.0;
};

Validation validate(const std::vector<float>& actual, const std::vector<float>& reference) {
    Validation result;
    for (std::size_t i = 0; i < actual.size(); ++i) {
        if (!std::isfinite(actual[i]) || !std::isfinite(reference[i])) {
            result.correct = false;
            result.finite = false;
            result.max_absolute_error = std::numeric_limits<double>::infinity();
            result.max_tolerance_ratio = std::numeric_limits<double>::infinity();
            continue;
        }
        const double error = std::abs(static_cast<double>(actual[i]) - reference[i]);
        const double tolerance = kAtol + kRtol * std::abs(static_cast<double>(reference[i]));
        result.max_absolute_error = std::max(result.max_absolute_error, error);
        result.max_tolerance_ratio = std::max(result.max_tolerance_ratio, error / tolerance);
        result.correct = result.correct && error <= tolerance;
    }
    return result;
}

struct Timing {
    double minimum_ms;
    double median_ms;
    double mean_ms;
    std::vector<double> samples;
};

template <typename Launch>
Timing benchmark(const Options& options, Launch launch) {
    for (int i = 0; i < options.warmup; ++i) launch();
    HIP_CHECK(hipDeviceSynchronize());
    hipEvent_t start, stop;
    HIP_CHECK(hipEventCreate(&start));
    HIP_CHECK(hipEventCreate(&stop));
    std::vector<double> samples;
    samples.reserve(options.repeat);
    for (int i = 0; i < options.repeat; ++i) {
        HIP_CHECK(hipEventRecord(start, nullptr));
        launch();
        HIP_CHECK(hipEventRecord(stop, nullptr));
        HIP_CHECK(hipEventSynchronize(stop));
        float elapsed = 0.0f;
        HIP_CHECK(hipEventElapsedTime(&elapsed, start, stop));
        samples.push_back(elapsed);
    }
    HIP_CHECK(hipEventDestroy(start));
    HIP_CHECK(hipEventDestroy(stop));
    auto sorted = samples;
    std::sort(sorted.begin(), sorted.end());
    const std::size_t middle = sorted.size() / 2;
    const double median = sorted.size() % 2 == 0
        ? (sorted[middle - 1] + sorted[middle]) / 2.0 : sorted[middle];
    return {sorted.front(), median,
            std::accumulate(samples.begin(), samples.end(), 0.0) / samples.size(), samples};
}

void print_result(const Options& options, const std::string& version,
                  const Validation& validation, const Timing* timing,
                  const char* precheck, const char* postcheck) {
    const std::size_t intermediate_bytes = 2ULL * options.seq * options.seq * sizeof(float);
    std::cout << std::setprecision(17)
              << "RESULT operator=attention implementation=hip-" << version
              << " runtime=hip config_id=" << options.config_id
              << " shape=" << options.seq << 'x' << options.dim
              << " seq=" << options.seq << " dim=" << options.dim
              << " dtype=float32 input_precision=fp32 block=" << options.block
              << " block_k=" << (version == "online" ? 1 : 0)
              << " block_d=0 num_warps=0 launches=" << (version == "materialized" ? 3 : 1)
              << " warmup=" << options.warmup << " repeat=" << options.repeat
              << " seed=" << options.seed << " process=" << options.process
              << " timing=gpu-event scope=gpu-event-full-operator input=" << options.input
              << " reference=fp64-attention-to-fp32 atol=" << kAtol << " rtol=" << kRtol
              << " intermediate_allocated_bytes=" << intermediate_bytes
              << " intermediate_used_bytes=" << (version == "materialized" ? intermediate_bytes : 0)
              << " output_finite=" << (validation.finite ? "OK" : "FAIL")
              << " timed=" << (timing != nullptr) << " correct=" << (validation.correct ? "OK" : "FAIL")
              << " precheck=" << precheck << " postcheck=" << postcheck;
    if (timing)
        std::cout << " min_ms=" << timing->minimum_ms << " median_ms=" << timing->median_ms
                  << " mean_ms=" << timing->mean_ms;
    else
        std::cout << " min_ms=NA median_ms=NA mean_ms=NA";
    std::cout << " max_abs_error=" << validation.max_absolute_error
              << " max_tolerance_ratio=" << validation.max_tolerance_ratio << '\n';
}

void save_samples(const Options& options, const std::string& version, const Timing& timing) {
    if (options.samples.empty()) return;
    std::ofstream file(options.samples);
    if (!file) usage_error("cannot open sample file: " + options.samples);
    file << "config_id,implementation,runtime,shape,dtype,block,block_k,block_d,num_warps,launches,warmup,repeat,seed,process,scope,input,input_precision,atol,rtol,intermediate_allocated_bytes,intermediate_used_bytes,sample,ms\n";
    file << std::setprecision(17);
    const std::size_t intermediate_bytes = 2ULL * options.seq * options.seq * sizeof(float);
    for (std::size_t i = 0; i < timing.samples.size(); ++i)
        file << options.config_id << ",hip-" << version << ",hip,"
             << options.seq << 'x' << options.dim << ",float32," << options.block << ','
             << (version == "online" ? 1 : 0) << ",0,0," << (version == "materialized" ? 3 : 1)
             << ',' << options.warmup << ',' << options.repeat << ',' << options.seed << ','
             << options.process << ",gpu-event-full-operator," << options.input << ",fp32,"
             << kAtol << ',' << kRtol << ',' << intermediate_bytes << ','
             << (version == "materialized" ? intermediate_bytes : 0) << ',' << i << ',' << timing.samples[i] << '\n';
    if (!file) usage_error("failed writing sample file: " + options.samples);
}

int main(int argc, char** argv) {
    const Options options = parse_options(argc, argv);
    const int qkv_elements = options.seq * options.dim;
    const int score_elements = options.seq * options.seq;
    const std::size_t qkv_bytes = static_cast<std::size_t>(qkv_elements) * sizeof(float);
    const std::size_t score_bytes = static_cast<std::size_t>(score_elements) * sizeof(float);
    int device = 0;
    hipDeviceProp_t properties{};
    HIP_CHECK(hipGetDevice(&device));
    HIP_CHECK(hipGetDeviceProperties(&properties, device));
    std::cout << "ENV runtime=hip gpu=\"" << properties.name << "\" arch="
              << properties.gcnArchName << " wave_size=" << properties.warpSize << '\n';

    const Inputs input = make_input(options);
    const std::vector<float> reference = reference_attention(input, options.seq, options.dim);
    std::vector<float> actual(qkv_elements);
    float *d_q, *d_k, *d_value, *d_output, *d_scores, *d_probabilities;
    HIP_CHECK(hipMalloc(&d_q, qkv_bytes));
    HIP_CHECK(hipMalloc(&d_k, qkv_bytes));
    HIP_CHECK(hipMalloc(&d_value, qkv_bytes));
    HIP_CHECK(hipMalloc(&d_output, qkv_bytes));
    // Both paths keep the same host allocation plan. Online avoids accessing
    // scores/P, but this executable does not demonstrate a lower allocation peak.
    HIP_CHECK(hipMalloc(&d_scores, score_bytes));
    HIP_CHECK(hipMalloc(&d_probabilities, score_bytes));
    HIP_CHECK(hipMemcpy(d_q, input.q.data(), qkv_bytes, hipMemcpyHostToDevice));
    HIP_CHECK(hipMemcpy(d_k, input.k.data(), qkv_bytes, hipMemcpyHostToDevice));
    HIP_CHECK(hipMemcpy(d_value, input.value.data(), qkv_bytes, hipMemcpyHostToDevice));
    const float scale = static_cast<float>(1.0 / std::sqrt(static_cast<double>(options.dim)));
    const int score_grid = (score_elements + options.block - 1) / options.block;
    const int output_grid = (qkv_elements + options.block - 1) / options.block;
    const std::size_t online_shared = (options.block + options.dim + 4) * sizeof(float);
    auto launch = [&](const std::string& version) {
        if (version == "materialized") {
            hipLaunchKernelGGL(scores_kernel, dim3(score_grid), dim3(options.block), 0, 0,
                               d_q, d_k, d_scores, options.seq, options.dim, scale);
            HIP_CHECK(hipGetLastError());
            hipLaunchKernelGGL(softmax_rows_kernel, dim3(options.seq), dim3(options.block),
                               options.block * sizeof(float), 0,
                               d_scores, d_probabilities, options.seq);
            HIP_CHECK(hipGetLastError());
            hipLaunchKernelGGL(probability_value_kernel, dim3(output_grid), dim3(options.block), 0, 0,
                               d_probabilities, d_value, d_output, options.seq, options.dim);
        } else {
            hipLaunchKernelGGL(online_attention_kernel, dim3(options.seq), dim3(options.block),
                               online_shared, 0, d_q, d_k, d_value, d_output,
                               options.seq, options.dim, scale);
        }
        HIP_CHECK(hipGetLastError());
    };
    bool all_correct = true;
    for (const std::string version : {"materialized", "online"}) {
        if (options.version != "all" && options.version != version) continue;
        HIP_CHECK(hipMemset(d_output, 0xFF, qkv_bytes));
        HIP_CHECK(hipMemset(d_scores, 0xFF, score_bytes));
        HIP_CHECK(hipMemset(d_probabilities, 0xFF, score_bytes));
        launch(version);
        HIP_CHECK(hipDeviceSynchronize());
        HIP_CHECK(hipMemcpy(actual.data(), d_output, qkv_bytes, hipMemcpyDeviceToHost));
        const Validation precheck = validate(actual, reference);
        if (!precheck.correct) {
            print_result(options, version, precheck, nullptr, "FAIL", "NA");
            all_correct = false;
            continue;
        }
        const Timing timing = benchmark(options, [&] { launch(version); });
        HIP_CHECK(hipMemcpy(actual.data(), d_output, qkv_bytes, hipMemcpyDeviceToHost));
        const Validation postcheck = validate(actual, reference);
        print_result(options, version, postcheck, &timing, "OK", postcheck.correct ? "OK" : "FAIL");
        all_correct = all_correct && postcheck.correct;
        if (postcheck.correct) save_samples(options, version, timing);
    }
    HIP_CHECK(hipFree(d_q));
    HIP_CHECK(hipFree(d_k));
    HIP_CHECK(hipFree(d_value));
    HIP_CHECK(hipFree(d_output));
    HIP_CHECK(hipFree(d_scores));
    HIP_CHECK(hipFree(d_probabilities));
    return all_correct ? 0 : 1;
}

这份文件包含本路线的输入准备、kernel、启动、校验与计时;后续配置共用它,按函数和运行参数定位改动。

先编译一次。未修改源码时可复用本轮可执行文件;修改源码后请新建结果目录再编译:

bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
if [[ ! -x "$RUN/build/attention_hip" ]]; then
  hipcc --offload-arch=gfx1201 -O3 -std=c++17 \
    chapter12/attention_hip.hip -o "$RUN/build/attention_hip"
fi

launch() 用三个 kernel 对应三个阶段:

阶段分工写出什么
scores_kernel一个线程计算一个 query-key 点积一个 Score
softmax_rows_kernel一个 block 协作处理一行分数这一行概率
probability_value_kernel一个线程累加一个输出分量一个 O 元素

同一 query 的不同 key 点积互不依赖,分数阶段可以同时展开多个 query-key 对;最终加权和则沿 key 归约。行 Softmax 的 LDS 复用遵循第 10 章的同步规则:归约完成后,必须等所有线程读完最大值,才可用同一片 LDS 保存下一次归约的部分和。

先运行物化路径,为本路线建立基线:

bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
test ! -e "$RUN/n128x64-hip-materialized-b256-p1.csv" && \
"$RUN/build/attention_hip" \
  --block 256 --version materialized --seq 128 \
  --dim 64 --warmup 10 --repeat 50 \
  --seed 20260920 --input dyadic --config-id hip-materialized-b256 \
  --samples "$RUN/n128x64-hip-materialized-b256-p1.csv" --process 1
真实输出:hip-materialized-b256,shape=128x64,第 1 个独立进程
text
ENV runtime=hip gpu="AMD Radeon RX 9070 XT" arch=gfx1201 wave_size=32
RESULT operator=attention implementation=hip-materialized runtime=hip config_id=hip-materialized-b256 shape=128x64 seq=128 dim=64 dtype=float32 input_precision=fp32 block=256 block_k=0 block_d=0 num_warps=0 launches=3 warmup=10 repeat=50 seed=20260920 process=1 timing=gpu-event scope=gpu-event-full-operator input=dyadic reference=fp64-attention-to-fp32 atol=2.0000000000000002e-05 rtol=0.00020000000000000001 intermediate_allocated_bytes=131072 intermediate_used_bytes=131072 output_finite=OK timed=1 correct=OK precheck=OK postcheck=OK min_ms=0.059241000562906265 median_ms=0.059581000357866287 mean_ms=0.059687860012054443 max_abs_error=2.2351741790771484e-08 max_tolerance_ratio=0.00098910493985020415

先检查 correct/precheck/postcheck、有限值与容差比,再核对 shape=128x64,launches=3。主形状基线完整时间为 59.681 μs,三个进程中位数范围为 59.581–59.741 μs。源码中的 Scores/P 交接已经提供了融合假设,不需要先采 profiler 才能提出它。

这条路径保存了完整中间矩阵,也进行了三次 kernel 调用。接下来尝试在一个 block 内直接累计一行输出,检验这些中间成本是否值得省去。

12.5.2 HIP 改动:一个 block 完成在线累计 ​

在线版本用 blockIdx.x 选择 query 行。每读一个 key,线程协作算完点积,再更新这一行的最大值、指数和与向量累计。这样最终只需写出 O[S,D],kernel 不再读写 Scores/P。

线程 0 更新当前 key 的标量状态:

cpp
if (tid == 0) {
    float score = reduction[0] * scale;
    float old_max = state[0];
    float new_max = fmaxf(old_max, score);
    float alpha = expf(old_max - new_max);
    float beta = expf(score - new_max);
    state[0] = new_max;
    state[1] = state[1] * alpha + beta;
    state[2] = alpha;
    state[3] = beta;
}

屏障之后,其他线程读取本轮的 alpha/beta,分别更新自己负责的 numerator[d]。更新结束后再同步,下一轮才能覆盖系数。

HIP · 固定 key 2 的共享依赖系数写好、取得副本、消费完成
共享状态 · 线程 0 写入已得到 key 2 的 score = 4α = 等待β = 等待m = 2l = 1.3679旧基准为 2;本轮系数尚未发布写完 → 全 block 同步 → 才能读消费者 0V₂[0] = 3α = 等待β = 等待旧 a = 等待读取新 a = 待计算消费者 1V₂[1] = 1α = 等待β = 等待旧 a = 等待读取新 a = 待计算各自计算 α×旧 a + β×V₂[d]共享 numerator · 各自更新一项本轮旧值 [1, 0.7358]a[0]=1a[1]=0.7358仍有人使用系数时,不得覆盖共享状态 · 线程 0 写入已得到 key 2 的 score = 4α = 等待β = 等待m = 2l = 1.3679旧基准为 2;本轮系数尚未发布写完 → 全 block 同步 → 才能读消费者 0V₂[0] = 3α = 等待β = 等待旧 a = 等待读取新 a = 待计算消费者 1V₂[1] = 1α = 等待β = 等待旧 a = 等待读取新 a = 待计算各自计算 α×旧 a + β×V₂[d]共享 numerator · 各自更新一项本轮旧值 [1, 0.7358]a[0]=1a[1]=0.7358仍有人使用系数时,不得覆盖

固定已经算完 score=4 的时刻旧最大值为 2、l≈1.3679、numerator≈[1,0.7358],都保存在共享空间。本图不重演前面的点积归约。

只放大两个输出分量的消费者;其余线程仍参与协作。这里不统计全部屏障,也不表示执行耗时。

图 12.4 只放大 key 2 的系数交接,起点是已经得到 score=4。线程 0 写好共享系数,全 block 同步后消费者才能取得它们;各自更新共享 numerator 后,还需等待消费完成才能复用系数。图中仅展开两个输出分量,其余线程仍参与协作,不统计完整 kernel 的屏障次数。

图 12.4 标出了系数写入与消费之间的依赖。物化基线会展开多个 query-key 对,在线版则在每个 block 内按顺序遍历 key;它还需要保存跨轮状态,并多次等待点积和状态更新完成。省去中间矩阵,并不意味着剩余计算的分工与协作成本保持不变。

在线算法改变了跨轮状态,先让后续 key 的最大值持续升高,检查分母与分子是否一起重缩放:

bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
"$RUN/build/attention_hip" \
  --block 256 --version online --seq 65 \
  --dim 64 --warmup 0 --repeat 1 \
  --seed 20260920 --input rising-max --config-id hip-online-b256
正确性输出:HIP online,65×64,rising-max
text
ENV runtime=hip gpu="AMD Radeon RX 9070 XT" arch=gfx1201 wave_size=32
RESULT operator=attention implementation=hip-online runtime=hip config_id=hip-online-b256 shape=65x64 seq=65 dim=64 dtype=float32 input_precision=fp32 block=256 block_k=1 block_d=0 num_warps=0 launches=1 warmup=0 repeat=1 seed=20260920 process=1 timing=gpu-event scope=gpu-event-full-operator input=rising-max reference=fp64-attention-to-fp32 atol=2.0000000000000002e-05 rtol=0.00020000000000000001 intermediate_allocated_bytes=33800 intermediate_used_bytes=0 output_finite=OK timed=1 correct=OK precheck=OK postcheck=OK min_ms=0.12640099227428436 median_ms=0.12640099227428436 mean_ms=0.12640099227428436 max_abs_error=1.4901161193847656e-08 max_tolerance_ratio=0.00060550631823742102

再检查只有一个 key 的情形,输出应等于 value:

bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
"$RUN/build/attention_hip" \
  --block 256 --version online --seq 1 \
  --dim 1 --warmup 0 --repeat 1 \
  --seed 20260920 --input dyadic --config-id hip-online-b256
正确性输出:HIP online,单 key
text
ENV runtime=hip gpu="AMD Radeon RX 9070 XT" arch=gfx1201 wave_size=32
RESULT operator=attention implementation=hip-online runtime=hip config_id=hip-online-b256 shape=1x1 seq=1 dim=1 dtype=float32 input_precision=fp32 block=256 block_k=1 block_d=0 num_warps=0 launches=1 warmup=0 repeat=1 seed=20260920 process=1 timing=gpu-event scope=gpu-event-full-operator input=dyadic reference=fp64-attention-to-fp32 atol=2.0000000000000002e-05 rtol=0.00020000000000000001 intermediate_allocated_bytes=8 intermediate_used_bytes=0 output_finite=OK timed=1 correct=OK precheck=OK postcheck=OK min_ms=0.012880000285804272 median_ms=0.012880000285804272 mean_ms=0.012880000285804272 max_abs_error=0 max_tolerance_ratio=0

两条命令仅读正确性,不比较一次采样的时间。检查通过后,回到与基线相同的 dyadic 主输入,测完整在线版本:

bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
test ! -e "$RUN/n128x64-hip-online-b256-p1.csv" && \
"$RUN/build/attention_hip" \
  --block 256 --version online --seq 128 \
  --dim 64 --warmup 10 --repeat 50 \
  --seed 20260920 --input dyadic --config-id hip-online-b256 \
  --samples "$RUN/n128x64-hip-online-b256-p1.csv" --process 1
真实输出:hip-online-b256,shape=128x64,第 1 个独立进程
text
ENV runtime=hip gpu="AMD Radeon RX 9070 XT" arch=gfx1201 wave_size=32
RESULT operator=attention implementation=hip-online runtime=hip config_id=hip-online-b256 shape=128x64 seq=128 dim=64 dtype=float32 input_precision=fp32 block=256 block_k=1 block_d=0 num_warps=0 launches=1 warmup=10 repeat=50 seed=20260920 process=1 timing=gpu-event scope=gpu-event-full-operator input=dyadic reference=fp64-attention-to-fp32 atol=2.0000000000000002e-05 rtol=0.00020000000000000001 intermediate_allocated_bytes=131072 intermediate_used_bytes=0 output_finite=OK timed=1 correct=OK precheck=OK postcheck=OK min_ms=0.22992299497127533 median_ms=0.23068299889564514 mean_ms=0.23063763827085496 max_abs_error=2.1653249859809875e-08 max_tolerance_ratio=0.0010513768060094045

先核对检查通过、launches=1 与 intermediate_used_bytes=0,再读完整时间;后者不代表主机没有分配这两块缓冲。

HIP 物化与在线 Attention 的完整时间

图 12.5 S=128、D=64,两版 block 都为 256,比较完整前向路径。在线版减少目标 kernel 数和中间读写,但没有保持相同的 key 并行分工。

在线版为 230.643 μs,范围 230.523–230.683 μs,用时约为物化基线的 3.86 倍。独立确认仍为 230.602 μs 对 59.961 μs。本轮保留物化实现作为 HIP 方案;在线公式通过了正确性检查,但这套执行安排没有带来性能收益。

这个负结果修正了最初的假设:仅仅省去中间矩阵还不够,还要考虑原本可以展开的 key 工作,现在怎样被顺序处理。

12.5.3 HIP 诊断:区分阶段时间与原因 ​

这次要回答:完整路径的退步,是否也出现在在线 kernel 的设备执行区间? 这会帮助决定继续研究设备端的执行安排,还是先调查测量与提交方式。仍在 code/part2-kernels/,分别采两条路径;每个 kernel 按名字筛选:

bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
mkdir "$RUN/profile-hip-materialized-b256" && \
rocprofv3 --kernel-trace --output-format csv \
  --output-directory "$RUN/profile-hip-materialized-b256" --output-file hip-materialized-b256 -- \
  "$RUN/build/attention_hip" \
    --block 256 --version materialized --seq 128 \
    --dim 64 --warmup 5 --repeat 10 \
    --seed 20260920 --input dyadic --config-id hip-materialized-b256 && \
python chapter8/inspect_trace.py \
  "$RUN/profile-hip-materialized-b256/hip-materialized-b256_kernel_trace.csv" \
  --kernel scores_kernel --skip 6 --take 10 && \
python chapter8/inspect_trace.py \
  "$RUN/profile-hip-materialized-b256/hip-materialized-b256_kernel_trace.csv" \
  --kernel softmax_rows_kernel --skip 6 --take 10 && \
python chapter8/inspect_trace.py \
  "$RUN/profile-hip-materialized-b256/hip-materialized-b256_kernel_trace.csv" \
  --kernel probability_value_kernel --skip 6 --take 10
筛选后的真实 trace:hip-materialized-b256,shape=128x64
text
kernel_name="scores_kernel(float const*, float const*, float*, int, int, float)"
csv_rows=51 target_rows=16 skipped_rows=6 kept_rows=10
sort=Start_Timestamp_ascending
first_start_ns=887497031786019 first_end_ns=887497031805419 first_duration_ns=19400
kernel_min_us=8.040000 kernel_median_us=8.140500 kernel_max_us=19.400000
grid_work_items_xyz=16384x1x1 workgroup_size_xyz=256x1x1 workgroups_xyz=64x1x1
workgroups_total=64
vgpr_count=8 sgpr_count=128 lds_bytes=0 scratch_bytes=0

kernel_name="softmax_rows_kernel(float const*, float*, int)"
csv_rows=51 target_rows=16 skipped_rows=6 kept_rows=10
sort=Start_Timestamp_ascending
first_start_ns=887497031809699 first_end_ns=887497031814260 first_duration_ns=4561
kernel_min_us=4.480000 kernel_median_us=4.560000 kernel_max_us=4.640000
grid_work_items_xyz=32768x1x1 workgroup_size_xyz=256x1x1 workgroups_xyz=128x1x1
workgroups_total=128
vgpr_count=16 sgpr_count=128 lds_bytes=0 scratch_bytes=0

kernel_name="probability_value_kernel(float const*, float const*, float*, int, int)"
csv_rows=51 target_rows=16 skipped_rows=6 kept_rows=10
sort=Start_Timestamp_ascending
first_start_ns=887497031845800 first_end_ns=887497031880681 first_duration_ns=34881
kernel_min_us=34.761000 kernel_median_us=34.921000 kernel_max_us=35.040000
grid_work_items_xyz=8192x1x1 workgroup_size_xyz=256x1x1 workgroups_xyz=32x1x1
workgroups_total=32
vgpr_count=16 sgpr_count=128 lds_bytes=0 scratch_bytes=0
原始 kernel trace CSV:hip-materialized-b256,shape=128x64
text
"Kind","Agent_Id","Queue_Id","Stream_Id","Thread_Id","Dispatch_Id","Kernel_Id","Kernel_Name","Correlation_Id","Start_Timestamp","End_Timestamp","LDS_Block_Size","Scratch_Size","VGPR_Count","Accum_VGPR_Count","SGPR_Count","Workgroup_Size_X","Workgroup_Size_Y","Workgroup_Size_Z","Grid_Size_X","Grid_Size_Y","Grid_Size_Z"
"KERNEL_DISPATCH","Agent 1",1,0,1226358,1,13,"__amd_rocclr_fillBufferUnAligned",1,887497019338020,887497019342420,0,0,8,0,128,256,1,1,2048,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,2,13,"__amd_rocclr_fillBufferUnAligned",2,887497019527222,887497019529382,0,0,8,0,128,256,1,1,4096,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,3,13,"__amd_rocclr_fillBufferUnAligned",3,887497019534822,887497019537502,0,0,8,0,128,256,1,1,4096,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,4,22,"scores_kernel(float const*, float const*, float*, int, int, float)",4,887497031213151,887497031221671,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,6,20,"probability_value_kernel(float const*, float const*, float*, int, int)",6,887497031235871,887497031274031,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,5,21,"softmax_rows_kernel(float const*, float*, int)",5,887497031226351,887497031231351,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,7,22,"scores_kernel(float const*, float const*, float*, int, int, float)",7,887497031366783,887497031385263,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,8,21,"softmax_rows_kernel(float const*, float*, int)",8,887497031389743,887497031394383,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,9,20,"probability_value_kernel(float const*, float const*, float*, int, int)",9,887497031412503,887497031450264,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,10,22,"scores_kernel(float const*, float const*, float*, int, int, float)",10,887497031454704,887497031462864,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,11,21,"softmax_rows_kernel(float const*, float*, int)",11,887497031467224,887497031484384,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,12,20,"probability_value_kernel(float const*, float const*, float*, int, int)",12,887497031488824,887497031526665,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,13,22,"scores_kernel(float const*, float const*, float*, int, int, float)",13,887497031531105,887497031539345,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,14,21,"softmax_rows_kernel(float const*, float*, int)",14,887497031543825,887497031558706,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,15,20,"probability_value_kernel(float const*, float const*, float*, int, int)",15,887497031563146,887497031599186,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,16,22,"scores_kernel(float const*, float const*, float*, int, int, float)",16,887497031603386,887497031611546,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,17,21,"softmax_rows_kernel(float const*, float*, int)",17,887497031615786,887497031632627,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,18,20,"probability_value_kernel(float const*, float const*, float*, int, int)",18,887497031636947,887497031671907,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,19,22,"scores_kernel(float const*, float const*, float*, int, int, float)",19,887497031676147,887497031684267,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,20,21,"softmax_rows_kernel(float const*, float*, int)",20,887497031688428,887497031693148,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,21,20,"probability_value_kernel(float const*, float const*, float*, int, int)",21,887497031710188,887497031745268,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,22,22,"scores_kernel(float const*, float const*, float*, int, int, float)",22,887497031786019,887497031805419,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,23,21,"softmax_rows_kernel(float const*, float*, int)",23,887497031809699,887497031814260,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,24,20,"probability_value_kernel(float const*, float const*, float*, int, int)",24,887497031845800,887497031880681,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,25,22,"scores_kernel(float const*, float const*, float*, int, int, float)",25,887497031916561,887497031934041,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,26,21,"softmax_rows_kernel(float const*, float*, int)",26,887497031938281,887497031942841,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,27,20,"probability_value_kernel(float const*, float const*, float*, int, int)",27,887497031961122,887497031996082,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,28,22,"scores_kernel(float const*, float const*, float*, int, int, float)",28,887497032026443,887497032034683,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,29,21,"softmax_rows_kernel(float const*, float*, int)",29,887497032051403,887497032055883,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,30,20,"probability_value_kernel(float const*, float const*, float*, int, int)",30,887497032078044,887497032113004,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,31,22,"scores_kernel(float const*, float const*, float*, int, int, float)",31,887497032142405,887497032150525,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,32,21,"softmax_rows_kernel(float const*, float*, int)",32,887497032166525,887497032171085,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,33,20,"probability_value_kernel(float const*, float const*, float*, int, int)",33,887497032197535,887497032232296,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,34,22,"scores_kernel(float const*, float const*, float*, int, int, float)",34,887497032261136,887497032269257,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,35,21,"softmax_rows_kernel(float const*, float*, int)",35,887497032285577,887497032290217,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,36,20,"probability_value_kernel(float const*, float const*, float*, int, int)",36,887497032316177,887497032351098,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,37,22,"scores_kernel(float const*, float const*, float*, int, int, float)",37,887497032380338,887497032388418,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,38,21,"softmax_rows_kernel(float const*, float*, int)",38,887497032404539,887497032409139,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,39,20,"probability_value_kernel(float const*, float const*, float*, int, int)",39,887497032436719,887497032471640,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,40,22,"scores_kernel(float const*, float const*, float*, int, int, float)",40,887497032501280,887497032509480,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,41,21,"softmax_rows_kernel(float const*, float*, int)",41,887497032526001,887497032530521,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,42,20,"probability_value_kernel(float const*, float const*, float*, int, int)",42,887497032556701,887497032591502,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,43,22,"scores_kernel(float const*, float const*, float*, int, int, float)",43,887497032620342,887497032628422,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,44,21,"softmax_rows_kernel(float const*, float*, int)",44,887497032644942,887497032649502,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,45,20,"probability_value_kernel(float const*, float const*, float*, int, int)",45,887497032675313,887497032710353,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,46,22,"scores_kernel(float const*, float const*, float*, int, int, float)",46,887497032740154,887497032748314,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,47,21,"softmax_rows_kernel(float const*, float*, int)",47,887497032764874,887497032769394,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,48,20,"probability_value_kernel(float const*, float const*, float*, int, int)",48,887497032793595,887497032828555,0,0,16,0,128,256,1,1,8192,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,49,22,"scores_kernel(float const*, float const*, float*, int, int, float)",49,887497032857916,887497032865956,0,0,8,0,128,256,1,1,16384,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,50,21,"softmax_rows_kernel(float const*, float*, int)",50,887497032882356,887497032886876,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226358,51,20,"probability_value_kernel(float const*, float const*, float*, int, int)",51,887497032912706,887497032947627,0,0,16,0,128,256,1,1,8192,1,1
bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
mkdir "$RUN/profile-hip-online-b256" && \
rocprofv3 --kernel-trace --output-format csv \
  --output-directory "$RUN/profile-hip-online-b256" --output-file hip-online-b256 -- \
  "$RUN/build/attention_hip" \
    --block 256 --version online --seq 128 \
    --dim 64 --warmup 5 --repeat 10 \
    --seed 20260920 --input dyadic --config-id hip-online-b256 && \
python chapter8/inspect_trace.py \
  "$RUN/profile-hip-online-b256/hip-online-b256_kernel_trace.csv" \
  --kernel online_attention_kernel --skip 6 --take 10
筛选后的真实 trace:hip-online-b256,shape=128x64
text
kernel_name="online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)"
csv_rows=19 target_rows=16 skipped_rows=6 kept_rows=10
sort=Start_Timestamp_ascending
first_start_ns=887497198778368 first_end_ns=887497199001372 first_duration_ns=223004
kernel_min_us=222.683000 kernel_median_us=222.903000 kernel_max_us=240.603000
grid_work_items_xyz=32768x1x1 workgroup_size_xyz=256x1x1 workgroups_xyz=128x1x1
workgroups_total=128
vgpr_count=16 sgpr_count=128 lds_bytes=0 scratch_bytes=0
原始 kernel trace CSV:hip-online-b256,shape=128x64
text
"Kind","Agent_Id","Queue_Id","Stream_Id","Thread_Id","Dispatch_Id","Kernel_Id","Kernel_Name","Correlation_Id","Start_Timestamp","End_Timestamp","LDS_Block_Size","Scratch_Size","VGPR_Count","Accum_VGPR_Count","SGPR_Count","Workgroup_Size_X","Workgroup_Size_Y","Workgroup_Size_Z","Grid_Size_X","Grid_Size_Y","Grid_Size_Z"
"KERNEL_DISPATCH","Agent 1",1,0,1226368,1,7,"__amd_rocclr_fillBufferUnAligned",1,887497185858205,887497185863325,0,0,8,0,128,256,1,1,2048,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,3,7,"__amd_rocclr_fillBufferUnAligned",3,887497186061257,887497186063977,0,0,8,0,128,256,1,1,4096,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,2,7,"__amd_rocclr_fillBufferUnAligned",2,887497186053457,887497186055617,0,0,8,0,128,256,1,1,4096,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,4,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",4,887497197260764,887497197502048,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,5,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",5,887497197602929,887497197827973,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,6,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",6,887497197832453,887497198055896,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,7,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",7,887497198060216,887497198283460,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,8,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",8,887497198287620,887497198510664,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,9,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",9,887497198515024,887497198738147,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,10,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",10,887497198778368,887497199001372,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,11,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",11,887497199039912,887497199262956,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,12,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",12,887497199292596,887497199515720,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,13,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",13,887497199545600,887497199768324,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,14,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",14,887497199798325,887497200021128,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,15,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",15,887497200051049,887497200273892,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,16,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",16,887497200303453,887497200526416,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,17,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",17,887497200555537,887497200778340,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,18,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",18,887497200808061,887497201030744,0,0,16,0,128,256,1,1,32768,1,1
"KERNEL_DISPATCH","Agent 1",1,0,1226368,19,19,"online_attention_kernel(float const*, float const*, float const*, float*, int, int, float)",19,887497212227815,887497212468418,0,0,16,0,128,256,1,1,32768,1,1

先读报告的 target_rows/skipped_rows/kept_rows,核对分析窗口;再读名称与 launch,最后比较 kernel_median_us 和范围。物化三个阶段分别约 8.141、4.560、34.921 μs,在线则如下文。不要把三个独立中位数相加当成完整 benchmark。

先在 trace 中按 scores_kernel、softmax_rows_kernel、probability_value_kernel 筛选物化路径,再查看 online_attention_kernel。确认同一 shape、block 参数和完整调用范围,排除初始化与预检调用。阶段 trace 能显示耗时主要落在哪个目标 kernel,独立采集时的总记录数不是一次 Attention 的调用数。

本次每个目标 kernel 都有 16 条记录:1 次预检、5 次预热、10 次目标重复,排除前 6 条后统计。在线 kernel 的 trace 中位数为 222.903 μs,说明较长的设备执行本身值得继续研究;不能拿它与另一轮 event 相减,计算某项启动或同步成本。

本轮 block=256,D=64 时,Softmax 动态 LDS 为 256×4=1024 B,在线版为 (256+64+4)×4=1296 B。这些容量来自实际启动参数。trace 的 LDS_Block_Size 为 0,没有包含这项动态申请,因此不能读作“不使用 LDS”。

源码确认在线版在每个 key 后需要协作,trace 确认它的执行时间;两者还不能说明每个屏障贡献了多少延迟。继续优化时,可以提出“按 key tile 合并多轮工作”的下一项假设,但必须保留相同公式与正确性检查,再建立单独对照。直接删掉承担数据依赖的屏障,会先改变程序是否正确。

12.6 完整路径比较与方案选择 ​

物化与在线计算保留相同的 Attention 语义,却用不同方式安排中间结果。一次融合是否值得,要同时检查省掉了什么、增加了什么,并用同范围的计时作选择。

路线与主形状 S×D=128×64主扫描完整时间(μs)本轮决定
HIP materialized / block25659.681保留为 HIP 方案
HIP online / block256230.643保留正确实现与负结果,继续诊断执行安排
Triton materialized / key3237.940保留为路线原始基线
Triton online / key3214.720采用在线版本,保留当前 tile32
Triton online / key6414.280候选与 tile32 的范围重叠,暂不替换默认选择

Attention 六个配置的独立确认

图 12.9 RX 9070 XT、ROCm 10.0,S=128、D=64,完整路径 event。两条路线分开比较;每项为三个新进程,不与主扫描合并。

独立确认保留了所有六个配置,包括较慢的在线 HIP 和接近的 Triton tile32/64。HIP 的负结果、Triton 在线相对物化的收益都复现了;tile32 与 tile64 的差异仍不足以据此作稳定选择。

Attention 在两组附加形状上的完整比较

图 12.10 S×D=256×64 与 128×65 各测全部六项。每个面板单独标注路线与形状,刻度独立,不能跨面板直接比较柱长。

两组附加形状中,HIP 物化仍快于当前在线实现,Triton 在线仍快于本路线物化实现;哪种在线 key tile 更合适则发生变化。形状迁移保留了全部配置,未只展示有利的一组。

本章的选择范围是已经检查过的 shape、输入、精度与设备。它不等于生产 Attention 的普遍结论:当前实现没有 query tile 之间的高级复用,没有混合精度矩阵路径,也没有 batch/head/causal。换成其他输入范围、序列长度或融合边界后,需要重新选择基线并验证。

12.7 运行与诊断 ​

前面的命令便于逐项理解。要完成三个独立进程的整组比较,仍在已激活的 code/part2-kernels/ 中,使用批量入口;它以尚不存在的 $RUN/rounds 子目录保存本轮结果:

bash
test -d "${RUN:?请先准备本章环境与结果目录}/build" && \
python chapter12/run_rounds.py --output "$RUN/rounds" --phase main && \
python chapter12/plot_rounds.py \
  --evidence "$RUN/rounds/summary" --out-dir "$RUN/figures/main" \
  --round hip-fusion --round triton-fusion --round triton-tile

主阶段固定 S=128,D=64,先完成 36 项边界与 rising-max 检查,再运行六配置。 每项三个独立进程,各预热 10 次、计时 50 次,轮换候选顺序;先检查正确性,再汇总性能。main 不采集 profiler。图中心为三个进程中位数的中位数,误差线为它们的最小值到最大值,不是置信区间。

需要检查形状迁移时,再运行 validation:

bash
python chapter12/run_rounds.py --output "$RUN/rounds" --phase validation && \
python chapter12/plot_rounds.py \
  --evidence "$RUN/rounds/summary" --out-dir "$RUN/figures/validation" \
  --round shapes

两组附加形状是 S×D=256×64 与 128×65,每组测全部六项。这一步继续使用本轮冻结的源码和配置,并检查实际环境。两条绘图命令都显式读取你的本轮 summary,不会用教程发布的成绩替缺失结果补值。

选出候选后,再以独立目录确认:

bash
python chapter12/verify_rounds.py \
  --frozen-run "$RUN/rounds" --output "$RUN/confirmation"

确认保留 HIP 两版、Triton 物化与 online16,再加最快在线配置及距它不超过 5% 的候选,重复项只运行一次。本章测试因此确认了全部六项配置。确认数据保存在自己的 summary/summary.csv、summary/process-summary.csv 与 confirmation.json 中,不自动并入父实验。先比较同形状的新旧进程范围,再决定是否保留候选;不要复制教程中的确认文件到新目录拼图。

程序把逐次计时写入 samples/,把检查与终端记录写入 logs/;source/ 保存本轮源码,commands.jsonl 保存实际参数。已有主目录拒绝被新的 main 覆盖;修改代码、改变条件或重新采一轮时,新建 RUN。第一次编译和 Triton JIT 在稳态计时之外,后续终端继续使用本篇环境。

本章图使用 chapter12/evidence/rounds/ 的三进程实测与独立确认;折叠终端输出来自其中第 1 个进程的原始日志,保存在 chapter12/evidence/walkthrough/。一次进程的中位数不必等于图的中心。按需 trace 的采集命令与阅读报告已经放在对应问题旁;批量 --phase profile 仅供需要完整诊断矩阵时选用。

本章实测条件为 RX 9070 XT、ROCm SDK 10.0.0、原生 Ubuntu 24.04.5,HIP 7.15.26333、Triton 3.8.0;GPU 非独占、不锁频,输入数组重复使用且不清缓存。PMC、ATT 等工具并非本章操作前提,当前可用能力与限制见第 8 章。

12.8 练习与下一轮实验 ​

  1. 仍用分数 [2,1,4] 和三个 value,手算第三轮的 α,l,a。如果分母重缩放而分子没有,哪些输出分量会改变?
  2. 把 key 顺序改成 [4,2,1],同时重排对应 value。数学结果应怎样变化?FP32 的最后几位是否必须一致?
  3. 对 S=33,分别画出 key tile 16 与 32 的有效位置。保持其他参数不变,先验证答案,再记录完整时间,并说明变化是否超过独立进程间的范围。
  4. HIP 选读:按 图 12.4 标出每个屏障保护的读写依赖。若下一轮尝试一次处理多个 key,哪些状态可以局部合并,哪些依赖仍必须等待?先说明设计,再改代码。

本章小结 ​

Attention 的输出依赖所有 key 的权重,但不一定需要保存全部分数。在线算法通过最大值、指数和及向量累计保留历史贡献;最大值改变时,分母和分子一起重缩放。

融合改变的不只有中间写回,也可能改变并行分工、同步次数和同时保存的数据量。HIP 的物化与在线对照、Triton 的融合与 key tile 扫描都要由完整计时和正确性检查决定是否采用。下一章会把这套方法用于更紧凑的 RMSNorm,让你独立完成一次候选配置的选择与复测。

延伸阅读 ​