![]()
NVIDIA CUDA是GPU加速计算的基石,支撑着从科学仿真到大规模AI训练的各类应用。
然而,编写正确、可维护且高性能的CUDA代码并非易事:内存错误往往隐蔽难查,缺乏合适工具时性能瓶颈几乎无从发现,手写GPU算法的效率也很难媲美经过优化的库。好消息是,现代CUDA工具链已日趋成熟,上述难题大多都有了简明的解决方案。
本文将介绍NVIDIA提供的一套调试、基准测试与性能优化工具,并通过对示例代码的逐步改造,演示如何仅凭少量代码改动,就让程序变得更安全、更易维护、更快速。
全文分六个递进步骤,涵盖以下内容:
如何通过采用现代CCCL API与Compute Sanitizer快速定位索引越界错误
如何借助NVTX提升Nsight Systems的基准测试可读性
如何在块级与设备级使用CUB的优化算法
如何通过内存池容器管理GPU内存
如何借助固定内存容器加速主机到设备的数据传输
如何为每个线程分配独立流并使用异步传输来并行化GPU任务
本文还配套提供了完整代码,并支持在Google Colab上直接运行。
起点:图像处理流水线示例
示例任务如下:从输入的红、绿、蓝三通道图像流开始,先将数据从CPU传输至GPU,随后将RGB图像转换为灰度图。接着,对图像中每个32×32像素的分块,通过排序像素并取中间值来计算中值。最后,将每个分块的中值结果从GPU复制回CPU。
基础代码示例
以下是完整的初始代码,后续每个步骤都在此基础上进行改进。
```cpp
#define CUDA_CHECK_ERROR(call) \\
do { \\
cudaError_t err = call; \\
if (err != cudaSuccess) { \\
std::exit(EXIT_FAILURE); \\
} while (0)
using pixel_t = uint8_t;
__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;
const int i = x + y * width;
d_image_gray[i] = static_cast(
0.299f * d_image_r[i] + 0.587f * d_image_g[i] + 0.114f * d_image_b[i]);
template
__global__ void computeMedian(pixel_t *d_image_gray, pixel_t *d_median, int width, int height)
const int x = threadIdx.x + blockIdx.x * blockDim.x;
const int y = threadIdx.y + blockIdx.y * blockDim.y;
__shared__ pixel_t tile[TILE_WIDTH * TILE_WIDTH];
const int index = x + y * width;
tile[index] = d_image_gray[index];
__syncthreads();
if (threadIdx.x == 0 && threadIdx.y == 0) {
if (tile[i] > tile[j]) cuda::std::swap(tile[i], tile[j]);
const int medianIndex = (TILE_WIDTH * TILE_WIDTH) / 2;
d_median[blockIdx.x + blockIdx.y * gridDim.x] = tile[medianIndex];
int main() {
constexpr auto TILE_WIDTH = 32;
constexpr auto HISTO_SIZE = 256;
constexpr auto NB_TILE_X = 250;
constexpr auto NB_TILE_Y = NB_TILE_X;
constexpr auto IMAGE_LENGTH = TILE_WIDTH * NB_TILE_X;
constexpr auto IMAGE_SIZE = IMAGE_LENGTH * IMAGE_LENGTH;
constexpr auto NB_IMAGES = 3;
constexpr auto INIT_VALUE = 4;
std::vector> h_images_r(NB_IMAGES, std::vector(IMAGE_SIZE, 4));
std::vector> h_images_g(NB_IMAGES, std::vector(IMAGE_SIZE, 4));
std::vector> h_images_b(NB_IMAGES, std::vector(IMAGE_SIZE, 4));
std::vector> h_images_gray(NB_IMAGES, std::vector(IMAGE_SIZE, 0));
std::vector> h_medians(NB_IMAGES, std::vector(NB_TILE_X * NB_TILE_Y));
#pragma omp parallel for
pixel_t *d_image_r, *d_image_g, *d_image_b, *d_image_gray, *d_median;
CUDA_CHECK_ERROR(cudaMalloc(&d_image_r, IMAGE_SIZE * sizeof(pixel_t)));
CUDA_CHECK_ERROR(cudaMalloc(&d_image_g, IMAGE_SIZE * sizeof(pixel_t)));
CUDA_CHECK_ERROR(cudaMalloc(&d_image_b, IMAGE_SIZE * sizeof(pixel_t)));
CUDA_CHECK_ERROR(cudaMalloc(&d_image_gray, IMAGE_SIZE * sizeof(pixel_t)));
CUDA_CHECK_ERROR(cudaMalloc(&d_median, (NB_TILE_X * NB_TILE_Y) * sizeof(pixel_t)));
CUDA_CHECK_ERROR(cudaMemcpy(d_image_r, h_images_r[i].data(), IMAGE_SIZE * sizeof(pixel_t), cudaMemcpyHostToDevice));
CUDA_CHECK_ERROR(cudaMemcpy(d_image_g, h_images_g[i].data(), IMAGE_SIZE * sizeof(pixel_t), cudaMemcpyHostToDevice));
CUDA_CHECK_ERROR(cudaMemcpy(d_image_b, h_images_b[i].data(), IMAGE_SIZE * sizeof(pixel_t), cudaMemcpyHostToDevice));
dim3 blockSize(TILE_WIDTH, TILE_WIDTH);
dim3 gridSize(cuda::ceil_div(IMAGE_LENGTH, blockSize.x), cuda::ceil_div(IMAGE_LENGTH, blockSize.y));
CUDA_CHECK_ERROR(cudaGetLastError());
CUDA_CHECK_ERROR(cudaGetLastError());
CUDA_CHECK_ERROR(cudaMemcpy(h_medians[i].data(), d_median, (NB_TILE_X * NB_TILE_Y) * sizeof(pixel_t), cudaMemcpyDeviceToHost));
CUDA_CHECK_ERROR(cudaFree(d_image_r));
CUDA_CHECK_ERROR(cudaFree(d_image_g));
CUDA_CHECK_ERROR(cudaFree(d_image_b));
CUDA_CHECK_ERROR(cudaFree(d_image_gray));
CUDA_CHECK_ERROR(cudaFree(d_median));
return 0;
代码定义了两个核函数:
computeRGBToGray:读取红、绿、蓝三通道输入图像的像素值,将其转换后写入灰度输出图像。
computeMedian:计算灰度图中每个分块的中值。每个线程块先将分块数据从全局内存加载到共享内存,再由单个线程对数组进行排序,并将排序后中间位置的值(即中值)写入全局输出数组。
在主函数中,定义完示例所需的常量后,为各图像和中值结果分配CPU内存。随后借助OpenMP并行地对三张图像运行处理流水线:先在GPU上分配内存,再将数据从CPU传至GPU,依次启动RGB转灰度和中值计算两个核函数,最后将中值结果拷回CPU并释放GPU内存。
该代码存在若干缺陷,将在后续步骤中逐一修正。
第一步:Compute Sanitizer与CCCL API——轻松发现错误,编写更安全的代码
首先尝试运行代码:
code_steps$ ./build/0_base_error_example
CUDA error in 0_base_error_example.cu at line 105: an illegal memory access was encountered
代码虽然包含错误检查,但遇到"非法内存访问"这类报错时,应当使用compute-sanitizer进一步排查。
使用NVIDIA的功能正确性检测工具Compute Sanitizer,可以直接定位一处难以肉眼发现的错误:
$ compute-sanitizer ./build/0_base_error_example
========= COMPUTE-SANITIZER
========= Invalid __shared__ write of size 1 bytes
========= by thread (0,3,0) in block (20,0,0)
========= Access at 0x6440 is out of bounds
工具明确指出,第55行存在共享内存越界写入:
```cpp
tile[index] = d_image_gray[index];
这行代码使用了全局索引来访问共享内存,而共享内存是以线程块为单位分配的,因此索引方式有误。为了避免此类索引错误,CCCL引入了新的API,用于区分全局索引和块级索引。使用方式如下,首先通过新的`cuda::launch` API启动核函数:
```cpp
auto config = cuda::make_config(cuda::block_dims(...), cuda::grid_dims(...));
cuda::launch(stream, config, kernel_name, input);
然后在核函数内部使用新的索引API:
```cpp
template
__global__ void kernel_name(Configuration config, ...) {
const auto [x, y, z] = cuda::gpu_thread.index(cuda::grid, config);
const auto block_idx = cuda::gpu_thread.index(cuda::block, config);
即便不使用Compute Sanitizer或新API,也可以通过将裸指针替换为`cuda::std::span`或其多维变体`cuda::std::mdspan`来直接发现此类错误。这两者均为连续内存的非拥有视图,在调试模式下,越界访问会触发断言,比裸指针更安全。
核函数可更新为:
```cpp
template
using span_2d = cuda::std::mdspan>;
template
__global__ void computeMedian(..., span_2dd_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.
共享内存中的访问同样应使用`cuda::shared_memory_mdspan`加以保护:
```cpp
__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的信息,请参阅NVIDIA官方文档。
第二步:Nsight Systems与NVTX——对代码进行准确的基准测试
代码修复完毕后,可以使用NVIDIA Nsight Systems进行基准测试。该工具能可视化程序的执行时间线,清晰呈现每个函数的调用时机与持续时长。
为了让时间线更易于阅读,可使用NVTX对关键代码段进行标注:
```cpp
void image_compute(...) {
nvtx3::scoped_range fun_scope("Image compute");
nvtxRangePushA("Kernel median");
// 启动计算中值的GPU核函数
nvtxRangePop();
分析结果显示:
在分析器输出的GPU硬件(CUDA HW)部分,GPU时间中98.5%用于核函数执行,仅1.5%用于内存操作。
两个核函数中,中值计算核函数占用了绝大部分运行时间,每张图像耗时约2.1秒(computeMedian的统计数据显示耗时2.142秒)。
从CPU(线程)部分来看,三张图像的全部计算共耗时6.8秒,其中大部分时间用于三张灰度图像的中值计算。
由此可以确定首要优化目标。更多关于Nsight Systems的信息请参阅NVIDIA官方文档,NVTX的详细用法亦可查阅相关资料。
第三步:CUB——直接在GPU上表达算法
对于常见算法,手写CUDA核函数不仅容易出错,而且效率往往不尽如人意。无论是设备级模式还是核函数内部的原语,都建议优先使用CUB。
CUB是通过CCCL提供的NVIDIA并行算法库,提供了跨多个粒度的高度优化例程:设备级(`cub::Device*`)、块级(`cub::Block*`)和线程束级(`cub::Warp*`)。
对于RGB转灰度步骤,可以用`cub::DeviceTransform::Transform`替换自定义核函数。该接口接受一组输入迭代器的元组及一个用户自定义函数,将结果写入输出迭代器,整个过程在GPU上执行:
```cpp
cub::DeviceTransform::Transform(
cuda::std::make_tuple(d_image_r, d_image_g, d_image_b),
d_image_gray,
IMAGE_SIZE,
[] __host__ __device__ (pixel_t r, pixel_t g, pixel_t b) {
return static_cast(0.299f * r + 0.587f * g + 0.114f * b);
},
stream);
对于中值计算,手动实现并行块级排序既复杂又低效。可以直接在核函数中使用CUB的块级基数排序:
```cpp
__shared__ typename BlockRadixSort::TempStorage temp_storage;
pixel_t thread_keys[1];
thread_keys[0] = d_image_gray(y, x);
BlockRadixSort(temp_storage).Sort(thread_keys);
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微秒,速度提升了2717倍。三张图像的总计算时间降至635毫秒,整体提速10倍。
重新评估当前瓶颈:内存分配耗时约占图像计算总时长的83%,仍有很大优化空间。
第四步:内存池容器——更便捷、更快速的内存管理
使用`cudaMalloc`分配GPU内存存在两个潜在问题:忘记调用`cudaFree`导致内存泄漏,以及在性能关键路径上内存操作开销过高。
推荐使用CCCL的异步内存容器`cuda::device_buffer`。与C++的`std::vector`类似,容器离开作用域时会自动释放内存,且其底层由内存池支持,反复分配和释放时无需每次都承担`cudaMalloc`/`cudaFree`的完整开销。
代码更新如下:
```cpp
cuda::device_memory_pool_ref device_resource = cuda::device_default_memory_pool(cuda::device_ref{0});
cuda::stream stream{cuda::device_ref{0}};
cuda::device_bufferd_image_r = cuda::make_buffer(stream, device_resource, IMAGE_SIZE, cuda::no_init);
应用此改动后再次分析时间线:内存分配耗时几乎可以忽略不计,单张图像计算速度提升2.6倍。
此时GPU时间已由内存操作主导——三张图像计算的绝大部分时间,都消耗在从CPU向GPU传输红、绿、蓝三个通道的数据上。主机到设备的内存传输还有很大的提速空间。
第五步:固定内存——加速主机到设备的数据传输
CPU的内存分配默认使用可分页内存,GPU无法直接访问。CUDA驱动必须先将数据复制到一块页锁定(固定)的临时缓冲区,再从该缓冲区传输到GPU显存,引入了额外开销。
当CPU内存确定会被传输至GPU时,建议直接使用固定内存分配。CCCL提供了固定内存主机容器`cuda::host_buffer`,可通过`cuda::make_pinned_buffer`工厂函数创建:
```cpp
std::vector> h_images_r(
NB_IMAGES,
cuda::make_pinned_buffer(stream, IMAGE_SIZE, ...));
应用此改动后再次基准测试:主机到设备的内存传输时间大幅缩短,三张图像的总处理时间降至25毫秒,速度提升10倍。
细心的读者可能早已注意到一个现象:尽管使用了多个CPU线程,所有GPU操作(内存操作和核函数)仍然是串行执行的。接下来解决这个问题。
第六步:流——并行化GPU操作
默认情况下,所有操作(核函数、内存分配或传输)都在默认流上启动,可将其视为GPU按顺序执行任务的队列。
本例中,每张图像/线程都需要独立的流。CCCL提供了`cuda::stream`,它是CUDA流的自管理拥有型封装,可在并行for循环内部构造,从而让每个OpenMP线程拥有各自的GPU任务队列。
要充分利用多流的优势,还需使用异步API:CPU提交给GPU的每个操作都不应等待其完成,而应尽可能快地连续提交,以充分饱和GPU。核函数和CUB设备级调用默认已是异步的,会在传入的流上执行。主机与设备之间的异步拷贝,则使用CCCL新提供的`cuda::copy_bytes` API来实现。
在现代CUDA代码中,建议始终使用显式流,避免依赖默认流。
代码更新如下:
```cpp
cuda::stream init_stream{cuda::device_ref{0}};
std::vector> h_images_r(
NB_IMAGES,
cuda::make_pinned_buffer(init_stream, IMAGE_SIZE, ...));
init_stream.sync();
#pragma omp parallel for
cuda::stream stream{cuda::device_ref{0}};
cuda::device_bufferd_image_r = cuda::make_buffer(stream, device_resource, IMAGE_SIZE, cuda::no_init);
cuda::copy_bytes(stream, h_images_r[i], d_image_r);
cub::DeviceTransform::Transform(..., stream.get());
cuda::launch(stream, ...);
cuda::copy_bytes(stream, d_median, h_medians[i]);
stream.sync();
最终时间线显示:核函数与内存拷贝操作现已实现完全重叠。经过全部优化后,三张图像的总处理时间为23毫秒,相比初始的6.8秒,整体提速约300倍。
动手实践
借助CUDA开发者工具箱,我们在不使用任何底层优化手段的前提下,将代码变得更安全、更易维护,同时实现了300倍的性能提升。
欢迎自行尝试这些代码,也可以在Google Colab上直接运行。NVIDIA还提供了完整的工具使用教程课程,已在YouTube上免费发布,并附有Google Colab练习链接。
Q&A
Q1:Compute Sanitizer能检测出哪些类型的CUDA错误?
A:Compute Sanitizer是NVIDIA提供的功能正确性检测工具套件,可以检测多种常见错误,包括共享内存或全局内存的越界读写、非法内存访问、内存竞争条件等。在本文示例中,它直接定位了computeMedian核函数中使用全局索引访问共享内存导致的越界写入错误,而这类错误仅凭肉眼审查代码很难发现。
Q2:CUB的BlockRadixSort相比手写冒泡排序快多少?
A:在本文的测试场景中,将手写单线程冒泡排序替换为CUB的块级基数排序后,中值计算核函数的运行时间从每张图像约2.1秒缩短至773微秒,速度提升了约2717倍。三张图像的总计算时间也从6.8秒降至635毫秒,整体加速约10倍。这一差距主要来自CUB将排序工作并行分配给整个线程块,而非由单一线程串行完成。
Q3:cuda::host_buffer固定内存为什么能加速GPU数据传输?
A:普通CPU内存(可分页内存)在传输到GPU时,CUDA驱动需要先将数据复制到一块临时的页锁定缓冲区,再从该缓冲区传输至GPU显存,相当于多了一次额外拷贝。使用固定内存(即页锁定内存)直接分配,GPU可以通过DMA直接访问该内存,省去了中间环节,从而显著降低传输延迟。在本文示例中,采用cuda::host_buffer后,三张图像的总处理时间从数百毫秒降至约25毫秒,速度提升约10倍。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.