DCU使用技术报告_上篇_初识海光DCU与HIP移植实战
海光DCU深度使用手记(上):从零开始——初识DCU与HIP移植实战
前言
今年参加MCC2026超算竞赛时,我们的赛题之一是OSTIA SST海表温度的气候态计算——用30年(1991-2020)逐日卫星观测数据,对6到8月每一天计算出气候态平均值和P90极端阈值。原始MATLAB代码在超算单节点上要跑17个半小时(62768秒),非常夸张。我们的最终优化版本跑到了9秒出头,加速比接近7000倍。
在这个过程中,除了CPU端的极限优化(滑动窗口、多进程并行I/O、异步预取流水线等),我们还专门做了一轮GPU加速——用的不是NVIDIA的卡,而是国产的海光DCU。这篇文章就基于这次实战经历,聊聊海光DCU到底怎么用、坑在哪里、跟NVIDIA GPU有什么异同。
三篇文章的结构是这样:
- 上篇(本篇):海光DCU是什么、HIP/ROCm开发环境怎么搭、从CPU代码到DCU的首次移植过程
- 中篇:DCU深度优化——显存管理、内核设计、I/O流水线、时序分块策略,以及各种踩坑记录
- 下篇:DCU研发的经验总结、与CPU方案的对比分析、给DCU初学者的实战建议
一、海光DCU到底是什么
先说个很多人容易搞混的事。海光DCU不是GPU——至少官方不这么叫。DCU的全称是Deep Computing Unit(深度计算器),本质上是一块通用计算加速卡。但从程序员的角度看,你可以把它理解成一块兼容AMD ROCm生态的GPGPU。
关键点来了:海光DCU的软件栈走的是AMD ROCm/HIP路线,不是NVIDIA的CUDA。这意味着什么?
- 你不能用
nvcc编译,得用hipcc - 你不能
#include <cuda_runtime.h>,得用#include <hip/hip_runtime.h> - 你不能
cudaMalloc,得用hipMalloc - 你不能
<<<grid, block>>>启动CUDA kernel,但你可以用完全一样的<<<grid, block>>>语法启动HIP kernel
也就是说,HIP在语法层面做了大量兼容——如果你之前写过CUDA,迁移到DCU的代码改动比你想象的小得多。很多情况下甚至可以直接写一个脚本把cuda全局替换成hip就完事。当然,实际优化的时候远没有这么简单,后面会细说。
二、我们的超算环境
先交代一下我们这次用的具体环境,方便读者有个参照:
- DCU型号:海光Z100系列(
gfx906架构,兼容ROCm) - DTK版本:DTK 25.04.4(海光的ROCm定制工具链)
- 编译器:
hipcc(基于clang,路径在/public/software/compiler/rocm/dtk-25.04.4/bin/hipcc) - 数学库:NetCDF 4.4.1 + GCC
- 调度系统:SLURM,需要
--gres=dcu:1申请DCU资源 - 节点配置:单节点4块DCU,搭配32核CPU
环境模块加载很简单,就一行:
module load rocm/dtk-25.04.4
SLURM脚本里申请DCU资源:
#SBATCH -p kshdmcc2026
#SBATCH -N 1
#SBATCH -n 4
#SBATCH --gres=dcu:1
#SBATCH --exclusive
注意这里用的是--gres=dcu:1而非NVIDIA GPU的--gres=gpu:1——SLURM通过不同的资源类型名称区分DCU和GPU,需要根据集群的具体配置来确定。一个节点上有4块DCU可用,如果任务需要多卡并行,可以把数字调大。
编译命令长这样:
/public/software/compiler/rocm/dtk-25.04.4/bin/hipcc \
-O3 -std=c++11 -fopenmp \
--offload-arch=gfx906 \
-I/opt/hpc/software/mathlib/netcdf/4.4.1/gcc/include \
-L/opt/hpc/software/mathlib/netcdf/4.4.1/gcc/lib \
-o get_climatology_gpu get_climatology_gpu.cpp \
-lnetcdf -lm
注意--offload-arch=gfx906——这个是AMD GPU的架构代号,海光DCU因为兼容ROCm所以也用这套命名。如果你用的是其他型号的DCU,这个参数可能会不一样,要看具体硬件对应的架构代号。
三、从CPU到DCU:第一次移植
我们不是一上来就直接写GPU代码的。在此之前,我们已经在CPU上做了十几轮的迭代优化(具体过程可以看我之前的优化报告),CPU版本最终稳定在9-13秒。做DCU加速的动机很简单:CPU版本虽然已经很快了,但I/O等待时间始终占大头——在共享的Lustre文件系统上,3060个NetCDF文件的读取时间波动很大,有时候I/O能占到总时间的60%以上。我们想看看GPU/DCU能不能在计算侧进一步压榨,同时通过一次性的全量预读策略把I/O波动隔离掉。
3.1 代码结构设计
第一次移植的时候,我们没有简单粗暴地把整个程序扔到DCU上。而是做了一个分阶段的流水线设计:
Phase 1 (CPU): 一次性预读全部3060个文件到CPU内存(~16GB)
Phase 2 (H2D): 单次大块上传到DCU显存
Phase 3 (DCU): 在DCU上完成全部92天的计算
Phase 4 (D2H): 单次大块下载结果回CPU
Phase 5 (CPU): 批量写出NetCDF结果文件
这个设计的核心思路是I/O和计算彻底分离——所有文件读取在计算开始前一次性完成,DCU计算期间零I/O。这样做的好处很明显:DCU的计算时间是完全确定的,不受文件系统状态影响。
对于海光DCU来说,这个策略还有一个额外的好处:虽然DCU卡上的HBM2显存带宽很高(~1 TB/s级别),但CPU到DCU的PCIe带宽是有限的。如果把数据切成小块反复搬运,PCIe开销会吃掉很多加速收益。一次大块传输才能充分利用PCIe带宽。
3.2 关键代码:HIP初始化与设备信息
拿到DCU之后第一件事,肯定是先看看手里这块卡什么配置:
#include <hip/hip_runtime.h>
int gpu_count;
hipGetDeviceCount(&gpu_count);
printf("检测到 %d 块DCU设备\n", gpu_count);
hipDeviceProp_t dev_prop;
hipGetDeviceProperties(&dev_prop, 0);
printf("DCU: %s\n", dev_prop.name);
printf("显存: %.1f GB\n", dev_prop.totalGlobalMem / 1e9);
printf("计算单元数: %d\n", dev_prop.multiProcessorCount);
hipSetDevice(0); // 选择第0块DCU
这段代码跟CUDA的cudaGetDeviceProperties几乎一模一样。实际跑出来的信息大概是:显存16GB或32GB HBM2,几十个计算单元。具体数字取决于你用的DCU型号。
3.3 HIP错误处理宏
HIP的API调用基本都返回hipError_t,每个调用都要检查返回值。跟CUDA一样,写个宏省事:
#define HIP_CHECK(call) do { \
hipError_t err = (call); \
if (err != hipSuccess) { \
fprintf(stderr, "HIP error %s:%d — %s\n", \
__FILE__, __LINE__, hipGetErrorString(err)); \
exit(1); \
} \
} while(0)
使用方法:
HIP_CHECK(hipMalloc(&gpu_pool, total_bytes));
HIP_CHECK(hipMemcpy(gpu_pool, cpu_data, total_bytes, hipMemcpyHostToDevice));
HIP_CHECK(hipDeviceSynchronize());
HIP_CHECK(hipFree(gpu_pool));
跟CUDA的区别几乎只有前缀——cuda换成hip。
3.4 显存分配与数据传输
我们这次的数据规模:30年 × 102天(DOY 147-248,窗口覆盖范围)× 1440×721格点 × 4字节(float) = 约12.7GB。加上输出缓冲区(92天×2变量×1440×721×8字节(double))约1.5GB,DCU显存总需求约14-15GB。
// DCU显存分配:输入数据池
float *gpu_pool;
HIP_CHECK(hipMalloc(&gpu_pool, N_TOTAL_GRIDS * grid_cells * sizeof(float)));
// DCU显存分配:输出缓冲区
double *gpu_clim_out, *gpu_p90_out;
HIP_CHECK(hipMalloc(&gpu_clim_out, TOTAL_DOYS * grid_cells * sizeof(double)));
HIP_CHECK(hipMalloc(&gpu_p90_out, TOTAL_DOYS * grid_cells * sizeof(double)));
// DCU显存分配:陆地掩码
unsigned char *gpu_land_mask;
HIP_CHECK(hipMalloc(&gpu_land_mask, grid_cells * sizeof(unsigned char)));
数据上传——用hipMemcpy从主机到设备(H2D):
HIP_CHECK(hipMemcpy(gpu_pool, full_data,
full_buf_cells * sizeof(float),
hipMemcpyHostToDevice));
HIP_CHECK(hipMemcpy(gpu_land_mask, land_mask,
grid_cells * sizeof(unsigned char),
hipMemcpyHostToDevice));
这里有一个优化细节不能不说:hipHostRegister。如果CPU端的缓冲区是普通malloc出来的可分页内存,H2D传输时DCU驱动要先把数据拷到内部的一个pin住的内存区域再做DMA,多了一次拷贝。我们用的是mmap分配的共享内存,配上hipHostRegister直接pin住,让DCU的DMA引擎直接读——在我们的环境里,这个操作让H2D带宽从大约6-7 GB/s提升到了13 GB/s左右,几乎翻倍。
// Pin mmap缓冲区,让DCU做DMA直接读取
HIP_CHECK(hipHostRegister(full_data, full_buf_cells * sizeof(float),
hipHostRegisterDefault));
// ... H2D传输 ...
// 传输完成后解除pin
HIP_CHECK(hipHostUnregister(full_data));
注意一点:hipHostRegister之后这块内存就不能page out了,所以传输完成之后记得hipHostUnregister,不然会一直占着物理内存。
3.5 第一个DCU Kernel
我们第一个DCU kernel是计算气候态统计的——每个格点从330个样本(30年×11天滑动窗口)中算平均值和P90分位数。写HIP kernel和写CUDA kernel在语法层面差异极小:
__global__ void temporal_batch_kernel(
const float *__restrict__ pool, // 输入数据池
const unsigned char *__restrict__ land_mask, // 陆地掩码
double *__restrict__ clim_out, // 输出:平均值
double *__restrict__ p90_out, // 输出:P90分位数
size_t grid_cells, // 总格点数
int B, // 批次包含的DOY数
int pool_offset, // 池偏移
int batch_total_days, // 批次覆盖的天数
int n_unique, // 唯一网格数
float fill_val, // 填充值
int has_fill // 是否有填充值
)
{
const int T = 16; // 每个workgroup处理16个cell
size_t c = (size_t)blockIdx.x * T + threadIdx.x;
if (c >= grid_cells) return;
for (int j = 0; j < B; j++) {
size_t out_idx = (size_t)j * grid_cells + c;
if (land_mask[c]) {
// 陆地格点直接填NaN
clim_out[out_idx] = -9999.0;
p90_out[out_idx] = -9999.0;
} else {
clim_out[out_idx] = 0.0;
p90_out[out_idx] = 0.0;
}
}
}
上面这个是简化版。完整的kernel还包含了共享内存协同加载和quickselect排序。启动方式跟CUDA完全一致:
int batch_wg = (grid_cells + 15) / 16; // workgroup数量
temporal_batch_kernel<<<batch_wg, 16>>>(
gpu_pool, gpu_land_mask,
gpu_clim_out + offset, gpu_p90_out + offset,
grid_cells, B, batch_start,
batch_total_days, n_unique,
fill_val, has_fill
);
HIP_CHECK(hipGetLastError());
HIP_CHECK(hipDeviceSynchronize());
这里<<< >>>的语法、hipDeviceSynchronize()的语义、hipGetLastError()的用法,跟CUDA完全一样。第一次在DCU上看到kernel跑通、出正确结果的时候,我的感受是:国产加速卡的程序员体验已经能做到跟NVIDIA CUDA近乎无缝切换的程度了,这比我想象的要成熟得多。
四、第一次移植的性能数据
第一版DCU程序的性能表现:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| Upfront I/O | ~12s | CPU端一次性读取3060个文件 |
| H2D上传 | ~1.1s | 12.7GB数据传到DCU显存 |
| DCU计算 | 4.4s | 92个DOY在DCU上全部算完 |
| D2H下载 | ~1.7s | 结果从DCU传回CPU |
| NetCDF写出 | ~2s | 92个结果文件写出 |
| Wall Clock | ~21.5s | 端到端 |
DCU的计算时间只有4.4秒——平均每个DOY才48毫秒。对比我们CPU版本计算阶段约12-14秒,DCU纯计算加速了约3倍。不过因为CPU版本已经把I/O和计算做了深度流水线重叠,所以端到端对比的时候,第一版GPU方案(21.5s)反而不如CPU最终版(9-13s)。这不是DCU的问题,而是I/O策略还没跟上——第一版的所有文件读取都在" upfront I/O"阶段串行等完了才给DCU喂数据。
这个发现直接引出了我们后续的优化方向(中篇会详细讲),也让我意识到一个很重要的原则:加速卡编程,瓶颈往往不在卡上,而在怎么把数据高效地喂给卡。
五、第一次移植的几点感受
-
HIP生态的成熟度超出预期。从CUDA迁移到HIP的代码改动量很小,很多地方直接替换前缀就行。
hipcc编译器用起来跟nvcc体验接近,编译速度也不慢。 -
海光DCU跟AMD ROCm的兼容性做得不错。我们用AMD的ROCm文档来查API基本没问题,DTK工具链的命令行参数也跟ROCm保持一致。这对于习惯了查英文文档的开发者来说很友好。
-
DTK版本很重要。我们用的是DTK 25.04.4,不同版本的DTK可能在
--offload-arch支持上有差异。如果你用的是更早的版本(比如23.x),可能需要不同的编译参数。 -
显存是实实在在的限制。我们的数据规模需要约15GB显存,刚好卡在16GB DCU的极限。如果你的数据更大,可能得做显存优化——分批次处理或者用统一内存(managed memory)。这也是我们中篇要重点讨论的话题。
-
hipHostRegister的效果很明显。如果你的数据是mmap或者malloc出来的大块连续内存,配上host register让DCU DMA直接访问,H2D带宽可以提升近一倍。
下一篇(中篇)我会展开讲DCU的深度优化——包括时序分块策略怎么把计算效率再翻倍、kernel内部用共享内存做协同加载、以及我们在DCU上踩过的各种坑:quickselect在GPU上的实现细节、显存不足怎么处理、多进程I/O和DCU的配合等等。都是真实踩过的坑,qwq。
更多推荐


所有评论(0)