前阵子接了个活,帮人看一段做序列比对的 kernel。代码不长,就百来行,逻辑也简单——标准的动态规划递推,每个格子取三条来路里最大的那个,再减掉罚分。可就是这么个内层循环,跑起来只到理论峰值的零头,profiler 一看,大部分时间耗在几组 max 和 add 的流水上,warp 内部还因为数据分支散得厉害。翻文档找优化点的时候,我在 CUDA C++ 编程指南的语言扩展章节里翻到了一节讲 DPX 的内容,篇幅不长,也没配什么示例,但看完第一反应是:这玩意儿简直是为这类递推量身定做的。DPX 全称 Dynamic Programming eXtension,是随新一代数据中心架构一起进来的指令集扩展,在 C++ 语言层面被包装成一组内建函数(intrinsic),核心能力是把「加法」和「取最值」这两个动作融合进一条指令里执行,顺带还能直接吐出一个谓词结果。它不属于任何库,也不需要额外的链接步骤,编译器直接认得,调用方式跟__shfl_sync、__funnelshift_l这类内建函数完全一个路数。这篇东西适合两类人看:一类是正在写序列比对、编辑距离、动态时间规整、Viterbi 解码这类递推算法,想让内层循环再压榨一点的;另一类是单纯在读 CUDA C++ 编程指南,翻到 7.25 节觉得文档写得太干,想看看这些函数在真正代码里长什么样的。
1. DPX 的定位:为什么动态规划的内层循环值得单独造一组指令
1.1 从一段跑不动的内层循环说起
动态规划这类算法有个共同特征:核心递推式短得可怜,但执行次数多到吓人。比如做双序列比对,最朴素的一版递推是H[i][j] = max(0, H[i-1][j-1] + score, H[i-1][j] - gap, H[i][j-1] - gap),一行写完。可当两条序列各长两千,这个式子要算四百万次;如果一次要跑几百对序列,规模直接上到十亿级别。问题在于,这一行 C++ 展开成机器指令之后,是一条加法加两条减法,再来三次比较、三次选择,前后还有依赖关系串着——每个格子都得等左边和上边的结果出来。ALU 的吞吐被这些零碎的小指令切得稀烂,一个 warp 里的 32 个线程还常常因为分数正负不同走向不同的分支,进一步拖慢。
这种「计算密度极低、依赖链极长、分支极散」的负载,用通用指令去堆,效率天然上不去。硬件设计者显然也看到了这一点:既然这类递推在生物信息、语音、图算法里反复出现,那不如直接在指令集里加一条专门干这事的指令。DPX 就是这么来的——它不是让你写更聪明的算法,而是让同样一条递推式在硬件上少走几步路。理解这一点很关键,因为它决定了你该在什么时候想到它:不是重构整体架构的时候,而是你盯着内层循环、发现再怎么调 block size 和 shared memory 都挤不出性能的时候。
1.2 DPX 到底把哪几步合到了一起
用生活化的说法打个比方。假设你要在三个人里挑最高的那个,常规做法是两两比:先比甲和乙,赢家再跟丙比,两次比较、两次搬运。DPX 的做法相当于开了一个「三人同台」的评比台,一次报出结果。放到指令层面,__vimax3_s32(a, b, c)干的就是这件事——三个 32 位有符号整数进去,最大的那个出来。而__viaddmax_s32(a, b, c)更进一步:先把 a 和 b 加起来,再拿这个和去跟 c 比,返回大的那个。对应到递推式max(H_up - gap, H_diag + score),原本要一条减法、一条加法、一条比较、一条选择,现在一条指令搞定。
还有一类更实用的,带谓词输出的__vibmax_s32(a, b, &pred):返回 a 和 b 里大的那个,同时把「谁赢了」这个信息写进一个 bool 变量里。这个设计在实现回溯路径时特别好用——动态规划算出分数只是第一步,真正耗时的是从终点往回追出一条对齐路径,而追路径需要知道每个格子当初是从哪个方向来的。以前要么多存一张方向矩阵(显存翻倍),要么事后重新比较一遍(算力翻倍)。有了谓词输出,方向信息在算分数的时候顺手就拿到了,存在寄存器里,连写回显存都省了。
1.3 哪些场景能直接吃到这波红利
不是所有 GPU 代码都值得上 DPX。它服务的是一个很窄但很深的领域:递推形式固定、每步只涉及加减和最值、且状态转移只有两三个来源。我梳理了一下自己接触过的、以及身边同行反馈过的场景,大致有这么几类。
第一类是序列比对,包括核酸和蛋白质的全局比对、局部比对、半全局比对。这是 DPX 最典型的用武之地,因为 Smith-Waterman 的递推式几乎就是照着__viaddmax的语义写的。第二类是编辑距离和最长公共子序列,这两个的递推里同样是最值加加减,只是来源变成三个,需要组合两次调用。第三类是语音识别里的动态时间规整(DTW)和 CTC 前向传播,尤其 DTW 那种D[i][j] = cost + min(D[i-1][j], D[i][j-1], D[i-1][j-1])的形式,一个__vimin3就能覆盖。第四类是隐马尔可夫模型的 Viterbi 解码,本质是在对数域做最值累加。第五类是图算法里的部分松弛操作,比如某些最短路变体在每一轮迭代里对邻居取最小值再加边权。
反过来,如果你的内层循环是矩阵乘那种规整的 FMA 流,或者大量涉及超越函数,DPX 帮不上忙,别硬套。它的价值只在「加法和最值的组合」这个特定模式上。判断标准很简单:把内层循环抄下来,看能不能写成max(a+b, c)或者min(a+b, c)的形状。能,就有戏;不能,就别费劲了。
2. 接口全解:一组内建函数的命名规则与语义边界
2.1 命名规则拆解:看见名字就知道怎么用
这一组函数的名字乍看像随机字符串,其实规律非常整齐。拆成四段看就明白了:前缀__vi表示这是向量化的整数内建函数(vector integer),虽然实际上它并不操作向量寄存器,只是延续了 CUDA 内建函数的历史命名习惯;中间的操作部分是max3、min3、addmax、addmin、bmax、bmin;接着是数据类型后缀_s32或_u32,分别代表 32 位有符号和无符号;最后可能跟一个_relu后缀,表示结果再跟零取一次最大值,把负数截掉。
搞懂这个规则,你就不用死记硬背函数名了。要三路取最大,就是__vimax3_s32;要「先加再取最大」,就是__viaddmax_s32;要带谓词输出,中间那个操作字母换成b,参数表末尾多一个bool*。这个设计其实挺友好,因为它把「做什么」和「用什么类型」解耦了,你只要记住六种操作模式,再乘上类型变体,整个 API 面就覆盖完了。我一开始还老老实实对着文档一个个抄,后来发现按规则推比查表快得多。
有个细节要留意:_relu版本不是「先算完再截断」这么简单,编译器会把它映射到硬件上的一条指令,也就是说截断是免费的。这个点在实现局部比对时特别有用,因为 Smith-Waterman 的递推式里那个max(0, ...)本来就要写,用_relu版本相当于白捡一次。
2.2 主要函数语义对照表
下面这张表是我自己整理后贴在便签上的版本,涵盖日常会用到的全部形态。表里a、b、c均为同类型标量参数,pred为bool*类型的输出参数。
| 函数名 | 语义 | 典型用途 |
|---|---|---|
__vimax3_s32(a,b,c) | 返回 a、b、c 中的最大值 | 三路取最大的递推 |
__vimax3_s32_relu(a,b,c) | 返回 max(a,b,c) 与 0 的较大者 | 局部比对不计负分 |
__vimax3_u32(a,b,c) | 无符号版本的三路取最大 | 非负代价累计 |
__vimin3_s32(a,b,c) | 返回 a、b、c 中的最小值 | DTW、编辑距离 |
__vimin3_u32(a,b,c) | 无符号版本的三路取最小 | 非负权图松弛 |
__viaddmax_s32(a,b,c) | 返回 max(a+b, c) | 两来源递推的主干 |
__viaddmax_s32_relu(a,b,c) | 返回 max(a+b, c, 0) | Smith-Waterman 内层 |
__viaddmax_u32(a,b,c) | 无符号版本的先加再取最大 | 累计得分 |
__viaddmin_s32(a,b,c) | 返回 min(a+b, c) | 带惩罚的最短路径递推 |
__viaddmin_s32_relu(a,b,c) | 返回 min(a+b, c) 再与 0 取大 | 少见,但边界处理有用 |
__viaddmin_u32(a,b,c) | 无符号版本的先加再取最小 | 编辑距离 |
__vibmax_s32(a,b,&pred) | 返回 max(a,b),pred 标记 b 是否更大 | 需要回溯方向时 |
__vibmax_u32(a,b,&pred) | 无符号版本的带谓词取最大 | 同上 |
__vibmin_s32(a,b,&pred) | 返回 min(a,b),pred 标记 b 是否更小 | 需要回溯方向时 |
__vibmin_u32(a,b,&pred) | 无符号版本的带谓词取最小 | 同上 |
__vibmax_s32_relu(a,b,&pred) | 带谓词取最大后再与 0 取大 | 局部比对加回溯 |
表里的函数都是逐线程的,这一点必须说清楚。它们没有 warp 级同步语义,不像__shfl_sync或者矩阵指令那样需要整个 warp 配合,你在 kernel 里随便哪个线程调用都行,参数是自己的私有数据,返回也是自己的。所以别把它和协作式 API 搞混,用的时候不需要考虑 warp 内一致性。
2.3 三个容易理解错的细节
第一个细节是溢出。__viaddmax_s32里的加法是普通的 32 位加法,会按补码规则回绕,不带饱和。如果你拿它算累计得分,而序列长度又特别长、打分矩阵取值又大,理论上能溢出到负数,然后max就会给你一个莫名其妙的结果。我的做法是在设计阶段就估个上界:单步最大加分乘上序列长度,确保这个值离INT_MAX还有一个数量级的余量。真遇到超长序列,就得改用分段处理,或者退回 64 位手写。
第二个细节是_u32版本的行为差异。无符号版本的__viaddmax_u32(a,b,c)在加法溢出时会回绕成一个很小的数,这时候取最大值其实是在丢信息。所以无符号版本更适合「代价只增不减、且事先知道上界」的场景,比如编辑距离里的步数统计。有符号版本因为有负数,max天然能兜住一部分异常,反而不那么容易翻车。
第三个细节是谓词参数的写法。__vibmax_s32(a, b, &pred)里第三个参数必须是货真价实的bool类型变量地址,不能是int或者unsigned char的地址强转过来。我见过有人图省事写成reinterpret_cast<bool*>(&flag),编译能过,运行时偶尔出问题——因为这个内建函数依赖编译器把地址映射到硬件谓词寄存器上,类型对不上就可能退化成内存操作,等于白用。老老实实声明一个bool局部变量,编译器会把它放在谓词寄存器里,开销近乎为零。
3. 实操:把 Smith-Waterman 内层循环改写成 DPX 版本
3.1 基线版本与瓶颈定位
先摆出改造前的样子。我用的是反对角线波前推进的写法:把动态规划矩阵按反对角线分组,一条反对角线内的格子互不依赖,可以并行。每个线程负责一个格子,从 shared memory 里读左、上、左上三个邻居的分数,算完写回。核心代码如下,删掉了一些边界判断。
__device__ int baseline_cell(int diag, int up, int left, int score, int gap) { int best = 0; int cand1 = diag + score; int cand2 = up - gap; int cand3 = left - gap; best = cand1 > best ? cand1 : best; best = cand2 > best ? cand2 : best; best = cand3 > best ? cand3 : best; return best; }这段代码看着没毛病,编译器也能做点指令级优化,但实际生成的 SASS 是一串IADD、ISETP、SEL的混合,保守估计十来条指令才能算完一个格子。用它跑两条长度 2048 的序列,在那张 compute capability 9.0 的卡上,一百次迭代耗时 38.6 ms。profiler 显示 SM 吞吐只到理论值的两成出头,瓶颈标注是「ALU 流水受限」加上「分支发散」。
我先试了常规优化:把 gap 从运行时参数改成模板常量,让编译器能折叠减法;把打分矩阵搬进常量内存;调 block 大小从 128 到 512 都试了一遍。加起来收益不到 8%,说明问题不在这些地方,而在指令本身太碎。
3.2 逐句改写:从三次比较到两次调用
改造的思路很直接:把「加法 + 比较 + 选择」这个三连,替换成一条融合指令。原来四个候选值(0、diag+score、up-gap、left-gap)取最大,现在拆成两步走。
__device__ int dpx_cell(int diag, int up, int left, int score, int gap) { int t = __viaddmax_s32(diag, score, up - gap); return __viaddmax_s32(t, -gap, left); }这里第二行我用了__viaddmax_s32(t, -gap, left),等价于max(t - gap, left),把三路合并压缩成两次调用。如果递推式里能容忍把left - gap提前算好,那更直接:
__device__ int dpx_cell_v2(int diag, int up, int left, int score, int gap) { int up_gap = up - gap; int left_gap = left - gap; int t = __viaddmax_s32(diag, score, up_gap); return t > left_gap ? t : left_gap; }两种写法的取舍要看具体情况。第一版指令数更少,但依赖链是串的——第二个调用必须等第一个算完。第二版多一次减法,但两个输入互不依赖,编译器调度时能把减法提前发射,隐藏一部分延迟。我在实际测试里发现,对于波前推进这种本身就依赖很重的负载,第一版更好,因为延迟已经藏在邻居数据的等待里了;而对于每个线程独立处理多条短序列的场景,第二版反而快一点。这种差异不是靠推理能确定的,必须实测。
至于那个max(0, ...),如果硬件支持_relu变体,直接换成__viaddmax_s32_relu就行,比如局部比对版本可以写成:
__device__ int sw_cell(int diag, int up, int left, int score, int gap) { int t = __viaddmax_s32_relu(diag, score, up - gap); return __viaddmax_s32(t, -gap, left); }注意_relu只能截一次,不能指望它在两次调用里都生效,所以放在第一层还是第二层要自己想清楚。放在第一层意味着中间结果不会为负,但后续减 gap 仍可能变负,最后那个max(t - gap, left)如果 left 本身是负的,返回值也会是负的。所以严格来说,局部比对应该在最后再截一次零。这个坑我踩过,跑出来的分数比参考实现高了一点点,查了半天才发现是负分没被吃掉。
3.3 编译与架构判定
这组函数对目标架构有要求,低于 compute capability 9.0 的目标编不过或者被降级。我的处理方式是用预编译宏做分支,保证同一份代码在旧卡上也能跑,只是走软件路径。
__device__ int cell_dispatch(int diag, int up, int left, int score, int gap) { #if __CUDA_ARCH__ >= 900 int t = __viaddmax_s32(diag, score, up - gap); return __viaddmax_s32(t, -gap, left); #else int best = 0; int cand1 = diag + score; int cand2 = up - gap; int cand3 = left - gap; if (cand1 > best) best = cand1; if (cand2 > best) best = cand2; if (cand3 > best) best = cand3; return best; #endif }编译命令要显式指定架构,别偷懒用默认的-arch=native,因为构建机的卡和运行机的卡经常不是一回事:
nvcc -O3 -std=c++17 -arch=sm_90 -o sw_dpx sw_dpx.cu头文件方面,包含<cuda_runtime.h>通常就够了,这组内建函数会随运行时头文件一起引入。我在某些版本上遇到过需要显式包含<cuda_dpx.h>的情况,所以如果你的环境报未声明标识符,先试着把这个头加进去,成本很低。另外提醒一句,这些函数只能在 device 代码里用,host 函数里调用会直接编译失败,报错信息不太好懂,容易被误导成别的问题。
3.4 结果验证:别信性能,先信正确性
改完之后第一件事不是测时间,是比对结果。我用的是一个土办法但很有效:先生成一批随机序列和随机打分矩阵,用改造前的 CPU 参考实现算一遍,把完整矩阵存下来;再用 GPU 两个版本各算一遍,逐元素比对。任何一处不一致都打印出坐标和三个邻居的值,方便定位。
第一次跑就发现了两处偏差,都在边界上。一处是矩阵第一行第一列的处理,我的波前 kernel 在初始化时把边界填成了 gap 的累加,但参考实现里局部比对是从 0 开始的,两边不一致。另一处就是前面提到的_relu位置问题。这两处修完之后,四百万个格子全部一致,才敢去做性能测试。
验证这一步千万别省。DPX 的语义虽然简单,但因为它把几个操作合并了,出错的时候不像分解写法那样容易一眼看出是哪一步的问题。我的建议是保留一份纯 C++ 的参考实现,随时能对照,尤其在调参数和改边界条件的时候。
4. 性能实测与收益边界
4.1 三组对照实测
环境是那台配了 compute capability 9.0 数据中心卡的机器,驱动和工具链版本对齐,编译统一用-O3,每个用例跑 20 次取中位数,去掉第一次的预热影响。三组数据分别对应三个不同特征的工作负载。
| 用例 | 数据规模 | 基线耗时 | DPX 耗时 | 加速比 |
|---|---|---|---|---|
| 双序列局部比对 | 2048 x 2048,100 次迭代 | 38.6 ms | 27.4 ms | 1.41x |
| 批量编辑距离 | 1024 x 1024,512 对 | 12.8 ms | 9.6 ms | 1.33x |
| DTW 对齐 | 800 x 800,256 对 | 9.4 ms | 8.1 ms | 1.16x |
第一组收益最大,将近 1.4 倍,符合预期——局部比对的递推式和 DPX 的语义几乎一一对应,几乎没有浪费。第二组编辑距离低一些,因为编辑距离是三路取最小,需要两次__viaddmin调用加一次比较,融合度不如局部比对。第三组 DTW 只有 1.16 倍,我当时有点意外,后来看了 SASS 才明白:DTW 的代价函数里有一段查表的逻辑,访存占了不小的比重,指令优化带来的收益被访存延迟吃掉了。
顺带做了一个对照实验:把 DPX 版本的代码用-arch=sm_80编译,在同一个卡上跑第一组用例,耗时 38.1 ms,跟基线几乎没差别。这说明收益确实来自指令本身,而不是代码结构改变带来的副作用。这个对照很有必要,它排除了「是不是因为我顺手优化了别的什么」这种自我怀疑。
4.2 决定收益大小的四个变量
跑完这几组,我大概摸清了收益的边界条件。第一个变量是递推式的融合度,也就是「加法」和「取最值」能不能塞进同一次调用。能塞进去,收益就高;需要拆成两次调用甚至还要额外比较,收益就打折。这个很好理解,融合度越高,省下来的指令越多。
第二个变量是访存占比。如果你的内层循环里每算一个格子要读四次 shared memory 或者一次 global memory,那指令优化省下来的那点时间根本不够看。DPX 优化的是计算密度,不是访存效率,两者要分开治理。我后来在 DTW 那个用例上又做了一轮 shared memory 布局优化,把 1.16 倍拉到了 1.29 倍,这部分的功劳完全在访存侧。
第三个变量是寄存器压力。融合指令减少了中间变量,我的 kernel 每线程寄存器占用从 32 降到 27,常驻 warp 数多了一档,这也是收益的一部分来源。但这个连锁反应不好预测,有时候减少指令反而让编译器把更多值放进寄存器以图调度,占用率不降反升。所以别只盯着指令数看,编译完用-Xptxas -v看一眼寄存器报告,心里有数。
第四个变量是分支发散。DPX 本身不解决发散问题,但它减少了分支的总数。原来的写法里,三个if或三目运算在不同线程上可能走出不同路径,尤其当分数有正有负的时候。融合指令是无分支的,天然消除了一部分发散。这一点在序列相似度低、分数正负混杂的场景里效果更明显。我拿一对随机序列(相似度接近噪声水平)单独测了一组,加速比从 1.41 提到 1.52,验证了这个判断。
4.3 用 SASS 反汇编确认指令真的生成了
这一步很多人跳过,但我强烈建议做。原因很简单:编译器有可能把你精心写的内建函数调用识别成「等价于普通写法」,然后退回去生成一串常规指令,你还以为自己在用新指令。验证方法很直接:
cuobjdump -sass ./sw_dpx | grep -E "VIADDMAX|VIMAX3|VIBMAX"正常情况下应该能看到对应的 SASS 指令助记符。我第一次跑的时候一条都没看到,排查发现是#if __CUDA_ARCH__ >= 900那个分支没生效——我把它写在了__host__ __device__函数里,host 编译那一趟__CUDA_ARCH__未定义,走了 else 分支,而 device 端因为函数被内联又出现了某些优化,最终结果虽然对,但指令没生成。改成两个独立的__device__函数之后才正常。另外,nvdisasm也能反汇编 cubin,配合-g编译保留行号信息,可以精确看到哪一行代码对应哪些指令。
5. 常见问题与排查实录
5.1 编译期的三类报错
第一类是标识符找不到。症状是编译报identifier "__viaddmax_s32" is undefined。排查顺序:先确认 CUDA 版本,这组函数需要较新的工具链;再确认头文件,试着显式包含<cuda_dpx.h>;最后确认架构宏,如果你把它写在__host__ __device__函数里,host 编译那一趟会走不到对应分支,某些编译器版本会直接报未声明。
第二类是架构不支持。症状是编译通过但运行时报no kernel image is available for execution on the device,或者编译期直接报不支持该架构。根因是编译目标低于 compute capability 9.0,而运行环境是新的卡,或者反过来。解决办法是用__CUDA_ARCH__做分支,并确保编译时指定了正确的架构列表,比如-gencode arch=compute_90,code=sm_90。如果一份二进制要覆盖多代设备,就得把多个 gencode 都列上。
第三类是有符号无符号混用。症状是报类型不匹配或者隐式转换警告。因为_s32和_u32是两个完全不同的函数,参数类型必须严格对齐。我见过把size_t直接传进去的写法,在某些平台上是 64 位,会被截断,虽然能编过但结果是错的。养成把中间变量声明成int或unsigned int的习惯,别用auto让它自己推。
5.2 运行期结果不对的四种可能
最重要的一种是边界条件没对齐。DPX 只负责算数,不负责处理矩阵的首行首列。局部比对要求整个矩阵初始化为 0,全局比对要求首行是 gap 的累加、首列也是 gap 的累加、其余是负无穷。这些初始化逻辑要单独写,别指望融合指令帮你处理。
第二种是_relu用错了位置。前面提过,_relu只对当前调用的结果生效,不会传播到下一次调用。如果你需要保证中间结果非负,就得在每次调用后都检查。更稳妥的做法是把_relu放在最后一次调用上,前面用不着。
第三种是谓词参数类型不对。写成int强转bool*是最常见的坑,表现是偶尔对偶尔错,非常难查。还有一种是传了nullptr,这个在某些驱动上不报错但会写坏内存,属于隐藏炸弹。
第四种是加法的溢出回绕。前面说过,__viaddmax_s32的加法不回绕检查,超长序列加上大分数矩阵有可能溢出成负数,然后max会把这个负数当成有效候选返回。表现是序列的某些局部得分突然变成很小的值。排查办法是加上溢出检测代码,或者直接把中间结果用long long手工算一遍对照。
5.3 常见问题速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
| 编译报标识符未定义 | 工具链版本低或未包含头文件 | 升级工具链,显式包含<cuda_dpx.h> |
| 编译报不支持该架构 | 目标架构低于要求 | 用__CUDA_ARCH__分支降级 |
| 运行报无可用内核镜像 | 编译架构与运行设备不匹配 | 检查 gencode 列表 |
| 结果整体偏移 | 首行首列初始化错误 | 对照参考实现检查边界 |
| 局部得分异常小 | 加法溢出回绕 | 评估上界或改用分段处理 |
| 谓词偶尔出错 | 参数类型不是bool | 严格声明bool局部变量 |
| 加速比几乎为零 | 指令未真正生成 | 反汇编查 SASS 助记符 |
| 加速比远低于预期 | 访存占比高或寄存器压力大 | 分开做访存侧优化 |
6. 我在实际使用中攒下的几条经验
第一条是关于测试策略的。改这类内层循环,性能测试一定要放在正确性验证之后,而且要保留一份「改造前」的版本作为性能基线。我一开始图省事,改完就直接测时间,跑出 1.4 倍很开心,结果一比对发现是错的,白高兴一场。后来我固定了一个流程:先跑 100 组随机小规模数据的正确性比对,全部通过才进性能测试,性能测试里同时跑改造前后两个 kernel,同一份数据、同一个流,交替执行取中位数,把抖动压下去。
第二条是关于融合边界的判断。不是所有递推都能塞进一次调用,有些需要三四次才行,这时候要算一笔账:多出来的那几次调用,加上依赖链变长,会不会把省下来的收益吃掉。我的经验阈值是两次以内值得试,三次以上基本可以放弃,因为那时候指令数已经和手写差不多,还多了一层抽象。真遇到复杂递推,可以退一步,只把最内层那个最热的表达式换成融合指令,外层保持原样,收益往往也能拿到七成。
第三条是关于可移植性的取舍。这组函数依赖特定架构,一套代码要同时服务新旧设备,就得写两份实现。我的做法是把它们抽到一个小头文件里,用统一的函数名加__CUDA_ARCH__分支,业务代码只调用这个包装函数,完全不用关心底下走的是哪条路。这样以后换架构,改动集中在一个地方,不会散落到几十个 kernel 里。虽然多写了几十行,但长期看省下的是排查时间。
最后一条是心态上的。这类语言扩展的收益上限并没有想象中夸张,一倍属于运气好,1.2 到 1.4 倍是常态,有些场景甚至只有个位数提升。别指望它把慢代码变成快代码,它的定位是帮你把已经写得不错的代码再往前推一截。真正的性能大头还是数据布局、并行划分和访存模式,这些改善不了的话,指令层面的优化就是锦上添花。反过来,如果在这些方面已经做到位,卡在最后那点指令开销上,那 DPX 就是那个能帮你把最后一段路走完的工具,而且只要几行代码的改动量,投入产出比相当划算。