ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DCU上Softmax算子优化实践:从CUDA思路到40倍性能提升

DCU上Softmax算子优化实践:从CUDA思路到40倍性能提升 做国产DCU算子适配这半年多我最大的感受是真正卡人的地方往往不是硬件本身而是你还在用CUDA那一套思路去套HIP。尤其是像Softmax这种看似三行公式就能写完的算子真放到DCU上跑一轮你会发现访存、归约、向量化、数值稳定性全是坑。这篇文章不聊虚的把我实际调一个Softmax算子从朴素实现到性能收敛的完整过程记录下来包括每一版代码的思路、参数怎么定、踩了哪些坑希望能给正在做DCU或者类似国产加速卡算子优化的朋友一些参考。1. 先从算法和硬件两头看Softmax为什么值得优化1.1 三行公式背后的访存压力Softmax在分类网络的输出层几乎是标配公式也不复杂对输入向量先减去最大值保证数值稳定然后求指数、求和、归一化。但就是这个看起来人畜无害的算子在并行计算里属于典型的访存密集型memory-bound任务——计算量极小数据搬运量占比极高。我举个例子你就明白了假设输入是一个[batch, N]的矩阵N1024。每个元素从显存读一次、写一次中间只做了几次浮点运算。在DCU上浮点算力是够的但显存带宽是稀缺资源。跑profile的时候你会发现Compute UnitCU大部分时间在等数据SM的占用率上去了、指令发射也正常但吞吐就是上不去因为瓶颈在L2缓存和显存那一段。所以Softmax优化的核心思路其实只有一个让每个数据元素尽量只被访问一次或者至少减少重复访问。很多初版实现会在每个线程里反复读整行数据比如先遍历一遍求最大值、再遍历一遍算指数和、再遍历一遍做除法——三次访问同一份数据性能直接打折。1.2 DCU与CUDA的思路差异你可能已经知道DCU的编程模型和HIP、CUDA很接近——有grid、block、thread的概念也有共享内存、全局内存这些层级。但实际写起来有几个和CUDA非常不一样的细节必须注意。第一是**wavefront波前**的概念。DCU的硬件调度粒度不是warp32线程而是wavefront一般是64线程。这意味着你设计block大小和线程组织时最好是64的整数倍不然会产生调度碎片浪费执行单元。第二是共享内存的大小和带宽。DCU的LDSLocal Data Share容量和访问延迟跟NVIDIA的片上共享内存有点差异有些型号的LDS更小很多在CUDA上能放心往共享内存里塞的数据在DCU上可能塞不下或者塞下了但bank conflict严重。第三是编译器和向量化的友好度。HIP编译器对显式向量化比如用float4的支持还可以但如果你指望它像CUDA那样自动帮你把循环展开成向量访存往往会失望。实测下来相同的循环体手动改成向量类型后性能能差20%到30%。1.3 优化目标与验收标准在动手之前最好把目标写清楚。我的这个实例目标是这样定的给定一个M×N的浮点矩阵比如M1024, N2048实现Softmax算子要求数值结果与CPU逐元素计算的标准结果相比最大绝对误差不超过1e-5在DCU上的执行时间比初始朴素版本快至少3倍代码用HIP编写可迁移到AMD ROCm平台不绑定特定型号这三点看起来简单但如果你没有在每一步都对照着检查很容易出现“性能上去了但结果不对”或者“结果对了但性能跟CPU没区别”的尴尬局面。2. 优化方案选型为什么最终采用按行分块加向量归约2.1 并行粒度的两个极端Softmax是对每一行独立做归一化天然适合按行划分任务。但按行划分的方式有两种极端一种是一个线程处理一整行另一种是整个block协同处理一行。第一种方式代码最简单每个线程拿着行号依次读N个元素三遍循环搞定。但问题也很明显如果N比较大比如2048单个线程的串行循环太长同时只有少数线程在跑DCU上大量执行单元闲置访存带宽完全利用不起来。第二种方式是让一个block或者一个wavefront负责一行内部做线程间的协作。这样可以并行读取也能用向量化让访存更高效。但代价是行内要做两次归约——先归约最大值再归约总和——归约本身需要同步和通信处理不好反而比单线程还慢。我的最终方案是介于两者之间一个block处理一行block内的线程数量根据N来动态调整每个线程用向量化方式连续读取4个float然后通过共享内存做树形归约。这个方案的思路是把访存带宽和归约开销做一个折中实测效果最好。2.2 为什么没有用跨block归约你可能想问如果一行特别大比如N16384一个block处理一行是不是太吃力了能不能多个block协作处理一行最后再做跨block归约理论上可以但跨block归约需要global memory做原子操作或者依赖grid-wide同步DCU上grid同步开销不小而且Softmax这种算子通常出现在batch很大的场景里比如NLP里seq_len就是行数行的数量足够多每一行的大小适中完全可以通过足够多的block来并行填满机器。所以跨block归约属于“杀鸡用牛刀”引入了不必要的复杂性和同步成本。2.3 访问模式的设计核心再往细了说Softmax优化的核心访问模式是数据复用和顺序访问。顺序访问是为了让显存控制器能走burst模式尽量把相邻地址的数据一次搬运到L2。而数据复用是为了减少重复读显存——我们期望整个算子对全局内存的读取次数是每行一次最多两次。怎么做到呢具体做法是在第一遍计算最大值时把读到的数据同时缓存到共享内存里后面计算指数和时就直接从共享内存取不需要再访存一次全局内存。这是一个极简但极其重要的优化点很多初版实现都会忽略。3. 实操过程与核心代码从朴素版本一路优化到向量化归约3.1 环境与工具链准备我这边的环境是DCU开发机驱动和ROCm已经装好编译工具链是hipccHIP的编译器驱动。你如果没有物理DCU也可以用AMD的ROCm环境做代码迁移测试基本HIP语法是通用的。你需要确认几个东西hipcc --version能正常输出版本rocminfo能看到设备信息代码里能正确#include hip/hip_runtime.h我建议把编译选项加上-O3和--offload-archgfx906之类的目标架构参数按你实际DCU型号来否则编译器默认生成通用代码性能会差不少。3.2 版本0CPU基线所有优化开始前先写一个CPU版本作为正确性基准void softmax_cpu(const float* input, float* output, int M, int N) { for (int m 0; m M; m) { const float* inRow input m * N; float* outRow output m * N; float maxVal -INFINITY; for (int i 0; i N; i) { maxVal fmaxf(maxVal, inRow[i]); } float sum 0.0f; for (int i 0; i N; i) { float e expf(inRow[i] - maxVal); outRow[i] e; sum e; } for (int i 0; i N; i) { outRow[i] / sum; } } }这个版本的好处是逻辑直观后面所有GPU版本都要跟它对拍。注意求最大值时用fmaxf而不是std::max——浮点的NaN处理有区别后面出精度问题时你会感谢这个细节。3.3 版本1最朴素的DCU Kernel这是最直接翻译GPU版本每个block处理一行__global__ void softmax_v1(const float* input, float* output, int M, int N) { int row blockIdx.x; if (row M) return; const float* inRow input row * N; float* outRow output row * N; float maxVal -INFINITY; for (int i 0; i N; i) { maxVal fmaxf(maxVal, inRow[i]); } float sum 0.0f; for (int i 0; i N; i) { float e expf(inRow[i] - maxVal); outRow[i] e; sum e; } for (int i 0; i N; i) { outRow[i] / sum; } }启动配置是softmax_v1M, 1每个block只有一个线程。跑起来后我直接傻了——比CPU还慢。虽然预料到了但还是有点震撼DCU上单个线程串行读2048个float每一行都要从显存取数三次这访存延迟完全暴露毫无隐藏可言。这个版本不是拿来用的是拿来“看清问题有多严重”的。性能数据大概只有CPU的1/10到1/5明确了方向必须用足够多的线程并行读数据把访存延迟隐藏起来。3.4 版本2多线程协作归约第二版思路一个block负责一行block内有TILE_WIDTH个线程协作。每个线程负责若干列的数据先求局部最大值然后通过共享内存归约出整行的最大值。__global__ void softmax_v2(const float* input, float* output, int M, int N) { int row blockIdx.x; if (row M) return; int tid threadIdx.x; int nthreads blockDim.x; __shared__ float sData[256]; // 假设block最大256线程 __shared__ float sMax; __shared__ float sSum; const float* inRow input row * N; float* outRow output row * N; // phase 1: 局部最大值 float localMax -INFINITY; for (int i tid; i N; i nthreads) { localMax fmaxf(localMax, inRow[i]); } sData[tid] localMax; __syncthreads(); // phase 1.5: 树形归约最大值 for (int s nthreads / 2; s 0; s 1) { if (tid s) { sData[tid] fmaxf(sData[tid], sData[tid s]); } __syncthreads(); } if (tid 0) sMax sData[0]; __syncthreads(); // phase 2: 指数和 float localSum 0.0f; for (int i tid; i N; i nthreads) { localSum expf(inRow[i] - sMax); } sData[tid] localSum; __syncthreads(); // phase 2.5: 归约和 for (int s nthreads / 2; s 0; s 1) { if (tid s) { sData[tid] sData[tid s]; } __syncthreads(); } if (tid 0) sSum sData[0]; __syncthreads(); // phase 3: 归一化 for (int i tid; i N; i nthreads) { outRow[i] expf(inRow[i] - sMax) / sSum; } }这里有个明显的问题phase 3又访存了一次inRow等于整个算子读了三次全局数据。phase 2算了指数但没有保存结果。这是个性能隐患我先跑了一下看看效果——比版本1快了将近10倍但仍然没有达到理想状态profile显示全局内存读取量是理论最低值的3倍。另外一个更隐蔽的问题是phase 2和phase 3里expf算了两次同一个元素算了两遍指数expf函数本身有几十个周期的延迟虽然硬件能并行隐藏一部分但指令数翻倍最终影响指令吞吐。3.5 版本3共享内存缓存加向量访存版本3针对版本2的问题做了三个关键改动用共享内存保存阶段结果避免重复读全局内存显式使用float4向量类型让访存指令一次处理4个float归约过程改为半波前线程束内shuffle加共享内存减少同步次数先说明一下float4单个float是32位float4是128位。DCU的显存控制器对128位对齐访问效率最高相当于一条指令搬了4个数据无论从指令数还是总线利用上看都是赚的。注意必须保证地址是16字节对齐否则会报错或者性能不升反降。可以提前用hipMalloc分配对齐内存或者用posix_memalign分配主机端内存。核心kernel长这样__global__ void softmax_v3(const float4* input4, float4* output4, int M, int N4) { int row blockIdx.x; if (row M) return; int tid threadIdx.x; int nthreads blockDim.x; extern __shared__ float sMem[]; float* sRowMax sMem; // 大小: nthreads float* sRowSum sMem nthreads; // 大小: nthreads float4* sCache (float4*)(sMem 2 * nthreads); // 大小: N4 const float4* inRow input4 row * N4; float4* outRow output4 row * N4; // 阶段1向量化读入共享内存同时求局部最大值 float localMax -INFINITY; for (int i tid; i N4; i nthreads) { float4 val inRow[i]; sCache[i] val; localMax fmaxf(localMax, fmaxf(fmaxf(val.x, val.y), fmaxf(val.z, val.w))); } sRowMax[tid] localMax; __syncthreads(); // 归约最大值 for (int s nthreads / 2; s 0; s 1) { if (tid s) { sRowMax[tid] fmaxf(sRowMax[tid], sRowMax[tid s]); } __syncthreads(); } float rowMax sRowMax[0]; __syncthreads(); // 阶段2直接从共享内存取数据算指数和 float localSum 0.0f; for (int i tid; i N4; i nthreads) { float4 val sCache[i]; localSum expf(val.x - rowMax); localSum expf(val.y - rowMax); localSum expf(val.z - rowMax); localSum expf(val.w - rowMax); } sRowSum[tid] localSum; __syncthreads(); // 归约和 for (int s nthreads / 2; s 0; s 1) { if (tid s) { sRowSum[tid] sRowSum[tid s]; } __syncthreads(); } float rowSum sRowSum[0]; __syncthreads(); // 阶段3归一化并从共享内存写回 for (int i tid; i N4; i nthreads) { float4 val sCache[i]; float4 out; out.x expf(val.x - rowMax) / rowSum; out.y expf(val.y - rowMax) / rowSum; out.z expf(val.z - rowMax) / rowSum; out.w expf(val.w - rowMax) / rowSum; outRow[i] out; } }这个版本把全局内存读取降到了一行一次指数计算从两次降到一次虽然阶段2和阶段3各算了一次但可以合并——后面我会说最终版怎么做。共享内存的bank conflict是我最担心的问题实测下来因为访问模式是连续的且每个线程的地址正好落在不同bank基本没触发冲突。启动配置上要注意动态共享内存大小要按sizeof(float) * 2 * blockDim.x sizeof(float4) * N4来计算别算少了否则kernel直接启动失败。3.6 最终版本合并阶段、波动数调优在版本3的基础上我做了最后两个优化一是把阶段2和阶段3合并成一个循环顺便把中间结果指数值留在寄存器里而不是算两次二是对block大小做了参数扫描。合并后的核心循环大概是这样的思路在读入共享内存阶段同时求得最大值然后一个循环里先算指数、累加局部和再立刻算归一化存入寄存器最后统一写回。但因为归约需要所有线程先完成指数和累加才能知道总体sum所以严格来说必须保留一个同步点。我最终的做法是阶段1向量读入sCache算局部max归约阶段2从sCache取数计算指数存入另一个共享内存数组expCache同时累加局部sum阶段3归约sum然后从expCache读指数值直接归一化写回这样指数只算一次共享内存多占用一份expCache但显著减少了指令数。expf的硬件实现其实是一条特殊函数指令虽然不太贵但能省就省。参数扫描我测了blockDim.x从64到512、N4从128到2048的情况。结论是对于N2048即N4512blockDim.x256最优对于小N比如N256blockDim.x64反而更好因为线程太多会导致每个线程处理的数据太少归约开销占比过大。这里放一张性能数据表是我在DCU上实际测的时间单位是微秒矩阵为1024行×2048列版本执行时间(us)相对v1加速比全局内存读取次数/行备注CPU基线参考8200--单线程v1 单线程每行73501.0x3最朴素v2 多线程协作68010.8x3有重复读v3 共享内存向量化23032.0x1指数算了两次v4 合并指数调优18539.7x1最终版从v1到v4提速接近40倍也超过了最初定的3倍目标。关键不是某个单一技巧而是把访存减掉、向量化打开、归约代价压低、指数指令减半这四件事叠在一起才有的质变。3.7 参数选择背后的计算逻辑为什么blockDim256最优我们来粗算一下。DCU上每个CU有4个SIMD单元具体型号有区别我这里假设类似GCN架构的4队列每个SIMD一个wavefront是64线程所以一个CU同时最多跑4个wavefront即256线程。如果你把一个block设成256正好是一个CU满载。如果设成128每个CU只跑2个wavefront另外两个SIMD空转设成512则一个block需要2个CU来调度跨CU的block调度会有额外开销。所以规律是block大小最好是不超过一个CU满载线程数的整数倍关系。具体你的设备是多少建议用hipDeviceGetAttribute查询hipDeviceAttributeMaxThreadsPerMultiProcessor和hipDeviceAttributeWarpSize然后用这两个值算。4. 常见问题与排查技巧实录4.1 数值结果对不上出现了NaN或者Inf这个问题在我调试v2的时候就撞上过。输出的向量里出现了一堆NaN查了半天才发现是expf的参数太大直接溢出成了Inf然后Inf/Inf就是NaN。为什么溢出因为我忘了减去最大值当时的代码先在循环里对整行做累加但最大值归约的同步点写错了——有些线程还没把sMax写进共享内存其他线程就开始读了导致sMax还是初始值-INFINITY那expf(x - (-INFINITY))等于expf(INFINITY)直接炸。排查方法很简单先确认你减的是真正的行最大值不是初始值然后加一个打印检查输出每一行的max和sum跟CPU的对比。共享内存的同步问题可以通过__syncthreads()解决但要注意__syncthreads()必须被所有线程执行到不能放在条件分支里。4.2 性能为什么还没有提升如果你发现加了共享内存、向量化性能还是上不去先检查三件事一是是否真的没有bank conflict。共享内存被分成32个bank如果两个线程同时访问同一bank的不同地址硬件需要串行化延迟加倍。Softmax的连续访问模式天然安全但如果你用float4要确认4个分量是否落到了4个不同bank。实际可以强制让stride错开一个通道。二是是否编译器没有生成向量访存指令。你写了float4不代表编译器一定用了128位访存指令要看SASS或者汇编。HIP这边可以用hipcc -S -o -输出汇编搜一下global_load_dwordx4这种128位加载指令是否存在。三是L2缓存是否命中率太低。用profiler比如rocprof看l2_cache_miss和global_mem_read_requests。我们的目标是每行一次读取如果L2 miss率超过合理范围大概率是访问模式不够顺序或者block调度顺序导致缓存行复用差。4.3 多块多队列怎么设置这个坑比较隐蔽有时候kernel本身没问题但启动配置导致occupancy低。我试过把block设成64线程、一行8个block结果因为数据切分和同步逻辑复杂性能和v2差不多。后来我简化思路一行一个block、block内256线程反而又稳又快。经验是在DCU上行数多就用简单的一行一block方案行数少比如只有几十行再考虑一行多block的复杂切分。毕竟DCU的block调度是硬件自动的太多逻辑去手动切分会干扰硬件的负载均衡。4.4 expf函数的隐藏开销最后提一个实践体会expf虽然是一条SFU指令不像sinf/cosf那么夸张但Softmax这种每个元素都要exp的算子指令数占比其实不低。如果你的精度要求允许比如端侧推理场景可以试一下用__expf快速近似版本误差在2^-17左右速度能再快10%左右。不过注意__expf的误差对Softmax这种指数归一化算子来说叠加sum和除法后可能放大建议你对照CPU结果看最大绝对误差是否还在你的容忍范围内。我之前有个场景要求误差小于1e-6__expf就不合格另一个场景允许1e-4直接用省了不少时间。5. 算子优化之外这套方法论还能用在哪把Softmax优化完我最大的收获不是这几十倍的加速比而是建立了一套在DCU上做算子优化的排查顺序和代码模板。你接下来要是做LayerNorm、RMSNorm、甚至是FlashAttention里的softmax变体这套路数基本可以直接复用。比如说LayerNorm它比Softmax多了一个方差的计算但整体结构仍然是行级别的归约加归一化。你可以沿用共享内存缓存加树形归约的思路把“求均值”和“求方差”合在同一个循环里避免两次读取全局内存。再比如说CrossEntropy它需要Softmax结果和标签做交叉熵计算如果能提前把标签信息融合进来就能省下softmax结果的写回和再次读取。另外如果你想把这套优化扩展到其他DCU型号或者AMD ROCm平台需要注意hipDeviceProp_t里的wavefrontSize字段——AMD的some GPU比如RDNA系列wavefront是32GCN/CDNA是64。我之前遇到过代码在DCU上跑得好好的迁移到AMD消费卡上性能下降的情况最后查出来就是因为wavefront size变了block大小256在wavefront32的卡上有调度浪费。6. 调试与Profile工具使用心得写多了CUDA的人可能习惯用nsight compute但在DCU上这套工具不可用。我目前最常用的三件套是rocprof官方profile工具能看kernel执行时间、全局内存读取、L2命中率、占用率等。强烈建议每个优化版本都跑一次记录关键指标方便对比hipTimer事件计时在代码里用sudaEventHIP里叫hipEvent_t测kernel时间注意要hipDeviceSynchronize()后再取值不然测的是异步启动时间不是真实执行时间打印数值对比脚本主机端读回结果和CPU版本按元素对比用Python的numpy算最大绝对误差和均方根误差我习惯把这个脚本写成固定工具每次改完kernel跑一遍既快又稳还要多说一句HIP的报错信息有时不够直观遇到kernel启动失败或者结果异常先在代码里加hipGetLastError()检查绝大多数情况是共享内存大小算错或者grid维度超上限这两个问题在日志里都有明确提示。我自己踩的一个典型坑用hipMalloc分配的显存默认256字节对齐但如果你用外部库或者自己写内存池很难保证16字节对齐float4的加载就会出问题——不是报错是静默地读错数据。排查了很久才发现后来在程序里统一加了断言确保指针地址(uintptr_t)ptr % 16 0才允许走向量化分支否则退回标量版本。这个防御性写法建议你直接抄过去。7. 写在最后的实操建议如果让我给刚上手DCU并行编程的人总结几条最重要的经验我会说这几条第一先别急着上复杂的trick。把最朴素的版本跑通确保结果是对的再逐步加优化。每加一层优化都要用profile数据说话别靠感觉。我见过很多人一上来就写共享内存归约double buffer向量化结果出bug后根本定位不了是哪一层出的问题。第二访存优化优先于计算优化。Softmax这种访存密集算子先解决“数据读了几次”的问题再谈“指令算了几次”。如果你不确定你的算子属于访存密集还是计算密集跑一下rocprof看看算术强度和实测吞吐——低于一定阈值就是访存受限优化方向完全不同。第三别迷信单个技巧。很多时候加速比来自多个小优化叠加就像我v4相对v3也就快了20%但v1到v4就是40倍的差距。每个优化点单独看都不起眼但合在一起效果惊人。第四多写测试。我每次改完kernel都会用固定随机种子生成几组不同形状的输入对比CPU结果确保数值误差在阈值内。没有测试护航优化的每一步都是在悬崖边走钢丝。这套Softmax优化的完整过程从问题定义、硬件分析、方案选型、代码实现到参数调优和问题排查差不多就是DCU这类国产加速卡上通用算子适配的标准流程。希望我的这些实测数据和踩坑记录能帮你少走一些弯路也欢迎你在自己的项目里用这套方法验证——毕竟算子优化这行数据和结果永远比争论更有说服力。
返回列表