Tracing Hanging and Complicated GPU Kernels Down To The Source Code
Tracing Hanging and Complicated GPU Kernels Down To The Source Code
发表时间: 2025-12 · Blog post by Kaichao You (vLLM) (blog.vllm.ai)
原文: https://blog.vllm.ai/blog/2025-12-03-improved-cuda-debugging
作者/机构:Kaichao You (vLLM)
速读
一句话结论 本文提出了一套基于 GPU 核心转储与二进制反汇编的底层调试工作流,成功解决了 CUDA 内核死锁挂起无法排查,以及复杂 C++ 内联函数报错时调试器无法定位到真实业务源码行的问题。
要解决什么问题 现代 GPU 的算力呈指数级增长,但显存带宽未能同步提升。这种不平衡迫使高性能内核引入了极其复杂的异步内存访问模式和同步机制,在复杂的代码库中极易引发竞争条件和死锁,导致 GPU 内核挂起。原有做法在面对内核挂起时存在严重卡点:由于 Python 解释器正在等待底层的 CUDA API 返回,此时并没有 Python 代码在运行,因此无法捕获 Ctrl-C 发出的 SIGINT 信号。程序会完全失去响应,开发者只能强杀进程,这导致丢失所有现场信息,只能通过二分法修改代码并反复测试来盲猜错误根源。此外,在排查非法内存访问等崩溃问题时,即使开启了核心转储,现有的 cuda-gdb 调试器也存在致命缺陷。由于 C++ 代码为了消除运行时函数调用开销而重度依赖编译器内联(如 PyTorch 或 CUTLASS 库),cuda-gdb 往往要么完全找不到报错代码,要么只能显示编译器内联展开后的最后一行底层代码。这种机制丢失了完整的调用栈,导致开发者看着底层的通用报错行,却根本不知道是上层哪一行具体的业务代码触发了该错误。
怎么做的 核心思路是利用 CUDA 驱动的系统级管道强制捕获死锁现场,并绕过调试器的默认高层显示逻辑,直接对包含调试信息的底层二进制文件进行反汇编与堆栈重建。该方法由两个关键部件构成,分别负责捕获挂起和追踪内联源码。第一个部件是用户触发的 GPU 核心转储机制。通过配置 CUDA_ENABLE_USER_TRIGGERED_COREDUMP=1 等环境变量,CUDA 驱动会在操作系统中生成一个通信管道。当内核死锁且程序无响应时,向该管道写入数据(例如执行 dd if=/dev/zero bs=1M count=1 > 管道路径)即可强制驱动将当前 GPU 状态转储为文件,从而让开发者直接看到是哪个内核卡死。第二个部件是完整的内联堆栈追踪链路。为了绕开 cuda-gdb 丢失内联信息的卡点,首先必须在编译时注入行号信息:
当程序崩溃生成转储文件后,先在 cuda-gdb 中使用 info symbol $errorpc 命令获取发生错误的程序计数器(PC)地址以及对应的 .cubin 二进制文件路径。随后脱离调试器,使用 nvdisasm 工具直接解析二进制文件:
-ndf 用于禁用数据流分析以加速输出,-c 仅打印代码段,而最关键的 -gi 参数负责将源码行信息和函数内联信息强制附加到汇编指令旁。通过在输出中搜索报错的 PC 地址,即可完整还原出从顶层业务代码到底层调用的整个内联链条。如果同一个 PC 地址匹配到多个内核,则引入 cuobjdump -elf 查找目标函数在 ELF 文件中的段索引,并通过 nvdisasm 的 -fun 参数进行精准过滤。
效果如何 作者在 Triton 编写的条件死锁脚本和 PyTorch(版本大于等于 2.9.0)的非法内存访问脚本上搭建了验证实验。对比基线为两种常规路线:一是代表传统排查路线的强杀进程盲猜法,二是代表现有转储路线的仅依赖 cuda-gdb 默认输出的调试法。在 PyTorch 的 index_elementwise_kernel 越界案例中,常规 cuda-gdb 路线只能定位到无意义的底层内联展开行,而新方法成功打印出了包含多层调用的完整内联堆栈,精准锁定了引发越界的原始源码。在真实业务场景的极限测试下,该工作流被用于排查 vLLM 项目中 CUTLASS MLA 注意力后端一个极难复现的挂起问题。面对深度嵌套的 CUTLASS 函数调用,新方法成功剥离了复杂的内联干扰,将错误精准定位至上游 CUTLASS 的示例代码中,该问题随后在 CUTLASS v4.3.0 中被官方修复。作者也明确指出了该方案的代价与局限:为了确保行号信息被正确注入,必须彻底禁用 ccache 等编译缓存机制,因为缓存会忽略新添加的编译标志并复用旧的无行号产物;对于即时编译代码,则需要查阅特定工具的文档来手动添加行号;此外,核心转储管道的路径由 CUDA 驱动动态生成,通常需要通过 ls /proc/<pid>/fd/ 检查进程的文件描述符才能找到触发入口。
A1 主要贡献
此前,作者曾介绍过使用CUDA Core Dump作为调试CUDA内核非法内存访问问题的有效工具,它使开发者能够精确定位导致故障的具体内核,从而解决了GPU异步执行导致错误信息具有误导性的难题。随着该技术的普及,开发者提出了对更细粒度信息的需求——即希望定位到触发问题的确切源代码行。
本文的核心贡献在于填补了这一空白,具体提出并演示了两种高级调试技术:
- 识别并定位挂起(Hanging)的GPU内核:解决了常规方法(如Ctrl-C)无法终止并提供挂起原因的问题。
- 追踪复杂内核的源代码:克服了默认调试工具在面对现代C++代码中大量使用的编译器内联(Inlining)时,无法准确显示真实错误代码行的局限性。
A3 背景知识与关键Observation
GPU内存带宽不平衡与挂起问题
GPU的计算能力呈指数级增长,但内存带宽并未保持同步。这种不平衡导致了越来越复杂的内存访问模式。近年来,旗舰级数据中心GPU引入了异步内存访问模式,在实现高性能内核时需要复杂的同步机制。这些同步机制在复杂的代码库中极易引发竞争条件(Race conditions)和死锁(Deadlocks)。当GPU内核挂起时,程序通常会冻结或无响应。
为什么按 Ctrl-C 无法停止挂起的CUDA内核?
按下Ctrl-C会向进程发送SIGINT信号。如果进程正在运行Python代码,Python解释器会捕获该信号,将其转换为KeyboardInterrupt异常,并将其排队等候在进程返回运行Python代码后处理。然而,如果进程正在运行CUDA内核并等待GPU完成,它实际上是在等待底层的CUDA API返回,此时没有Python代码在运行,因此无法抛出KeyboardInterrupt异常。
解决此问题的权宜之计:可以在脚本开头添加 import signal; signal.signal(signal.SIGINT, signal.SIG_DFL),使Python解释器不捕获SIGINT信号,从而让Ctrl-C成功终止进程。但缺点是Python解释器在被Ctrl-C停止时将无法显示错误堆栈。
默认调试工具在复杂内核中的局限性
即使使用-lineinfo环境变量将行信息嵌入到编译后的二进制文件中,cuda-gdb 默认显示行信息的方式仍存在缺陷:
- 对于某些复杂的内核,
cuda-gdb无法找到引发问题的正确代码行。 - 即使找到了代码行,它通常只显示编译器内联(Inlining)后的最后一行,这往往不是实际引发问题的原始代码行。由于C++代码严重依赖内联来消除运行时函数调用的开销,开发者必须获取完整的内联栈(Inline stack)才能真正理解问题。
A2 方法细节
定位挂起的内核
CUDA驱动程序包含一个名为用户触发的GPU核心转储生成(user induced GPU core dump generation)的功能:驱动程序在操作系统中打开管道(pipes),允许用户通过向其写入数据来触发核心转储。触发后,CUDA驱动会将GPU状态转储到核心转储文件中,从而允许开发者检查GPU内部状态并确定哪个内核处于挂起状态。
1. 构造条件挂起内核示例
作者提供了一个基于Triton的条件挂起内核,当传入的flag为0时,将陷入无限循环:
# save as conditional_hang.py
import triton
import triton.language as tl
import torch
@triton.jit
def conditional_hang_kernel(x_ptr,
flag, # int32 scalar
n_elements, # int32 scalar
BLOCK_SIZE: tl.constexpr):
pid = tl.program_id(0)
offs = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
mask = offs < n_elements
# Load values
x = tl.load(x_ptr + offs, mask=mask, other=0)
# If flag == 1: do a normal "+1" update
if flag == 1:
x = x + 1
tl.store(x_ptr + offs, x, mask=mask)
else:
# Else: non-terminating loop, no break.
# The loop condition depends on `flag`, which is invariant,
# so this is effectively an infinite loop when flag == 0.
while flag == 0:
# do something trivial so the loop isn't optimized away
x = x + 1
tl.store(x_ptr + offs, x, mask=mask)
x = torch.ones(16, dtype=torch.float32, device="cuda")
n_elements = x.numel()
BLOCK_SIZE = 16
# 1) Normal behavior: increment by 1
conditional_hang_kernel[(1,)](
x,
flag=1,
n_elements=n_elements,
BLOCK_SIZE=BLOCK_SIZE,
)
print("After flag=1:", x) # should be all 2s
# 2) Hanging behavior: this will spin forever
conditional_hang_kernel[(1,)](
x,
flag=0,
n_elements=n_elements,
BLOCK_SIZE=BLOCK_SIZE,
)
# this print will hang, because printing x will synchronize the device,
# and the kernel will never finish.
print("After flag=0:", x)
# the following line will never be reached
x = x + 2
torch.cuda.synchronize()
2. 启用环境变量并运行
为了调试挂起问题,通过配置环境变量启用用户触发的GPU核心转储机制并运行脚本:
CUDA_ENABLE_USER_TRIGGERED_COREDUMP=1 \
CUDA_COREDUMP_PIPE="/tmp/cuda_coredump_pipe_%h.%p.%t" \
CUDA_ENABLE_COREDUMP_ON_EXCEPTION=1 \
CUDA_COREDUMP_SHOW_PROGRESS=1 \
CUDA_COREDUMP_GENERATION_FLAGS='skip_nonrelocated_elf_images,skip_global_memory,skip_shared_memory,skip_local_memory,skip_constbank_memory' \
CUDA_COREDUMP_FILE="/tmp/cuda_coredump_%h.%p.%t" \
python conditional_hang.py
3. 触发核心转储
在代码无限期运行时,通过向管道写入数据来触发CUDA核心转储。由于管道缓冲的原因,简单的echo命令可能不起作用,因此使用dd命令写入1MB的零数据:
dd if=/dev/zero bs=1M count=1 > /tmp/cuda_coredump_pipe_hostname.3000837.1764236276
4. 分析转储文件
触发转储后,原始终端会打印转储进度日志。随后,使用 cuda-gdb 打开生成的核心转储文件:
Opening GPU coredump: /tmp/cuda_coredump_hostname.3000837.1764236276
[Current focus set to CUDA kernel 0, grid 53, block (0,0,0), thread (0,0,0), device 0, sm 124, warp 0, lane 0]
#0 0x00007f2e6fbff300 in conditional_hang_kernel<<<(1,1,1),(128,1,1)>>> () at conditional_hang.py:31
31 tl.store(x_ptr + offs, x, mask=mask)
此方法不仅能识别出挂起的内核是conditional_hang_kernel,还能精确定位到其挂起的具体代码行(第31行)。
5. 寻找管道路径的技巧
由于核心转储管道的路径是由CUDA驱动动态生成的,较难直接找到。可以通过 CUDA_COREDUMP_PIPE 环境变量指定管道的模板路径,然后通过检查进程的文件描述符来轻松定位:
$ ls /proc/3037675/fd/ -alth | grep /tmp/cuda_coredump_pipe_
lr-x------ 1 user user 64 Nov 27 01:50 98 -> /tmp/cuda_coredump_pipe_hostname.3037675.1764237014
追踪复杂内核的源代码
对于复杂的C++内核,仅依靠cuda-gdb往往只能看到内联后的最底层代码。为了获取完整的内联栈,需要结合使用多个底层工具。
1. 构造非法内存访问示例
以下Python脚本通过PyTorch构造了一个非法的内存访问异常:
# save as illegal_memory_access.py
from dataclasses import dataclass
import torch
@dataclass
class TensorWrapper:
data_ptr: int
size_in_bytes: int
@property
def __cuda_array_interface__(self):
return {
"shape": (self.size_in_bytes,),
"typestr": '|u1',
"data": (self.data_ptr, False),
"version": 3,
}
def from_buffer(data_ptr: int, size_in_bytes: int, device: str, dtype: torch.dtype) -> torch.Tensor:
return torch.as_tensor(TensorWrapper(data_ptr, size_in_bytes), device=device).view(dtype)
data = from_buffer(123456, 1024, device="cuda:0", dtype=torch.uint8)
index = torch.ones(10, device="cuda", dtype=torch.int32) + 100
print(data[index])
2. 获取引发异常的内核名称
启用CUDA core dump环境变量运行上述脚本,转储进度日志中会明确暴露出引发异常的内核名称(例如 _ZN2at6native24index_elementwise_kernel...),这表明问题出在PyTorch的 index_elementwise_kernel。
3. 带行信息编译并获取错误PC地址
使用 export NVCC_PREPEND_FLAGS='-lineinfo' 环境变量从源码重新编译PyTorch。再次运行并生成Core dump后,使用 cuda-gdb 打开转储文件:
(cuda-gdb) target cudacore /tmp/cuda_coredump_flow-matic.3756036.1764250282
...
CUDA Exception: Warp Illegal Address
The exception was triggered at PC 0x7ff533bb91d0 ...
此时cuda-gdb会显示异常为 Warp Illegal Address,并给出程序计数器(Program Counter, PC)地址为 0x7ff533bb91d0。
4. 提取错误位置符号与Cubin文件路径
在 cuda-gdb 中,使用 info symbol $errorpc 命令获取更多信息:
(cuda-gdb) info symbol $errorpc
void at::native::index_elementwise_kernel... + 11472 in section .text._ZN2at6native24index_elementwise_kernel... of /tmp/cuda-dbg/2123124/session1/elf.21407f80.24fe2940.o.4gyLzn
该输出表明 cuda-gdb 解包了编译后的二进制文件,并生成了包含内核的cubin文件(路径为 /tmp/cuda-dbg/.../elf...o...),错误精确发生在该cubin文件的 0x7ff533bb91d0 位置。
5. 使用 nvdisasm 反汇编并查看完整内联栈
利用 nvdisasm 工具对生成的cubin文件进行反汇编,以查看确切的代码执行栈:
$ nvdisasm -ndf -c -gi /tmp/cuda-dbg/2123124/session1/elf.21407f80.24fe2940.o.4gyLzn > output.txt
$ grep -C20 7ff533bb91d0 output.txt
...
/*7ff533bb9190*/ IMAD.IADD R19, R23, 0x1, R3 ;
.L_x_27840:
//## File "/data/youkaichao/pytorch/aten/src/ATen/native/cuda/IndexKernel.cu", line 203 inlined at "/data/youkaichao/pytorch/aten/src/ATen/native/cuda/IndexKernel.cu", line 118
//## File "/data/youkaichao/pytorch/aten/src/ATen/native/cuda/IndexKernel.cu", line 118 inlined at "/data/youkaichao/pytorch/aten/src/ATen/native/cuda/IndexKernel.cu", line 37
//## File "/data/youkaichao/pytorch/aten/src/ATen/native/cuda/IndexKernel.cu", line 37
/*7ff533bb91a0*/ ULDC.64 UR4, c[0x0][0x480] ;
/*7ff533bb91b0*/ IADD3 R2, P0, P1, R22, UR4, R2 ;
/*7ff533bb91c0*/ IADD3.X R3, R19, UR5, RZ, P0, P1 ;
/*7ff533bb91d0*/ LDG.E.U8 R3, desc[UR36][R2.64] ;
...
命令参数解释:
-ndf: 反汇编后禁用数据流分析器。-c: 仅打印代码段。-gi: 使用从.debug_line段获取的源行信息以及函数内联信息(如果存在)来注释反汇编结果。-C20:grep参数,显示匹配PC地址7ff533bb91d0前后的20行上下文。
通过这种方式,开发者可以看到引发问题的完整内联栈,而默认的 cuda-gdb 仅会显示最后一次内联展开。
6. 处理多内核PC地址冲突
如果cubin文件包含多个具有相同PC地址的内核(即 grep 结果显示多个匹配项),需要使用 cuobjdump 进一步过滤信息:
$ cuobjdump -elf /tmp/cuda-dbg/2123124/session1/elf.21407f80.24fe2940.o.4gyLzn > elf.txt $ cat elf.txt | grep ".text._ZN2at6native24index_elementwise_kernel..." | grep PROGBITS 1ac 1b83f80 b200 0 80 PROGBITS 6 3 26a .text._ZN2at6native24index_elementwise_kernel... $ nvdisasm -ndf -c -gi -fun 0x26a /tmp/cuda-dbg/2123124/session1/elf.21407f80.24fe2940.o.4gyLzn > output.txt $ grep -C20 7ff533bb91d0 output.txt
这里的核心区别在于,通过 cuobjdump 搜索函数的ELF段,获取了CUDA函数的索引(在此例中为 26a),然后通过 -fun 0x26a 参数传递给 nvdisasm,从而精准定位目标内核。
7. 真实复杂内联场景分析
在真实的复杂内核中(如CUTLASS),内联栈可能会非常深:
//## File "/data/youkaichao/data/vllm_flash_attn/csrc/cutlass/include/cute/arch/copy_sm90.hpp", line 93 inlined at "/data/youkaichao/data/vllm_flash_attn/csrc/cutlass/include/cute/arch/util.hpp", line 158
//## File "/data/youkaichao/data/vllm_flash_attn/csrc/cutlass/include/cute/arch/util.hpp", line 158 inlined at "/data/youkaichao/data/vllm_flash_attn/csrc/cutlass/include/cute/arch/util.hpp", line 185
... (多层内联省略)
//## File "/data/youkaichao/data/vllm_flash_attn/csrc/cutlass/include/cutlass/device_kernel.h", line 122
/*7eebf5e9eb80*/ STSM.16.M88.4 [R13], R4 ;
/*7eebf5e9eb90*/ MOV R34, R26 ;
在上述案例中,有缺陷的源代码调用了CUTLASS函数,且包含该缺陷的函数又被上层调用者内联。此时 cuda-gdb 无法正确关联行信息,或者只能显示CUTLASS内部深处的展开行(如 copy_sm90.hpp 第93行),这对调试毫无帮助。通过本文揭示的方法,开发者能够获取完整的内联链,逐帧检查并最终定位到真实的错误代码行。
A4 实验环境
-
软件配置:
- PyTorch >= 2.9.0(必须包含特定commit以修复Host memory未向CUDA设备注册的报错)。
- Triton 框架。
- CUDA 底层工具链:
cuda-gdb,nvdisasm,cuobjdump。 - CUTLASS 库。
-
硬件配置:提及了具有异步内存访问特性的旗舰级数据中心GPU(Flagship datacenter GPUs)。
A4 实验结果
- 针对挂起内核的实验:成功通过向管道写入1MB零数据触发了Core Dump,利用
cuda-gdb精确到了基于Triton编写的条件挂起脚本(conditional_hang.py)的第31行(执行tl.store的位置)。 - 针对复杂内核的实验:在运行PyTorch非法内存访问脚本时,成功通过
nvdisasm配合-gi参数,反汇编了由cuda-gdb提取出的cubin文件,还原了由于C++高度内联导致cuda-gdb无法有效显示的完整源代码内联栈,精确定位到了IndexKernel.cu的第203行。 - 真实场景应用:结合上述两种技术,作者团队最近成功调试了CUTLASS MLA attention后端中一个难以复现且极为棘手的挂起问题。该问题实际源自上游的CUTLASS代码示例,目前已在CUTLASS v4.3.0中得到修复。
A5 结论
本文介绍了两种针对CUDA内核的高级调试技术:第一种技术利用用户触发的核心转储来识别处于挂起状态的内核;第二种技术通过利用嵌入在编译二进制文件中的行信息,将高度复杂的内核追踪回其确切的源代码行。这两种技术协同使用,构成了调试CUDA内核复杂问题(尤其是在非法内存访问和并发死锁方面)的强大工具链。vLLM项目致力于提供简单、快速且经济的LLM服务,而降低底层调试门槛是实现这一使命的重要环节。未来,团队将继续分享更多调试技巧,共同构建强大的LLM推理生态系统。
A7 补充细节
关于编译缓存的严重警告
为了最大化CUDA核心转储的优势,行信息至关重要。强烈推荐使用环境变量 export NVCC_PREPEND_FLAGS='-lineinfo' 进行编译,因为它无需修改编译脚本即可透明地应用于所有编译内核。然而,这种透明性也带来了一个隐患:如果使用了编译缓存机制(如 ccache),它可能会忽略该环境变量标志,直接复用以前编译好(但不包含行信息)的缓存结果,而不进行实际编译。
- 源码编译:从源码编译时,必须确保禁用所有的编译缓存机制。
- JIT编译:如果使用即时编译(Just-In-Time compilation),开发者需要查阅相应的JIT工具文档,了解如何显式添加行信息。
💬 评论讨论
欢迎在这里分享您的想法和见解!