欢迎光临
我们一直在努力

AMD DCU 加速器上的算子优化策略教学——以气候态海温百分位计算为例

摘要

AMD DCU(Deep Computing Unit)是 ROCm 生态下的 GPU 加速器,已在多个国产超算系统中部署。本文以一个气候态海温第 90 百分位计算程序为教学案例,面向 DCU 上的 HIP 编程初学者,系统介绍三类典型算子(Top-K 归约、滑动窗口统计矩、多级流水线)的优化思路。全文围绕七条策略展开:wavefront 对齐与线程块配置、寄存器级归约与 launch_bounds 约束、分支分歧消除与统计矩替代、加法群降维、多流并发与 DMA 计算重叠、流水线环数匹配、pinned memory 管理。每条策略都包含架构根因分析、完整代码示例和量化对比,旨在为 DCU 上的性能优化提供一份可参考的教学材料。实验部分给出了各优化步骤的加速效果:GPU kernel 时间从 8.0 秒降至 0.08 秒,总程序从 14.0 秒降至 4.2 秒。

关键词:AMD DCU;HIP;GPU 算子优化;归约;滑动窗口;流水线

*1. 引言

1.1 为什么学习 DCU 优化?

AMD DCU(Deep Computing Unit)是 AMD 面向 HPC 和 AI 场景设计的 GPU 加速器。它的编程模型 HIP(Heterogeneous-compute Interface for Portability)在语法上高度兼容 CUDA——如果你写过 CUDA,HIP 代码看起来几乎一模一样:

*但底层硬件有本质差异。最显著的区别是:NVIDIA 的调度单位是 32 线程的 warp,而 DCU 是 64 线程的 wavefront。这个 2× 的差异会传导到几乎所有优化决策上——线程块大小设多少、寄存器够不够用、分支分歧有多大代价。本文的核心目的就是帮你建立 DCU 的”硬件直觉”:知道你的代码在 DCU 上是怎么跑的,才能知道怎么让它跑得更快。

1.2 教学案例:气候态 SST 百分位计算

本文用一个真实的气候海洋学问题作为贯穿全文的教学案例。问题是这样的:

 给定 1991 年到 2020 年共 30 年的全球逐日海表温度(SST)数据,网格分辨率 0.25°×0.25°(经度 1440 格 × 纬度 721 格),对于每个海洋格点,计算每个日历日(一年中的第 152 天到第 243 天)的:

 

气候态均值:该日前后各 5 天(共 11 天)滑动窗口内,30 年所有有效 SST 的平均值

第 90 百分位:同样窗口内所有有效 SST 的第 90 百分位值

 

这个问题具备三个典型的 GPU 计算特征:

数据并行:每个格点的计算独立,天然适合 GPU

归约密集:每个格点需要从约 330 个值中找出百分位

滑动窗口:相邻日历日的窗口有 10⁄11 的重叠

数据存储为逐日的 NetCDF4/HDF5 文件,单文件约 8.3 MB,总共约 5280 个文件。输入规模约 25 GB/进程。

1.3 文章结构

第 2 节:DCU 架构详解——你写代码之前必须知道的硬件知识

第 3 节:七条优化策略,每条包含”为什么→怎么做→代码→效果”

第 4 节:实验数据汇总

第 5 节:常见陷阱与调试建议

第 6 节:总结

*2. DCU 架构特征

2.1 芯片规格速览

本文使用的 DCU 型号是 AMD MI50(架构代号 gfx906):

*2.2 Wavefront:DCU 上最重要的概念

NVIDIA GPU 的调度单位是 warp(32 线程)。DCU 的调度单位是 wavefront(64 线程)。这不是一个数字差异,它从根本上决定了你的优化策略。

什么是 wavefront?

当你在 HIP 中写 kernel<<<256, 128>>>(…) 时,你以为你在控制线程。实际上,DCU 硬件并不直接调度线程——它调度的是 wavefront(一组 64 个线程)。128 个线程的块会被拆成 2 个 wavefront,每个 64 线程,在两个不同的 SIMD 单元上执行。

线程块 (128线程):

  ├── Wavefront 0: 线程 0-63 → SIMD单元 0

  └── Wavefront 1: 线程 64-127 → SIMD单元 1

为什么 wavefront = 64 很重要?

*实践要点:

线程块大小应为 64 的倍数(128、256 最优)

避免 32、96、160 这些非 64 倍数的大小

分支分歧在 DCU 上比 NVIDIA 贵 2 倍

2.3 寄存器:最稀缺的资源

DCU 的每 CU 寄存器预算仅 64 KB。对比 NVIDIA V100 的 256 KB/CU,差距 4 倍。这意味着 DCU 上的寄存器更加紧张。

这 64 KB 被所有 resident wavefront 共享。wavefront 越少、每线程可用寄存器越多;wavefront 越多、并行度越高但每线程寄存器越少。

案例:每 CU 4 个 thread block,每 block 256 线程

→ 每 CU 线程数 = 4 × 256 = 1024

→ 每 CU wavefront 数 = 1024 / 64 = 16

→ 每 wavefront 寄存器 = 65536 / 16 = 4096 bytes = 1024 register slots

→ 每线程寄存器 = 1024 / 64 = 16 个

等等,16 个寄存器远远不够!大多数 kernel 至少需要 30-50 个寄存器。

这就是 launch_bounds 的作用。它告诉编译器你预期每 CU 有多少个 block,编译器据此算出每线程可用寄存器数,如果超过则 spill 到 local memory。

__global__ void __launch_bounds__(256, 4) my_kernel()

// 含义:最大 256 线程/块,最少 4 block/CU

// 编译器保证:65536 / (4 × 256) = 64 寄存器/线程

当你不用 __launch_bounds__ 时,编译器会取一个保守值(通常更多寄存器),导致实际 wavefront 数减少,从而降低 occupancy。反之如果设得太紧,编译器会 spill,性能更差。

后面 3.2 节会专门讲如何用 launch_bounds。

2.4 访存层次

Host (CPU) Memory

    │ PCIe 3.0 ×16 (~12 GB/s)

    ▼

DCU Device Memory (HBM2, 16 GB, ~1 TB/s)

    │

    ├── L2 缓存 (4 MB, 所有 CU 共享)

    │ │

    │ ▼

    │ ┌──────────────────────┐

    │ │ 每 CU: L1 16KB │

    │ │ 或 共享内存 64KB │

    │ │ 或 L1 16KB + 共享 48KB

    │ └──────────────────────┘

    │ │

    │ ▼

    │ 寄存器文件 (64 KB/CU)

    │ │

    │ ▼

    │ ALU 计算

层级含义:

Global memory (HBM2):大容量、高带宽,但延迟 ~300-500 cycles

L2 缓存:4 MB 片上缓存,所有 CU 共享,延迟 ~100 cycles

L1 缓存 / 共享内存:每 CU 独立,可配置为 16 KB L1 + 48 KB 共享,或全部 64 KB 共享

寄存器:最快,延迟 1 cycle,但每 CU 仅 64 KB

实践要点:

线程私有数据尽量放寄存器(即局部变量),而非 global memory

需要线程间共享的数据放共享内存

只读的大数组用 const __restrict__ 修饰,帮助编译器生成向量化 load 指令

*3. 七条算子优化策略

3.1 策略一:Wavefront 对齐与线程块配置

3.1.1 为什么需要对齐?

如前所述,DCU 以 wavefront(64 线程)为最小调度单位。如果你的线程块大小不是 64 的倍数,最后一个 wavefront 会包含空闲线程——这些空闲线程虽然不做任何计算,但仍然占用 CU 资源(寄存器、执行时间)。

3.1.2 具体做法

// ❌ 错误:非 64 倍数

int thr = 32; // 32 = 0.5 wavefront,浪费 50%

int thr = 96; // 96 = 1.5 wavefront,浪费 25%

int thr = 160; // 160 = 2.5 wavefront,浪费 20%

 

// ✅ 正确:64 倍数

int thr = 64; // 1 wavefront,完整利用

int thr = 128; // 2 wavefront

int thr = 256; // 4 wavefront(推荐)

3.1.3 线程块大小选择的权衡

*3.1.4 实例:配置我们的 kernel

// 每块 DCU 处理约 259,560 个格点

size_t sz = 259560;

 

// 用 256 线程块,需要 ceil(259560/256) = 1014 个块

int thr = 256;

int blk = (int)((sz + thr – 1) / thr); // = 1014

 

// 线程总数 = 1014 × 256 = 259,584

// 多出 24 个线程(ti >= sz 时直接 return)

// 这 24 个线程浪费可以忽略(0.009%)

kernel<<<blk, thr, 0, stream>>>(…);

*3.2 策略二:寄存器级归约与 launch_bounds

3.2.1 问题场景

在 SST 百分位计算中,每个格点需要维护一个大小为 34 的浮点堆(Top-K 最小堆)。如果这个堆存在 global memory 中,每次堆操作需要 1-2 次全局内存访问(~300 cycles),34 次值的插入就是 10,000+ cycles 的开销——太慢了。

3.2.2 寄存器级归约

将堆存储在寄存器数组中——每个线程有自己独立的 34 个 float 寄存器,堆操作(比较、交换)全部在寄存器内完成,0 次全局内存访问。

// ★ 寄存器级归约:堆在寄存器中

__global__ void __launch_bounds__(256, 4)

topk_kernel(const double* input, float* heap_out,

            size_t sz, int n_vals)

{

    size_t ti = blockIdx.x * blockDim.x + threadIdx.x;

    if(ti >= sz) return;

    

    // ★ heap[34] 全部存在寄存器中!

    float heap[34];

    #pragma unroll

    for(int i = 0; i < 34; i++) heap[i] = -1e38f;

    

    double sum = 0.0; // 也在寄存器中

    int count = 0; // 也在寄存器中

    

    // 遍历这个格点的所有值

    for(int i = 0; i < n_vals; i++){

        double v = input[(size_t)i * /*stride*/ + ti];

        if(!isnan(v)){

            sum += v;

            count++;

            

            // ★ 堆操作完全在寄存器内执行

            if(v > (double)heap[0]){

                heap[0] = (float)v;

                // sift-down:只需寄存器操作

                int pos = 0;

                while(pos < 34){

                    int left = 2*pos + 1;

                    int right = 2*pos + 2;

                    int smallest = pos;

                    if(left < 34 && heap[left] < heap[smallest])

                        smallest = left;

                    if(right < 34 && heap[right] < heap[smallest])

                        smallest = right;

                    if(smallest == pos) break;

                    float tmp = heap[pos];

                    heap[pos] = heap[smallest];

                    heap[smallest] = tmp;

                    pos = smallest;

                }

            }

        }

    }

    // ★ 整个归约过程只有两次全局内存访问:

    // 1. 读取 input[ti]

    // 2. 写回 heap_out[ti * 34 .. ti * 34 + 33]

    for(int i = 0; i < 34; i++)

        heap_out[(size_t)ti * 34 + i] = heap[i];

}

3.2.3 为什么需要 launch_bounds

heap[34] 用掉了 34 个浮点寄存器。加上 sum、count、索引变量等,总量约 45-50 个寄存器。如果不加 __launch_bounds__,编译器不知道每 CU 会有多少线程,会按最坏情况分配——可能只允许 2 block/CU,导致 occupancy 从 4 block 降到 2 block。

// 不加 __launch_bounds__:

// 编译器假设:未知线程数 → 保守分配 ~50 reg/thread

// 每 CU 线程数 = 65536/50 ≈ 1310 → 约 5 wavefront

// 每 block 256 线程 = 4 wavefront → 只能 1 block/CU

// occupancy ≈ 25% ← 差!

 

// 加 __launch_bounds__(256, 4):

// 编译器知道:256 thread/block,至少 4 block/CU

// 每线程寄存器上限 = 65536/(256×4) = 64

// 实际用 50 < 64 → 不 spill

// occupancy ≈ 100% ← 优

验证寄存器使用量:

# 用 hipcc 编译时加上 –save-temps 查看汇编

hipcc -c kernel.cpp –save-temps -o kernel.o

 

# 查看寄存器使用(在 .hsaco 或 .o 文件中)

# 搜索 "vgpr" 或 "sgpr"

# 例如:vgpr_count: 48 表示用了 48 个向量寄存器

3.2.4 寄存器 vs 共享内存 vs 全局内存

*结论:对于线程私有的归约操作,寄存器永远是第一选择。

*3.3 策略三:分支分歧消除与统计矩替代

3.3.1 为什么分支在 DCU 上特别贵?

在 wavefront 内,所有 64 个线程共享一个程序计数器(PC)。这意味着它们在同一时刻执行同一条指令。当遇到条件分支时:

if(condition){

    // path A: 线程 0-31 走这里

} else {

    // path B: 线程 32-63 走这里

}

DCU 的处理方式是:

先执行 path A,同时掩蔽(mask off)不走 path A 的 32 个线程

再执行 path B,同时掩蔽不走 path B 的 32 个线程

浪费:50% 的执行资源被掩蔽

在 NVIDIA 上(32 线程 warp):

16 线程 + 16 线程 = 50% 浪费

在 DCU 上(64 线程 wavefront):

32 线程 + 32 线程 = 50% 浪费(比例相同)

但 64 个线程被掩蔽 vs 32 个线程被掩蔽,总时间片浪费翻倍

3.3.2 堆插入的分歧问题

Top-K 堆方案中,每个值都需要判断 if(v > heap[0])——大约只有 34⁄330 ≈ 10% 的值会进入堆。这意味着 90% 的线程走 else 路径,10% 走 if 路径,分歧严重。

// ★ 分歧在这里

if(v > (double)heap[0]){ // ~10% true, ~90% false

    heap[0] = (float)v; // 仅 10% 线程执行

    // sift-down 循环(更多分支)

    while(…){ … }

}

// else: 什么都不做(90% 线程空闲)

3.3.3 统计矩:完全消除分支

统计矩方案的核心思想是:不维护精确堆,只维护 4 个统计矩。矩的累加不需要任何条件判断,所有线程执行完全相同的 4 条 FMA 指令:

// ★ 无分支、无分歧、所有线程执行相同代码

float fv = (float)v;

m1 += fv; // 一阶矩:∑x

m2 += fv*fv; // 二阶矩:∑x²

m3 += fv*fv*fv; // 三阶矩:∑x³

m4 += fv*fv*fv*fv; // 四阶矩:∑x⁴

count++;

DCU 上的执行效果:

没有 if-else → 没有 wavefront 分歧

所有线程 ALU 100% 忙碌

DCU 利用率从 ~40% 提升到 ~95%

3.3.4 从矩到 P90:Cornish-Fisher 展开

有了 4 个统计矩之后,如何得到第 90 百分位?Cornish-Fisher 展开提供了从矩到分位数的近似公式:

Step 1: 计算均值和方差

float n = (float)count;

float mean = m1 / n;

float variance = m2 / n – mean * mean;

float std = sqrtf(variance);

Step 2: 计算偏度和峰度

// 偏度 Skewness:衡量分布不对称程度

float skewness = (m3/n – 3*mean*variance – mean*mean*mean) 

                 / (variance * std);

 

// 峰度 Kurtosis:衡量分布尾部厚度

float kurtosis = (m4/n – 4*mean*m3/n + 6*mean*mean*variance 

                  + mean*mean*mean*mean) / (variance*variance) – 3;

Step 3: Cornish-Fisher 展开求 P90

// 标准正态分布的 P90 分位数

float z = 1.2816f;

 

// Cornish-Fisher 展开(取前三阶修正)

float cf = z 

         + (z*z – 1) * skewness / 6

         + (z*z*z – 3*z) * kurtosis / 24

         – (2*z*z*z – 5*z) * skewness * skewness / 36;

 

float p90 = mean + std * cf;

精度说明:对于 SST 数据,如果分布接近正态,CF 展开的误差 < 0.1°C。对于有偏分布(如洋流交汇区),误差最大约 0.5°C。在气候监测应用中,这个精度是可接受的。

3.3.5 性能对比

**3.4 策略四:加法群降维

3.4.1 问题:滑动窗口的冗余计算

SST 百分位计算中,每个 DOY 使用前后各 5 天的数据(11 天窗口)。相邻 DOY 的窗口有 10 天是重叠的:

DOY 152: [147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157]

DOY 153: [148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158]

          ↑ 这10天是重复计算的!

DOY 154: [149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159]

          ↑ 又有10天重复

传统方法中,每个 DOY 独立计算 30年 × 11天 = 330 次 FMA。12 个 DOY 就是 12 × 330 = 3960 次 FMA/格点。其中约 91% 是重复的!

3.4.2 核心洞察:矩是加法群

统计矩具有完美的加法性质:

*

也就是说,窗口的矩 = 窗口内单天矩的和。这意味着我们不需要为每个窗口独立计算——先算好每个”单天”的矩,然后用”加法 + 减法”来滑动窗口。

3.4.3 两阶段 kernel 实现

第一阶段:day_moment kernel——只算单天,不做窗口

这个 kernel 只做一件事:对每个日历日,累加 30 年数据的 4 阶矩。不涉及任何滑动窗口。

// ★ 第一阶段:单天矩累加

// 输入:d_ring(BATCH年 × ns天的数据)

// 输出:day_mom(ns天 × 每格点 × 4矩)

__global__ void

day_moment(const double* r, // 输入数据

           float* dm, // day_mom输出:ns × sz × 4

           int* dc, // day_cnt输出:ns × sz

           size_t sz, int nd, int ny)

{

    size_t ti = blockIdx.x * blockDim.x + threadIdx.x;

    if(ti >= sz) return;

    

    // 遍历所有年份和天数

    for(int y = 0; y < ny; y++) // 年份循环

    for(int d = 0; d < nd; d++){ // 天数循环

        double v = r[((size_t)y * nd + d) * sz + ti];

        if(!isnan(v)){

            float fv = (float)v;

            // ★ 累加到第 d 天、第 ti 格点的矩中

            float* m = &dm[((size_t)d * sz + ti) * 4];

            m[0] += fv; // 矩1

            m[1] += fv*fv; // 矩2

            m[2] += fv*fv*fv; // 矩3

            m[3] += fv*fv*fv*fv; // 矩4

            dc[(size_t)d * sz + ti]++; // 计数

        }

    }

}

第二阶段:slide_moment kernel——滑动窗口合成 DOY 矩

这个 kernel 利用已算好的单天矩,用前缀和方法快速合成每个 DOY 的 11 天窗口矩。

// ★ 第二阶段:滑动窗口合成

// 输入:day_mom(单天矩)

// 输出:doy_mom(每个DOY的窗口矩)

__global__ void

slide_moment(

    const float* dm, // day_mom: 单天矩

    const int* dc, // day_cnt: 单天计数

    float* ym, // doy_mom: 输出DOY矩

    int* yc, // doy_cnt: 输出DOY计数

    size_t sz, int ndo, int ns, int nd)

{

    size_t ti = blockIdx.x * blockDim.x + threadIdx.x;

    if(ti >= sz) return;

    

    // 跨 DOY 的滑动累加器(在寄存器中持续更新)

    float pm1 = 0, pm2 = 0, pm3 = 0, pm4 = 0;

    int pvc = 0;

    

    for(int di = 0; di < ndo; di++){

        if(di == 0){

            // ★ 第一个 DOY:累加 nd 天(11天)

            for(int d = 0; d < nd; d++){

                const float* m = &dm[((size_t)d * sz + ti) * 4];

                pm1 += m[0]; pm2 += m[1];

                pm3 += m[2]; pm4 += m[3];

                pvc += dc[(size_t)d * sz + ti];

            }

        } else {

            // ★ 后续 DOY:只做 加新天 – 减旧天

            int L = di – 1; // 滑出的天

            int R = di + nd – 1; // 滑入的天

            const float* mL = &dm[((size_t)L * sz + ti) * 4];

            const float* mR = &dm[((size_t)R * sz + ti) * 4];

            

            pm1 += -mL[0] + mR[0];

            pm2 += -mL[1] + mR[1];

            pm3 += -mL[2] + mR[2];

            pm4 += -mL[3] + mR[3];

            pvc += -dc[(size_t)L * sz + ti] + dc[(size_t)R * sz + ti];

        }

        // 写回当前 DOY 的结果

        float* ym2 = &ym[((size_t)di * sz + ti) * 4];

        ym2[0] = pm1; ym2[1] = pm2;

        ym2[2] = pm3; ym2[3] = pm4;

        yc[(size_t)di * sz + ti] = pvc;

    }

}

3.4.4 计算量对比

 

传统方法 ka_moment(每DOY遍历11天×30年):

  12 DOY × 30年 × 11天 × 4 FMA = 15,840 FMA

 

降维法 day_moment(每个单天遍历30年):

  22天 × 30年 × 4 FMA = 2,640 FMA

 

降维法 slide_moment(滑动窗口):

  第1 DOY: 11天 × 8 add/sub = 88 ops

  第2~12 DOY: 各 8 add/sub = 88 ops

  合计: 176 ops

 

降维法总操作: 2,640 + 176 = 2,816 ops(含FMA + 加减)

 

不对,让我们回到 kernel 的实际循环。

 

ka_moment 的双重循环:

for di(12) for y(30) for d(11) {

    m1 += v; m2 += v²; m3 += v³; m4 += v⁴;

}

= 12 × 30 × 11 × 4 = 15,840 FMA

 

day_moment + slide_moment:

day: for y(30) for d(22) { 4 FMA } = 30×22×4 = 2,640 FMA

slide: 第1DOY = 11×4 = 44 加减 + 后续11×8 = 88 = 132 加减

总 = 2,640 + 132 = 2,772 ops

 

节省 = (15,840 – 2,772) / 15,840 = 82.5%

GPU kernel 时间从 0.35s(ka_moment)降至 0.08s(day_moment + slide_moment),降幅 77%。

3.4.5 可推广性

加法群降维不仅适用于 SST 百分位计算。任何具有以下特征的滑动窗口计算都可以用:

窗口高度重叠(相邻窗口重叠率 > 50%)

统计量满足可加性(矩、直方图、计数、求和)

可推广场景:

气象数据的滑动平均、滑动方差

金融时间序列的移动窗口统计

信号处理中的短时傅里叶变换

图像处理的滑动窗口滤波

*3.5 策略五:多流并发与 DMA 计算重叠

3.5.1 背景:DCU 的 DMA 引擎

DCU 内部有两个独立工作的硬件单元:

DMA 引擎:负责 host↔device 数据传输

计算引擎:负责执行 kernel 指令

这两个引擎可以同时运行。这意味着 DCU 可以在传输数据的同时进行计算——前提是你用对了编程模式。

3.5.2 默认行为 vs 多流并发

默认(单流):

hipMemcpy(d_ring, host_ring, size, hipMemcpyHostToDevice); // 同步传输

kernel<<<…, stream>>>(d_ring, …); // 然后计算

hipStreamSynchronize(stream);

时间线:[H2D][H2D]….[Compute][Compute] → 串行执行

多流并发:

// 创建两个流

hipStream_t st_copy, st_cmpt;

hipStreamCreate(&st_copy);

hipStreamCreate(&st_cmpt);

 

// H2D 传输在 st_copy 上

hipMemcpy2DAsync(d_ring[1], dst_pitch,

    host_ring[next], src_pitch, width, height,

    hipMemcpyHostToDevice, st_copy);

 

// 计算在 st_cmpt 上(使用另一份数据)

kernel<<<grid, block, 0, st_cmpt>>>(d_ring[0], …);

时间线:[H2D…H2D] 与 [Compute…Compute] 并行 → 重叠执行

3.5.3 双缓冲 + 双流实战

// ★ 双缓冲 + 双流:H2D 和 kernel 在不同流上重叠

 

// 流 0:负责 H2D 传输(未来批次的预加载)

hipStream_t st_copy[4];

// 流 1:负责 kernel 计算(当前批次的处理)

hipStream_t st_cmpt[4];

 

// d_ring 也分两份,避免数据竞争

double* d_ring[4][2];

 

for(int b = 0; b < nb; b++){

    int cur_buf = b % 2; // 当前用哪份 buffer

    int next_buf = 1 – cur_buf; // 下一份 buffer

    

    // ★ 在当前流上启动当前批次的 kernel

    // (kernel 读 d_ring[g][cur_buf])

    ka_kernel<<<blk, thr, 0, st_cmpt[g]>>>(

        d_ring[g][cur_buf], …);

    

    // ★ 在拷贝流上启动下一批数据的 H2D 传输

    // (DMA 写 d_ring[g][next_buf])

    // ★ 和上方的 kernel 同时执行!

    HIP_CHECK(hipMemcpy2DAsync(

        d_ring[g][next_buf], dpitch,

        host_ring[next], spitch,

        width, height,

        hipMemcpyHostToDevice,

        st_copy[g])); // ← 不同流

    

    // ★ 只需要同步计算流

    HIP_CHECK(hipStreamSynchronize(st_cmpt[g]));

    // 拷贝流不需要在这里同步——下次用到 next_buf 时自然保证就绪

}

3.5.4 收益分析

*当 H2D 时间 ≈ GPU 时间时,双流重叠的加速比接近 2×。

*3.6 策略六:流水线环数匹配

3.6.1 核心矛盾

在 SST 计算程序中,文件 I/O 是比 GPU 计算慢得多的环节:

*GPU 只用了 0.15s 就处理完一批数据,但下一批数据还要 2.3s 才能读完。这就意味着 GPU 大部分时间在等数据——即我们前面测量的 HIDDEN_WAIT。

3.6.2 环数的数学推导

流水线中的每个”环”(ring buffer)经过三个阶段:

Ring 生命周期:[文件读取 (T_io)] → [H2D+GPU (T_gpu)] → [空闲]

如果有 R 个环交替使用,那么一个新批次的读取可以提前 R-1 个批次开始。只要提前开始的时间足够覆盖读取时间,GPU 就不用等。

条件:提前开始的时间 ≥ 读取时间 

*

 

最小环数: 

*

 

代入我们的数据:*,最小 17 环。

但 17 个环 × 915 MB = 15.5 GB 的 pinned memory,超出了每节点的 /dev/shm 容量。所以只能取折中。

3.6.3 实际效果

*等待时间的可视化(4环 vs 2环):

 

2环:

GPU: [批0][____等____][批1][____等____][批2][____等____]…

读: [读0] [读1] [读2]

← 每批GPU只做0.15s,等2.15s,利用率6.5%

 

4环:

GPU: [批0][批1][批2][批3][__等__][批4][批5]…

读: [读0][读1][读2][读3] [读4][读5]

← 前3批零等待,后3批少量等待,利用率约40%

3.6.4 环数实现要点

// 4 环流水线主循环

 

// 预读第 1,2,3 批到 ring[1], ring[2], ring[3]

for(int i = 1; i < 4; i++) start_read(i, i);

 

for(int b = 0; b < nb; b++){

    int ri = b % 4; // 当前使用的环

    

    // 等待当前环的数据就绪

    if(rd_batch[ri] >= 0) rd_fut[ri].get();

    

    // 用这个刚释放的环预读后面的批次

    start_read(b + 4, ri);

    

    // H2D + GPU 计算

    hipMemcpy2DAsync(d_ring, host_ring[ri], …);

    kernel<<<…>>>(d_ring, …);

    hipStreamSynchronize(stream);

}

*3.7 策略七:Pinned Memory 管理

3.7.1 为什么需要 pinned memory?

DCU 通过 PCIe DMA 引擎访问 host 内存。DMA 要求源/目标地址的物理页是锁定在内存中的(不会被交换出去)。malloc 分配的内存物理页可以被操作系统换出,因此 DMA 无法直接访问——需要 ROCm 驱动做一次中转拷贝,速度减半。

// ❌ malloc:DMA 无法直接访问

double* buf = (double*)malloc(size); // 虚拟内存,页可能被换出

hipMemcpy(d_ring, buf, size, …); // 实际走:buf→临时pinned buffer→device

 

// ✅ hipHostMalloc:DMA 可直接访问

double* buf;

hipHostMalloc(&buf, size); // /dev/shm 上的 pinned memory

hipMemcpy(d_ring, buf, size, …); // DMA 直传,无中转

3.7.2 性能差异

*对于 915 MB 的 H2D 传输:

malloc:915/4 = 228 ms

hipHostMalloc:915/12 = 76 ms

3× 差距。

3.7.3 注意事项

1. /dev/shm 容量限制

hipHostMalloc 分配的内存来自 /dev/shm(tmpfs)。总量受 /dev/shm 大小限制:

$ df -h /dev/shm

tmpfs 62G 14G 49G 22% /dev/shm

每节点 49 GB 可用。4 ring × 915 MB × 4 rank = 14.6 GB,在安全范围内。

2. 多进程共享

DCU 上使用 hipHostMallocPortable 标记可以让分配的内存被所有进程访问(适用于 MPI 场景):

hipHostMalloc(&buf, size, hipHostMallocPortable);

3. 清理顺序

// ★ 正确的清理顺序

hipHostFree(host_ring[0]); // 立即释放 /dev/shm

hipHostFree(host_ring[1]);

hipHostFree(host_ring[2]);

hipHostFree(host_ring[3]);

 

// ★ 跳过 hipFree(GPU 显存释放不是 /dev/shm 的问题)

// ★ 必须调用 MPI_Finalize(清理 PMIX 小文件残留)

MPI_Finalize();

 

// ★ 最后兜底清扫

system("rm -rf /dev/shm/* 2>/dev/null");

*4. 实验数据汇总

4.1 硬件环境

*4.2 各版本性能

*4.3 各策略加速因子

**5. 常见陷阱与调试建议

5.1 陷阱一:hipHostMalloc 静默失败

hipHostMalloc(&buf, huge_size); // 如果 /dev/shm 不够,返回非NULL但性能极差

// 应该:

hipError_t e = hipHostMalloc(&buf, huge_size);

if(e != hipSuccess) printf("pinned alloc failed, will be slow!\\n");

5.2 陷阱二:kernel 编译成功但不执行

kernel<<<grid, block, 0, stream>>>(…);

// ★ 没有返回值检查!kernel launch 可能失败。

// 应该:

kernel<<<grid, block, 0, stream>>>(…);

hipError_t e = hipGetLastError(); // launch 错误

hipStreamSynchronize(stream); // 执行错误

5.3 陷阱三:核函数访问越界

用 hipMemcheck 工具检测:

hipcc -g -O0 kernel.cpp -o kernel

hipMemcheck ./kernel

5.4 陷阱四:launch_bounds 设得太紧

__launch_bounds__(256, 4) // 承诺最多 256 线程/块,至少 4 block/CU

// 但如果 kernel 实际需要 >64 寄存器,编译器只能 spill

// 症状:kernel 编译成功但性能比不加还差

// 解决:调低 N(min blocks),让编译器分配更多寄存器

__launch_bounds__(256, 2) // 允许 2 block/CU,每线程 128 寄存器

5.5 陷阱五:忽略 device 切换

hipSetDevice(0);

hipMalloc(&ptr0, size); // 在 device 0 上分配

 

// ★ 忘了 hipSetDevice(1)!

hipMalloc(&ptr1, size); // 仍然在 device 0 上分配!

 

// 然后:

kernel<<<…, stream_on_dev1>>>(ptr1, …);

// HIP_CHECK(hipGetLastError()) → hipErrorInvalidValue

// 因为 ptr1 在 device 0 上,但 stream 在 device 1 上

*6. 总结

本文以气候态 SST 百分位计算为教学案例,系统介绍了 AMD DCU 上的七条算子优化策略。核心要点:

DCU 不是 CUDA:wavefront 64、寄存器 64K/CU、L1 16KB——这些数字决定了你的优化方向

分歧在 DCU 上最贵:64 线程 wavefront 使分支分歧的代价是 NVIDIA 的 2 倍

寄存器是命根子:善用 __launch_bounds__ + 寄存器级归约,避免 spill

数学可以替代计算:统计矩替代精确堆(-60% 计算量),加法群降维(-83% 计算量)

系统优化比 kernel 优化更重要:当 GPU 时间降到 0.08s(仅占 2%),I/O 才是真瓶颈

最终 GPU kernel 从 8.0s 降到 0.08s(99% 降幅),整体程序从 14.0s 降到 4.2s(3.3× 加速)。

*参考文献

[1] AMD, Inc. “AMD ROCm Documentation: HIP Programming Guide v5.x.” https://rocm.docs.amd.com/projects/HIP/en/latest/

[2] AMD, Inc. “AMD Instinct MI50 Accelerator Specifications.” https://www.amd.com/en/products/accelerators/instinct/mi50.html

[3] AMD, Inc. “AMD GPU Architecture: CDNA 2.” Whitepaper, 2022.

[4] Hobday, A.J., et al. “A hierarchical approach to defining marine heatwaves.” Progress in Oceanography, 141:227-238, 2016. DOI: 10.1016/j.pocean.2015.12.014

[5] Cornish, E.A. & Fisher, R.A. “Moments and cumulants in the specification of distributions.” Revue de l’Institut International de Statistique, 5(4):307-320, 1937.

[6] NVIDIA Corporation. “CUDA C++ Programming Guide.” https://docs.nvidia.com/cuda/cuda-c-programming-guide/

[7] Unidata/UCAR. “NetCCF Documentation.” NetCDF | NSF Unidata

[8] The HDF Group. “HDF5 Documentation.” https://portal.hdfgroup.org/display/HDF5/HDF5

https://zhuanlan.zhihu.com/p/2052383479850078572https://zhuanlan.zhihu.com/p/2052383479850078572(这个是本人zh发布同一文章)

赞(0)
未经允许不得转载:171主机测评 » AMD DCU 加速器上的算子优化策略教学——以气候态海温百分位计算为例
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址