使用 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 跑完才结束。

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


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

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

c 复制代码
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 重复运行多次(其中第一次作为预热)。

python 复制代码
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

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 并观察输出

bash 复制代码
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 完成属性设置。

代码示例:

python 复制代码
# 预热阶段
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 是必须每次都调用,还是一次性的?如果是后者,预热即可。
相关推荐
南境十里·墨染春水2 小时前
linux学习进展 网络基础
linux·网络·学习
实心儿儿2 小时前
Linux —— 基础IO - 一切皆文件 + 缓冲区
linux·运维·服务器
实心儿儿2 小时前
Linux —— 基础IO - 自己实现libc库
linux
深邃-2 小时前
【Web安全】-Kali,Linux基础(3):Linux路径操作,Linux文件权限,Linux文件下载
linux·运维·安全·web安全·网络安全·系统安全
原来是猿3 小时前
Linux线程同步与互斥(四):日志系统与策略模式
linux·运维·开发语言·策略模式
九皇叔叔10 小时前
Ubuntu 22.04 版本常用设置
linux·运维·ubuntu
南境十里·墨染春水10 小时前
linux学习进展 线程同步——互斥锁
java·linux·学习
杨云龙UP12 小时前
ODA登录ODA Web管理界面时提示Password Expired的处理方法_20260423
linux·运维·服务器·数据库·oracle
songx_9912 小时前
Linux基础2
linux·运维·服务器