使用 nsys + gdb 寻找阻塞 API (cuKernelSetAttribute) 并解决

使用 nsys + gdb 寻找阻塞 API (cuKernelSetAttribute) 并解决

作者注:本文记录了我作为 CUDA 新手,从遇到诡异阻塞,到利用 Nsight Systems 定位,再到通过 GDB 深入理解 API 行为,最终用“预热”优雅解决问题的全过程。

一、问题现象:GreenContext 下的“伪异步”

我们使用 CUDA 的 GreenContext 将 H200 的 132 个 SM 分成两个独立区域:

  • streamA → 运行一个耗时极长的 kernel(比如大矩阵乘法)
  • streamB → 运行 F.linear()(GEMM 类 kernel)

理想中,两个 stream 中的 kernel 应该真正并发,互不干扰。但实际跑起来却发现:

streamB 上出现一个超长耗时cuKernelSetAttribute 调用,它一直阻塞,直到 streamA 的长 kernel 跑完才结束。

linear-block

这不仅浪费了 GPU 资源,还让 GreenContext 的隔离性形同虚设。为了搞清楚为什么,我必须从最基础的 API 开始理解。


二、深入理解 cuKernelSetAttribute:API 签名与同步语义

2.1 API 原型(从 CUDA Driver API 文档摘录)

CUresult cuKernelSetAttribute(
    CUfunction_attribute attrib,  // 要设置的属性类型
    int val,                       // 属性值
    CUfunction kernel,            // 内核函数句柄(可以理解为函数指针)
    CUdevice dev                  // 设备号
);

各参数详解

  • CUfunction_attribute attrib:指定我们要修改 kernel 的哪个内在属性。常见的有:
    • CU_FUNC_ATTRIBUTE_MAX_DYNAMIC_SHARED_SIZE_BYTES(数值 8):该 kernel 最多能用多少动态共享内存。
    • CU_FUNC_ATTRIBUTE_NON_PORTABLE_CLUSTER_SIZE_ALLOWED(数值 14):设置是否为便携性cluster size。
  • int val:对应的属性值,比如共享内存大小(字节)。
  • CUfunction kernel关键所在CUfunction 是一个不透明句柄,代表一个编译好的 CUDA kernel(类似于函数指针)。只有拿到这个句柄,才能修改它的属性。
  • CUdevice dev:指定 GPU 设备(多卡环境)。

返回值CUresult,成功返回 CUDA_SUCCESS,否则返回错误码。

2.2 同步语义:官方文档的提示

官方 Note 写道:“The API has stricter locking requirements in comparison to its legacy counterpart cuFuncSetAttribute() due to device-wide semantics.”

翻译:相比旧版 cuFuncSetAttribute,这个新版 API 因为具有设备范围的语义,所以加锁更严格。

这意味着什么?
当你在任何一个线程、任何一个流中调用 cuKernelSetAttribute 修改某个 kernel 的属性时,它会在整个设备上施加一个全局屏障——等待所有之前提交的 GPU 工作完成,才能安全地修改属性。这就是为什么 streamB 上的这个调用会被 streamA 上正在运行的长 kernel 阻塞!

如果当时我第一时间就去读这段文档,就不会花几天时间瞎猜了。所以第一步永远是:读官方文档

2.3 关键疑问:CUfunction kernel 是唯一的吗?

CUfunction 代表一个特定的 kernel 实例。如果我多次调用同一个 kernel(比如同样的 F.linear 形状),那么它对应的 CUfunction 指针应该是相同的。那么:

既然属性是一次性设置,那么同一个 kernel 第二次调用时,是不是就不再需要 cuKernelSetAttribute 了?

如果这个猜想成立,那么我只要在程序开始时预热(warmup)一次,让所有 kernel 的属性都设置好,后面的真正计算就不会再有 cuKernelSetAttribute 阻塞,从而恢复并发。

如何验证? 我必须观察程序运行时 cuKernelSetAttribute 究竟被调用了多少次、什么时机调用、针对哪个 kernel。这就需要动用 GDB


三、用 GDB 观察函数调用:参数、时机、返回值

网上很少有教程教你如何用 GDB 动态追踪 CUDA Driver API 的调用细节。我自己摸索出了一套脚本化方法,极其有效,分享给大家。

3.1 准备一个可调试的 Python 程序(使用 PyTorch + CUDA)

我们写一个简单的 Python 脚本,模拟 GreenContext 下的双流操作,并对 F.linear 重复运行多次(其中第一次作为预热)。

import torch
import torch.nn.functional as F

# 假设已经创建了两个 green context 对应的 stream
stream_b = torch.cuda.Stream()

# 准备数据
weight = torch.randn(4096, 7168, device='cuda')
input_b = torch.randn(16, 7168, device='cuda')

# 循环两次,第一次 warmup,第二次正式
for repeat in range(2):
    print(f"\n[shape] repeat={repeat}")
    with torch.cuda.stream(stream_b):
        for _ in range(10):
            out = F.linear(input_b, weight)  # 会触发一系列 CUDA API 调用
    torch.cuda.synchronize()

3.2 编写 GDB 自动化脚本:断点 + 打印参数

我们希望:

  • cuKernelSetAttributecuKernelGetAttribute 处break。
  • 自动打印调用时的参数(属性、值、kernel 指针)和精确时间
  • 当然也可以用 bt 观察调用栈情况。
  • 继续执行,不打断程序。

创建 trace_cuda.gdb

set pagination off
set confirm off
set breakpoint pending on

# 定义辅助函数:打印带纳秒时间戳的字符串
define print_time
  shell date +%S.%N
end

# 断点:cuKernelSetAttribute
break cuKernelSetAttribute
commands
  silent
  printf "\n>>> [GDB-TRACE] [cuKernelSetAttribute] Time: "
  print_time
  # x86-64 调用约定:rdi=attrib, rsi=val, rdx=kernel, rcx=dev
  printf "  attrib=%-2d  val=%-8d  kernel=0x%lx  dev=%d\n", $rdi, $rsi, $rdx, $rcx
  continue
end

# 断点:cuKernelGetAttribute
break cuKernelGetAttribute
commands
  silent
  printf ">>> [GDB-TRACE] [cuKernelGetAttribute] attrib=%-2d  kernel=0x%lx\n", $rsi, $rdx
  continue
end

printf "GDB Automation Ready. Starting Program...\n"
run

关键解释

  • silent:避免每次断点都打印默认的停靠信息,保持输出干净。
  • print_time:调用 shell 命令获取当前秒数.纳秒,这样时间戳精度足够分辨 API 调用顺序。
  • 寄存器与参数的对应(System V AMD64 ABI):
    • rdi → 第一个参数(attrib
    • rsi → 第二个参数(val
    • rdx → 第三个参数(kernel
    • rcx → 第四个参数(dev
    • cuKernelGetAttribute 操作类似;

3.3 执行 GDB 并观察输出

gdb -x trace_cuda.gdb --args python test_greenctx.py

运行后,我们得到类似下面的输出(节选):

[shape] repeat=0
>>> [GDB-TRACE] [cuKernelGetAttribute] attrib=8   kernel=0x5c64f6a0
>>> [GDB-TRACE] [cuKernelSetAttribute] Time: 52.010133726  attrib=8   val=180676   kernel=0x5c64f6a0  dev=0
>>> [GDB-TRACE] [cuKernelGetAttribute] attrib=14  kernel=0x5c64f6a0
>>> [GDB-TRACE] [cuKernelSetAttribute] Time: 52.065440659  attrib=14  val=1        kernel=0x5c64f6a0  dev=0
... (后面跟着大量 GetAttribute,没有 SetAttribute)
[shape] repeat=1
>>> [GDB-TRACE] [cuKernelGetAttribute] attrib=8   kernel=0x5c64f6a0
>>> [GDB-TRACE] [cuKernelGetAttribute] attrib=14  kernel=0x5c64f6a0
... (只有 Get,没有 Set)

观察结论

  1. 相同的 kernel 指针 0x5c64f6a0 在第一次循环(repeat=0)中出现了两次 cuKernelSetAttribute(attrib=8 和 14)。
  2. 第二次循环(repeat=1)中完全没有 cuKernelSetAttribute,只有 cuKernelGetAttribute
  3. 这完美证实了:每个 kernel 的属性只在第一次使用时设置一次,后续复用不再触发设备级同步。

3.4 为什么 GDB 方法如此本质?

  • 调用时机:可以看到 Set 是在第一次执行 F.linear 时发生的,而不是在 kernel launch 的瞬间,而是在更早的属性查询/设置阶段。
  • 参数详情:知道了具体设置的是哪些属性(8 和 14)以及值的大小(比如 180676 字节动态共享内存),我们可以进一步思考:能否通过调整启动参数来避免设置某些属性?
  • kernel 句柄:多次运行发现同一个形状的 F.linear 总是复用同一个 kernel 句柄,这为预热提供了理论基础。

四、解决方案:预热(Warmup)

基于以上发现,解决方案极其简单:

在实际并发任务开始之前,先用 dummy 数据在每个 stream 中执行一遍相同的运算,让所有 kernel 完成属性设置。

代码示例:

# 预热阶段
with torch.cuda.stream(stream_b):
    dummy = torch.randn(1, 7168, device='cuda')
    _ = F.linear(dummy, weight)   # 触发 cuKernelSetAttribute

torch.cuda.synchronize()  # 等待预热完成

# 正式并发运行
with torch.cuda.stream(stream_a):
    long_kernel()          # 耗时任务
with torch.cuda.stream(stream_b):
    for real_input in real_inputs:
        _ = F.linear(real_input, weight)  # 不会再阻塞

再次用 Nsight Systems 验证:预热阶段出现 cuKernelSetAttribute(此时没有长 kernel 运行,不构成阻塞),正式阶段该 API 完全消失,两个 stream 真正并发。


五、总结与反思

5.1 知识点沉淀

问题答案
cuKernelSetAttribute 为什么阻塞?设备级锁,等待所有先前 GPU 工作完成。
CUfunction kernel 是什么?一个 kernel 的句柄,相同 kernel 复用相同句柄。
怎么知道某个 API 被调用了、参数是什么?GDB 断点 + 打印寄存器(rdi, rsi, rdx, rcx)。
如何避免阻塞?预热:在串行阶段提前调用一次 kernel。

5.2 总结

  • 第一步永远读文档:如果我先看了 cuKernelSetAttribute 的同步语义,至少能少花 80% 的瞎猜时间。
  • nsys 给宏观视野:快速定位到底是哪个 API 在阻塞。
  • gdb 给微观证据:确认调用的参数、次数、时机,这是推理的根本依据。
  • 预热思维:很多一次性开销(JIT 编译、内存池初始化、kernel 属性设置)都可以通过预热来规避,是一种通用性能优化技巧。

5.3 给读者的建议

如果你也遇到类似的 CUDA 阻塞问题,不妨按这个步骤走:

  1. Nsight Systems 抓时间轴,确定卡在哪个 API。
  2. 查阅官方文档,理解该 API 的同步语义。
  3. 写 GDB 脚本,断点该 API,观察参数和调用模式。
  4. 思考:这个 API 是必须每次都调用,还是一次性的?如果是后者,预热即可。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值