写在前面一个 kernel 为什么慢优化前应该先看什么以及一次 kernel 改动怎样才算在工程里真正成立对 Kerminal 来说kernel 优化不是生成一段代码而是进入真实工程上下文理解代码、定位瓶颈、修改实现并通过编译、测试和性能验证完成闭环。今天我们先从最基础、也最容易被忽略的问题开始聊聊数据到底是怎么搬的。很多 CUDA kernel 在功能上看起来都没有问题。代码能跑结果正确也没有报错。但一旦开始看性能就会发现一个很典型的现象GPU 的带宽没有被真正打满。这类问题在 CUDA 开发里非常常见。它往往不是因为计算太复杂也不是因为 GPU 算不动而是因为数据在 global memory 里的访问方式不够友好。换句话说问题不一定出在“算”而可能出在“搬”。01 一个典型例子矩阵转置我们看一个最经典的例子矩阵转置。假设输入矩阵是 height × width输出矩阵是 width × height。一个直接的 CUDA 写法可能长这样这段代码逻辑没有问题。输入位置是 (y, x)输出位置是 (x, y)确实完成了转置。但性能问题也正藏在这里。02读是顺的写是不顺的先看读取在同一个 warp 里线程的 x 通常是连续变化的而 y 基本保持不变。也就是说这些线程会去读取一段连续的 global memory 地址。这种访问模式对 GPU 来说是比较友好的。内存请求可以更容易合并带宽利用率也比较高。再看写入问题出现了。同一个 warp 内x 在变化y 基本不变。代入地址计算后相邻线程写入的位置不再是连续地址而是相隔 height 个元素。如果 height 很大那么同一个 warp 里的线程会把数据写到相距很远的位置上。这会导致 global memory 写入无法很好地合并memory transaction 被拆散最终带宽利用率下降。于是就出现了一种很常见的情况这个 kernel 不是算得慢而是写得“不顺”。03GPU 不只关心你算什么也关心你怎么访问内存对 GPU 来说访存模式非常关键。一个 warp 里的线程如果访问连续地址硬件可以更高效地组织内存请求。但如果线程访问的是分散地址即使每个线程只读写一个很简单的 float实际产生的内存 transaction 也可能变多。这就是为什么很多 kernel 从代码上看很简单但性能并不好。它没有做复杂计算也没有大量分支但只要 global memory 访问方式不合理就很容易跑不满带宽。04常见优化用 shared memory 做 tiling矩阵转置的经典优化方法是 shared memory tiling。思路并不复杂先让线程块从 global memory 中连续读取一块 tile 到 shared memory然后在 shared memory 内完成转置最后再把转置后的结果连续写回 global memory。也就是说我们用 shared memory 做了一次“中转”把原本不友好的跨步访问尽量变成 global memory 上更连续、更容易合并的访问。一个常见写法是这里的 1 不是随手多开一个元素。它的作用是避免 shared memory bank conflict。因为 shared memory 也有自己的访问组织方式。如果多个线程在同一时刻访问落在同一个 bank 上的数据就会产生冲突影响性能。对转置这种访问模式来说给 tile 的第二维加一列 padding是一种常见的避免 bank conflict 的手段。05这类问题的本质矩阵转置只是一个很小的例子但它反映的是 CUDA kernel 优化里非常基础也非常重要的一类问题代码正确不代表访问模式合理计算量不大不代表 kernel 一定会快GPU 没跑满也不一定是算力不够。很多时候真正拖慢 kernel 的是数据在内存里的移动方式。所以在优化 CUDA kernel 时一个很重要的判断是这个 kernel 到底是在“算”还是在“等数据”如果瓶颈来自 global memory 访问那么优化方向就不应该先放在增加计算指令、展开循环或者盲目调整 block size 上而应该先回到 memory layout、coalesced access、shared memory tiling 这些问题上。很多 GPU kernel 的性能问题本质上不是“算不动”而是数据“走得不顺”。下篇预告下一篇我们继续看另一个更容易被忽略的问题为什么有些 kernel 优化之后性能反而更差#计算加速 #kernel #内存访问 #开发