NVBit 内部机制深度分析
本文档通过源码分析、二进制逆向、strace/ltrace/GDB 运行时追踪,全面剖析 NVBit v1.7.6 的内部工作原理。 适合希望理解 NVBit 底层实现并进行扩展开发的读者。
目录
- 1. 整体架构
- 2. 注入机制:从加载到拦截
- 3. CUDA API 拦截原理
- 4. SASS 反汇编流水线
- 5. 二进制打补丁引擎
- 6. 寄存器保存与恢复
- 7. GPU-CPU 通信机制
- 8. ELF/Cubin 处理
- 9. 硬件抽象层 (HAL)
- 10. libnvbit.a 内部结构
- 11. 工具 .so 的二进制结构
- 12. 完整运行时序
- 13. 关键 API 参考
- 14. 扩展开发指南
- 15. strace 实证分析
- 16. 使用非 CUDA 语言编写 NVBit 工具
- 17. 实战 POC:手写 PTX 作为 NVBit 设备函数
1. 整体架构
NVBit 的架构分为四个层次:
┌─────────────────────────────────────────────────────┐
│ 用户工具层 (Tool) │
│ instr_count.cu / inject_funcs.cu / Makefile │
│ 实现 nvbit_at_init, nvbit_at_cuda_event 等回调 │
├─────────────────────────────────────────────────────┤
│ NVBit 公开 API 层 │
│ nvbit.h: nvbit_get_instrs, nvbit_insert_call, ... │
│ nvbit_reg_rw.h: nvbit_read_reg, nvbit_write_reg │
│ utils/channel.hpp: ChannelDev, ChannelHost │
├─────────────────────────────────────────────────────┤
│ NVBit 核心引擎 (libnvbit.a) │
│ Nvbit 单例类 → 回调分发、模块管理、函数追踪 │
│ Function 类 → 反汇编、代码生成、二进制打补丁 │
│ SassInstr 类 → SASS 指令编解码 │
│ ELF 处理库 → cubin 解析、符号表、重定位 │
├─────────────────────────────────────────────────────┤
│ 硬件抽象层 (HAL) │
│ gk11x_hal (Kepler) │ gm10x_hal (Maxwell) │
│ gv10x_hal (Volta) │ gv11x_hal / tu10x_hal (Turing) │
│ ga10x_hal (Ampere) │ gh10x_hal (Hopper) │
│ gb10x_hal (Blackwell SM100) │ gb12x_hal (SM120) │
└─────────────────────────────────────────────────────┘
1.1 libnvbit.a 内部模块
静态库 libnvbit.a 包含 24 个目标文件,按功能可分为:
| 模块 | 目标文件 | 代码大小 | 功能 |
|---|---|---|---|
| 核心引擎 | nvbit_imp.o |
239KB text + 372KB data | NVBit 主实现 + 预编译 GPU 辅助内核 |
| 代码补丁 | function.o |
129KB | SASS 代码生成、trampoline、分支修正 |
| 指令处理 | instr.o |
55KB | SASS 指令解析 (SassInstr 类) |
| 公开 API | nvbit.o |
25KB | C 风格 API 包装 (委托给 Nvbit:: 类方法) |
| HAL 后端 | 9 个 *_hal.o |
332KB | 架构特定的 SASS 指令编码器 |
| ELF 处理 | Elf.o + tools_shared_readelf*.o |
82KB | GPU ELF 格式解析 (32/64位) |
| 数据结构 | tools_shared_{hashmap,list,...}.o |
17KB | 哈希表、链表、红黑树、区间映射 |
其中 nvbit_imp.o 是最大的单体,372KB 的 data 段包含为所有支持的 SM 架构预编译的 GPU 辅助内核(用于运行时寄存器读写)。
2. 注入机制:从加载到拦截
2.1 两种注入方式
NVBit 工具以共享库 (.so) 形式存在,通过以下方式注入到目标应用:
# 方式一:LD_PRELOAD(利用动态链接器)
LD_PRELOAD=./tools/instr_count/instr_count.so ./app
# 方式二:CUDA_INJECTION64_PATH(CUDA 原生注入机制,推荐)
CUDA_INJECTION64_PATH=./tools/instr_count/instr_count.so ./app
关键区别:LD_PRELOAD 是 Linux 通用机制,在所有库之前加载 .so。而 CUDA_INJECTION64_PATH 是 NVIDIA CUDA 驱动提供的官方工具注入接口,NVBit 优先使用。strace 可以观察到 CUDA 驱动在初始化时检查 /dev/shm/cuda_injection_path_shm。
2.2 加载顺序(LD_DEBUG=libs 实证)
通过 LD_DEBUG=libs 跟踪,库的加载顺序为:
1. ld-linux-x86-64.so.2 (动态链接器)
2. libc.so.6 等系统库
3. libstdc++.so.6 (C++ 运行时)
4. libcuda.so.1 (NVIDIA 驱动, ~88MB)
5. libcudart.so.12 (CUDA 运行时, ~5MB)
6. instr_count.so (NVBit 工具, ~2.6MB, 最后加载)
这个顺序是有意为之的:NVBit 需要 libcuda.so.1 先完成加载,才能在其上注册回调。
2.3 初始化链
工具 .so 的 .init_array 段包含 10 个构造函数,按顺序执行:
.init_array 执行流程:
1. libcudart_static 初始化
2. frame_dummy (GCC 帧初始化)
3. __sti____cudaRegisterAll() ← 向 CUDA 运行时注册 fatbin(设备代码)
4. __sti____cudaRegisterAll() ← 额外的 CUDA 注册
5. C++ 全局构造函数 ← 工具自身的全局变量
6-10. NVBit 内部初始化 ← Nvbit 单例构造、HAL 初始化等
之后调用链为:
Nvbit::Nvbit() → 构造单例
→ Nvbit::init() → 初始化回调系统
→ toolsElfLibInitialize() → ELF 处理库初始化
→ init_hal_gk11x/gm10x/...() → 初始化所有 HAL 后端
→ cuGetExportTable() → 注册 CUDA Tools 回调
→ nvbit_at_init() → 调用工具的初始化回调
2.4 核心拦截点:cuGetExportTable
NVBit 并不是通过 LD_PRELOAD 符号拦截来 hook 单个 CUDA 函数。它使用 NVIDIA 的内部函数表机制 cuGetExportTable,这是 NVIDIA 官方提供给性能分析工具的接口(nvprof、Nsight 使用相同机制,因此不能同时运行)。
通过 nm 分析确认:
nvbitToolsCallbackFunc 的签名为:
NVBit 通过 cuGetExportTable 获取 CUDA 驱动内部的回调订阅接口,然后将 nvbitToolsCallbackFunc 注册为回调处理器。此后,CUDA 驱动在每次 API 调用的入口和出口都会调用此函数。
3. CUDA API 拦截原理
3.1 回调域
NVBit 订阅了 CUDA Tools API 的多个回调域(CUtools_cb_domain_enum):
| 回调域 | 事件示例 | 说明 |
|---|---|---|
| Context | CONTEXT_CREATED, CONTEXT_DESTROY_STARTING | GPU 上下文生命周期 |
| Module | MODULE_LOADED, MODULE_UNLOAD_STARTING | GPU 模块(cubin)加载/卸载 |
| Function | FUNCTION_LOADING, FUNCTION_LOADED, FUNCTION_PATCHED | 函数加载和补丁 |
| Launch | LAUNCH, BEFORE_LAUNCH_PUSHED, AFTER_GRID_LAUNCHED | 内核启动 |
| Graph | GRAPH_CREATED, GRAPHEXEC_CREATED, GRAPHNODE_CREATED | CUDA Graph 操作 |
| Library | LIBRARY_LOADED | 库加载 |
| CUDA API | cuLaunchKernel, cuMemAlloc, cuModuleLoad, ... | 所有 CUDA 驱动 API |
3.2 回调分发流程
应用调用 cudaLaunchKernel()
→ libcudart.so 转换为 cuLaunchKernel()
→ libcuda.so 内部执行
→ 触发 NVBit 注册的回调
→ nvbitToolsCallbackFunc(ptr, domain, cbid, params)
→ Nvbit::callback() [内部分发]
→ nvbit_at_cuda_event() [工具自定义回调]
3.3 工具可拦截的 CUDA API
NVBit 拦截 数百个 CUDA 驱动 API 函数,包括所有 _v2、_v3、_ptsz 变体。在 tools_cuda_api_meta.h 中,每个 API 都有唯一的 enum ID(cbid),工具通过比较 cbid 来过滤感兴趣的事件:
void nvbit_at_cuda_event(CUcontext ctx, int is_exit, nvbit_api_cuda_t cbid,
const char* name, void* params, CUresult* pStatus) {
if (cbid == API_CUDA_cuLaunchKernel && !is_exit) {
// 内核启动前:进行 instrumentation
instrument_function_if_needed(ctx, func);
}
if (cbid == API_CUDA_cuLaunchKernel && is_exit) {
// 内核启动后:收集结果
cudaDeviceSynchronize();
printf("instructions: %ld\n", counter);
}
}
参数 params 可以转换为 generated_cuda_meta.h 中定义的结构体,获取 API 调用的具体参数。
3.4 可选回调(弱符号探测)
NVBit 通过 dlsym 运行时探测工具是否定义了可选回调:
nvbit_at_ctx_init → 可选,CUDA 上下文创建时调用
nvbit_tool_init → 可选,首次内核启动前调用(可安全进行 CUDA 内存分配)
nvbit_at_ctx_term → 可选,上下文销毁时调用
nvbit_at_load_device_var → 可选,设备变量加载时调用
LD_DEBUG 输出中的 "undefined symbol" 错误实际上不是错误,只是 NVBit 在探测可选符号。
4. SASS 反汇编流水线
4.1 外部工具依赖
NVBit 没有内置完整的 SASS 反汇编器。它通过 system() 调用外部的 nvdisasm 和 cuobjdump:
4.2 反汇编流程(strace 实证)
阶段一:提取工具设备代码
NVBit 首次加载时,使用 cuobjdump 从工具 .so 中提取匹配当前 GPU 架构的 cubin:
# NVBit 内部执行的命令(strace 捕获)
cp ./tools/instr_count/instr_count.so /tmp/nvbit_tool_tmpdir.XmFNnT
cd /tmp/nvbit_tool_tmpdir.XmFNnT
cuobjdump instr_count.so -arch sm_120 -xelf all > /dev/null
# 产出: instr_count.1.sm_120.cubin
阶段二:内核函数反汇编
当需要分析某个 GPU 内核时(通常在首次启动该内核时触发):
# 1. 将内核的原始二进制代码写入临时文件
mkstemp("/tmp/nvbit_code_XXXXXX") # 如 /tmp/nvbit_code_WnBVBR
# 2. 创建 SASS 输出文件
mkstemp("/tmp/nvbit_sass_XXXXXX") # 如 /tmp/nvbit_sass_yVdJsU
# 3. 调用 nvdisasm 进行反汇编
nvdisasm -b SM120 /tmp/nvbit_code_WnBVBR > /tmp/nvbit_sass_yVdJsU
# 4. 读取 SASS 输出,解析为 Instr 对象
# 5. 删除临时文件
unlink("/tmp/nvbit_code_WnBVBR")
unlink("/tmp/nvbit_sass_yVdJsU")
阶段三:SASS 解析
SassInstr 类将 nvdisasm 的文本输出解析为结构化的指令对象:
输入 (nvdisasm 文本):
/*0000*/ LDC R1, c[0x0][0x37c] ;
/*0010*/ S2R R0, SR_TID.X ;
/*00d0*/ LDG.E.64 R2, desc[UR4][R2.64] ;
输出 (Instr 对象):
Instr { idx=0, offset=0x0, opcode="LDC", sass="LDC R1, c[0x0][0x37c]" }
Instr { idx=1, offset=0x10, opcode="S2R", sass="S2R R0, SR_TID.X" }
Instr { idx=13, offset=0xd0, opcode="LDG.E.64", sass="LDG.E.64 R2, desc[UR4][R2.64]" }
每条 SM120 指令为 16 字节(0x10 偏移步长)。
4.3 TOOL_VERBOSE=1 输出示例
inspecting vecAdd(double*, double*, double*, int) - num instrs 32
Instr 0 @ 0x0 (0) - LDC R1, c[0x0][0x37c] ;
Instr 1 @ 0x10 (16) - S2R R0, SR_TID.X ;
Instr 2 @ 0x20 (32) - S2UR UR4, SR_CTAID.X ;
...
Instr 13 @ 0xd0 (208) - LDG.E.64 R2, desc[UR4][R2.64] ;
Instr 14 @ 0xe0 (224) - LDG.E.64 R4, desc[UR4][R4.64] ;
Instr 18 @ 0x120 (288) - STG.E.64 desc[UR4][R8.64], R6 ;
Instr 19 @ 0x130 (304) - EXIT ;
Instr 20 @ 0x140 (320) - BRA 0x140; // 无限循环(不可达代码)
Instr 21-31: NOP 填充至 512 字节对齐
5. 二进制打补丁引擎
这是 NVBit 最核心的部分:在 SASS 二进制层面修改 GPU 代码。
5.1 补丁流水线
Nvbit::module_loaded() ← 模块加载,解析 ELF
→ Nvbit::func_loading() ← 函数加载
→ Nvbit::disassemble_func() ← 调用 nvdisasm 反汇编
→ Nvbit::get_instrs() ← 返回指令列表给工具
→ [工具调用 nvbit_insert_call()] ← 记录注入点(此时不修改代码)
→ [工具调用 nvbit_add_call_arg_*()] ← 记录参数信息
→ nvbit_enable_instrumented() ← 触发实际代码生成
→ Function::register_assignment_for_parameters() ← 分配寄存器
→ Function::gen_save_routine() ← 生成寄存器保存代码
→ Function::gen_call_args() ← 生成参数传递代码 (35KB)
→ Function::gen_restore_routine() ← 生成寄存器恢复代码
→ Function::gen_new_code() ← 生成完整新代码 (29KB,最大函数)
→ Function::compute_new_code_offsets() ← 重算所有分支目标偏移
→ Function::gen_patched_code() ← 编码为最终二进制
→ Nvbit::patch_func() ← 将补丁代码安装到 GPU
→ Nvbit::config_patched_func() ← 配置补丁函数执行
→ Nvbit::select_instrumented_or_orig() ← 运行时选择原始/补丁版本
5.2 Trampoline 机制
对于每条被 instrumented 的指令,NVBit 生成一个 trampoline(跳板):
原始代码: 打补丁后的代码:
... ...
LDG.E.64 R2, [R4] JMP trampoline_13 ← 跳转到跳板
... ...
trampoline_13: ← 跳板代码(NVBit 生成)
[保存寄存器到本地内存] ← gen_save_routine()
[设置参数: pred, opcode_id, addr, ...] ← gen_call_args()
JCAL count_instrs ← 调用注入的设备函数
[恢复寄存器] ← gen_restore_routine()
LDG.E.64 R2, [R4] ← 执行原始指令
JMP next_instr ← 跳回原始代码流
5.3 代码生成原语(HAL 提供)
每个 HAL 后端提供的 SASS 指令生成原语:
| 原语 | 功能 | 用途 |
|---|---|---|
gen_stl(RZ, offset, reg) |
STL(存储到本地内存) | 保存寄存器 |
gen_ldl(reg, offset) |
LDL(从本地内存加载) | 恢复寄存器 |
gen_ldc(reg, bank, offset) |
LDC(从常量内存加载) | 加载栈指针 |
gen_mov32i(reg, imm) |
MOV 立即数 | 设置常量参数 |
gen_iadd32i(reg, reg, imm) |
IADD 立即数 | 栈帧分配/释放 |
gen_jcal(target) |
JCAL(函数调用) | 调用注入函数 |
gen_jmp(target) |
JMP(无条件跳转) | trampoline 跳转 |
gen_jcal_pred / gen_jmp_pred |
条件调用/跳转 | 谓词控制 |
gen_depbar() |
依赖屏障 | 同步依赖 |
5.4 分支目标修正
由于插入了 trampoline 代码,所有指令的偏移量都发生了变化。Function::compute_new_code_offsets() 负责重新计算所有分支指令(BRA、BRX、BSSY、CALL、RET、JMP)的目标地址。
5.5 SASS 指令编解码器
NVBit 内置了多代 SASS 指令集的编解码器:
| 命名空间 | ISA 版本 | 对应架构 |
|---|---|---|
NV::Sass7 |
SM 7.x | Volta/Turing (sm_70, sm_72, sm_75) |
NV::Sass8 |
SM 8.x | Ampere (sm_80, sm_86, sm_87) |
NV::Sass9 |
SM 8.9 | Ada Lovelace (sm_89) |
NV::Sass10 |
SM 9.0 | Hopper (sm_90) |
NV::Sass12 |
SM 10.x/12.x | Blackwell (sm_100, sm_101, sm_120) |
每个命名空间提供:
- DecodeOpcode(InstructionBits) — 解码原始指令字为操作码枚举
- GetField/SetField(bits, start, end) — 位域提取/插入
- 各指令类的 encode/decode:BRA, BRX, CALL_ABS_I, CALL_REL_I, CALL_REL_R, RET, JMP_I, WARPSYNC_*, BMOV_*, NANOSLEEP_I, LDC, LEPC 等
6. 寄存器保存与恢复
6.1 问题
注入的设备函数(如 count_instrs)会使用 GPU 寄存器,可能覆盖应用原本正在使用的寄存器值。NVBit 必须在调用注入函数前保存、之后恢复所有受影响的寄存器。
6.2 保存的状态类型
SAVE_REGS_SPACE — 通用寄存器 (R0-R255)
SAVE_UREG_SPACE — 统一寄存器 (Uniform Registers)
SAVE_PRED_SPACE — 谓词寄存器 (P0-P7)
SAVE_UPRED_SPACE — 统一谓词寄存器
SAVE_CC_SPACE — 条件码寄存器
SAVE_MREF_SPACE — 内存引用地址
此外,还需要保存 CBU(Convergence Barrier Unit)状态,通过 BMOV_CLEAR_RD 和 BMOV_PQUAD_R 指令实现。
6.3 实现方式
保存/恢复使用本地内存(每线程私有的栈空间):
# 保存阶段 (gen_save_routine)
LDC R1, c[0x0][stack_offset] # 加载栈指针
IADD32I R1, R1, -sp_size # 分配栈帧
STL [R1+0x00], R0 # 保存 R0
STL [R1+0x04], R2 # 保存 R2
...
# 调用注入函数
JCAL count_instrs
# 恢复阶段 (gen_restore_routine)
LDL R0, [R1+0x00] # 恢复 R0
LDL R2, [R1+0x04] # 恢复 R2
...
IADD32I R1, R1, sp_size # 释放栈帧
6.4 寄存器限制
注入函数的寄存器使用不能超过 24 个(-maxrregcount=24 编译选项限制)。这避免了寄存器压力过大导致的 spill 问题。
6.5 预编译寄存器读写辅助内核
nvbit_reg_rw.h 中定义的寄存器读写函数使用 TAKE_CODE_SPACE(N) 宏预分配大量 GPU 指令空间:
__device__ __noinline__ int32_t nvbit_read_reg(uint64_t reg_num) {
TAKE_CODE_SPACE(1024); // 预留 1024 条指令的空间
return 0;
}
这些预留空间是"代码洞"(code cave),NVBit 在运行时用实际的寄存器访问指令替换这些占位代码。nvbit_imp.o 的 372KB data 段就包含了为所有 SM 架构预编译的这些辅助内核。
7. GPU-CPU 通信机制
NVBit 工具使用两种 GPU-CPU 通信模式:
7.1 模式 A:CUDA 托管内存(简单工具)
使用工具:instr_count, instr_count_bb, instr_count_cuda_graph, opcode_hist
__managed__ uint64_t counter = 0; // GPU/CPU 共享的托管内存
// GPU 端:原子累加
atomicAdd((unsigned long long*)&counter, count);
// CPU 端:同步后直接读取
cudaDeviceSynchronize();
printf("instructions: %ld\n", counter);
优点:实现简单,适合聚合统计。 缺点:只能做简单的原子操作,无法传输复杂数据。
7.2 模式 B:Channel 环形缓冲区(复杂工具)
使用工具:mem_trace, mem_printf2, record_reg_vals
这是一个门铃驱动的填充-冲刷缓冲区(Doorbell-based fill-and-flush buffer),而非经典环形缓冲区。
架构图
GPU 端 (ChannelDev) CPU 端 (ChannelHost)
┌───────────────────────┐ ┌──────────────────────┐
│ buff ──────────────── │ ─── PCIe ───→ │ recv_buffer │
│ buff_write_head_ptr │ │ │
│ buff_write_tail_ptr │ │ recv() { │
│ buff_end │ │ check doorbell │
│ │ │ cudaMemcpyAsync │
│ doorbell* ←──── host-mapped ────────→ │ clear doorbell │
│ │ │ } │
│ push() { │ │ │
│ atomicAdd(head) │ │ 接收线程 (pthread) │
│ memcpy │ │ while(!done) { │
│ atomicAdd(tail) │ │ recv() │
│ } │ │ process() │
│ │ │ } │
│ flush() { │ │ │
│ __threadfence_sys() │ │ │
│ *doorbell = nbytes │ │ │
│ spin(*doorbell==0) │ │ │
│ } │ │ │
└───────────────────────┘ └──────────────────────┘
push() 协议详解
Warp A 调用 push(data, 284 bytes):
1. curr = atomicAdd(&head, 284) ← 原子预留槽位
2. if (curr + 284 > buff_end): ← 缓冲区满?
if (curr <= buff_end): ← 我是第一个检测到溢出的 warp
spin_wait(tail == curr) ← 等待所有先前写入完成
flush() ← 冲刷缓冲区
else: ← 后续检测到溢出的 warp
spin_wait(head <= buff_end) ← 等待冲刷完成
retry ← 重试
3. memcpy(curr, data, 284) ← 拷贝数据
4. atomicAdd(&tail, 284) ← 确认写入完成
flush() 协议详解
flush():
1. nbytes = tail - buff ← 计算已缓冲字节数
2. __threadfence_system() ← 确保所有写入对 PCIe 可见
3. *doorbell = nbytes ← 通知 CPU(host-mapped 内存)
4. spin_wait(*doorbell == 0) ← 等待 CPU 读取完成
5. tail = buff ← 重置尾指针
6. __threadfence() ← 内存屏障
7. head = buff ← 重置头指针
关键:步骤 5 先重置 tail,再在屏障后重置 head。其他在溢出处自旋的 warp 监视 head,这个顺序保证它们在看到 head 重置时,tail 已经安全。
门铃(Doorbell)
Doorbell 是一个 host-mapped 的整数,通过 cudaHostAlloc + cudaHostAllocMapped 分配,同时在 CPU 和 GPU 端可见。GPU 写入 doorbell 通知 CPU 有数据可读,CPU 清零 doorbell 通知 GPU 可以继续写入。
内核结束时的冲刷
每个内核执行完成后,工具会启动一个单线程内核来冲刷剩余数据:
// 在 nvbit_at_cuda_event (is_exit=1) 中
flush_channel<<<1,1>>>(ctx_state->channel_dev);
cudaDeviceSynchronize();
性能特征
- 缓冲区大小:通常 1MB (
CHANNEL_SIZE = 1 << 20) - 阻塞性:GPU 在 flush 期间自旋等待 CPU,性能受限于 PCIe 传输速度
- 数据包大小:
mem_access_t= 284 字节/每次内存访问事件 - 并发安全:通过 atomicAdd 头指针实现无锁多 warp 并发写入
8. ELF/Cubin 处理
GPU 的编译产物(cubin)是 ELF 格式。NVBit 包含完整的 ELF 处理库。
8.1 核心 ELF 类型
NV::Symbolics::Elf<Elf32Types, true> // 只读 32位 ELF
NV::Symbolics::Elf<Elf64Types, true> // 只读 64位 ELF
NV::Symbolics::Elf<Elf32Types, false> // 可变 32位 ELF(用于打补丁)
NV::Symbolics::Elf<Elf64Types, false> // 可变 64位 ELF
8.2 关键 ELF 操作
| 函数 | 功能 |
|---|---|
toolsElf64GetTextSectionContents() |
提取内核代码段 |
toolsElf64GetCudaSMVersion() |
获取 cubin 的 SM 版本 |
toolsElf64ListKernelNames() |
列举所有内核函数名 |
toolsElf64GetCallgraphFuncs() |
构建函数调用图 |
toolsElf64GetRelatedFuncs() |
获取内核调用的设备函数 |
toolsElf64GetLineInfo() |
提取源码行号信息 |
toolsElf64GetDebugFrame() |
解析 DWARF 调试帧 |
toolsElf64GetAtomSysInstrOffsets() |
定位系统级原子指令 |
toolsElf64GetCoopGroupInstrOffsets() |
定位协作组指令 |
toolsElf64GetWarpWideInstrOffsets() |
定位 warp 级指令 |
toolsElf64StripHiddenSymbols() |
去除隐藏符号 |
8.3 处理的 ELF 段
.nv.info — NVIDIA 特定的内核元数据
.nv.constant — 常量内存段
.text.<function> — 各内核函数的代码段
.nv_fatbin — 嵌入的 fat binary 容器
8.4 缓存机制
NVBit 维护多级缓存来避免重复解析:
- ELF 模块缓存:
elfModuleHashMap全局哈希表 - 行号缓存:
insertIntoLineCache()/searchLineCache() - 内核缓存:
searchKernelCacheByName()/searchKernelCacheByRelocLoc() - 重定位缓存:
createElfReloc()/getRelocByOffset()
9. 硬件抽象层 (HAL)
9.1 HAL 架构
每个 GPU 代际有独立的 HAL 后端,通过函数指针表 hal_t 提供统一接口:
各后端的代码量反映了 SASS ISA 的复杂度演进:
| HAL 后端 | GPU 架构 | SM 版本 | 代码大小 |
|---|---|---|---|
gk11x_hal |
Kepler | sm_35-37 | 10 KB |
gm10x_hal |
Maxwell | sm_50-53 | 10 KB |
gv10x_hal |
Volta | sm_70 | 35 KB |
gv11x_hal |
Volta+ | sm_72-75 | 35 KB |
tu10x_hal |
Turing | sm_75 | 41 KB |
ga10x_hal |
Ampere | sm_80-86 | 45 KB |
gh10x_hal |
Hopper | sm_89-90 | 54 KB |
gb10x_hal |
Blackwell | sm_100 | 54 KB |
gb12x_hal |
Blackwell+ | sm_120 | 54 KB |
从 Kepler 的 10KB 到 Blackwell 的 54KB,指令编码复杂度增加了 5 倍多。
9.2 添加新架构支持
要支持新的 GPU 架构,需要:
1. 在 HAL 层添加新的 init_hal_XXX() 实现
2. 在 SASS 编解码器中添加新的 NV::SassN 命名空间
3. 将新 HAL 注册到 Nvbit::init() 的初始化链中
这是 NVBit 最主要的扩展点之一,但由于 libnvbit.a 是闭源的,外部开发者无法直接添加 HAL 后端。
10. libnvbit.a 内部结构
10.1 目标文件列表
ar t core/libnvbit.a 输出:
nvbit.o ← 公开 API 包装
nvbit_imp.o ← 核心引擎(最大,627KB)
function.o ← Function 类(代码生成)
instr.o ← SassInstr 类(指令解析)
Elf.o ← ELF 解析库
gk11x_hal.o ~ gb12x_hal.o ← 9个 HAL 后端
tools_shared_readelf32.o ← 32位 ELF 读取
tools_shared_readelf64.o ← 64位 ELF 读取
tools_shared_readelf_common.o ← ELF 通用函数
tools_shared_hashmap.o ← 哈希映射
tools_shared_hashset.o ← 哈希集合
tools_shared_list.o ← 链表
tools_shared_rangemap.o ← 区间映射
tools_shared_rbtr.o ← 红黑树
10.2 核心类层次
Nvbit (单例)
├─ callback() → 回调分发
├─ module_loaded() → 模块加载处理
├─ func_loading() → 函数加载处理
├─ disassemble_func() → 反汇编
├─ patch_func() → 安装补丁
├─ config_patched_func() → 配置补丁函数
├─ select_instrumented_or_orig()→ 运行时代码选择
├─ build_callgraph_from_elf() → 构建调用图
├─ get_function() → 获取 Function 对象
├─ normalize_cufunc() → 规范化 CUfunction 句柄
├─ create_ctx() / destroy_ctx() → 上下文管理
└─ init() / term() → 初始化/终止
Function
├─ add_orig_instr() → 添加原始指令
├─ register_assignment_for_parameters() → 寄存器分配
├─ gen_save_routine() → 生成保存代码
├─ gen_call_args() → 生成参数代码
├─ gen_restore_routine() → 生成恢复代码
├─ gen_new_code() → 生成新代码
├─ gen_patched_code() → 编码最终二进制
├─ gen_CFG() → 生成控制流图
├─ compute_new_code_offsets() → 重算分支偏移
└─ dump_sass() → 输出 SASS
SassInstr
├─ decode() → 解码 SASS 文本
├─ encode() → 编码为二进制
├─ tokenize() → 词法分析
├─ to_str() → 转为字符串
└─ get_reg() / gen_reg() → 寄存器操作
10.3 关键 IPC 机制
libnvbit.a 使用以下 IPC 与 CUDA 驱动通信:
动态符号依赖:
dladdr, dlvsym, dlmopen — 动态符号解析
mkstemp — 安全创建临时文件
socket, bind, recvmsg — 与 CUDA 驱动的 socket 通信
shmget — 共享内存
mkfifo — 命名管道
syscall — 直接系统调用
11. 工具 .so 的二进制结构
11.1 段布局
以 instr_count.so (2.6MB) 为例:
| 段 | 大小 | 内容 |
|---|---|---|
.text |
1.12 MB | 可执行代码(NVBit 核心 + 工具逻辑) |
.rodata |
115 KB | 只读数据、字符串常量 |
.data |
380 KB | 可写数据(预编译 GPU 内核) |
.nv_fatbin |
80.9 KB | 嵌入的 CUDA fat binary |
.eh_frame |
148 KB | 异常处理帧 |
.plt |
24 KB | PLT(604 个条目) |
.init_array |
80 B | 10 个构造函数指针 |
.bss |
12 KB | 未初始化数据 |
.tbss |
8 KB | 线程本地存储 |
11.2 嵌入的 Fat Binary
每个工具 .so 是一个"胖二进制",包含 17-18 个架构的预编译 cubin:
cuobjdump 输出:
sm_50, sm_52, sm_53, sm_60, sm_61, sm_62,
sm_70, sm_72, sm_75,
sm_80, sm_86, sm_87, sm_89, sm_90,
sm_100, sm_101, sm_120
+ PTX 后备 (sm_52, sm_120)
这确保了工具的设备函数(如 count_instrs)在任何支持的 GPU 上都能运行。
11.3 CUDA 注册机制
工具 .so 通过标准 CUDA 注册 API 注册其设备代码:
__cudaRegisterFatBinary ← 注册 fat binary
__cudaRegisterFunction ← 注册设备函数
__cudaRegisterVar ← 注册设备变量
__cudaRegisterManagedVar ← 注册托管变量
__cudaUnregisterFatBinary ← 卸载时取消注册
12. 完整运行时序
以 instr_count 工具分析 vectoradd 为例的完整时序:
═══════════════════════════════════════════════════════════
阶段一:加载和初始化
═══════════════════════════════════════════════════════════
应用启动
│
├─ 动态链接器加载 instr_count.so (LD_PRELOAD)
│ ├─ 依赖: libcuda.so.1 (88MB), libcudart.so.12, libstdc++.so.6
│ └─ instr_count.so 最后初始化 (在 libcuda.so.1 之后)
│
├─ .init_array 构造函数执行
│ ├─ __sti____cudaRegisterAll() → 注册 fatbin
│ └─ NVBit 内部初始化
│
├─ Nvbit::init()
│ ├─ toolsElfLibInitialize() → ELF 库初始化
│ ├─ init_hal_gk11x ~ gb12x() → 初始化所有 9 个 HAL 后端
│ └─ cuGetExportTable() → 注册 nvbitToolsCallbackFunc
│
└─ nvbit_at_init() → 工具打印 banner,读取环境变量
═══════════════════════════════════════════════════════════
阶段二:CUDA 上下文创建
═══════════════════════════════════════════════════════════
应用调用 cudaMalloc() (首次 CUDA 调用)
│
├─ CUDA 驱动打开 /dev/nvidiactl, /dev/nvidia0, /dev/nvidia-uvm
├─ 创建 GPU 上下文
│ ├─ 分配命令通道 (每个 4KB MMIO 共享映射,约 8 个)
│ ├─ 预留 ~8.6GB 虚拟地址空间 (PROT_NONE, 不占物理内存)
│ └─ 创建 3 个额外线程: CUDA 内部线程 + cuda-EvtHandlr
│
├─ NVBit 回调: nvbit_at_ctx_init()
│
├─ cuobjdump 提取工具 cubin (首次)
│ └─ cp instr_count.so /tmp/nvbit_tool_tmpdir.XXX
│ cuobjdump -arch sm_120 -xelf all
│ → /tmp/nvbit_tool_tmpdir.XXX/instr_count.1.sm_120.cubin
│
└─ nvbit_tool_init() (如果定义了)
═══════════════════════════════════════════════════════════
阶段三:内核启动和 Instrumentation
═══════════════════════════════════════════════════════════
应用调用 cudaLaunchKernel(vecAdd, <<<98,1024>>>)
│
├─ CUDA 驱动触发回调 (is_exit=0, cbid=cuLaunchKernel)
│ └─ nvbit_at_cuda_event(ctx, is_exit=0, ...)
│
├─ instrument_function_if_needed(ctx, func)
│ │
│ ├─ nvbit_get_related_functions(ctx, func)
│ │ → 获取所有相关设备函数
│ │
│ ├─ nvbit_get_instrs(ctx, func)
│ │ └─ Nvbit::disassemble_func()
│ │ ├─ 写入 /tmp/nvbit_code_XXXXXX (原始二进制)
│ │ ├─ 执行: nvdisasm -b SM120 /tmp/nvbit_code_XXXXXX
│ │ │ > /tmp/nvbit_sass_XXXXXX
│ │ ├─ 解析 SASS 文本 → 32 个 Instr 对象
│ │ └─ 清理临时文件
│ │
│ └─ 对每条指令:
│ ├─ nvbit_insert_call(instr, "count_instrs", IPOINT_BEFORE)
│ ├─ nvbit_add_call_arg_guard_pred_val(instr)
│ ├─ nvbit_add_call_arg_const_val32(instr, count_warp_level)
│ └─ nvbit_add_call_arg_const_val64(instr, &counter)
│
├─ nvbit_enable_instrumented(ctx, func, true)
│ └─ Nvbit::patch_func()
│ ├─ Function::gen_save_routine() → 寄存器保存代码
│ ├─ Function::gen_call_args() → 参数传递代码
│ ├─ Function::gen_restore_routine() → 寄存器恢复代码
│ ├─ Function::gen_new_code() → 完整新代码
│ ├─ Function::compute_new_code_offsets() → 修正分支目标
│ ├─ Function::gen_patched_code() → 编码为 SASS 二进制
│ └─ 上传补丁 cubin 到 GPU
│
├─ Nvbit::config_patched_func()
│ └─ Nvbit::select_instrumented_or_orig()
│ → 切换到补丁版本执行
│
└─ counter = 0 → 重置计数器
═══════════════════════════════════════════════════════════
阶段四:GPU 执行 (补丁后的内核)
═══════════════════════════════════════════════════════════
98 个线程块 × 1024 线程 在 GPU 上执行:
对于 vecAdd 内核中的每条指令:
│
├─ [NVBit trampoline 开始]
│ ├─ 保存寄存器到本地内存
│ ├─ 设置参数 (pred, count_warp_level, &counter)
│ └─ JCAL count_instrs
│
├─ count_instrs() 执行:
│ ├─ active_mask = __ballot_sync(__activemask(), 1)
│ ├─ predicate_mask = __ballot_sync(__activemask(), pred)
│ ├─ laneid = get_laneid()
│ ├─ first_lane = __ffs(active_mask) - 1
│ └─ if (laneid == first_lane):
│ atomicAdd(&counter, 1) // warp 级:每 warp 加 1
│
├─ [NVBit trampoline 结束]
│ └─ 恢复寄存器
│
└─ 执行原始指令
═══════════════════════════════════════════════════════════
阶段五:结果收集
═══════════════════════════════════════════════════════════
CUDA 驱动触发回调 (is_exit=1, cbid=cuLaunchKernel)
│
├─ nvbit_at_cuda_event(ctx, is_exit=1, ...)
│ ├─ cudaDeviceSynchronize() → 等待 GPU 完成
│ ├─ 读取 counter = 62588 → 从托管内存
│ └─ printf("kernel 0 - _Z6vecAddPdS_S_i - #thread-blocks 98,
│ kernel instructions 62588, total instructions 62588")
│
└─ tot_app_instrs += counter
═══════════════════════════════════════════════════════════
阶段六:清理和退出
═══════════════════════════════════════════════════════════
应用退出
├─ nvbit_at_ctx_term(ctx) → 上下文清理
├─ nvbit_at_term() → 打印 "Total app instructions: 62588"
├─ Nvbit::~Nvbit() → 析构单例
└─ toolsElfLibFinalize() → ELF 库清理
13. 关键 API 参考
13.1 工具回调 API(工具实现,NVBit 调用)
| 回调 | 必须? | 调用时机 | 注意事项 |
|---|---|---|---|
nvbit_at_init() |
是 | 工具加载时 | 不要进行 CUDA 内存分配 |
nvbit_at_term() |
是 | 程序退出时 | 最终统计输出 |
nvbit_at_cuda_event() |
是 | 每次 CUDA API 调用 | is_exit=0 入口, is_exit=1 出口 |
nvbit_at_ctx_init() |
否 | CUDA 上下文创建 | 不要 CUDA 内存分配,会死锁 |
nvbit_at_ctx_term() |
否 | 上下文销毁 | 清理 channel 等资源 |
nvbit_tool_init() |
否 | 首次内核启动前 | 安全进行 CUDA 内存分配 |
nvbit_at_graph_node_launch() |
否 | CUDA Graph 节点启动 | 配合 nvbit_set_at_launch |
13.2 指令检查 API
// 获取函数的所有指令
const std::vector<Instr*>& nvbit_get_instrs(CUcontext ctx, CUfunction func);
// 获取控制流图
const CFG_t& nvbit_get_CFG(CUcontext ctx, CUfunction func);
// 获取相关设备函数
std::vector<CUfunction> nvbit_get_related_functions(CUcontext ctx, CUfunction func);
// 获取函数名(mangled=true 返回修饰名)
const char* nvbit_get_func_name(CUcontext ctx, CUfunction f, bool mangled = false);
// 获取源码行号信息
bool nvbit_get_line_info(CUcontext ctx, CUfunction func, uint32_t offset,
char** file_name, char** dir_name, uint32_t* line);
// 获取函数地址、判断是否为内核
uint64_t nvbit_get_func_addr(CUcontext ctx, CUfunction func);
bool nvbit_is_func_kernel(CUcontext ctx, CUfunction func);
// 获取函数配置(网格/块维度、寄存器数等)
void nvbit_get_func_config(CUcontext ctx, CUfunction func, func_config_t *config);
// dump cubin 到文件
bool nvbit_dump_cubin(CUcontext ctx, CUfunction func, const char *filename);
// 获取 SM 计算能力
uint32_t nvbit_get_sm_family(CUcontext ctx);
13.3 Instr 类方法
const char* getSass(); // 完整 SASS 字符串
const char* getOpcode(); // 完整操作码 (如 "LDG.E.64")
const char* getOpcodeShort(); // 短操作码 (如 "LDG")
uint32_t getOffset(); // 字节偏移
uint32_t getIdx(); // 指令索引
bool isLoad() / isStore(); // 是否为内存操作
int getSize(); // 访问大小(字节)
InstrType::MemorySpace getMemorySpace(); // 内存空间类型
int getNumOperands(); // 操作数数量
const operand_t* getOperand(int n); // 获取操作数
bool hasPred(); // 是否有谓词
int getPredNum(); // 谓词号
bool isPredNeg(); // 谓词是否取反
void printDecoded(); // 打印解码信息
13.4 注入 API
// 在指令前/后插入设备函数调用
void nvbit_insert_call(const Instr* instr, const char* func_name, ipoint_t point);
// 添加参数
void nvbit_add_call_arg_guard_pred_val(const Instr* instr); // 保护谓词值
void nvbit_add_call_arg_const_val32(const Instr* instr, uint32_t val); // 32位常量
void nvbit_add_call_arg_const_val64(const Instr* instr, uint64_t val); // 64位常量
void nvbit_add_call_arg_reg_val(const Instr* instr, int reg_num); // 寄存器值
void nvbit_add_call_arg_ureg_val(const Instr* instr, int reg_num); // 统一寄存器值
void nvbit_add_call_arg_mref_addr64(const Instr* instr, int id = 0); // 内存引用地址
void nvbit_add_call_arg_launch_val64(const Instr* instr, int offset); // 启动时参数
void nvbit_add_call_arg_cbank_val(const Instr* instr, int bank, int off); // 常量bank值
void nvbit_add_call_arg_pred_val_at(const Instr* instr, int pred_num); // 特定谓词值
// 删除原始指令(如 mov_replace 工具)
void nvbit_remove_orig(const Instr* instr);
// 运行时控制
void nvbit_enable_instrumented(CUcontext ctx, CUfunction func, bool flag);
void nvbit_set_at_launch(CUcontext ctx, CUfunction func, uint64_t val, ...);
// 线程管理
void nvbit_set_tool_pthread(pthread_t t); // 注册工具线程(避免触发回调)
void nvbit_unset_tool_pthread(pthread_t t);
13.5 数据结构
// 控制流图
typedef struct { std::vector<Instr*> instrs; } basic_block_t;
typedef struct {
bool is_degenerate; // CFG 是否退化(动态跳转)
std::vector<basic_block_t*> bbs; // 基本块列表
} CFG_t;
// 函数配置
typedef struct {
uint32_t blockDimX, blockDimY, blockDimZ;
uint32_t gridDimX, gridDimY, gridDimZ;
uint32_t shmem_static_nbytes, shmem_dynamic_nbytes;
uint32_t num_registers;
} func_config_t;
// 操作数类型
enum class OperandType {
IMM_UINT64, IMM_DOUBLE, REG, PRED, UREG, UPRED,
CBANK, MREF, GENERIC, MEM_DESC
};
// 内存空间
enum class MemorySpace {
NONE, LOCAL, GENERIC, GLOBAL, SHARED, CONSTANT,
GLOBAL_TO_SHARED, SURFACE, TEXTURE,
DISTRIBUTED_SHARED, TENSOR_MEM, TENSOR_CORE_MEM
};
14. 扩展开发指南
14.1 创建新工具的步骤
# 1. 复制模板
cp -r tools/instr_count tools/my_tool
cd tools/my_tool
# 2. 重命名文件
mv instr_count.cu my_tool.cu
# 3. 修改 Makefile
# 更改 NVBIT_TOOL 变量为 my_tool.so
# 更改源文件列表
14.2 工具基础骨架
my_tool.cu(主机端):
#include "nvbit_tool.h"
#include "nvbit.h"
#include "utils/utils.h"
#include "common.h" // 如需自定义数据结构
// 全局状态
__managed__ uint64_t my_counter = 0;
std::unordered_set<CUfunction> already_instrumented;
pthread_mutex_t mutex;
void nvbit_at_init() {
setenv("CUDA_MANAGED_FORCE_DEVICE_ALLOC", "1", 1);
GET_VAR_INT(verbose, "TOOL_VERBOSE", 0, "Enable verbose output");
pthread_mutex_init(&mutex, NULL);
}
void instrument_function_if_needed(CUcontext ctx, CUfunction func) {
auto related = nvbit_get_related_functions(ctx, func);
related.push_back(func);
for (auto f : related) {
if (!already_instrumented.insert(f).second) continue;
const auto& instrs = nvbit_get_instrs(ctx, f);
for (auto instr : instrs) {
// 你的过滤逻辑:选择要 instrument 的指令
if (!instr->isLoad() && !instr->isStore()) continue;
nvbit_insert_call(instr, "my_instrument_func", IPOINT_BEFORE);
nvbit_add_call_arg_guard_pred_val(instr);
nvbit_add_call_arg_const_val64(instr, (uint64_t)&my_counter);
// 添加更多参数...
}
}
}
void nvbit_at_cuda_event(CUcontext ctx, int is_exit, nvbit_api_cuda_t cbid,
const char* name, void* params, CUresult* pStatus) {
if (cbid == API_CUDA_cuLaunchKernel_ptsz ||
cbid == API_CUDA_cuLaunchKernel ||
cbid == API_CUDA_cuLaunchKernelEx_ptsz) {
if (!is_exit) {
pthread_mutex_lock(&mutex);
// 获取 CUfunction(根据 cbid 不同用不同方式)
cuLaunchKernel_params* p = (cuLaunchKernel_params*)params;
instrument_function_if_needed(ctx, p->f);
nvbit_enable_instrumented(ctx, p->f, true);
my_counter = 0;
pthread_mutex_unlock(&mutex);
} else {
pthread_mutex_lock(&mutex);
cudaDeviceSynchronize();
printf("Result: %ld\n", my_counter);
pthread_mutex_unlock(&mutex);
}
}
}
void nvbit_at_term() {
printf("Done.\n");
}
inject_funcs.cu(设备端):
#include <stdint.h>
#include "utils/utils.h"
// 必须声明为 extern "C" __device__ __noinline__
extern "C" __device__ __noinline__ void my_instrument_func(
int pred, // 来自 nvbit_add_call_arg_guard_pred_val
uint64_t pcounter) // 来自 nvbit_add_call_arg_const_val64
{
if (!pred) return; // 谓词为假则跳过
// Warp 级聚合(减少原子操作冲突)
const int active_mask = __ballot_sync(__activemask(), 1);
const int laneid = get_laneid();
const int first_laneid = __ffs(active_mask) - 1;
if (laneid == first_laneid) {
atomicAdd((unsigned long long*)pcounter, 1);
}
}
14.3 Makefile 关键编译选项
# inject_funcs.cu 必须使用这些特殊标志
$(NVCC) $(INCLUDES) -Xptxas -astoolspatch --keep-device-functions \
-arch=$(ARCH) -Xcompiler -fPIC -c inject_funcs.cu -o inject_funcs.o
# -Xptxas -astoolspatch : 启用 AST 工具补丁模式(NVBit 必需)
# --keep-device-functions : 防止优化器删除未引用的设备函数
# -arch=$(ARCH) : 为所有目标架构生成代码
# 最终链接必须用 g++,不能用 nvcc
g++ -shared -fPIC $(OBJECTS) -L$(NVBIT_PATH) -lnvbit \
-L$(CUDA_LIB) -lcuda -lcudart_static -lpthread -ldl -o my_tool.so
14.4 常见扩展方向
| 方向 | 说明 | 参考工具 |
|---|---|---|
| 自定义指令计数 | 按条件统计特定类型指令 | instr_count, opcode_hist |
| 内存访问分析 | 追踪全局/共享内存访问模式 | mem_trace |
| 数据流分析 | 记录寄存器值追踪数据流 | record_reg_vals |
| 指令替换 | 修改 GPU 代码行为 | mov_replace |
| 性能分析 | 基本块级计数降低开销 | instr_count_bb |
| CUDA Graph 支持 | 支持图模式的内核分析 | instr_count_cuda_graph |
| Channel 通信 | 传输复杂结构化数据 | mem_trace, record_reg_vals |
14.5 扩展限制
由于 libnvbit.a 是闭源的,以下扩展不可能直接实现:
- 添加新 GPU 架构的 HAL 后端 — 需要 NVIDIA 更新 libnvbit.a
- 修改补丁引擎的行为 — 代码生成逻辑在闭源库中
- 修改 CUDA API 拦截的范围 — 回调订阅在初始化时完成
- 替换 nvdisasm 依赖 — 反汇编路径硬编码
可以做的扩展:
- 新工具 — 组合现有 API 实现新的分析工具
- 自定义设备函数 — inject_funcs.cu 完全可控
- 自定义通信机制 — 可以不用 Channel,使用自己的方案
- 后处理 — 收集数据后做任意分析
- 与其他系统集成 — 通过工具的主机端代码连接外部系统
15. strace 实证分析
15.1 系统调用统计
对 instr_count + vectoradd 的一次运行:
总系统调用数: ~458
子进程数: 11 (shell + which + cuobjdump + nvdisasm)
线程数: 4 (主线程 + 2 CUDA内部 + cuda-EvtHandlr)
ioctl 调用: 427 (GPU 资源管理)
mmap 调用: 100 (内存映射)
openat 调用: 111 (文件操作)
15.2 关键设备文件
| 设备 | 功能 | 打开次数 |
|---|---|---|
/dev/nvidiactl |
NVIDIA 控制设备 | 多次 (fd 8+) |
/dev/nvidia0 |
GPU 0 设备 | ~20次 (不同 fd) |
/dev/nvidia-uvm |
统一虚拟内存 | 2次 (fd 9,10) |
15.3 内存映射模式
阶段一 - 库加载:
libcuda.so.1: 88MB 映射 (PROT_READ, PROT_READ|PROT_EXEC)
libcudart.so.12: ~5MB 映射
阶段二 - CUDA 初始化:
134MB 匿名映射 (PROT_NONE) — CUDA 内部堆
~8.6GB 预留 (PROT_NONE @ 0x727428000000) — GPU 地址空间
~4.3GB 预留 (PROT_NONE @ 0x200000000) — 设备映射内存
阶段三 - GPU 通道:
8× 4KB 共享映射 — GPU 命令提交通道 (doorbell 寄存器)
阶段四 - 内核执行:
2MB MAP_SHARED|MAP_FIXED — GPU MMIO 区域
2MB 匿名共享映射 — 主机-设备共享缓冲区
注意:8.6GB + 4.3GB 的预留是虚拟地址预留(PROT_NONE),不消耗物理内存。
15.4 ioctl 命令模式
0x46, 0x2a — GPU 资源管理(最频繁)
0x46, 0x2b — GPU 资源分配/绑定
0x46, 0x4e — 内存对象创建(总是跟着 mmap)
0x46, 0xc9 — GPU 初始化/配置
0x46, 0xd6 — 驱动参数查询
UVM ioctls — 内存注册、映射、页面管理
15.5 Compute Cache
NVBit/CUDA 使用 ~/.nv/ComputeCache/ 缓存 JIT 编译结果,避免重复编译。
附录:环境变量速查表
NVBit 核心环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
NVDISASM |
nvdisasm |
覆盖 nvdisasm 路径 |
NOBANNER |
0 |
禁止打印 NVBit banner |
NO_EAGER_LOAD |
0 |
关闭急切模块加载 |
ACK_CTX_INIT_LIMITATION |
0 |
抑制 ctx_init 警告 |
工具通用环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
INSTR_BEGIN |
0 |
开始 instrument 的指令索引 |
INSTR_END |
UINT32_MAX |
结束 instrument 的指令索引 |
START_GRID_NUM / KERNEL_BEGIN |
0 |
开始 instrument 的内核编号 |
END_GRID_NUM / KERNEL_END |
UINT32_MAX |
结束 instrument 的内核编号 |
TOOL_VERBOSE |
0 |
启用详细输出 |
COUNT_WARP_LEVEL |
1 |
1=warp级计数, 0=线程级计数 |
EXCLUDE_PRED_OFF |
0 |
排除谓词关闭的指令 |
ACTIVE_FROM_START |
1 |
1=立即开始, 0=等待 cuProfilerStart |
MANGLED_NAMES |
1 |
1=打印修饰名, 0=反修饰名 |
16. 使用非 CUDA 语言编写 NVBit 工具
NVBit 工具当前必须用 CUDA C/C++ 编写,但经过深入分析,这个限制是可以突破的。本章探讨使用 LLVM IR、eBPF、Rust、Zig 等替代方案编写 NVBit 设备端插桩函数的可行性。
16.1 当前架构的约束分析
NVBit 工具由两部分组成,各有不同的约束:
| 部分 | 当前实现 | 核心约束 | 可替代性 |
|---|---|---|---|
| 主机端 | C++ 共享库 (.so) | 导出 nvbit_at_init 等 C ABI 符号 |
高 — 任何能生成 .so + C ABI 的语言 |
| 设备端 | CUDA C (inject_funcs.cu) | 编译为 PTX → ptxas -astoolspatch → cubin |
高 — 任何能生成 PTX 的工具链 |
设备端函数的硬性要求:
- 函数签名:
extern "C"(无名称修饰) +__noinline__(禁止内联) + 设备函数(PTX 中为.visible .func,不是.entrykernel) - 编译标志:必须用
ptxas -astoolspatch编译,告诉 ptxas 这是工具补丁代码 - Dummy kernel:需要一个
__global__函数(.entry)触发 CUDA 模块加载,否则 NVBit 找不到设备函数 - 按名称引用:主机端通过字符串名(如
"count_instrs")引用设备函数,NVBit 在 .so 的 cubin 段中查找
16.2 GPU 原语的 LLVM IR 等价映射
所有 NVBit 设备函数使用的 CUDA 原语在 LLVM NVPTX 后端中都有对应的 intrinsic,无一例外:
Warp 级通信原语
| CUDA 原语 | LLVM NVPTX Intrinsic | PTX 指令 |
|---|---|---|
__activemask() |
@llvm.nvvm.activemask() → i32 |
activemask.b32 |
__ballot_sync(mask, pred) |
@llvm.nvvm.vote.ballot.sync(i32, i1) → i32 |
vote.sync.ballot.b32 |
__shfl_sync(mask, val, lane) |
@llvm.nvvm.shfl.sync.idx.i32(i32, i32, i32, i32) → i32 |
shfl.sync.idx.b32 |
__popc(x) |
@llvm.ctpop.i32(i32) → i32 |
popc.b32 |
__ffs(x) |
@llvm.cttz.i32(i32, i1) → i32 (需 +1 调整) |
brev.b32 + bfind.shiftamt.u32 |
特殊寄存器读取
| CUDA 工具函数 | LLVM NVPTX Intrinsic | PTX 指令 |
|---|---|---|
get_laneid() |
@llvm.nvvm.read.ptx.sreg.laneid() → i32 |
mov.u32 %r, %laneid |
get_warpid() |
@llvm.nvvm.read.ptx.sreg.warpid() → i32 |
mov.u32 %r, %warpid |
get_ctaid().x/y/z |
@llvm.nvvm.read.ptx.sreg.ctaid.{x,y,z}() → i32 |
mov.u32 %r, %ctaid.{x,y,z} |
get_smid() |
@llvm.nvvm.read.ptx.sreg.smid() → i32 |
mov.u32 %r, %smid |
原子操作与内存屏障
| CUDA 原语 | LLVM IR 表达 | PTX 指令 |
|---|---|---|
atomicAdd(uint64_t*) |
atomicrmw add ptr, i64 monotonic |
atom.add.u64 |
__threadfence_system() |
@llvm.nvvm.membar.sys() |
membar.sys |
__threadfence() |
@llvm.nvvm.membar.gl() |
membar.gl |
volatile load/store |
LLVM load volatile / store volatile |
ld.volatile / st.volatile |
结论
设备端代码零功能依赖于 CUDA 编译器特有的构造。所有功能都可以通过标准 LLVM IR + NVPTX intrinsic 表达。
16.3 路径一:LLVM IR → PTX → NVBit 工具(最通用)
这是最直接的路径,允许使用任何 LLVM 前端语言编写设备函数。
编译流水线
任意语言 (Rust / Zig / C / 手写 LLVM IR / ...)
↓ 各语言的 LLVM 前端
LLVM IR (.ll 文件)
↓ llc -march=nvptx64 -mcpu=sm_120 -mattr=+ptx83
PTX (.ptx 文件)
↓ ptxas -astoolspatch -arch=sm_120
cubin (.cubin 文件)
↓ fatbinary --create=tool.fatbin -64 --image=profile=sm_120,file=tool.cubin
fatbin → 嵌入 .so 共享库
↓ g++ -shared -o my_tool.so host.o -lnvbit -lcuda ...
NVBit 工具 (.so)
示例:用 LLVM IR 实现 count_instrs
以下 LLVM IR 等价于 instr_count/inject_funcs.cu 中的 count_instrs 函数:
; count_instrs.ll — NVBit 设备端插桩函数 (LLVM IR)
target datalayout = "e-i64:64-i128:128-v16:16-v32:32-n16:32:64"
target triple = "nvptx64-nvidia-cuda"
; 声明 LLVM NVPTX intrinsics
declare i32 @llvm.nvvm.activemask()
declare i32 @llvm.nvvm.vote.ballot.sync(i32, i1)
declare i32 @llvm.nvvm.read.ptx.sreg.laneid()
declare i32 @llvm.ctpop.i32(i32)
declare i32 @llvm.cttz.i32(i32, i1)
; extern "C" __device__ __noinline__ void count_instrs(
; int predicate, int count_warp_level, uint64_t pcounter)
define void @count_instrs(i32 %predicate, i32 %count_warp_level, i64 %pcounter) #0 {
entry:
; int active_mask = __activemask()
%active_mask = call i32 @llvm.nvvm.activemask()
; int laneid = get_laneid()
%laneid = call i32 @llvm.nvvm.read.ptx.sreg.laneid()
; int first_laneid = __ffs(active_mask) - 1
%ctz = call i32 @llvm.cttz.i32(i32 %active_mask, i1 false)
; __ffs 返回 1-based,cttz 返回 0-based,所以 cttz 直接就是 first_laneid
%first_laneid = add i32 %ctz, 0
; if (first_laneid != laneid) return
%is_first = icmp eq i32 %laneid, %first_laneid
br i1 %is_first, label %do_count, label %done
do_count:
; int predicate_mask = __ballot_sync(active_mask, predicate)
%pred_bool = icmp ne i32 %predicate, 0
%pred_mask = call i32 @llvm.nvvm.vote.ballot.sync(i32 %active_mask, i1 %pred_bool)
; int num_threads = __popc(predicate_mask)
%num_threads = call i32 @llvm.ctpop.i32(i32 %pred_mask)
%has_active = icmp ugt i32 %num_threads, 0
br i1 %has_active, label %do_atomic, label %done
do_atomic:
%counter_ptr = inttoptr i64 %pcounter to ptr addrspace(1)
; if (count_warp_level) atomicAdd(pcounter, 1) else atomicAdd(pcounter, num_threads)
%is_warp_level = icmp ne i32 %count_warp_level, 0
%num_ext = zext i32 %num_threads to i64
%add_val = select i1 %is_warp_level, i64 1, i64 %num_ext
%old = atomicrmw add ptr addrspace(1) %counter_ptr, i64 %add_val monotonic
br label %done
done:
ret void
}
; 属性:noinline 是 NVBit 的硬性要求
attributes #0 = { noinline nounwind }
编译步骤
# 1. LLVM IR → PTX
llc -march=nvptx64 -mcpu=sm_70 -mattr=+ptx75 count_instrs.ll -o count_instrs.ptx
# 2. PTX → cubin (使用 NVBit 必需的 -astoolspatch 标志)
ptxas -astoolspatch -arch=sm_70 count_instrs.ptx -o count_instrs.cubin
# 3. 打包为 fatbin (可包含多架构)
fatbinary --create=inject_funcs.fatbin -64 \
--image=profile=sm_70,file=count_instrs.sm70.cubin \
--image=profile=sm_120,file=count_instrs.sm120.cubin
需要额外处理的问题
问题 1:Dummy kernel
NVBit 需要一个 __global__ kernel 来触发模块加载。最简单的方案是保留一个最小的 CUDA C 文件:
// dummy_kernel.cu — 仅此一个文件需要 nvcc
#include "nvbit_tool.h" // 提供 load_module_nvbit_kernel 和 gen_mref_addr
或者在 PTX 中手写:
问题 2:gen_mref_addr
如果使用 nvbit_add_call_arg_mref_addr64(),需要提供 gen_mref_addr 函数。它是纯整数运算(移位、拼接、加法),用 LLVM IR 很容易实现:
define i64 @gen_mref_addr(i32 %ra_high, i32 %is_ra64, i32 %ra_low,
i32 %ra_stride, i32 %ru_high, i32 %is_ru64,
i32 %ru_low, i32 %imm, i32 %mref_idx) #0 {
; base = is_ra64 ? ((ra_high << 32) | (ra_low * ra_stride))
; : sign_extend_64(ra_low * ra_stride)
%ra_prod = mul i32 %ra_low, %ra_stride
%ra_prod64 = sext i32 %ra_prod to i64
%ra_high64 = zext i32 %ra_high to i64
%ra_high_shifted = shl i64 %ra_high64, 32
%ra_low64 = zext i32 %ra_prod to i64
%ra_combined = or i64 %ra_high_shifted, %ra_low64
%is_64 = icmp ne i32 %is_ra64, 0
%base = select i1 %is_64, i64 %ra_combined, i64 %ra_prod64
; offset = is_ru64 ? ((ru_high << 32) | ru_low) : sign_extend_64(ru_low)
%ru_low64 = zext i32 %ru_low to i64
%ru_low_sext = sext i32 %ru_low to i64
%ru_high64 = zext i32 %ru_high to i64
%ru_high_shifted = shl i64 %ru_high64, 32
%ru_combined = or i64 %ru_high_shifted, %ru_low64
%is_ru_64 = icmp ne i32 %is_ru64, 0
%offset = select i1 %is_ru_64, i64 %ru_combined, i64 %ru_low_sext
; result = base + offset + imm
%sum1 = add i64 %base, %offset
%imm64 = sext i32 %imm to i64
%result = add i64 %sum1, %imm64
ret i64 %result
}
问题 3:fatbin 嵌入 .so
需要将编译好的 cubin 打包为 fatbin 并嵌入到共享库中。有两种方式:
# 方式 A:使用 nvcc 编译 dummy_kernel.cu 时自动处理
# 将 LLVM IR 生成的 cubin 与 dummy_kernel 合并
# 方式 B:手动嵌入 fatbin
objcopy --input binary --output elf64-x86-64 \
--binary-architecture i386:x86-64 \
inject_funcs.fatbin inject_funcs_fatbin.o
# 然后链接进 .so
16.4 路径二:eBPF → PTX → GPU 插桩
eBPF 的核心理念——安全的沙箱化字节码、动态加载、map 通信——非常适合 GPU 插桩场景。
架构概览
用户编写 eBPF 程序 (受限 C)
↓ clang -target bpf -O2
eBPF 字节码 (.o)
↓ eBPF → PTX 翻译器
PTX 代码
↓ ptxas -astoolspatch
cubin
↓ 动态注入 GPU 内核
运行时插桩
与 NVBit 方案的对比
| 维度 | NVBit (当前) | eBPF on GPU |
|---|---|---|
| 插桩方式 | Trampoline(跳板 + 寄存器保存恢复) | 直接 inline 或 in-situ 执行 |
| 代码安全 | 无限制(可以 crash GPU) | eBPF 验证器保证安全 |
| 动态性 | 需预编译 .so | 可运行时加载/卸载 |
| 数据通信 | Channel 环形缓冲区 / 托管内存 | eBPF map(hash/array/ringbuf) |
| 性能开销 | 高(寄存器保存恢复 + 函数调用) | 据称低 3-10x |
| 表达能力 | 完整 C++ | 受限(无循环上界、栈深度限制) |
eGPU / bpftime 项目
eGPU(已合入 bpftime)是将 eBPF 概念引入 GPU 的首个框架,核心技术路线:
- eBPF 程序编译为字节码
- 字节码翻译为 PTX 指令序列
- 通过动态 PTX 注入插入 GPU 内核
- Helper trampoline 提供 GPU 端 map 操作、计时、上下文访问
- Host-GPU 通信使用 pinned shared memory + spinlock
16.5 路径三:Rust → PTX → NVBit 工具
Rust 的内存安全和类型系统对 GPU 插桩代码特别有价值(避免越界访问导致 GPU hang)。
可用工具链
| 项目 | 路径 | 成熟度 |
|---|---|---|
| rustc 内建 nvptx64 target | rustc --target nvptx64-nvidia-cuda → PTX |
Tier 3,可用 |
| rust-cuda (rustc_codegen_nvvm) | Rust → NVVM IR → PTX | 2025 年重启,活跃开发 |
| rust-gpu | Rust → SPIR-V (Vulkan) | 成熟,但不适用于 CUDA/NVBit |
示例概念(Rust + nvptx64)
// inject_funcs.rs — 概念性示例
#![no_std]
#![feature(abi_ptx)]
// NVBit 设备函数:extern "C" + no_mangle
#[no_mangle]
pub unsafe extern "ptx-kernel" fn count_instrs(
predicate: i32,
count_warp_level: i32,
pcounter: *mut u64,
) {
// 需要通过 FFI 或 inline asm 调用 NVPTX intrinsics
// 例如 core::arch::nvptx::_activemask() 等
// 当前 Rust 的 nvptx intrinsic 覆盖有限,可能需要 inline PTX asm
}
当前限制:Rust 的 nvptx64 target 是 Tier 3,core library 支持有限,warp-level intrinsic 需要通过 asm! 宏内联 PTX。适合实验,但生产使用需要更多工作。
16.6 路径四:运行时动态 PTX 生成
最灵活的方案——在运行时根据目标内核的特征动态生成插桩代码。
架构
// 在 nvbit_at_cuda_event() 中
void nvbit_at_cuda_event(CUcontext ctx, int is_exit, ...) {
if (cbid == API_CUDA_cuLaunchKernel && !is_exit) {
// 1. 分析目标内核
auto& instrs = nvbit_get_instrs(ctx, func);
// 2. 根据内核特征动态生成 PTX
std::string ptx = generate_specialized_ptx(instrs);
// 3. 编译 PTX → cubin
nvPTXCompilerHandle compiler;
nvPTXCompilerCreate(&compiler, ptx.size(), ptx.c_str());
const char* opts[] = {"--gpu-name=sm_120", "--compile-as-tools-patch"};
nvPTXCompilerCompile(compiler, 2, opts);
size_t elf_size;
nvPTXCompilerGetCompiledProgramSize(compiler, &elf_size);
char* elf = new char[elf_size];
nvPTXCompilerGetCompiledProgram(compiler, elf);
// 4. 加载编译后的模块
CUmodule module;
cuModuleLoadData(&module, elf);
// 5. 使用动态生成的函数进行 instrumentation
// ...
}
}
使用 PTX Compiler API
#include <nvPTXCompiler.h>
// 关键:opts 中传入 "--compile-as-tools-patch" 等价于 ptxas -astoolspatch
const char* compile_opts[] = {
"--gpu-name=sm_120",
"--compile-as-tools-patch", // NVBit 必需
"--maxrregcount=24" // 限制寄存器使用
};
nvPTXCompilerCompile(compiler, 3, compile_opts);
优势
- 自适应插桩:可以根据目标内核的指令类型、寄存器使用、内存访问模式生成专用代码
- 减少开销:只插桩需要的指令,生成最优化的补丁代码
- 无编译期依赖:不需要 nvcc,完全在运行时完成
16.7 工程实施要点
无论选择哪条路径,都需要解决以下共同问题:
ptxas -astoolspatch 的限制
$ ptxas --help | grep -A2 astoolspatch
--compile-as-tools-patch (-astoolspatch)
Compile patch code for CUDA tools. For codes compiled with this mode,
compiler sets maxrregcount to the minimum registers required by ABI.
限制条件:
- 不能使用 __shared__ 共享内存
- 不能与 -c(可重定位目标文件)或 -ewp(扩展整程序模式)组合
- 寄存器使用量被限制为 ABI 最小值
主机端的语言选择
主机端(回调函数)可以用多种语言实现,只要能导出 C ABI:
| 语言 | 方式 | 可行性 |
|---|---|---|
| C++ | 直接编写(现有方式) | 生产级 |
| Rust | #[no_mangle] pub extern "C" fn nvbit_at_init() |
高 |
| Zig | export fn nvbit_at_init() callconv(.C) void {} |
高 |
| Go | //export nvbit_at_init + cgo |
中(cgo 开销) |
混合方案(推荐起步方式)
最实际的起步方案是主机端保持 C++,仅替换设备端:
host 端: C++ (不变) ─── nvbit_at_init, nvbit_at_cuda_event 等
↓ nvbit_insert_call("count_instrs", ...)
device 端: LLVM IR/Rust/eBPF → PTX → cubin (新方案)
↑ 通过函数名字符串关联
这样可以复用现有的主机端代码和构建系统,只需要替换 inject_funcs.cu 的编译流水线。
16.8 可行性总结
| 方案 | 设备端可行性 | 主机端可行性 | 成熟度 | 最适合场景 |
|---|---|---|---|---|
| LLVM IR → PTX | 高(所有 intrinsic 已覆盖) | 高(C ABI .so) | 生产级 | 多语言前端、自动化代码生成 |
| eBPF → PTX | 中-高 | 高 | 研究原型 | 安全沙箱、动态加载卸载 |
| Rust → PTX | 中(intrinsic 覆盖有限) | 高(no_mangle extern "C") | 实验性 | 内存安全的插桩代码 |
| Zig → PTX | 低-中(Tier 4) | 高(callconv(.C)) | 实验性 | comptime 元编程 |
| 运行时 PTX 生成 | 高(API 已稳定) | 高 | 生产级 | 自适应插桩、无编译期依赖 |
| 手写 PTX | 高 | 高 | 完全支持 | 极致性能优化 |
推荐路线:从 LLVM IR → PTX 路径起步,先实现一个最简单的 count_instrs 验证整条流水线。成功后可以接入任意 LLVM 前端语言,或者探索 eBPF 方案实现更动态的插桩。
17. 实战 POC:手写 PTX 作为 NVBit 设备函数
本章记录了一个完整的 POC 实现:用手写 PTX 替代 CUDA C 编写 NVBit 的设备端插桩函数。 包含完整的构建流水线、工作原理分析,以及开发过程中遇到的所有坑。
POC 代码位于
tools/ptx_instr_count/。
17.1 目标与动机
NVBit 标准工具的设备函数(如 count_instrs)通常用 CUDA C 编写(inject_funcs.cu),由 nvcc 编译。但如果我们想使用 LLVM IR、eBPF、Rust 等非 CUDA 前端,第一步是验证:NVBit 能否使用手写 PTX 代替 nvcc 编译的设备函数?
答案是可以的,但过程中有若干隐蔽的坑。
17.2 构建流水线
标准 NVBit 工具的构建:
inject_funcs.cu → nvcc (--keep-device-functions, -astoolspatch) → .o
instr_count.cu → nvcc → .o
两个 .o + libnvbit.a → g++ -shared → tool.so
PTX POC 的构建:
inject_funcs.ptx (手写 PTX)
↓ ptxas -astoolspatch -arch=sm_120
inject_funcs.sm_120.cubin (已编译的 cubin)
↓ fatbinary --create -64
inject_funcs.fatbin (含 cubin + PTX JIT 后备)
↓ inject_funcs_embed.S (.incbin)
inject_funcs_embed.o (fatbin 数据嵌入 .nv_fatbin ELF section)
+ inject_funcs_reg.cpp (host 注册桩,wrapper 在 .nvFatBinSegment)
+ instr_count.cu (host 端代码,nvcc 编译)
+ libnvbit.a
↓ g++ -shared
ptx_instr_count.so
17.3 手写 PTX 设备函数
以 count_instrs 为例,这是最核心的插桩函数。我们参考 nvcc 编译 inject_funcs.cu 产生的 PTX(通过 nvcc --keep 或 -ptx 获取 reference.ptx),然后手写等效的 PTX:
.version 8.8
.target sm_70
.address_size 64
.visible .func count_instrs(
.param .b32 count_instrs_param_0, // num_instrs
.param .b32 count_instrs_param_1, // count_warp_level
.param .b64 count_instrs_param_2 // pcounter
)
{
.reg .pred %p<5>;
.reg .b32 %r<10>;
.reg .b64 %rd<6>;
ld.param.u32 %r2, [count_instrs_param_0];
ld.param.u32 %r3, [count_instrs_param_1];
ld.param.u64 %rd1, [count_instrs_param_2];
// 获取活跃 lane mask
activemask.b32 %r4;
mov.pred %p1, -1;
vote.sync.ballot.b32 %r1, %p1, %r4;
// 获取 lane ID
mov.u32 %r5, %laneid;
// 找到第一个活跃 lane
brev.b32 %r6, %r1;
bfind.shiftamt.u32 %r7, %r6;
// 只有第一个活跃 lane 执行 atomic add
setp.ne.s32 %p3, %r7, %r5;
@%p3 bra $L_done;
setp.eq.s32 %p4, %r3, 0;
@%p4 bra $L_thread_level;
// Warp-level: atomicAdd(pcounter, num_instrs)
cvt.s64.s32 %rd2, %r2;
atom.add.u64 %rd3, [%rd1], %rd2;
bra.uni $L_done;
$L_thread_level:
// Thread-level: atomicAdd(pcounter, popc(active_mask) * num_instrs)
popc.b32 %r8, %r1;
mul.lo.s32 %r9, %r8, %r2;
cvt.s64.s32 %rd4, %r9;
atom.add.u64 %rd5, [%rd1], %rd4;
$L_done:
ret;
}
关键点:
- 必须用 .visible .func(不是 .entry)—— 这是设备函数,不是 kernel
- 函数名 count_instrs 必须与主机端 nvbit_insert_call(i, "count_instrs", ...) 一致
- 参数命名格式 count_instrs_param_N 是 PTX 约定
17.4 Host 端注册机制
绕过 nvcc 后,我们需要手动完成 nvcc 通常自动生成的 fatbin 注册逻辑:
// inject_funcs_reg.cpp
// CUDA 内部注册 API(稳定 ABI,不在公开头文件中)
extern "C" {
void** __cudaRegisterFatBinary(void*);
void __cudaRegisterFatBinaryEnd(void**);
void __cudaUnregisterFatBinary(void**);
}
// 从 inject_funcs_embed.S 来的符号
extern "C" {
extern unsigned char _inject_funcs_fatbin_data[]
__attribute__((visibility("hidden")));
}
// CUDA fatbin wrapper (magic = 0x466243B1)
struct __fatBinC_Wrapper_t {
int magic;
int version;
const void* data;
void* filename_or_fatbins;
};
// 必须放在 .nvFatBinSegment section!(见踩坑记录)
static __fatBinC_Wrapper_t __fatDeviceText
__attribute__((aligned(8), section(".nvFatBinSegment"))) = {
0x466243B1, 1,
_inject_funcs_fatbin_data,
nullptr
};
// .so 加载时注册
__attribute__((constructor))
static void __cuda_register_inject_funcs() {
auto handle = __cudaRegisterFatBinary(&__fatDeviceText);
if (handle) __cudaRegisterFatBinaryEnd(handle);
}
17.5 开发踩坑记录
坑 1:ptxas 不接受非 ASCII 字符
现象:
ptxas fatal : Unexpected non-ASCII character encountered on line 1
ptxas fatal : Unexpected non-ASCII character encountered on line 13
原因:PTX 注释中使用了 UTF-8 编码的 em-dash 字符(—,编码为 0xe2 0x80 0x94)。ptxas 严格要求整个文件为纯 ASCII。
解决:将所有 — 替换为 ASCII 的 -。
教训:PTX 文件必须是纯 ASCII 编码,即使是注释中也不允许出现任何非 ASCII 字符。编写 PTX 时建议设置编辑器编码为 ASCII。
坑 2:xxd 嵌入方式无法被 cuobjdump 发现
现象:
cuobjdump info : No ELF file found to extract from 'ptx_instr_count.so'
ASSERT FAIL: function.cpp:792: instrumentation function count_instrs not found in binary!
尝试的方案(失败):
原因:NVBit 启动时运行 cuobjdump -xelf all <tool.so> 来提取设备代码。cuobjdump 不会扫描 .rodata,它只查找 .nv_fatbin ELF section 中的 fatbin 数据。用 xxd 生成的 C 数组会被编译到 .rodata,cuobjdump 完全看不到。
关键发现:通过 readelf -S 对比正常工作的 instr_count_bb.so 和我们的 .so,发现正常工具有以下特殊 ELF section:
[15] __nv_module_id PROGBITS
[16] .nv_fatbin PROGBITS ← cuobjdump 在这里找 fatbin 数据
[28] .nvFatBinSegment PROGBITS ← cuobjdump 在这里找 fatbin wrapper 指针
坑 3:objcopy 嵌入到 .nv_fatbin 仍然不够
尝试的方案(部分有效):
objcopy -I binary -O elf64-x86-64 \
--rename-section .data=.nv_fatbin,alloc,load,readonly,data \
inject_funcs.fatbin inject_funcs_fatbin.o
现象:fatbin 数据确实出现在 .nv_fatbin section 中(通过 objdump -s -j .nv_fatbin 可验证 magic number 0xBA55ED50 存在),但 cuobjdump 仍然找不到。
原因:cuobjdump 不是直接扫描 .nv_fatbin section 找 magic number。它通过 .nvFatBinSegment section 中的 __fatBinC_Wrapper_t 结构体里的 data 指针 来定位 fatbin。我们的 wrapper 没有放在 .nvFatBinSegment 中。
坑 4:wrapper 放入 .nvFatBinSegment 但指针重定位类型错误
尝试的方案(仍然失败):
static __fatBinC_Wrapper_t __fatDeviceText
__attribute__((aligned(8), section(".nvFatBinSegment"))) = {
0x466243B1, 1,
_binary_inject_funcs_fatbin_start, // objcopy 生成的 extern 符号
nullptr
};
现象:.nvFatBinSegment 中现在有了 2 个 wrapper entry(一个来自 libnvbit.a,一个是我们的),但 cuobjdump 仍然只找到 libnvbit 的 sm_52 cubin。
深层原因(通过 readelf -r 分析):
# libnvbit 的 wrapper data 指针:
000000210fa0 R_X86_64_RELATIVE 17ae50 ← cuobjdump 能处理
# 我们的 wrapper data 指针:
000000210fb8 R_X86_64_64 _binary_inject_funcs_fatbin_start + 0 ← cuobjdump 无法处理!
cuobjdump 是一个静态分析工具,它不运行动态链接器。它能处理 R_X86_64_RELATIVE 重定位(只需要加上基地址),但无法处理 R_X86_64_64 重定位(需要符号解析)。
objcopy 生成的 _binary_*_start 符号是全局可见的外部符号,链接器为其生成的是 R_X86_64_64 重定位。而 nvcc 编译的代码中,fatbin 数据和 wrapper 在同一编译单元内,编译器直接引用,链接器生成 R_X86_64_RELATIVE。
坑 4 的解决:用汇编 .incbin + hidden visibility
最终方案:创建一个汇编文件 inject_funcs_embed.S,用 .incbin 指令嵌入 fatbin,并标记为 hidden visibility:
.section .nv_fatbin, "a", @progbits
.align 8
.globl _inject_funcs_fatbin_data
.hidden _inject_funcs_fatbin_data
.type _inject_funcs_fatbin_data, @object
_inject_funcs_fatbin_data:
.incbin "inject_funcs.fatbin"
.size _inject_funcs_fatbin_data, . - _inject_funcs_fatbin_data
然后在 inject_funcs_reg.cpp 中引用时也标记 hidden:
extern "C" {
extern unsigned char _inject_funcs_fatbin_data[]
__attribute__((visibility("hidden")));
}
为什么这样有效:hidden visibility 告诉链接器这个符号不会被运行时替换(不需要符号插入),因此链接器生成 R_X86_64_RELATIVE 而不是 R_X86_64_64:
验证:
$ cuobjdump -xelf all ptx_instr_count.so
Extracting ELF file 1: ptx_instr_count.1.sm_52.cubin # libnvbit
Extracting ELF file 2: ptx_instr_count.2.sm_120.cubin # 我们的手写 PTX!
17.6 cuobjdump 如何发现设备代码(逆向总结)
通过上述踩坑过程,我们逆向出了 cuobjdump 发现设备代码的完整流程:
cuobjdump -xelf all tool.so
│
├── 1. 读取 ELF section headers
│ 找到 .nvFatBinSegment section
│
├── 2. 读取 .rela.dyn 重定位表
│ 只处理 R_X86_64_RELATIVE 类型
│ (无法处理 R_X86_64_64 符号重定位)
│
├── 3. 遍历 .nvFatBinSegment 中的 __fatBinC_Wrapper_t
│ 每个 wrapper 24 字节:
│ ┌─────────┬─────────┬──────────────┬──────────────┐
│ │ magic │ version │ data pointer │ filename_ptr │
│ │ 4 bytes │ 4 bytes │ 8 bytes │ 8 bytes │
│ └─────────┴─────────┴──────────────┴──────────────┘
│ 验证 magic == 0x466243B1
│ 应用 RELATIVE 重定位修正 data pointer
│
├── 4. 跟随 data pointer 到 .nv_fatbin section
│ 验证 fatbin magic == 0xBA55ED50
│ 解析 fatbin 内部的 cubin/PTX 记录
│
└── 5. 提取每个 cubin(ELF 格式的 SASS 代码)
关键约束:
- wrapper 必须在 .nvFatBinSegment section
- fatbin 数据必须在 .nv_fatbin section
- wrapper 中的 data pointer 必须通过 R_X86_64_RELATIVE 重定位可解析
- 使用 R_X86_64_64(符号重定位)会导致 cuobjdump 看到 data pointer 为 0,从而跳过该 fatbin
17.7 关键 ELF Section 对比
| Section | 来源 | 作用 |
|---|---|---|
.nv_fatbin |
nvcc 或 objcopy/asm .incbin |
存储 fatbin 原始数据 |
.nvFatBinSegment |
nvcc 或手动 section 属性 |
存储 __fatBinC_Wrapper_t 指针结构 |
__nv_module_id |
nvcc(可选) | 模块标识,缺少也能工作 |
__nv_managed_init_offsets |
nvcc(__managed__ 变量) |
managed 变量初始化 |
17.8 验证结果
用 vectoradd 测试,手写 PTX 版本与原始 CUDA C 版本输出完全一致:
# Warp-level counting (COUNT_WARP_LEVEL=1, 默认)
原版 instr_count_bb: kernel instructions 62588, total instructions 62588
PTX poc: kernel instructions 62588, total instructions 62588
# Thread-level counting (COUNT_WARP_LEVEL=0)
原版 instr_count_bb: kernel instructions 2002816, total instructions 2002816
PTX poc: kernel instructions 2002816, total instructions 2002816
17.9 踩坑清单(速查)
| # | 问题 | 错误信息 | 根因 | 解决 |
|---|---|---|---|---|
| 1 | PTX 非 ASCII | ptxas fatal: Unexpected non-ASCII character |
注释中有 UTF-8 字符 | PTX 全文件必须纯 ASCII |
| 2 | xxd 嵌入 | No ELF file found to extract |
fatbin 在 .rodata 而非 .nv_fatbin | 需要放入 .nv_fatbin section |
| 3 | objcopy 嵌入 | 同上 | wrapper 不在 .nvFatBinSegment | wrapper 加 section(".nvFatBinSegment") |
| 4 | 重定位类型 | cuobjdump 只找到 libnvbit 的 cubin | R_X86_64_64 vs R_X86_64_RELATIVE |
asm .incbin + hidden visibility |
17.10 对非 CUDA 前端的指导意义
这个 POC 证明了:NVBit 设备函数完全可以脱离 nvcc 编译。只要满足以下条件:
- PTX 或 cubin 通过
ptxas -astoolspatch编译 - fatbin 数据嵌入
.nv_fatbinELF section - wrapper 结构体放入
.nvFatBinSegmentsection - data pointer 使用
R_X86_64_RELATIVE重定位(hidden visibility) - 运行时通过
__cudaRegisterFatBinary()注册
满足这些条件后,设备代码可以来自任何来源:
- LLVM IR:llc -march=nvptx64 → PTX → 同样的流水线
- Rust:rustc --target nvptx64-nvidia-cuda → PTX
- 手写 PTX:直接编写(本 POC 的方式)
- 运行时生成:动态生成 PTX 字符串 → ptxas → fatbin → 注册