数据中心/云端

高效 CUDA 调试:使用 NVIDIA Compute Sanitizer 进行内存初始化和线程同步

 

NVIDIA Compute Sanitizer (NCS) 是一个功能强大的工具,它可以帮助您节省时间和精力,同时提高 CUDA 应用程序的可靠性和性能。

在我们之前的帖子 高效的 CUDA 调试:如何使用 NVIDIA Compute Sanitizer 追踪 Bug 中,我们探讨了并行编程领域的高效调试。我们讨论了在 CUDA 环境中调试代码的挑战性和耗时性,尤其是在处理数千个线程时,以及 NCS 如何帮助实现这一过程。

这篇文章继续我们对高效 CUDA 调试的探索。它重点介绍了更多的 NCS 工具,并介绍了几个示例。

NVIDIA Compute Sanitizer

NCS 是一套工具,可以对代码的功能正确性执行不同类型的检查。NCS 中有四个主要工具:

  • Memcheck 用于内存访问错误和泄漏检测
  • Racecheck:这是一个用于检测共享内存数据访问风险的工具。
  • Initcheck,这是一个用于检测未初始化的设备全局内存访问的工具
  • Synccheck:用于线程同步的危险检测。

除了这些工具之外,NCS 功能还包括:

本文将重点介绍如何使用 initcheck 调试代码和捕捉与未初始化的设备阵列相关的错误,以及如何使用同步检查。请参阅 高效的 CUDA 调试:如何使用 NVIDIA Compute Sanitizer 追踪 Bug 了解更多关于如何使用 memcheck 发现内存泄漏和跑道检查查找竞态条件的详细信息。

初始化检查

NCS Initcheck 帮助开发人员识别和解决 CUDA 代码中未初始化的内存访问错误。在 CUDA 应用程序中,未初始化的内存访问可能导致不可预测的行为和不正确的结果。

NCS Initcheck 可以检测设备代码中对全局内存的未初始化内存访问,并提供有关访问位置和时间的详细信息,以及访问线程的堆栈跟踪。这有助于揭示问题的根本原因并解决问题。

为了提供一个示例,下面的代码受益于初始化检查。

#include <stdio.h>

#define THREADS 32
#define BLOCKS 2

__global__ void addToVector(float *v) {
  int tx = threadIdx.x + blockDim.x * blockIdx.x;
  v[tx] += tx;
}

int main(int argc, char **argv) {
  float *d_vec = NULL;
  float *h_vec = NULL;

  h_vec = (float *)malloc(BLOCKS*THREADS * sizeof(float));
  cudaMalloc((void**)&d_vec, sizeof(float) * BLOCKS * THREADS);
  cudaMemset(d_vec, 0, BLOCKS * THREADS); // Zero the array

  addToVector<<<BLOCKS, THREADS>>>(d_vec);
  cudaMemcpy(h_vec, d_vec, BLOCKS*THREADS * sizeof(float), cudaMemcpyDeviceToHost);
  cudaDeviceSynchronize();
  printf("After : Vector 0, 1 .. N-1: %f %f .. %f\n", h_vec[0], h_vec[1], h_vec[BLOCKS*THREADS-1]);

  cudaFree(d_vec);
  free(h_vec);
  exit(0);
}

此代码包含一个名为addToVector它对向量中的每个元素执行简单的值相加,并将结果写回同一个元素。在‌乍一看,它看起来很好:用库达马洛克,然后将其归零cudaMemset,然后在内核中执行计算。它甚至打印出正确的答案:

$ nvcc -lineinfo initcheck_example.cu -o initcheck_example
$ ./initcheck_example
After : Vector 0, 1 .. N-1: 0.000000 1.000000 .. 63.000000

但代码中包含一个小错误。(如果你能发现的话,得 20 分。)

使用 NCSinitcheck工具,用于检查对设备上全局内存中矢量的任何访问是否试图读取未初始化的值。

$ compute-sanitizer --tool initcheck ./initcheck_example
========= COMPUTE-SANITIZER
========= Uninitialized __global__ memory read of size 4 bytes
=========     at 0x70 in /home/pgraham/Code/BlogExamples/initcheck_example.cu:8:addToVector(float *)
=========     by thread (16,0,0) in block (0,0,0)

. . .

========= Uninitialized __global__ memory read of size 4 bytes
=========     at 0x70 in /home/pgraham/Code/BlogExamples/initcheck_example.cu:8:addToVector(float *)
=========     by thread (17,0,0) in block (0,0,0)
. . . 
=========
After : Vector 0, 1 .. N-1: 0.000000 1.000000 .. 63.000000
========= ERROR SUMMARY: 48 errors

这应该会打印很多信息(为了简洁起见,显示的输出经过了编辑),但有些地方不正确。大量的输出是回溯信息,可以使用–显示回溯编号选项:

$ compute-sanitizer --tool initcheck --show-backtrace no ./initcheck_example

查看输出,您可以看到总共 48 个错误。报告显示,它们都是这种类型,未初始化的__global__内存读取大小为 4 字节.

每条消息都表示试图从全局设备内存中读取一些内容,并且这些内容的大小为 4 字节。一个合理的猜测是,错误指的是试图访问向量的元素,这些元素由每个 4 字节的浮点组成。

查看第一个错误,消息的下一部分指示是哪个线程和哪个线程块导致了错误。在这种情况下,它是块 0 中的线程 16。由于内核被设置为使得每个线程访问向量的不同元素、向量的元素 17,d_vec[16],未初始化。

在您的输出中,您可能会看到一个不同的线程第一一个导致错误。GPU 可以按其认为合适的任何顺序调度扭曲(32 个线程的组)。但是,检查输出的其余部分,并说服自己向量中导致错误的最低元素是元素 17(块 0 中的线程 16)。

接下来,查看初始化(或应该初始化)数组的代码行:

cudaMemset(d_vec, 0, BLOCKS * THREADS); // Zero the array

检查的定义cudaMemset,它需要三个参数:指向要设置的设备内存的指针(d_vec在这种情况下)每个字节在该内存区域中应该设置(在这种情况下为 0),以及要设置的字节数(块*螺纹)。

现在问题开始变得更加明显。矢量包含 64 个元素,由块*螺纹构成,但每个元素都是一个浮点值,因此整个向量的长度为 256 字节。cudaMemset 仅初始化前 64 个 字节 (前 16 个元素),这意味着剩余的 192 个字节(相当于 48 个元素)未初始化。这 48 个元素对应于 48 个错误。

这与元素 17(线程 16,块 0)是第一个导致错误的观察结果相一致。宾果,发现问题了。

若要解决此问题,请更改cudaMemset呼叫:

cudaMemset(d_vec, 0, sizeof(float) * BLOCKS * THREADS);

并检查以确保消毒液是愉快的。

检查未使用的内存

的另一个功能initcheck该工具正在识别应用程序结束时尚未访问的已分配设备内存。在某些程序中,这可能是经过深思熟虑的——例如,使用大的静态缓冲区来处理一系列潜在的问题大小。但是,当这更可能是一个导致错误的错误时,使用initcheck,如下所示。

#include <stdio.h>

#define N 10

__global__ void initArray(float* array, float value) {
  int threadGlobalID = threadIdx.x + blockIdx.x * blockDim.x;
  if (threadGlobalID < N)
    array[threadGlobalID] = value;
  return;
}

int main() {
  float* array;

  const int numThreadsPerBlock = 4;
  const int numBlocks = 2;

  cudaMalloc((void**)&array, sizeof(float) * N);

  initArray<<<numBlocks, numThreadsPerBlock>>>(array, 3.0);
  cudaDeviceSynchronize();

  cudaFree(array);
  exit(0);
}

这个非常基本的代码将揭示潜在的错误。它正在初始化一个数组,但线程的数量和块的数量是硬编码的。执行配置<<<…>>将启动一个由八个线程组成的网格,而数据集有 10 个元素(最后两个元素将不使用)。

使用 track unused memory(跟踪未使用的内存)选项进行检查。请注意,所需的语法将取决于所使用的 CUDA 版本。对于 12.3 之前的版本,使用以下内容提供参数“yes”:

--track-unused-memory yes ;

从 12.3 版本开始,不需要提供参数,如下所示:

$ nvcc -o unused -lineinfo unused.cu
$ compute-sanitizer --tool initcheck --track-unused-memory ./unused
========= COMPUTE-SANITIZER
=========  Unused memory in allocation 0x7fe0a7200000 of size 40 bytes
=========     Not written 8 bytes at offset 0x20 (0x7fe0a7200020)
=========     20% of allocation were unused.
=========
========= ERROR SUMMARY: 1 error

清晰地跟踪未使用的内存表示 40 个字节(10 x 4 字节浮动)的数组包含 8 个未写入的字节。请使用数组地址(第一个长 0x…数字)和偏移量(0 x 20,十进制为 32,因此为 32 个字节或 8 个浮动)查看哪些字节未使用。正如预期的那样,阵列中的浮动 9 和 10 没有被使用。

要解决此问题,请使用N定义numBlocks:

const int numBlocks = (N + numThreadsPerBlock - 1) / numThreadsPerBlock;

请注意–跟踪未使用的内存设计用于分配的设备内存库达马洛克。该功能不适用于统一内存(cudaMallocManaged分配的存储器)。

同步检查

协作组编程模型启用了在各种级别(不仅仅是块和扭曲)同步线程的强大 CUDA 功能。协作组是一个设备代码 API,用于定义、分区和同步线程组,相比标准提供了更多的灵活性和控制同步线程函数,用于同步块中的所有线程。有关更多详细信息,请参阅 协作组:灵活的 CUDA 线程编程

然而,这种能力带来了更多引入错误的机会。这就是 NCS同步检查可以帮助识别和解决 CUDA 代码中的同步错误。同步检查可以识别 CUDA 应用程序是否正确地使用同步原语及其协同组 API 对应方。

同步的一个有趣的应用是将掩码应用于线程的扭曲。设置扭曲,使一些线程为 true,另一些为 false,从而使每个线程能够根据该属性执行不同的操作。有关更多详细信息,请参阅 使用 CUDA 扭曲级别基本体

一个有用的功能是__气球同步定义为:

unsigned int __ballot_sync(unsigned int mask, int predicate);

面具是初始掩码,通常在所有位都设置为 1 的情况下创建,表示扭曲中的所有线程最初都处于活动状态。谓语是由每个线程计算的条件,其中谓词对每个线程的计算结果为 true(非零)或 false(零)。

投票函数评估 warp 中每个线程的谓词,并返回一个表示该线程结果的掩码。它还提供了一个同步点。经线中的所有线都必须达到__气球同步在他们中的任何一个能够进一步进行之前。

例如,设置一个遮罩,其中扭曲中的偶数线程为 true,奇数线程为 false:

__ballot_sync(0xffffffff, threadID % 2 == 0);

初始掩码0xffffff是十六进制表示,计算结果为1111111111111111二进制。这确保了所有 32 个线程都参与到投票中。

投票结果是一个面具,0xaaaaaaaa,二进制形式为10101010101010101010偶数线程(线程 ID 0、2、4…)被设置为 true,奇数线程被设置为 false。

选票通常与__同步扭曲,可以基于所提供的掩码同步经线中的线程。

以下示例同时使用_气球同步和_同步扭曲:

static constexpr int NumThreads = 32 ;

__shared__ int smem[NumThreads];

__global__ void sumValues(int *sum_out) {
    int threadID = threadIdx.x;

    unsigned int mask = __ballot_sync(0xffffffff, threadID < (NumThreads / 2));

    if (threadId <= (NumThreads / 2)) {
        smem[threadId] = threadId;

        __syncwarp(mask);

        if (threadID == 0) {
          *sum_out = 0;
          for (int i = 0; i < (NumThreads / 2); ++i)
            *sum_out += smem[i];
        }
    }

    __syncThreads();
}

int main(){
    int *sum_out = nullptr;

    cudaMallocManaged((void**)&sum_out, sizeof(int));

    sumVaules<<<1, NumThreads>>>(sum_out);
    cudaDeviceSynchronize();
    
    printf("Sum out = %d\n", *sum_out);
    cudaFree(sum_out);
    return 0;
}

在进一步阅读之前,请先看一下代码,并根据您对选票和同步扭曲功能。看看你是否能发现问题所在。(这次得了 50 分,更具挑战性。)

这个代码的目的是让每个线程为共享内存分配一个值,然后将所有值相加得到一个答案。但是,这只适用于一半的可用线程。通过执行配置设置了 32 个线程的单个翘曲<<<1,numThreads>>执行内核sumValues。

在该内核中,使用__气球同步具有threadID<线程数/2作为谓词,它将在曲速的前半部分求值为 true,其中螺纹 ID<16(线程 0、1、..15)。

对于这 16 个线程,为共享内存分配一个值(threadID),然后执行__syncwarp(遮罩)同步这些线程以确保它们拥有所有‌ 写入共享内存。然后基于这些值更新 sum_out 全局和。

接下来,尝试编译并运行以下代码:

$ nvcc -o ballot_example -lineinfo ballot_example.cu
$ ./ballot_example
Sum out = 0

答案为零,不正确。它应该是 120(15+14+13+…+2+1+0)。

你发现错误了吗?代码的条件部分使用 if 执行(threadId<=(线程数/2))。此代码使用<=而不是<作为比较器,这意味着前 17 个线程将执行。

当线程 17 尝试调用时会发生什么同步扭曲当它没有被包含在面具中时?它‌ 导致整个内核停止运行,因此永远不会达到总和计算。因此输出为零。

所有这些都会无声地失败,只有不正确的输出才表明有问题。在里面‌更复杂的代码,这可能是一场噩梦。

使用同步检查提供了以下内容:

$ compute-sanitizer --tool synccheck --show-backtrace no ./ballot_example
========= COMPUTE-SANITIZER
========= Barrier error detected. Invalid arguments
=========     at 0x220 in /home/pgraham/Code/devblog/NCS_Part2/ballot_example.cu:32:sumValues(int *)
=========     by thread (0,0,0) in block (0,0,0)
=========

. . .

========= Barrier error detected. Invalid arguments
=========     at 0x220 in /home/pgraham/Code/devblog/NCS_Part2/ballot_example.cu:32:sumValues(int *)
=========     by thread (16,0,0) in block (0,0,0)
=========
Sum out = 0
========= ERROR SUMMARY: 17 errors

关于这 17 个错误,“无效参数”synccheck 文档 声明,如果不是所有线程都到达 __同步扭曲,那么它们会在 mask 参数中声明自己。

在这种情况下,线程 17 或线程(16,0,0)不是 活跃在掩码中,所以它不应该调用同步扭曲。请注意,这会导致所有其他线程调用同步扭曲也登记一个错误。他们单独打电话同步扭曲,但因为其中一个导致它失败,而所有其他同步扭曲调用也必须失败。这是一个集体操作,总共导致 17 个错误。

结论

这篇文章介绍了如何使用 NVIDIA Compute Sanitizer 中的 initcheck 和同步检查功能。要开始使用 NCS,请下载 CUDA 工具包

要了解更多信息,请访问 NVIDIA/compute-sanitizer-samples 在 GitHub 上,并阅读 NCS 文件。欢迎加入 NVIDIA 开发者论坛,这是一个专门讨论 sanitize工具的平台。祝您好运!

 

Tags