实战现代 CUDA 工具箱:逐步优化指南
- 如何借助现代 CCCL API 与 Compute Sanitizer 快速定位索引 bug
- 如何利用 NVTX 增强 Nsight Systems 性能分析
- 如何在 block 级和 device 级使用 CUB 优化算法
- 如何通过池化容器管理 GPU 内存
- 如何通过 pinned 容器加速主机到设备的传输
- 如何通过为每个线程分配独立 stream 及异步传输来并行化 GPU 工作
作为本文的配套资源,我们提供了代码,并支持在Google Colab上运行。
起点:图像处理流水线示例
输入为红、绿、蓝三通道图像流,首先将数据从 CPU 传输至 GPU,随后将其从 RGB 转换为灰度图。
然后,对于图像中每个 32×32 像素的图块,通过排序像素并选取中间值来计算中位数。最后,将每个图块的中值复制回 CPU。
基础代码示例
下面是完整的起始代码。本文的每个优化步骤都建立在此基础上。
```cpp #define CUDA_CHECK_ERROR(call) do { \ cudaError_t err = call; \ if (err != cudaSuccess) { \ std::cerr << "CUDA error in " << __FILE__ << " at line " << __LINE__ << ": " \ << cudaGetErrorString(err) << std::endl; \ std::exit(EXIT_FAILURE); \ } \ } while (0) // 图像像素的类型别名 using pixel_t = uint8_t; // 将 RGB 三通道图像合并为单通道灰度图像的 kernel __global__ void computeRGBToGray(const pixel_t* d_image_r, const pixel_t* d_image_g, const pixel_t* d_image_b, pixel_t* d_image_gray, int width, int height) { // 计算线程在网格中的全局索引 const int x = threadIdx.x + blockIdx.x * blockDim.x; const int y = threadIdx.y + blockIdx.y * blockDim.y; // 边界检查,仅处理图像范围内的线程 if (x < width && y < height) { // 计算线程在图像中的索引 const int i = x + y * width; // 将 RGB 转换为灰度并存入全局内存 d_image_gray[i] = static_cast这段代码首先定义了两个内核:
computeRGBToGray从红、绿、蓝三张输入图像读取像素值,转换为灰度并写入输出图像。computeMedian计算输入灰度图像每个图块的中值。每个线程块将图块从全局内存加载到共享内存,随后由一个线程对数组排序,将中值(位于中间索引处)写入全局输出中值数组。
在 main 函数中,定义了示例所用的常量后,分别为每幅图像和中值数组分配了 CPU 内存。
接着使用 OpenMP 并行对三张图像依次运行图像处理流水线。流水线首先在 GPU 上分配所需内存,然后将数据从 CPU 传输到 GPU,之后启动 RGB 转灰度和计算中值的两个内核。最后将中值结果复制回 CPU 并释放内存。
这段代码存在若干缺陷,接下来将逐步修复。
1. Compute Sanitizer 与 CCCL API:轻松发现 bug,写出更安全的代码
我们先来运行这段代码。
code_steps$ ./build/0_base_error_example CUDA error in 0_base_error_example.cu at line 105: an illegal memory access was encountered
虽然代码中包含了一些错误检查,但遇到"illegal memory access"这类错误信息时,应首先使用 compute-sanitizer 进行深入排查。
借助 Compute Sanitizer(NVIDIA 功能正确性检查套件),我们可以直接定位到一个不易察觉的 bug:
$ compute-sanitizer ./build/0_base_error_example ========= COMPUTE-SANITIZER ========= Invalid __shared__ write of size 1 bytes ========= at void computeMedian<(int)32, (int)256>(unsigned char *, unsigned char *, int, int)+0x170 in 0_base_error_example.cu:55 ========= by thread (0,3,0) in block (20,0,0) ========= Access at 0x6440 is out of bounds
直接运行上述命令即可发现,0_base_error_example.cu 第 55 行存在共享内存越界写入问题。
tile[index] = d_image_gray[index];
代码在 shared memory 中错误地使用了全局索引来加载数据。由于 shared memory 是在 thread block 层面定义的,我们需要修改索引方式。为了避免索引错误,CCCL 中引入了新的 API 来区分全局索引和 block 级索引。使用它的第一步是用新的 cuda::launch API 启动 kernel:
auto config = cuda::make_config(cuda::block_dims(...), cuda::grid_dims(...)); cuda::launch(stream, config, kernel_name<decltype(config)>, input)
然后在 kernel 内部使用新的索引 API:
template <typename Configuration>
__global__ void kernel_name(Configuration config, ...) {
// 获取并展开每个全局索引
const auto [x, y, z] = cuda::gpu_thread.index(cuda::grid, config);
// 获取 block 索引结构(包含 block_idx.x、.y、.z)
const auto block_idx = cuda::gpu_thread.index(cuda::block, config);
}
如果不使用 compute-sanitizer 或新 API,也可以用 cuda::std::span 或其多维变体 cuda::std::mdspan 替代裸指针来直接发现这类错误。cuda::std::span 和 cuda::std::mdspan 是对连续内存的非所有权视图,可用于抽象掉具体容器类型。通过 span 访问数据比裸指针更安全,因为在调试模式下越界访问会触发断言。
kernel 应更新为:
// 二维 mdspan 的别名 template <typename T> using span_2d = cuda::std::mdspan<T, cuda::std::dims<2>>; template <typename Configuration> __global__ void computeMedian(..., span_2d<const pixel_t> d_image_gray, ...)
用这些改动运行代码后,你会看到类似如下的输出:
$ ./build/1_span libcudacxx/include/cuda/std/__mdspan/mdspan.h:436: operator(): block: [16,0,0], thread: [0,30,0] Assertion `mdspan: operator() out of bounds access` failed.
shared memory 中的内存访问也应通过 cuda::shared_memory_mdspan 加以保护,如下所示:
__shared__ pixel_t shared[TILE_WIDTH * TILE_WIDTH]; cuda::shared_memory_mdspan tile_2d(shared, TILE_WIDTH, TILE_WIDTH);
现在运行代码,一切应该能正常执行而不出错。
借助新的启动API及其索引机制、span(包裹原始指针),以及compute-sanitizer,越界访问要么根本不会发生,要么会被立即捕获。更多compute-sanitizer的信息,请参阅高效CUDA调试:如何使用NVIDIA Compute Sanitizer追踪Bug。2. Nsight Systems 与 NVTX:对代码进行规范基准测试
现在代码已无Bug,可以借助NVIDIA Nsight Systems进行基准测试。它能可视化程序时间线,让你清楚每个函数何时被调用、执行了多久。
为了让时间线可视化更清晰,我们用NVTX将各个关键代码段包裹起来:
void image_compute(...)
{
// 整个函数的NVTX作用域
nvtx3::scoped_range fun_scope("Image compute");
// 对特定代码段进行push/pop的NVTX范围
nvtxRangePushA("Kernel median");
// 启动GPU内核计算图像每个瓦片的像素中值
...
// 在特定代码段结束时pop该范围
nvtxRangePop();
}
得到的结果如下:
从图3的GPU硬件(CUDA HW)分析结果可以看到,GPU主要忙于内核计算(占GPU时间的98.5%),而内存操作仅占1.5%。
其中,中值计算内核占用了绝大部分运行时间,每个图像耗时2.1秒(见右侧黄色框,显示computeMedian的统计信息,耗时2.142秒)。
从CPU(线程)部分可见,图像处理总共耗时6.8秒,大部分时间花费在计算三个灰度图像的中值上。
至此,我们已经明确了最值得优先优化的操作。如需了解更多关于Nsight Systems的信息,请参阅使用NVIDIA Nsight Systems优化CUDA内存传输。关于NVTX的更多信息,请参阅CUDA技巧:使用NVTX生成自定义应用分析时间线。
3. CUB:在GPU上直接表达算法
处理常见算法时,手写自定义内核既容易出错,也很难得到高效实现。因此无论是对设备端模式还是核内原语,都应优先使用 CUB。 CUB 是 NVIDIA 并行算法库,随 CCCL 一同提供。它在多个粒度上暴露了高度优化的例程:设备级(cub::Device*)、线程块级(cub::Block*)和 warp 级(cub::Warp*)。
对于 RGB 转灰度的步骤,可以用 cub::DeviceTransform::Transform 替换自定义内核。它将用户提供的函数应用到一组输入迭代器,并将结果写入输出迭代器,在 GPU 上执行:
// Use CUB to convert the RGB images to grayscale
cub::DeviceTransform::Transform(
cuda::std::make_tuple(d_image_r, d_image_g, d_image_b), // inputs
d_image_gray, // output
IMAGE_SIZE, // size
[] __host__ __device__ (pixel_t r, pixel_t g, pixel_t b) // functor
{
return static_cast<pixel_t>(0.299f * r + 0.587f * g + 0.114f * b);
},
stream);
对于中值计算,手写并行块级排序既复杂又低效。更好的做法是直接在核内调用 CUB 的块级基数排序:
// Declare and allocate the storage for CUB BlockRadixSort
using BlockRadixSort = cub::BlockRadixSort<...>;
__shared__ typename BlockRadixSort::TempStorage temp_storage;
// Load the tile's grayscale value from global memory
pixel_t thread_keys[1];
thread_keys[0] = d_image_gray(y, x);
// Perform the thread-block-level radix sort
BlockRadixSort(temp_storage).Sort(thread_keys);
// Select the thread found at the middle index
// Write its value which is, after sorting, the median, in the global median array
if (block_idx.x == TILE_WIDTH / 2 && block_idx.y == TILE_WIDTH / 2)
d_median(grid_block_idx.y, grid_block_idx.x) = thread_keys[0];
完成上述改动后,我们再次使用 Nsight Systems 进行基准测试:
中位数的计算时间现已降至仅 773 微秒(再次参考 computeMedian 黄色弹出框中的经过时间),速度提升 2717 倍。计算全部三张图片的总耗时现在为 635 毫秒,整体提速 10 倍。
如果我们重新评估当前的瓶颈:内存分配所花费的时间约占总图像计算运行时间的 83%。
这可以得到很大改善。
4. 池化内存容器:便捷且高效的内存管理
使用 cudaMalloc 分配 GPU 内存可能带来意想不到的负面影响:例如忘记调用 cudaFree 导致内存泄漏,或在代码关键部分产生高昂的内存操作开销。
为此,我们建议使用 CCCL 的异步内存容器 cuda::device_buffer。与 C++ 的 std::vector 类似,容器一旦超出作用域,其内存将被自动释放。
此外,该缓冲区由内存池支撑,因此重复的分配与释放操作无需每次都承担完整的 cudaMalloc / cudaFree 开销。
为使用这些 GPU 内存容器,我们对代码进行如下更新:
// 用于管理 GPU 内存分配的资源
cuda::device_memory_pool_ref device_resource = cuda::device_default_memory_pool(cuda::device_ref{0});
// 稍后解释,当前不重要
cuda::stream stream{cuda::device_ref{0}};
// 使用未初始化的容器分配 GPU 内存
cuda::device_buffer<pixel_t> d_image_r = cuda::make_buffer<pixel_t>(stream, device_resource, IMAGE_SIZE, cuda::no_init);
...
修改后,我们重新分析时间线:
内存分配耗时几乎归零,单张图像处理速度提升了 2.6 倍。
GPU 时间现在主要由内存传输主导。计算所有图像的耗时几乎全花在第一步——将三张图像的 RGB 通道数据从 CPU 复制到 GPU。
可以通过优化显著提升主机到设备的内存传输速度。
5. Pinned 内存:加速主机到设备传输
CPU 数据分配默认是页面可移动的(pageable),GPU 无法直接访问。CUDA 驱动必须先分配一块临时的页锁定(pinned,即固定)主机数组,把数据复制到 pinned 数组,再从 pinned 数组传输到设备内存。
若提前知道 CPU 内存将复制到 GPU,建议直接使用 pinned 内存进行分配。
CCCL 提供了一个固定内存主机容器 `cuda::host_buffer`,可通过 `cuda::make_pinned_buffer` 工厂函数创建: ```cpp // 分配 CPU 内存以存储图像瓦片、中值以及红、绿、蓝和灰度图像 // 这些 CPU 容器与 std::vector 不同,使用固定内存分配 std::vector
6. 流:在 GPU 上并行化操作
默认情况下,所有操作(kernel、内存分配或数据搬运)都会在 default stream 上启动,可视为 GPU 需按序执行的任务队列。 本例中每个图像(或线程)均需独立流。CCCL 提供了cuda::stream,这是一种自管理且带所有权声明的 CUDA stream。只需在并行 for 循环内直接构造,每个 OpenMP 线程便各拥一条独立的 GPU 任务队列。
要高效利用 stream,必须搭配异步 API:CPU 发出 GPU 操作后不应阻塞等待。为跑满 GPU,每个 CPU 线程应尽可能快、尽可能多地并发发射操作,无需等待前序任务完成。Kernel 和 CUB device 调用默认即异步,会直接挂载到传入的 stream 上执行;而 host 与 device 间的异步拷贝,则通过 CCCL 新增的 cuda::copy_bytes API 完成。
现代 CUDA 编程的最佳实践是:摒弃对默认 stream 的依赖,全程显式使用自定义 stream。
据此更新代码:
针对 pinned host buffer 的初始分配,使用独立的 init_stream;并行 for 循环的每次迭代现均绑定一条专属的 cuda::stream 用于计算 pipeline:
```cpp
// 用于初始主机缓冲区分配的流
cuda::stream init_stream{cuda::device_ref{0}};
...
// 在 init_stream 上分配主机锁定缓冲区:
std::vector
现在,内核执行与内存拷贝已实现完全重叠。
经过所有优化后,计算全部三张图片的最终耗时为 23 毫秒,相比最初的 6.8 秒大幅缩短。
你来试试
借助 CUDA Developer's Toolbox,我们让代码更安全、更易维护、运行更快。未使用任何底层优化手段,但代码性能已提升 300 倍。
你可以亲自尝试这份 代码,也可在 Google Colab 上运行。
我们还制作了一套完整教程课程,帮助你深入学习这些工具的使用方法。课程免费发布于 YouTube,并附有 Google Colab 练习链接。
