数据中心/云端

使用 NVIDIA Compute Sanitizer 更有效地调试 CUDA

 

调试代码是软件开发的关键方面,但可能具有挑战性且耗时。并行编程可以为已经很复杂的调试过程引入新的维度,其中可以同时处理数千个线程。

开发人员可以使用各种工具和技术来帮助使调试变得更简单、更高效。本文介绍了一种调试工具:NVIDIA Compute Sanitizer。我们将探索这些功能,并通过示例向您展示它的用途,以便您可以在调试过程中节省时间和精力,同时提高 CUDA 应用程序的可靠性和性能。

Compute Sanitizer 随CUDA Toolkit一起捆绑。

什么是 Compute Sanitizer ?

Compute Sanitizer 是一套工具,可以对代码的功能正确性执行不同类型的检查。调试的一个关键挑战是找到错误的根本原因,解决它通常比追踪它更容易,尤其是在并行执行环境中,因为在这种环境中,错误的来源可能是瞬态的。

Compute Sanitizer 通过检查代码是否存在内存访问违规、竞争条件、对未初始化变量的访问以及同步错误,擅长于根本原因调试。所有这些都可能表现为 bug ,但其行为不一定会直接导致源代码中的根本原因。

您可能已经熟悉一种用于调试的工具:cuda-memcheck。但是,该工具已在 CUDA 11.6 中被弃用,并在 ZCk 12.0 及更高版本中被删除。Compute Sanitizer 已取代它的位置,提供了额外的功能,如改进的性能和对 Microsoft hardware-accelerated GPU scheduling 的支持,以及对内存检查之外的功能的更广泛支持。

Compute Sanitizer 中有四个主要工具:

  • memcheck:用于内存访问错误和泄漏检测
  • racecheck:共享内存数据访问危险检测工具
  • initcheck:未初始化的设备全局内存访问检测工具
  • synccheck:用于线程同步危险检测

除了这些工具, Compute Sanitizer 还有一些额外的功能:

开始使用 Compute Sanitizer

Compute Sanitizer 是 CUDA Toolkit 的一部分。要了解更多信息和获取工具包的链接,请访问 NVIDIA Compute Sanitizer

安装工具包后,使用以下格式从命令行启动 Compute Sanitizer :

$ compute-sanitizer [options] app_name [app_options]

表 1 显示了计算消毒器的选项。想要了解更多信息,请参阅 命令行选项,在 Compute Sanitizer 用户手册 中。

选项 描述
--kernel-regex kns=myKernel 子字符串 控制计算消毒器工具检查哪些内核。对于管理测试和工具输出的大型复杂代码非常有用。
–-launch-skip N 跳过N内核在开始检查之前启动。
–-log-file 文件名 设置 Compute Sanitizer 写入的文件。通常, Compute Sanitizer 直接写入stdout.
--generate-coredump yes 当检测到错误时创建一个 CUDA 核心转储,稍后可以加载到 CUDA debugger 中cuda-gdb以便进一步分析。
表 1 。 Compute Sanitizer 命令行界面的一些选项

为 Compute Sanitizer 编译

Compute Sanitizer 可以在没有任何特殊编译标志的情况下成功分析和检查 GPU 应用程序。但是,通过在代码的编译阶段包含一些额外的标志,可以使工具的输出更加有用,例如-lineinfo生成行号信息,而不会在优化级别上影响代码。然后 Compute Sanitizer 可以将错误归因于源代码行。

计算消毒器内存检查

也许 Compute Sanitizer 中最常用的工具是内存检查器。下面的代码示例显示了一个简单的 CUDA 程序,用于将数组的每个元素乘以标量。这个代码执行到完全没有抱怨,但你能看到它有什么问题吗?

#include <assert.h>
#include <stdio.h>

#define N 1023

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

int main() {
  float* array;
  cudaMallocManaged(&array, N*sizeof(float)); // Allocate, visible to both CPU and GPU
  for (int i=0; i<N; i++) array[i] = 1.0f;    // Initialize array

  printf("Before: Array 0, 1 .. N-1: %f %f %f\n", array[0], array[1], array[N-1]);
  scaleArray<<<4, 256>>>(array, 3.0);
  cudaDeviceSynchronize();

  printf("After : Array 0, 1 .. N-1: %f %f %f\n", array[0], array[1], array[N-1]);
  assert(array[N/2] == 3.0); // Check it's worked
  exit(0);
}

如果您发现越界数组访问,则得 10 分:

  • 执行配置<<<4, 256>>>启动 4 个块,每个块中有 256 个线程,因此总共有 1024 个线程。
  • 数组有长度N= 1023 ,索引为 0 , 1 …,N-2 = 1021 ,N-1 = 1022 。
  • 在某个点上, 1024 线程,它有一个threadGlobalID的值1023 = threadIdx.x + blockIdx.x * blockDim.x = 255+3*256,尝试执行代码。
  • 尝试将越界数组访问作为array[1023].

这导致了一个令人讨厌的错误:“未定义的行为”。它很可能会悄无声息地失败。在较大的程序中,它可能会导致严重的正确性问题,影响其他内存分配,甚至可能导致分段错误。

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

$ nvcc -lineinfo example1.cu -o example1.exe
$ ./example1.exe
Before: Array 0, 1 .. N-1: 1.000000 1.000000 1.000000
After : Array 0, 1 .. N-1: 3.000000 3.000000 3.000000

请来 Compute Sanitizer 提供帮助。尝试运行以下命令,您应该会看到类似的输出:

$ compute-sanitizer --tool memcheck ./example1.exe

========= COMPUTE-SANITIZER
Before: Array 0, 1 .. N-1: 1.000000 1.000000 1.000000
========= Invalid __global__ read of size 4 bytes
=========     at 0x70 in /home/pgraham/devblog/NCS/example1.cu:8:scaleArray(float *, float)
=========     by thread (255,0,0) in block (3,0,0)
=========     Address 0x7f3aae000ffc is out of bounds
=========     and is 1 bytes after the nearest allocation at 0x7f3aae000000 of size 4092 bytes
...

想要了解更多关于如何解释此输出的信息,请参阅 理解 Memcheck 错误,但我们可以讨论一些关键特性。首先,您会得到错误 info Invalid __global__ read,因为 GPU 正试图读取某个不是合法地址的全局存储器。然后,您可以获得文件和行号,以及导致此问题的实际线程和块。在这种情况下,example1.cu:8 映射到源中的直线 array[threadGlobalID] = array[threadGlobalID]*value;

现在您可以修复代码了。有多种选择,但添加if threadGlobalID<N之前的错误线路可能是最容易的。重新编译并运行memcheck 工具再次确认。

现在,你发现其他问题了吗?

如果你发现缺少,得 20 分cudaFree 对于MallocManaged 数组。同样,代码运行到完成。您似乎得到了正确的答案,但由于没有释放分配的内存,您引入了泄漏!这可能会减少后续应用程序可用的内存量,甚至导致系统不稳定。

香草味的memcheck 错过了这个。如何检查这些错误?的附加选项之一memcheck 该工具可以在以下方面为您提供帮助:--leak-check=full.

$ compute-sanitizer --tool memcheck --leak-check=full ./example1.exe

========= COMPUTE-SANITIZER
Before: Array 0, 1 .. N-1: 1.000000 1.000000 1.000000
After : Array 0, 1 .. N-1: 3.000000 3.000000 3.000000
========= Leaked 4092 bytes at 0x7ff652000000
=========     Saved host backtrace up to driver entry point at allocation time
=========     Host Frame: [0x2b7e93]
=========                in /usr/lib/x86_64-linux-gnu/libcuda.so.1
=========     Host Frame:__cudart585 [0x439a0]
=========                in /home/pgraham/devblog/NCS/./example1.exe
=========     Host Frame:__cudart836 [0x10c76]
=========                in /home/pgraham/devblog/NCS/./example1.exe
=========     Host Frame:cudaMallocManaged [0x51483]
=========                in /home/pgraham/devblog/NCS/./example1.exe
=========     Host Frame:cudaError cudaMallocManaged<float>(float**, unsigned long, unsigned int) [0xb066]
=========                in /home/pgraham/devblog/NCS/./example1.exe
=========     Host Frame:main [0xac2e]
=========                in /home/pgraham/devblog/NCS/./example1.exe
=========     Host Frame:__libc_start_main [0x24083]
=========                in /usr/lib/x86_64-linux-gnu/libc.so.6
=========     Host Frame:_start [0xab0e]
=========                in /home/pgraham/devblog/NCS/./example1.exe
=========
========= LEAK SUMMARY: 4092 bytes leaked in 1 allocations
========= ERROR SUMMARY: 1 error

您应该看到类似于代码示例中所示的输出。cudaError 突出显示,这表明您对cudaMallocManaged 创建了泄漏的内存。在代码退出之前,未释放分配的内存。正在添加cudaFree(array);就在最后exit(0);修复了这个问题。这样做,重新编译、执行并检查您(以及memcheck 工具)现在对您的代码感到满意。

这是一个简单的程序,用于在 GPU 上缩放阵列,以显示 Compute Sanitizer 和 memcheck 如何工作。使用网格步长循环,可以为任意大小的数组编写代码,以访问 CUDA 中的数组。要了解有关围绕 CUDA API 调用的错误检查代码的更多信息,请参阅 如何在 CUDA C/C++ 中查询设备属性和处理错误

什么是数据竞赛?

数据竞赛是并行编程方法特有的问题。当多个线程同时访问共享数据,并且至少有一个访问是写操作时,就会发生这种情况。图 1 显示了一个简单的示例。

Diagram shows threads A and B performing overlapping operations on values in shared memory so the local values are different and there is a question mark on the final shared value depending on when operations complete.
图 1 。具有重叠操作的并行线程的数据竞赛示例

使用声明的存储__shared__限定符被放置在片上共享存储器中。同一线程块内的所有线程都可以访问这种按块共享内存,与全局内存访问相比速度要快得多。共享内存经常用于线程间通信,并用作保存正在处理的数据的临时缓冲区。

考虑线程 A 和线程 B 并行工作,并将它们的本地计数贡献给共享计数器。线程将它们自己的本地值添加到共享值,并同时将它们的和写回共享内存。由于 A 和 B 现在正在向同一地址写入不同的值,因此发生了数据竞争,结果突然不正确,甚至可能是未定义的。

有一些机制可以避免这种情况。例如,锁和原子操作通过保护对共享值的更新来帮助确保正确的行为。然而,我们都容易犯错。在具有数千个线程的复杂代码中,是否存在问题可能是不明确的。共享值很可能仍然会增加,只是不是按照数据值所建议的方式增加,从而产生一个看似成功的、带有错误值的运行。

这就是 Compute Sanitizerracecheck功能是如此宝贵。此工具是一个竞争条件检测功能,可帮助您识别和解决 CUDA 代码中的数据竞争。

下面的代码示例显示了用于演示的 GPU 内核racecheck:

#include <assert.h>
#include <stdio.h>

#define N 1024

__global__ void blockReduceArray(int* array, int* sum) {
  int threadGlobalID = threadIdx.x + blockIdx.x * blockDim.x;
  __shared__ int blockSum;

  if (threadIdx.x  == 0 ) {
    sum[blockIdx.x] = 0; // Initialise the return value
    blockSum = 0;        // Initialise our block level counter
  }
  __syncthreads();

  // Add each thread's value to our block level total
  blockSum += array[threadGlobalID];
  __syncthreads();

  // Set the return value
  if (threadIdx.x  == 0 ) sum[blockIdx.x] = blockSum; 
  return;
}

int main() {
  int globalSum;
  int* sum;
  int* array;
  int numBlocks = 4;
  cudaMallocManaged(&array, N*sizeof(int));
  cudaMallocManaged(&sum, numBlocks*sizeof(int));
  for (int i=0; i<N; i++) array[i] = 1; // Initialize array

  blockReduceArray<<<numBlocks, N/numBlocks>>>(array, sum);
  cudaDeviceSynchronize();

  // Do a reduction on the host of the block values
  globalSum = 0;
  for (int i=0; i<numBlocks; i++) globalSum += sum[i];
  printf("After kernel - global sum = %d\n", globalSum);

  cudaFree(sum);
  cudaFree(array);
  exit(0);
} 

该示例将数组中的所有值相加以生成单个值,也称为减少活动它在 GPU 上的块级别进行汇总。然后,每个块的总和返回到主机,并再次求和,以返回将数组中的每个值相加的总值。此示例使用快速共享内存作为缓冲区,以保存数组元素添加的运行总数。

这种方法避免了对全局内存进行不必要的写入,直到内核结束时进行最终更新。在引入此类优化时,最好使用分析驱动的方法。对代码进行分析,检查是否存在任何瓶颈、未充分利用的硬件或要优化的算法;应用您的更改;然后重复。

在您熟悉了代码之后,编译并运行它,看看它是否有效。您正在将数组中的每个元素初始化为一,并且有 1024 个元素,因此最终的总和应该是 1024 。以下是输出:

$ nvcc -lineinfo example2.cu -o example2.exe
$ ./example2.exe
$
After kernel - global sum = 4

另一个错误: 4 绝对不是 1024 ,正如你所期望的那样!

计算消毒器racecheck 帮助您确定失败的原因并避免出现这种情况。跑道检查 命令的执行方式与memcheck下面的示例显示了该命令的输出。第 17 行出现问题,如错误消息所示。

$ compute-sanitizer --tool racecheck ./example2.exe

========= COMPUTE-SANITIZER
========= Error: Race reported between Read access at 0xe0 in /home/pgraham/devblog/NCS/example2.cu:17:blockReduceArray(int *, int *)
=========     and Write access at 0x100 in /home/pgraham/devblog/NCS/example2.cu:17:blockReduceArray(int *, int *) [16 hazards]
=========
After kernel - global sum = 4
========= RACECHECK SUMMARY: 1 hazard displayed (1 error, 0 warnings)

如果您查看突出显示的代码行,您可以看到问题:

  ... 
  // Add each thread's value to the block level total
  blockSum += array[threadGlobalID];
  ...

块中的所有线程同时尝试读取存储为blockSum,将它们的数组值添加到其中,并将其写回共享内存地址。这创建了一个竞赛条件,如图 1 中的示例所示。因此,每个线程读取共享值( 0 ),将其递增( 1 ),然后写回 1 。最终,共享值最终是 1 ,而不是 256 ,当将四个块中的每一个加在一起时,您会看到错误的答案 4 。

您可以通过将第 17 行更改为 atomicAdd 来实现:

atomicAdd(&blockSum, array[threadGlobalID]);

此操作保护对共享值的访问blockSum 通过确保它是由访问线程以串行方式读取、递增和写入的。代码现在可以正常运行。

顺便提一句,atomicAdd 在修复过程中可能会降低代码性能。例如,它可能会序列化每个块中的 256 个线程。NVIDIA CUB 是一个可重复使用的软件组件库,它提供块级和设备级原语,用于执行高度优化的缩减操作。

在可能的情况下,我们建议在开发和性能调优通用代码模式时使用库或组件(如 CUB ),因为它们通常会超过您在合理时间内可以实现的性能。而且它们通常是免费的!

如果不是这样简单的代码知道了预期的答案,那么像这样的比赛条件很容易被发现。所以racecheck 帮助避免了以后难以破解的问题。

结论

使用NVIDIA Compute Sanitizer,立即下载CUDA Toolkit

希望我们已经向您介绍了如何开始使用 Compute Sanitizer。当然,这些工具的功能非常丰富,我们只是略知一二。想要了解更多关于 Compute Sanitizer 的信息和示例,请访问 NVIDIA/compute-sanitizer-samples GitHub 样本回购和 Compute Sanitizer 用户手册

最近的 GTC 课程涵盖了 Compute Sanitizer 中引入的一些新功能:

为了获得支持,开发者论坛以及专门针对 sanitizer 工具的 子论坛都是不错的起点。

如果你想更深入地了解本文中没有讨论的任何功能,请告诉我们。祝你好运!

 

Tags