算力商业
developer.nvidia.com ·
@Elizabeth Goodman
现代CUDA工具箱实战:逐步优化指南
NVIDIA官方博客介绍CUDA优化实践,涵盖从基础到高级的优化技术,适用于科学计算和AI训练等场景。文章提供逐步指导,帮助开发者提升GPU加速应用性能。
AI推荐理由 NVIDIA官方技术博客提供CUDA优化实战教程,内容具体,面向开发者,具有较高实用价值。但摘要不完整,未提供具体优化步骤细节。
85
正文
开发者工具与技术
现代 CUDA 工具箱实践:逐步优化指南
2026年9月2日
作者:Nicolas Blin
喜欢
L
T
F
R
E
AI 生成摘要
NVIDIA CCCL 引入了现代启动 API 和索引模型,区分全局和块级坐标,消除了 Compute Sanitizer 捕获的越界共享内存访问。
用 CUB 设备和块级原语替换手写内核,将每张图像的中值计算时间从 2.1 秒减少到 773 微秒,加速 2,717 倍。
CCCL 池化的 cuda::device_buffer 容器由 cuda::device_memory_pool_ref 支持,消除了分配开销,将整个流水线时间缩短了 2.6 倍。
在 cuda::host_buffer 固定内存中分配主机数据,将主机到设备传输加速 10 倍,使三张图像的完整运行时间降至约 25 毫秒。
为每个 OpenMP 线程分配自己的 cuda::stream,并使用异步 cuda::copy_bytes,使内核和传输在流之间重叠,最终运行时间达到 23 毫秒——比原始的 6.8 秒快 300 倍。
后续步骤
尝试代码示例以重现优化。
运行 Google Colab 笔记本以获得实践环境。
观看 YouTube 课程以获取 CUDA 开发者工具箱的详细指导。
由 NVIDIA Nemotron 提供支持。AI 生成的内容可能不完整地总结信息。请验证重要信息。了解更多
NVIDIA CUDA 仍然是 GPU 加速计算的基础,为从科学模拟到大规模 AI 训练的一切提供动力。
但编写正确、可维护且高性能的 CUDA 代码可能具有挑战性:内存错误可能隐藏得很深,没有正确的工具性能瓶颈可能不可见,而手写的 GPU 算法很少能与优化库的效率相匹配。幸运的是,现代 CUDA 工具链已经显著成熟,许多这些挑战现在都有了简单的解决方案。
在这篇博客文章中,我们将介绍 NVIDIA 提供的用于调试、基准测试和改进代码的工具。每次只需少量代码更改,我们将使示例代码更安全、更易于维护且更快。
通过六个增量步骤,本文将涵盖:
如何通过采用现代 CCCL API 和 Compute Sanitizer 轻松发现索引错误
如何使用 NVTX 改进 Nsight Systems 基准测试
如何在块级和设备级使用 CUB 的优化算法
如何通过池化容器管理 GPU 内存
如何使用固定容器加速主机到设备传输
如何通过为每个线程提供自己的流和异步传输来并行化 GPU 工作
作为这篇博客文章的配套,我们提供了代码,并可以选择在 Google Colab 上运行。
起点:图像处理流水线示例
从红色、绿色和蓝色图像的输入流开始,首先将数据从 CPU 传输到 GPU。然后将这些图像从 RGB 转换为灰度。
图 1. 图像处理流水线的第一部分。首先将 RGB 图像从主机复制到设备,然后将它们从 RGB 转换为灰度
然后,对于图像中每个 32×32 像素的图块,通过排序像素并选择中间值来计算中值。最后,将每个图块的中值复制回 CPU。
图 2. 图像处理流水线的第二部分:将每张灰度图像划分为 32×32 像素的图块,对每个图块中的像素进行排序以选择其中值,然后将中值从设备复制到主机
基础代码示例
以下是完整的起始代码。本文中的每一步都对其进行了改进。
#define CUDA_CHECK_ERROR(call) do { \
cudaError_t err = call; \
if (err != cudaSuccess) { \
std::cerr (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;
// 边界检查,仅选择图像边界内的线程
if (!(x 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;
// 分配 CPU 内存以存储图像图块中值以及红色、绿色、蓝色和灰度图像
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
for (int i = 0; i >>(d_image_r, d_image_g, d_image_b, d_image_gray, IMAGE_LENGTH, IMAGE_LENGTH);
CUDA_CHECK_ERROR(cudaGetLastError());
// 启动 GPU 内核计算图像中每个图块的中值
computeMedian>>(d_image_gray, d_median, IMAGE_LENGTH, IMAGE_LENGTH);
CUDA_CHECK_ERROR(cudaGetLastError());
// 将 GPU 中值内存复制回 CPU
CUDA_CHECK_ERROR(cudaMemcpy(h_medians[i].data(), d_median, (NB_TILE_X * NB_TILE_Y) * sizeof(pixel_t), cudaMemcpyDeviceToHost));
// 释放 GPU 内存
CUDA_CHECK_ERROR(cudaFree(d_image_r));
CUDA_CHECK_ERROR(cudaFree(d_image_g));
CUDA_CHECK_ERROR(cudaFree(d_image_b));
CUDA_C
HECK_ERROR(cudaFree(d_image_gray));
CUDA_CHECK_ERROR(cudaFree(d_median));
}
return 0;
}
这段代码首先定义了两个内核:
computeRGBToGray 加载红色、绿色和蓝色输入图像的值,进行转换并写入灰度输出图像。
computeMedian 计算输入灰度图像中每个图块的中值。每个线程块将图块从全局内存加载到共享内存。然后,使用单个线程对数组进行排序,并将中间索引处找到的值(即中值)写入全局输出中值数组。
在 main 函数中,定义示例使用的常量后,为每张图像和中值分配 CPU 内存。
然后使用 OpenMP 并行对三张图像中的每一张运行图像处理流水线。流水线首先在 GPU 上分配所需内存,然后将数据从 CPU 传输到 GPU。随后启动两个内核,将 RGB 转换为灰度并计算中值。最后,在释放内存之前将中值结果复制回 CPU。
这段代码有几个缺陷,将逐步解决。
1. 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 进行进一步调查。
使用 Compute Sanitizer(NVIDIA 功能正确性检查套件),我们可以直接识别一个难以发现的错误:
$ compute-sanitizer ./build/0_base_error_example
========= COMPUTE-SANITIZER
========= Invalid __shared__ write of size 1 bytes
========= at void computeMedian(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];
该行错误地使用全局索引将数据加载到共享内存中。由于共享内存在线程块级别定义,我们需要更改索引。为了避免索引错误,CCCL 中引入了一个新 API 来区分全局和块级索引。要使用它,您首先需要使用新的 cuda::launch API 启动内核:
auto config = cuda::make_config(cuda::block_dims(...), cuda::grid_dims(...));
cuda::launch(stream, config, kernel_name, input)
然后,在内核中使用新的索引 API:
template
__global__ void kernel_name(Configuration config, ...) {
// 检索并展开每个全局索引
const auto [x, y, z] = cuda::gpu_thread.index(cuda::grid, config);
// 检索块索引结构(包含 block_idx.x、.y、.z)
const auto block_idx = cuda::gpu_thread.index(cuda::block, config);
}
如果不使用 compute-sanitizer 或新 API,也可以通过使用 cuda::std::span 或其 n 维变体 cuda::std::mdspan 代替原始指针来直接发现此错误。cuda::std::span 和 cuda::std::mdspan 是连续内存的非拥有视图,有助于抽象出确切的容器。通过 span 访问数据比通过原始指针更安全,部分原因是在调试模式下,越界访问将触发断言。
内核应更新为:
// 二维 mdspan 的别名
template
using span_2d = cuda::std::mdspan>;
template
__global__ void computeMedian(..., span_2d 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.
共享内存中的内存访问也应通过使用 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 查找错误》。
2. Nsight Systems 和 NVTX:正确地对代码进行基准测试
现在代码没有错误了,可以使用 NVIDIA Nsight Systems 进行基准测试。它允许您可视化程序时间线:知道每个函数何时被调用以及持续多长时间。
为了使时间线可视化更容易,我们使用 NVTX 包装每个有趣的代码部分:
void image_compute(...)
{
// 整个函数作用域的 NVTX 范围
nvtx3::scoped_range fun_scope("Image compute");
// 为特定代码部分推送然后弹出的 NVTX 范围
nvtxRangePushA("Kernel median");
// 启动 GPU 内核计算图像中每个图块的中值
...
// 在特定代码部分结束时弹出范围
nvtxRangePop();
}
这产生了以下结果:
图 3. 带有 NVTX 注释的初始 Nsight Systems 时间线。中值计算内核主导了配置文件,几乎占所有 GPU 活动,并占 6.8 秒图像处理持续时间的大部分
在上面的图 3 中,分析器输出的 GPU 硬件(CUDA HW)部分报告 GPU 主要忙于内核(占 GPU 时间的 98.5%),而内存操作仅占 GPU 时间的 1.5%。
在两个内核中,计算中值的内核占用了大部分运行时间,每张图像 2.1 秒(见右侧黄色框,其中
显示了 computeMedian 的统计信息,经过时间为 2.142 秒)。
从 CPU(线程)部分,我们看到图像计算总共需要 6.8 秒,大部分时间花在计算三张灰度图像的中值上。
我们现在知道第一个要优化的操作,以获得最大的影响。有关 Nsight Systems 的更多信息,请参阅《使用 NVIDIA Nsight Systems 优化 CUDA 内存传输》。有关 NVTX 的更多信息,请参阅《CUDA 专业提示:使用 NVTX 生成自定义应用程序配置文件时间线》。
3. CUB:直接在 GPU 上表达算法
在处理常见算法时,编写自定义内核容易出错,并且很可能导致低效的实现。只要可能,对于设备端模式和内核内原语,都建议使用 CUB。
CUB 是通过 CCCL 提供的 NVIDIA 并行算法库。它在多个粒度上公开了高度优化的例程:设备级(cub::Device*)、块级(cub::Block*)和线程束级(cub::Warp*)。
对于 RGB 到灰度步骤,我们可以用 cub::DeviceTransform::Transform 替换自定义内核。它将用户提供的函数应用于输入迭代器的元组,并将结果写入输出迭代器,在 GPU 上执行:
// 使用 CUB 将 RGB 图像转换为灰度
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 的块级基数排序:
// 声明并分配 CUB BlockRadixSort 的存储
using BlockRadixSort = cub::BlockRadixSort;
__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 进行基准测试:
图 4. 将自定义算法替换为 CUB 后的 Nsight Systems 时间线。中值计算减少到微秒级,整个流水线大约需要 635 毫秒,内存分配成为主要成本
计算中值所需的时间现在仅为 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 d_image_r = cuda::make_buffer(stream, device_resource, IMAGE_SIZE, cuda::no_init);
...
进行此更改后,我们再次分析时间线:
图 5. 采用池化设备内存容器后的 Nsight Systems 时间线。分配开销几乎消除,使主机到设备内存传输成为下一个可见瓶颈
内存分配所花费的时间现在几乎不存在;计算一张图像所需的时间提高了 2.6 倍。
GPU 时间现在以内存为主。计算所有图像的时间几乎全部花在首先将三张图像的红色、绿色和蓝色从 CPU 复制到 GPU 上。
可以大大加快主机到设备的内存传输。
5. 固定内存:更快的主机到设备内存传输
CPU 数据分配默认是可分页的,GPU 无法直接访问。CUDA 驱动程序必须首先分配一个临时的页锁定(或固定)主机数组,将主机数据复制到固定数组,然后将数据从固定数组传输到设备内存。
当预先知道 CPU 内存将被复制到 GPU 时,建议直接使用固定内存进行分配。
CCCL 公开了一个固定内存主机容器 cuda::host_buffer,可以通过 cuda::make_pinned_buffer 工厂构建:
// 分配 CPU 内存以存储图像图块、中值以及红色、绿色、蓝色和灰度图像
// 与 std::vector 相比,这些 CPU 容器使用固定内存分配
std::vector> h_images_r(NB_IMAGES, cuda::make_pinned_buffer(stream, IMAGE_SIZE, ...));
...
进行这些更改后,我们可以再次进行基准测试:
图 6. 在固定内存中分配主机数据后的 Nsight Systems 时间线;主机到设备传输更短,所有三张
图像在大约 25 毫秒内完成,尽管它们的操作仍然在默认流上顺序执行
主机到设备内存传输所需的时间已显著减少;现在计算所有图像仅需 25 毫秒,快了 10 倍。
有关固定内存的更多信息,请参阅《如何优化 CUDA C/C++ 中的数据传输》。
一个令人惊讶的行为可能从一开始就引起了一些读者的注意:
尽管我们使用不同的 CPU 线程,但所有操作(内存和内核)都在 GPU 上顺序执行。
让我们修复它。
6. 流:在 GPU 上并行化操作
默认情况下,所有操作(内核、内存分配或传输)都在我们所谓的默认流上启动:它可以被视为 GPU 需要按顺序执行的任务队列。
在此示例中,我们需要为每个图像/线程提供一个流。CCCL 提供了 cuda::stream,一个拥有资源的自管理 CUDA 流版本。它可以简单地在并行 for 循环内构造,因此每个 OpenMP 线程获得自己的 GPU 工作队列。
为了有效利用流,我们还需要使用异步 API:CPU 启动的每个 GPU 操作都不应等待其完成。为了充分利用 GPU,每个 CPU 线程应尽可能快地启动尽可能多的操作,而不等待它们首先完成。内核和 CUB 设备调用默认已经是异步的,并在传递的流上启动。要在主机和设备之间启动异步复制,我们使用新的 CCCL cuda::copy_bytes API。
在任何现代 CUDA 代码中,建议不要依赖默认流,而应始终依赖流。
我们相应地更新代码:
一个专用的 init_stream 用于固定主机缓冲区的初始分配。并行 for 循环的每次迭代现在拥有自己的 cuda::stream 用于计算流水线:
// 用于初始主机缓冲区分配的流
cuda::stream init_stream{cuda::device_ref{0}};
...
// 在 init_stream 上分配主机固定缓冲区:
std::vector> h_images_r(NB_IMAGES, cuda::make_pinned_buffer(init_stream, IMAGE_SIZE, ...));
...
// 在另一个流上启动操作之前同步:
init_stream.sync();
#pragma omp parallel for
for (int i = 0; i d_image_r = cuda::make_buffer(stream, device_resource, IMAGE_SIZE, cuda::no_init);
...
// 使用每个线程拥有的流异步将每个容器的内存从 CPU 复制到 GPU
cuda::copy_bytes(stream, h_images_r[i], d_image_r);
...
// 使用 CUB 使用每线程流异步将 RGB 图像转换为灰度
cub::DeviceTransform::Transform(..., stream.get());
// 使用每线程流启动 GPU 内核计算图像中每个图块的中值
cuda::launch(stream, ...);
// 将 GPU 中值内存复制回 CPU
cuda::copy_bytes(stream, d_median, h_medians[i]);
...
// 确保复制字节在访问主机上的结果之前完成
stream.sync();
}
进行这些更改后,我们可以最后看一下时间线:
图 7. 为每张图像分配 CUDA 流后的最终 Nsight Systems 时间线;内存传输和内核在三个流之间重叠,将总处理时间从 6.8 秒减少到约 23 毫秒
我们现在在内核和内存复制之间实现了完全重叠。
经过所有改进,计算所有三张图像的最终持续时间为 23 毫秒,从 6.8 秒开始。
轮到您了
使用 CUDA 开发者工具箱,我们使代码更安全、更易于维护且更快。没有使用低级优化,但代码快了 300 倍。
亲自尝试这段代码,如果您愿意,可以在 Google Colab 上运行。
我们还构建了一个完整的课程,以详细了解如何使用这些工具。它可以在 YouTube 上免费获取,并附有在 Google Colab 上练习的链接。
喜欢
标签
开发者工具与技术 | 通用 | cuBB | CUDA | 中级技术 | 深入探讨 | 教程
关于作者
关于 Nicolas Blin
Nicolas Blin 是 NVIDIA 的高级开发者技术工程师。Nicolas 拥有软件工程硕士学位。他具有图像处理和 GPU 加速算法的背景。他的兴趣包括大规模并行算法、性能优化以及组合和线性优化。
查看 Nicolas Blin 的所有帖子
查看原文(中文正文为 AI 翻译)
Developer Tools & Techniques
The Modern CUDA Toolbox in Practice: A Step-by-Step Optimization Walkthrough
Sep 02, 2026
By Nicolas Blin
Like
L
T
F
R
E
AI-Generated Summary
NVIDIA CCCL introduces a modern launch API and indexing model that distinguishes global and block-level coordinates, eliminating out-of-bounds shared-memory accesses caught by Compute Sanitizer.
Replacing hand-written kernels with CUB device and block-level primitives reduces median computation from 2.1 seconds to 773 microseconds per image, a 2,717x speedup.
CCCL pooled cuda::device_buffer containers backed by cuda::device_memory_pool_ref remove allocation overhead, cutting total pipeline time by 2.6x.
Allocating host data in cuda::host_buffer pinned memory accelerates host-to-device transfers 10x, dropping the full three-image run to ~25 ms.
Assigning each OpenMP thread its own cuda::stream and using asynchronous cuda::copy_bytes overlaps kernels and transfers across streams, achieving a final 23 ms runtime — 300x faster than the original 6.8 s.
Next Steps
Try the code examples to reproduce the optimizations.
Run the Google Colab notebook for a hands-on environment.
Watch the YouTube class for detailed instruction on the CUDA Developer's Toolbox.
Powered by NVIDIA Nemotron. AI-generated content may summarize information incompletely. Verify important information. Learn more
NVIDIA CUDA remains the foundation of GPU-accelerated computing, powering everything from scientific simulations to large-scale AI training.
But writing correct, maintainable, and performant CUDA code can be challenging: memory bugs hide in plain sight, performance bottlenecks can be invisible without the right instrumentation, and hand-rolled GPU algorithms rarely match the efficiency of optimized libraries. Fortunately, the modern CUDA toolchain has matured significantly, and many of these challenges now have straightforward solutions.
In this blog post, we will walk through the tools NVIDIA offers to debug, benchmark, and improve your code. With only small line changes each time, we are going to make the example code safer, easier to maintain, and faster.
Across six incremental steps, this post will cover:
How to easily find indexing bugs by adopting the modern CCCL API and Compute Sanitizer
How to improve Nsight Systems benchmarks with NVTX
How to use CUB’s optimized algorithms at the block and device level
How to manage GPU memory through pooled containers
How to speed up host-to-device transfers with pinned containers
How to parallelize GPU work by giving each thread its own stream and asynchronous transfers
As a companion to this blog post, we provide the code and the option to run on Google Colab.
Starting point: An image processing pipeline example
From an input stream of red, green, and blue images, start by transferring the data from the CPU to the GPU. Then convert those from RGB to grayscale.
Figure 1. The first part of the image-processing pipeline. First, copy the RGB images from host to device, then convert them from RGB to grayscale
Then, for each 32 by 32 pixel tile in the image, compute the median by sorting the pixels and selecting the middle value. Finally, copy the median of each tile back to the CPU.
Figure 2. The second part of the image-processing pipeline: Divide each grayscale image into 32-by-32-pixel tiles, sort the pixels in each tile to select its median, then copy the median values from device to host
The base code example
Below is the full starting code. Each step in this post improves on it.
#define CUDA_CHECK_ERROR(call) do { \
cudaError_t err = call; \
if (err != cudaSuccess) { \
std::cerr (0.299f * d_image_r[i] + 0.587f * d_image_g[i] + 0.114f * d_image_b[i]);
}
}
// Kernel computing the median of each tile in the grayscale image
template
__global__ void computeMedian(pixel_t *d_image_gray, pixel_t *d_median, int width, int height) {
// Compute the thread global index in the grid
const int x = threadIdx.x + blockIdx.x * blockDim.x;
const int y = threadIdx.y + blockIdx.y * blockDim.y;
// Boundary check selecting only threads within the image boundary
if (!(x tile[j])
cuda::std::swap(tile[i], tile[j]);
// Each thread block stores the median, found in the middle index after sorting, in the global median array
const int medianIndex = (TILE_WIDTH * TILE_WIDTH) / 2;
d_median[blockIdx.x + blockIdx.y * gridDim.x] = tile[medianIndex];
}
}
int main() {
// Define all the example constants
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;
// Allocate the CPU memory to store the images tiles medians and for the red, green, blue and grayscale images
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));
// Run the image processing pipeline for each image, in parallel
#pragma omp parallel for
for (int i = 0; i >>(d_image_r, d_image_g, d_image_b, d_image_gray, IMAGE_LENGTH, IMAGE_LENGTH);
CUDA_CHECK_ERROR(cudaGetLastError());
// Launch the GPU kernel to compute the median of every tile in the image
computeMedian>>(d_image_gray, d_median, IMAGE_LENGTH, IMAGE_LENGTH);
CUDA_CHECK_ERROR(cudaGetLastError());
// Copy the GPU median memory back to the CPU
CUDA_CHECK_ERROR(cudaMemcpy(h_medians[i].data(), d_median, (NB_TILE_X * NB_TILE_Y) * sizeof(pixel_t), cudaMemcpyDeviceToHost));
// Free the GPU memory
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;
}
This code starts by defining two kernels:
computeRGBToGray loads the values of the red, green, and blue input images to convert and write them to the grayscale output image.
computeMedian computes the median of each tile of an input grayscale image. Each thread block loads the tile from global memory to shared memory. Then, a single thread is used to sort the array and write the value found at the middle index, corresponding to the median, in the global output median array.
In the main, after defining the constants used for the example, the CPU memory is allocated for each image and for the medians.
The image processing pipeline is then run for each of the three images, in parallel, using OpenMP. The pipeline starts by allocating the required memory on the GPU before transferring the data from the CPU to the GPU. The two kernels to convert from RGB to grayscale and to compute the median are launched afterward. Finally, we copy back the median results to the CPU before freeing the memory.
This code has several flaws that will be addressed, step by step.
1. Compute Sanitizer and the CCCL API: Find bugs easily and write safer code
Let’s start by running the code.
code_steps$ ./build/0_base_error_example
CUDA error in 0_base_error_example.cu at line 105: an illegal memory access was encountered
While the code has some error checking in it, when you get an error message like this “illegal memory access,” you should start by using compute-sanitizer to investigate further.
Using Compute Sanitizer, the NVIDIA functional correctness checking suite, we can directly identify a bug that can be hard to spot:
$ compute-sanitizer ./build/0_base_error_example
========= COMPUTE-SANITIZER
========= Invalid __shared__ write of size 1 bytes
========= at void computeMedian(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
Running the above directly shows that the code suffers from an out-of-bound shared write at line 55 of 0_base_error_example.cu.
tile[index] = d_image_gray[index];
The line incorrectly loads data in shared memory, using a global index. Since shared memory is defined at the thread block level, we need to change the indexing. To avoid indexing mistakes, a new API was introduced in CCCL to distinguish global and block-level indexing. To use it, you first need to launch your kernel using the new cuda::launch API:
auto config = cuda::make_config(cuda::block_dims(...), cuda::grid_dims(...));
cuda::launch(stream, config, kernel_name, input)
Then, using the new indexing API inside the kernel:
template
__global__ void kernel_name(Configuration config, ...) {
// Retrieve and expand each global index
const auto [x, y, z] = cuda::gpu_thread.index(cuda::grid, config);
// Retrieve the block index structure (containing block_idx.x, .y, .z)
const auto block_idx = cuda::gpu_thread.index(cuda::block, config);
}
Without using compute-sanitizer or the new API, this mistake could also have been directly spotted by using cuda::std::span or its n-dimensional variant cuda::std::mdspan instead of raw pointers. cuda::std::span and cuda::std::mdspan are non-owning views over contiguous memory and are useful to abstract the exact container away. Accessing data through spans is safer than through raw pointers in part because in debug mode, out-of-bounds accesses will trigger an assertion.
The kernel should be updated as:
// Alias for a 2-dimensional mdspan
template
using span_2d = cuda::std::mdspan>;
template
__global__ void computeMedian(..., span_2d d_image_gray, ...)
If you run the code with these changes you’ll get something like the following:
$ ./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.
Memory accesses in shared memory should also be protected by using a cuda::shared_memory_mdspan as in the following snippet:
__shared__ pixel_t shared[TILE_WIDTH * TILE_WIDTH];
cuda::shared_memory_mdspan tile_2d(shared, TILE_WIDTH, TILE_WIDTH);
Now you can run and everything should execute properly without errors.
Using the new launch API and its indexing mechanism, spans over raw pointers, and compute-sanitizer, out-of-bounds accesses either do not happen or are caught right away. For more information on compute-sanitizer, see Efficient CUDA Debugging: How to Hunt Bugs with NVIDIA Compute Sanitizer.
2. Nsight Systems and NVTX: Benchmark your code properly
Now the code is bug-free, it is ready to be benchmarked using NVIDIA Nsight Systems. It allows you to visualize the program timeline: know when each function is called and for how long.
To make the timeline visualization easier, we wrap every interesting code section using NVTX:
void image_compute(...)
{
// NVTX range for the scope of the whole function
nvtx3::scoped_range fun_scope("Image compute");
// NVTX range that is pushed and then popped for a specific code section
nvtxRangePushA("Kernel median");
// Launch the GPU kernel to compute the median of every tile in the image
...
// Pop the range at the end of the specific code section
nvtxRangePop();
}
Which yields the following result:
Figure 3. The initial Nsight Systems timeline with NVTX annotations. Median-computation kernels dominate the profile, accounting for nearly all GPU activity and most of the 6.8-second image-processing duration
In the GPU hardware (CUDA HW) section of the profiler output in Figure 3, above, it is reported that the GPU is mainly busy with kernels (98.5% of the GPU time) while the memory operations only take 1.5% of the GPU time.
Of the two kernels, the one computing the medians is taking the majority of the runtime with 2.1 seconds for each image (see the yellow box on the right, where the stats for computeMedian are shown, and the elapsed time is 2.142s).
From the CPU (thread) section, we see the image computation takes 6.8 seconds in total, with most of the time being spent on computing the medians for the three grayscale images.
We now know the first operation to optimize in order to have the greatest impact. For more information on Nsight Systems, see Optimizing CUDA Memory Transfers with NVIDIA Nsight Systems. For more information on NVTX, see CUDA Pro Tip: Generate Custom Application Profile Timelines with NVTX.
3. CUB: Express algorithms directly on the GPU
When dealing with common algorithms, writing custom kernels is error-prone and will most likely result in an inefficient implementation. Whenever possible, both for device-side patterns and for in-kernel primitives, it is recommended to use CUB.
CUB is the NVIDIA parallel algorithm library shipped through CCCL. It exposes highly optimized routines at several granularities: device-wide (cub::Device*), block-level (cub::Block*), and warp-level (cub::Warp*).
For the RGB to grayscale step, we can replace the custom kernel with cub::DeviceTransform::Transform. It applies a user-provided function to a tuple of input iterators and writes the result to an output iterator, executing on the 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(0.299f * r + 0.587f * g + 0.114f * b);
},
stream);
For the median, programming a parallel block-level sort by hand is complex and slow. Instead, we directly leverage CUB’s block-level radix sort inside the kernel:
// 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];
Following this change, we benchmark again using Nsight Systems:
Figure 4. The Nsight Systems timeline after replacing the custom algorithms with CUB. Median computation is reduced to microseconds and the complete pipeline takes about 635 milliseconds, leaving memory allocation as the dominant cost
The time required to compute the median is now only 773 microseconds (again, look at the elapsed time in the computeMedian yellow pop-out image), 2717x faster. The overall time to compute all three images is now 635 milliseconds, 10x faster.
If we reassess the current bottleneck: the time spent on memory allocations represents around 83% of the total image compute runtime.
This can be greatly improved.
4. Pooled Memory Containers: Convenient and faster memory management
Allocating GPU memory using cudaMalloc can have unexpected negative effects: leaks by forgetting to call cudaFree and costly memory operations in critical parts of your code.
Instead, we recommend using CCCL’s asynchronous memory containers, cuda::device_buffer. Like C++ std::vector, the memory is automatically deallocated once the container goes out of scope.
Additionally, a memory pool backs the buffer, so repeated allocations and deallocations do not pay the full cost of cudaMalloc / cudaFree every time.
To use the GPU memory containers, we update the code accordingly:
// Resource to handle the GPU memory allocations
cuda::device_memory_pool_ref device_resource = cuda::device_default_memory_pool(cuda::device_ref{0});
// Explained at a later stage, unimportant for now
cuda::stream stream{cuda::device_ref{0}};
// Allocate the GPU memory using uninitialized containers
cuda::device_buffer d_image_r = cuda::make_buffer(stream, device_resource, IMAGE_SIZE, cuda::no_init);
...
After this change, we analyze the timeline again:
Figure 5. The Nsight Systems timeline after adopting pooled device-memory containers. Allocation overhead is nearly eliminated, making host-to-device memory transfers the next visible bottleneck
The time spent on memory allocation is now almost nonexistent; the time it takes to compute an image has improved by 2.6x.
The GPU time is now memory-dominated. Almost all the time to compute all images is spent on first copying the red, green and blue, for the three images, from CPU to GPU.
It is possible to greatly speed up the host-to-device memory transfers.
5. Pinned memory: Faster host-to-device memory transfers
CPU data allocations are pageable by default, which the GPU cannot access directly. The CUDA driver must first allocate a temporary page-locked, or pinned, host array, copy the host data to the pinned array, and then transfer the data from the pinned array to device memory.
When it is known in advance that CPU memory will be copied to the GPU, it is advised to directly allocate using pinned memory.
CCCL exposes a pinned-memory host container, cuda::host_buffer, that can be built through the cuda::make_pinned_buffer factory:
// Allocate the CPU memory to store the image tiles, medians, and for the red, green, blue, and grayscale images
// Those CPU containers, contrary to std::vector, are allocated using pinned memory
std::vector> h_images_r(NB_IMAGES, cuda::make_pinned_buffer(stream, IMAGE_SIZE, ...));
...
Following those changes, we can benchmark again:
Figure 6. The Nsight Systems timeline after allocating host data in pinned memory; host-to-device transfers are much shorter and all three images complete in about 25 milliseconds, although their operations still execute sequentially on the default stream
The time it takes to do the host-to-device memory transfers has been significantly reduced; it now only takes 25 ms to compute all images, 10x faster.
For more information on pinned memory, see How to Optimize Data Transfers in CUDA C/C++.
One surprising behavior might have caught the eye of some readers since the beginning:
Though we are using different CPU threads, all operations (memory and kernels) are being executed sequentially on the GPU.
Let’s fix it.
6. Streams: Parallelize operations on the GPU
By default, all operations (kernels, memory allocations, or transfers) are launched on what we call the default stream: it can be viewed as a queue of tasks the GPU needs to execute in order.
In this example, we need a stream for each image/thread. CCCL provides cuda::stream, an owning self-managed version of CUDA streams. It can simply be constructed inside the parallel for loop, so each OpenMP thread gets its own queue of GPU work.
To efficiently leverage streams, we also need to use the asynchronous API: each GPU operation launched by the CPU should not be waited upon until completion. To saturate the GPU, each CPU thread should launch as many operations as possible, as fast as possible, without waiting for them to first complete. Kernels and CUB device calls are already asynchronous by default and are launched on the passed stream. To launch asynchronous copies between the host and the device, we use the new CCCL cuda::copy_bytes API.
It is advised in any modern CUDA code never to rely on the default stream and to always rely on streams.
We update the code accordingly:
A dedicated init_stream is used for the initial allocations of the pinned host buffers. Each iteration of the parallel for loop now owns its own cuda::stream for the computation pipeline:
// Stream used for initial host buffer allocations
cuda::stream init_stream{cuda::device_ref{0}};
...
// Allocate the host pinned buffers on init_stream:
std::vector> h_images_r(NB_IMAGES, cuda::make_pinned_buffer(init_stream, IMAGE_SIZE, ...));
...
// Sync before launching operations on another stream:
init_stream.sync();
#pragma omp parallel for
for (int i = 0; i d_image_r = cuda::make_buffer(stream, device_resource, IMAGE_SIZE, cuda::no_init);
...
// Copy the memory of each container from CPU to GPU asynchronously using the stream owned by each thread
cuda::copy_bytes(stream, h_images_r[i], d_image_r);
...
// Use CUB to convert the RGB images to grayscale asynchronously using the per thread stream
cub::DeviceTransform::Transform(..., stream.get());
// Launch the GPU kernel to compute the median of every tile in the image using the per thread stream
cuda::launch(stream, ...);
// Copy the GPU median memory back to the CPU
cuda::copy_bytes(stream, d_median, h_medians[i]);
...
// To make sure the copy bytes is finished before accessing results on the host
stream.sync();
}
Following those changes, we can take a final look at the timeline:
Figure 7. The final Nsight Systems timeline after assigning a CUDA stream to each image; memory transfers and kernels overlap across three streams, reducing the total processing time from 6.8 seconds to about 23 milliseconds
We now have a complete overlap between our kernels and memory copies.
The final duration to compute all three images following all our improvements is 23 milliseconds, starting from 6.8 seconds.
Your turn
Using the CUDA Developer’s Toolbox, we made the code safer, easier to maintain and faster. No low-level optimizations were used, yet the code is 300x faster.
Try out this code yourself, and run on Google Colab if you like.
We have also built a full class to learn how to use those tools in detail. It is freely available on YouTube alongside links to practice on Google Colab.
Like
Tags
Developer Tools & Techniques | General | cuBB | CUDA | Intermediate Technical | Deep dive | Tutorial
About the Authors
About Nicolas Blin
Nicolas Blin is a senior developer technology engineer at NVIDIA. Nicolas holds an M.Sc. in software engineering. He has a background in image processing and GPU-accelerated algorithms. His interests include massively parallel algorithms, performance optimization, and combinatorial and linear optimization.
View all posts by Nicolas Blin