开发工具与技巧

现代 CUDA 工具箱的实际应用:分步优化演练

NVIDIA CUDA 仍然是 GPU 加速计算的基础,为从科学模拟到大规模 AI 训练的一切提供支持。

但是,编写正确、可维护和高性能的 CUDA 代码可能具有挑战性:显存错误隐藏在显而易见的视野中,如果没有正确的仪器,性能瓶颈可能是不可见的,手动滚动的 GPU 算法几乎无法与优化库的效率相匹配。幸运的是,现代 CUDA 工具链已经显著成熟,其中许多挑战现在都有简单直接的解决方案。

在本博文中,我们将介绍 NVIDIA 提供的用于调试、基准测试和改进代码的工具。每次只需更改少量行,我们就能使示例代码更安全、更易于维护和更快。

本博文将介绍六个渐进步骤:

  1. 如何通过采用现代 CCCL API 和 Compute Sanitizer 轻松找到索引错误
  2. 如何使用 NVTX 改进 Nsight Systems 基准测试
  3. 如何在块和设备级别使用 CUB 的优化算法
  4. 如何通过池化容器管理 GPU 显存
  5. 如何使用固定容器加速主机到设备的传输
  6. 如何通过为每个线程分配自己的流和异步传输来并行化 GPU 工作

作为本博文的配套内容,我们提供了在 Google Colab 上运行的代码和选项。

起点:图像处理工作流示例

从红色、绿色和蓝色图像的输入流开始,将数据从 CPU 传输到 GPU。然后将 RGB 转换为灰度。

然后,对于图像中的每个 32 x 32 像素图块,通过对像素进行排序并选择中间值来计算中值。最后,将每个图块的中位数复制回 CPU。

基础代码示例

以下是完整的起始代码。本博文中的每个步骤都会改进。

#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)
// Alias for an image pixel
using pixel_t = uint8_t;
// Kernel converting the red, green and blue images into a single gray image
__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) {
    // 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 < width && y < height) {
        // Compute the thread index in the image
        const int i = x + y * width;
        // Convert from rgb to grayscale and store the result in global memory
        d_image_gray[i] = static_cast<pixel_t>(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 <int TILE_WIDTH, int HISTO_SIZE>
__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 < width && y < height))
        return;
    // Allocate the shared memory in which we will store the tile
    __shared__ pixel_t tile[TILE_WIDTH * TILE_WIDTH];
    // Compute the thread index in the image
    const int index = x + y * width;
    // Load the tile's grayscale value from global memory into shared memory
    tile[index] = d_image_gray[index];
    // Synchronize to make sure all threads have loaded their data
    __syncthreads();
    // Sort the tile array using a single threaded bubble sort
    if (threadIdx.x == 0 && threadIdx.y == 0) {
        for (int i = 0; i < TILE_WIDTH * TILE_WIDTH; ++i)
            for (int j = i + 1; j < TILE_WIDTH * TILE_WIDTH; ++j)
                if (tile[i] > 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<std::vector<pixel_t>> h_images_r(NB_IMAGES, std::vector<pixel_t>(IMAGE_SIZE, 4));
    std::vector<std::vector<pixel_t>> h_images_g(NB_IMAGES, std::vector<pixel_t>(IMAGE_SIZE, 4));
    std::vector<std::vector<pixel_t>> h_images_b(NB_IMAGES, std::vector<pixel_t>(IMAGE_SIZE, 4));
    std::vector<std::vector<pixel_t>> h_images_gray(NB_IMAGES, std::vector<pixel_t>(IMAGE_SIZE, 0));
    std::vector<std::vector<pixel_t>> h_medians(NB_IMAGES, std::vector<pixel_t>(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 < NB_IMAGES; ++i)
    {
        pixel_t *d_image_r, *d_image_g, *d_image_b, *d_image_gray, *d_median;
        // Allocate the GPU memory for each container
        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)));
        // Copy the memory of each container from CPU to GPU
        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));
        // Launch a GPU kernel to convert the RGB images to grayscale
        dim3 blockSize(TILE_WIDTH, TILE_WIDTH);
        dim3 gridSize(cuda::ceil_div(IMAGE_LENGTH, blockSize.x), cuda::ceil_div(IMAGE_LENGTH, blockSize.y));
        computeRGBToGray<<<gridSize, blockSize>>>(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<TILE_WIDTH, HISTO_SIZE><<<gridSize, blockSize>>>(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;
}

此代码首先定义两个内核:

  • computeRGBToGray 加载红色、绿色和蓝色输入图像的值,以将其转换并写入灰度输出图像。
  • computeMedian 计算输入灰度图像每个图块的中位数。每个线程块将图块从全局内存加载到共享内存。然后,使用单个线程对数组进行排序,并写入在全局输出中值数组中的中间索引 (对应中值) 处找到的值。

在正文中,定义示例中使用的常量后,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

虽然代码中有一些错误检查,但当您收到类似“非法内存访问”的错误消息时,您应该首先使用 computer – sanitizer 进行进一步调查。

使用 NVIDIA 功能正确性检查套件 Compute Sanitizer,我们可以直接识别难以发现的错误:

$ 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];

该行使用全局索引错误地加载共享内存中的数据。由于共享内存是在线程块级别定义的,因此我们需要更改索引。为避免索引错误,我们在 CCCL 中引入了新的 API,以区分全局索引和块级索引。要使用它,您首先需要使用新的 cuda::launch API 启动内核:

auto config = cuda::make_config(cuda::block_dims(...), cuda::grid_dims(...));
cuda::launch(stream, config, kernel_name<decltype(config)>, input)

然后,在内核中使用新的索引 API:

template <typename Configuration>
__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);
}

如果不使用 computing – sanitizer 或新 API,也可以使用 cuda::std::span 或其 n 维变体 cuda::std::mdspan 而不是原始指针来直接发现此错误。cuda::std::spancuda::std::mdspan 是连续内存的非拥有视图,有助于抽象出确切的容器。通过 span 访问数据比通过原始指针更安全,部分原因是在调试模式下,越界访问将触发断言。

内核应更新为:

// Alias for a 2-dimensional 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.

还应使用 cuda::shared_memory_mdspan 保护共享内存中的内存访问,如下文代码段所示:

__shared__ pixel_t shared[TILE_WIDTH * TILE_WIDTH];
cuda::shared_memory_mdspan tile_2d(shared, TILE_WIDTH, TILE_WIDTH);

现在,您可以运行,一切都应正常执行,而不会出现错误。

使用新的启动 API 及其索引机制,跨原始指针,而计算清理器、越界访问要么不发生,要么立即被捕获。有关 compute-sanitizer 的更多信息,请参阅Efficient CUDA Debugging:How to Hunt Bug with NVIDIA Compute Sanitizer (高效 CUDA 调试:如何使用 NVIDIA Compute Sanitizer 追踪错误) 。

2. Nsight Systems 和 NVTX:正确地对代码进行基准测试

现在,该代码没有错误,可以使用 NVIDIA Nsight Systems 进行基准测试。它允许您可视化程序时间轴:了解每个函数何时被调用以及调用时长。

为简化时间轴可视化,我们使用 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();
}

这会生成以下结果:

在以上图 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 Pro 提示:使用 NVTX 生成自定义应用程序配置文件时间轴

3. CUB:直接在 GPU 上使用 Express 算法

在处理常见算法时,编写自定义内核容易出错,并且极有可能导致实现效率低下。无论对于设备端模式还是内核内基元,建议尽可能使用 CUB。

CUB 是通过 CCCL 提供的 NVIDIA 并行算法库。它以多种粒度呈现高度优化的例程:设备级 (cub::Device*) 、块级 (cub::Block*) 和线程束级 (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 yellow 弹出式图像中查看消耗时间) ,速度提高了 2717 倍。现在,计算全部三张图像的总时间为 635 毫秒,速度提高了 10 倍。

如果我们重新评估当前瓶颈:在内存分配上花费的时间约占总图像计算运行时的 83%。

这可以得到很大的改进。

4. 池化内存容器:便捷、快速的内存管理

使用 cudaMalloc 分配 GPU 显存可能会产生意想不到的负面影响:忘记调用 cudaFree 而造成泄露,以及在代码的关键部分进行昂贵的内存操作。

相反,我们建议使用 CCCL 的异步内存容器 cuda::device_buffer。与 C++ std::vector 类似,一旦容器超出范围,内存将自动取消分配。

此外,内存池支持缓冲区,因此重复分配和取消分配无法支付每次 cudaMalloc/ cudaFree 的全部成本。

为使用 GPU 显存容器,我们会相应地更新代码:

// 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<pixel_t> d_image_r = cuda::make_buffer<pixel_t>(stream, device_resource, IMAGE_SIZE, cuda::no_init);
...

更改后,我们将再次分析时间轴:

现在,分配内存所花费的时间几乎不复存在;计算图像所需的时间缩短了 2.6 倍。

GPU 时间现在以内存为主。在计算所有图像时,几乎所有时间都花在从 CPU 到 GPU 的三个图像的红色、绿色和蓝色上。

可以大幅加速主机到设备的内存传输。

5. 固定显存:更快的主机到设备显存传输

CPU 数据分配默认可分页,GPU 无法直接访问。CUDA 驱动程序必须首先分配一个临时的锁页或固定主机阵列,将主机数据复制到固定阵列,然后将数据从固定阵列传输到设备内存。

当预先知道 CPU 内存将复制到 GPU 时,建议使用固定内存直接分配。

CCCL 提供了一个固定内存主机容器 cuda::host_buffer,可通过 cuda::make_pinned_buffer 工厂进行构建:

// 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<cuda::host_buffer<pixel_t>> h_images_r(NB_IMAGES, cuda::make_pinned_buffer<pixel_t>(stream, IMAGE_SIZE, ...));
...

作出这些更改后,我们可以再次进行基准测试:

主机到设备内存传输所需的时间已显著缩短;现在计算所有图像只需 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

// 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<cuda::host_buffer<pixel_t>> h_images_r(NB_IMAGES, cuda::make_pinned_buffer<pixel_t>(init_stream, IMAGE_SIZE, ...));

...

// Sync before launching operations on another stream:
init_stream.sync();

#pragma omp parallel for
for (int i = 0; i < NB_IMAGES; ++i)
{
    // One different stream per thread
    cuda::stream stream{cuda::device_ref{0}};

    ...

    // GPU buffer allocations using the stream owned by each thread
    cuda::device_buffer<pixel_t> d_image_r = cuda::make_buffer<pixel_t>(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();
}

作出这些更改后,我们可以最终查看时间轴:

现在,内核和内存复制之间完全重叠。

经过所有改进后,计算全部三张图像的最终时长为 23 毫秒,从 6.8 秒开始。

轮到您了

借助 CUDA 开发者工具箱,我们使代码更安全、更易于维护且速度更快。虽然没有使用底层优化,但代码的速度提高了 300 倍。

自己试用此代码,如果您愿意,也可以在Google Colab上运行。

我们还构建了一个完整的课程来学习如何详细使用这些工具。您可以在 YouTube 上免费获取该模型,以及 Google Colab 上的练习链接。

标签