NVBit 插桩:从 0 到可用

想在不改用户代码的前提下“看见”每条 GPU 指令如何执行?NVBit 是 NVIDIA 开源的“轻量级 CUDA 指令级动态插桩框架”。本文以“能上手能采样”为目标,从 GPU 并行模型 → CUDA 基础 → PTX/SASS → Linux 注入机制 → NVBit API,总结一份可直接套用的插桩笔记。

1️⃣ GPU 计算硬件与并行模型

  • 层级概念
    • Thread:最小执行单元,对应内核中的一个逻辑线程。
    • Warp:通常 32 个线程的执行束,硬件以 warp 为粒度调度(SIMT)。
    • Block:多个 warp 组成的线程块,共享 Shared Memory__syncthreads()
    • Grid:多个 Block 的集合,即一次内核启动的整体并行范围。
  • 调度与分歧
    • Warp Scheduler 在 SM 上挑选可就绪的 warp 发射指令;
    • SIMT:同一 warp 的线程同发同取,但可因分支发生“分歧”,由硬件做掩码串行化执行;
    • 分歧越多,吞吐越差。
  • 内存层次Global(大容量/高延迟)、L2Shared(块内共享/低延迟)、Constant/Texture(只读/缓存友好)、Local(线程私有,实质访问全局内存)。
  • 隐式切换(Latency Hiding):高延迟访存期间,调度器切换到其他就绪 warp,靠并行隐藏延迟。

2️⃣ CUDA 编程基础(与插桩相关的最小知识)

  • Kernel 定义与启动
1
2
3
4
5
6
7
8
__global__ void saxpy(float a, const float* x, float* y, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) y[i] = a * x[i] + y[i];
}

// 启动:<<<grid, block[, shared_mem, stream]>>>
int n = 1<<20; dim3 block(256), grid((n+255)/256);
saxpy<<<grid, block>>>(2.0f, dx, dy, n);
  • 线程索引threadIdxblockIdxblockDimgridDim 用于计算全局线性索引。
  • 同步与原子:同 Block 内 __syncthreads();跨线程的共享写入使用 atomicAdd() 等。
  • Runtime vs Driver API
    • Runtime(cudaMalloc/cudaMemcpy/...)更易用;
    • Driver(cuInit/cuModuleLoad/cuLaunchKernel)更底层,NVBit 在 Driver 层拦截 cuLaunchKernel 以获取函数与指令信息。

常见编译选项(调试友好):

1
nvcc -g -G -lineinfo -Xptxas -v -arch=sm_80 app.cu -o app
  • -G 设备端调试符号(会降速,调试开启,性能测试关闭)。
  • -lineinfo 保留行号映射,便于日志对回源代码。
  • -Xptxas -v 打印寄存器、共享内存使用等编译信息。
  • -arch=sm_xx 指定目标架构,保证 PTX/SASS 与目标 GPU 匹配。

3️⃣ PTX & SASS:两层汇编

可以把“写 CUDA 代码 → 编译 → 在 GPU 上跑”这个过程类比成翻译:

  • 你写的 C/C++(CUDA)像“中文原稿”。
  • 编译器先把它翻成一种“标准中间语”(PTX),方便跨多种 GPU 架构复用。
  • 最后再由后端把 PTX 翻成“某一代显卡能直接听懂的话”(SASS 机器码)。

3.1 PTX 是什么?(跨架构的“中间语”)

  • 定位:PTX 是 NVIDIA 的虚拟指令集,跨架构、可读性强,类似“高级汇编”。
  • 记住三件事:
    • 指令名 = 动作 + 地址空间 + 数据类型,例如 ld.global.s32:从全局内存加载 32 位有符号整数。
    • 寄存器带类型,.s32/.u32/.f32/.pred 分别表示 有符号整型/无符号整型/浮点/谓词(布尔)。
    • 大多数算术/访存都显式写出类型,便于你“用肉眼推导”数据流。

一个极简 PTX 片段:

1
2
3
4
5
6
.reg .s32 r1, r2, r3;     // 三个 32 位整型寄存器
.reg .pred p; // 一个布尔寄存器
ld.global.s32 r1, [x]; // r1 ← *x(从全局内存地址 x 取 4 字节)
ld.global.s32 r2, [y]; // r2 ← *y
mad.lo.s32 r3, r1, a, r2; // r3 ← r1*a + r2 (乘加)
st.global.s32 [y], r3; // *y ← r3 (回写到全局内存)

如何读 PTX?

  • 先看“动词”(ld/st/mov/add/mad/...),判定动作。
  • 再看“地址空间/修饰符”(global/shared/local)。
  • 最后看“类型”(.s32/.f32/...)与操作数,推导数据宽度与含义。

常见类型速查:

  • .s32/.u32:32 位整型(有符号/无符号)
  • .f32/.f64:32/64 位浮点
  • .pred:谓词/布尔(常用于条件执行)

生成 PTX 的常用方法:

1
2
nvcc -ptx app.cu -o app.ptx             # 直接产出 PTX 文件
cuobjdump --dump-ptx app # 从可执行文件/库里抽取 PTX

3.2 SASS 是什么?(某一代 GPU 的“母语”)

  • 定位:SASS 是真实机器码的人类可读形式(反汇编结果),和 PTX 不保证一一对应(编译器/后端会做调度与优化)。
  • 读法套路:助记符 + 修饰符 + 数据宽度 + 操作数。例如:
1
2
3
LDG.E.32 R2, [R4]   // 从全局内存加载 4 字节到寄存器 R2(R4 为地址)
FADD R6, R2, R3 // 浮点加法:R6 = R2 + R3
STG.E.32 [R4], R6 // 把 R6 的 4 字节写回全局内存地址 R4

拿到 SASS 的方式:

1
2
3
nvcc -cubin -arch=sm_80 app.cu -o app.cubin  # 生成与架构绑定的 cubin
nvdisasm -g app.cubin | less # 反汇编 SASS(-g 带行号映射)
cuobjdump --dump-sass app # 也可从成品二进制中抽取

小贴士:不同 GPU 架构(如 Volta/Ampere/Hopper)SASS 语法/修饰符略有差异,读法不变:先看动词,再看宽度/修饰,再看寄存器/地址。

3.3 一眼看懂“从 C 到 PTX 到 SASS”的映射

y[i] = a * x[i] + y[i] 为例:

  • C/CUDA:从 x[i] 读,乘以 a,再加 y[i],写回 y[i]
  • PTX(示意):ld.global.f32 两次加载,fma.rn.f32mul+add 做计算,st.global.f32 回写。
  • SASS(示意):LDG.E.32FFMA/FADDSTG.E.32

不必逐条抠细节,抓主干就行:访存是 LD*/ST*,算术是 F*(浮点)或 I*(整数),控制流是 BRA/SSY/...

3.4 与 NVBit 有什么关系?

  • NVBit 在 Driver 层能拿到“函数的指令列表”,其中就包含 SASS 级别的信息(助记符、操作数等)。
  • 你可以按“指令类别”筛选(如只对 LDG/STG 插桩),或按助记符前缀聚焦热点(如分支、原子、内存)。
  • 牢记:PTX 更易读、跨架构;SASS 更贴近硬件、与性能强相关。做插桩时,两者都值得参考:用 PTX 快速理解语义,用 SASS 判断真实代价与分布。

3.5 三步拿到汇编(实操)

1
2
3
4
5
6
7
8
9
# 1) 生成 PTX,快速理解语义
nvcc -O2 -ptx app.cu -o app.ptx

# 2) 生成 cubin 并反汇编 SASS,观察真实发射的指令
nvcc -O2 -cubin -arch=sm_80 app.cu -o app.cubin
nvdisasm -g app.cubin | sed -n '1,120p' | cat

# 3) 若 app 已链接成可执行文件/动态库,从成品中抽取
cuobjdump --dump-ptx --dump-sass ./app | head -n 200 | cat

看懂这层关系后,你就能把“源代码的语义”和“GPU 上真实执行的代价”链接起来:这对插桩点选择、性能评估与采样策略都至关重要。


4️⃣ Linux 动态链接与环境变量

动态链接器(ld-linux.so)在程序启动时负责把用到的共享库(.so)装进进程,并把函数地址“接上去”。理解这套机制,有助于你知道“NVBit/LD_PRELOAD 是怎么把工具注入进去的”。

4.1 动态链接调用链详解

我们以一个调用 printf 的简单程序为例,看看从执行到函数调用的完整链路,并把核心概念放进去。

调用链分步走:

  1. 编译期
    • 你写 printf("hello"); 并用 gcc hello.c -o hello 编译。
    • 编译器生成一个 ELF 格式的可执行文件 hello
    • 这个文件里记录了:“我需要 libc.so.6 这个库”(NEEDED 条目),并且为 printf 准备了一个“跳转跳板”(PLT 条目)和一个“地址记录本”(GOT 条目)。此时,GOT 里记的不是真地址,而是一个“待解析”的占位符。
  2. 程序启动
    • 你在 shell 里输入 ./hello
    • 操作系统内核加载 hello,看到它是个动态链接程序,于是把控制权交给 动态链接器ld-linux.so.2)。
  3. 链接器工作
    • 动态链接器读取 helloNEEDED 列表,找到 libc.so.6
    • 它按“查找顺序”(LD_LIBRARY_PATH → 系统缓存 → 默认路径)定位并把 libc.so.6 加载到内存。
  4. 首次调用 printf
    • CPU 执行到 printf 调用时,它其实是 call printf@plt,即跳到 PLT 里的“跳板”。
    • “跳板”指令去查“地址记录本”(GOT)。
    • 发现 GOT 里是“待解析”占位符,于是这个“跳板”指令会把控制权再次交给动态链接器,说:“请帮我找到 printf 的真地址”。
    • 动态链接器在已加载的 libc.so.6 里查找 printf,找到后,把它的真实内存地址写回到 GOT 的 printf 条目里。
    • 最后,动态链接器直接跳转到 printf 的真实地址,函数得以执行。
  5. 再次调用 printf
    • CPU 再次执行 call printf@plt
    • “跳板”再次去查 GOT。
    • 这次 GOT 里已经是 printf 的真实地址了,于是“跳板”直接跳转到该地址,函数执行。整个过程不再需要动态链接器介入,非常快。

省流总结 程序喊:“我要用 printf!” → 先跳到一个“跳板”(PLT),跳板问“地址记录本”(GOT):“地址在哪?” → 第一次,记录本说“不知道”,于是喊“链接器”来找,找到后把地址记下来 → 第二次,记录本直接报出地址,程序直达。

名词速记ELF:Executable & Linkable Format,Linux 可执行/共享库的通用容器。
NEEDED:ELF Header 里的依赖库列表;readelf -d a.out 可见 NEEDED libm.so.6
动态链接器ld-linux-x86-64.so.2,启动时按搜索顺序(LD_LIBRARY_PATH → /etc/ld.so.cache/lib64) 找到并加载那些 .so。
PLT / GOT:Procedure Linkage Table & Global Offset Table;前者存“跳转占位指令”,后者存“真实地址”,首调用触发解析并回写 GOT,后续直跳。
dlsym:运行期手动查符号,用途:插件/热更新;本质:动态链接器里再查一次符号表。

4.2 LD_PRELOAD 如何影响调用链

LD_PRELOAD 是一种强大的动态链接插桩机制,它直接作用于我们在 4.1 节描述的调用链,主要影响第 3 步(链接器工作)第 4 步(首次调用)

影响调用链分步走:

  1. 影响第 3 步(链接器工作)
    • 当你设置 LD_PRELOAD=./libhook.so 并启动程序时,动态链接器在读取 NEEDED 列表、查找依赖库之前,会最优先libhook.so 加载到内存。
    • 这相当于在链接器的“查找顺序”里强行插入了一个最高优先级项。
  2. 影响第 4 步(首次调用 malloc
    • 当程序首次调用 malloc 时,和 printf 一样,会跳到 PLT,然后触发动态链接器去查找 malloc 的真实地址。
    • 链接器开始按顺序查找:它首先看最先加载的 libhook.so,结果发现里面就有一个叫 malloc 的函数。
    • 这就触发了 符号抢占 (Symbol Interposition):链接器“抢先”绑定了你的假 malloc,并把地址写回 GOT。它不会再继续往后找 libc.so.6 里的真 malloc 了。
    • 控制权交给你的假 malloc,拦截成功。
  3. 在拦截函数中调用原始函数
    • 在你的假 malloc 里,不能直接再写 malloc(),否则会无限递归调用自己。
    • 此时需要用 dlsym(RTLD_NEXT, "malloc")RTLD_NEXT 这个特殊句柄告诉动态链接器:“请从下一个加载的库(也就是 libc.so.6)开始,帮我查找 malloc”。
    • dlsym 会返回真正的 malloc 地址,你存下来就可以调用了。

省流总结 LD_PRELOAD 就像给链接器戴上了一副“有色眼镜”,让它最先看到你的 hook.so。当程序要找函数时,链接器就从你的库里找到了一个“冒名顶替”的版本(符号抢占),从而实现拦截。而 dlsym(RTLD_NEXT, ...) 则是摘下眼镜,让你能找到并调用被顶替的“真人”。

最小拦截示例:统计 malloc 使用次数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// file: hook.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

static void* (*real_malloc)(size_t) = NULL;
static size_t g_count = 0;

__attribute__((constructor)) static void init_hook() {
fprintf(stderr, "[hook] loaded\n");
}

void* malloc(size_t size) {
if (!real_malloc) {
// RTLD_NEXT: 从下一个库开始找 malloc
real_malloc = (void*(*)(size_t))dlsym(RTLD_NEXT, "malloc");
}
void* p = real_malloc(size);
g_count++;
if (getenv("HOOK_VERBOSE")) {
fprintf(stderr, "[hook] malloc(%zu) => %p (count=%zu)\n", size, p, g_count);
}
return p;
}

编译与运行

1
2
gcc -shared -fPIC -ldl hook.c -o libhook.so
LD_PRELOAD=$PWD/libhook.so HOOK_VERBOSE=1 ./your_program

要点 - 与 NVBit 的关系:NVBit 注入 libnvbit.so 来拦截 CUDA Driver API(如 cuLaunchKernel),原理与此完全相同。

4.3 环境变量:给工具“遥控器”

用环境变量控制行为是业界惯例:无需改代码/重编译,随开随关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdlib.h>
#include <string.h>

static int get_int_env(const char* key, int defv) {
const char* v = getenv(key);
return v ? atoi(v) : defv;
}

static int get_bool_env(const char* key, int defv) {
const char* v = getenv(key);
if (!v) return defv;
return (!strcmp(v, "1") || !strcmp(v, "true") || !strcmp(v, "on"));
}

// 用法:
// int verbose = get_bool_env("NVBIT_VERBOSE", 0);
// int sample_n = get_int_env("MY_TOOL_SAMPLE_N", 100);

实践建议:

  • 约定统一前缀(如 MYTOOL_*),避免与系统变量冲突。
  • 为每个开关提供默认值与帮助说明(打印到日志首行)。

4.4 /proc 与排错技巧

  • 看看进程到底加载了哪些库、地址在哪:
1
cat /proc/$$/maps | grep -E "libcuda|libnvbit|libhook"
  • 动态链接问题一把梭:
1
2
ldd ./app
LD_DEBUG=libs,symbols,bindings ./app 2>&1 | less
  • 了解即可:ptrace 可让调试器(gdb 等)附加进程;分析崩溃时有用,但与动态链接不是一件事。

5️⃣ GPU 指令级插桩基本思路

“钩子(Hook)是什么”:在不改原程序逻辑的前提下,拦截某个函数或事件的执行点,插入你的自定义代码(记录/修改/放行),然后把控制权交还原逻辑。

在本节语境里,常用两层 Hook:

  • 驱动事件 Hook:在 cuLaunchKernel 等 Driver API 的进入/退出时机回调你的代码,用来“决定是否对这次 Kernel 做插桩”。
  • 指令级 Hook:对目标 CUfunction 的每条(或部分)SASS 指令,在指令前/后插入对设备端处理函数的调用,实现“记录/采样/统计”。

优点:零侵入、上线快;注意:有额外开销,需配合过滤/采样,并避免递归拦截与线程不安全。

5.1 想达到什么目标?

  • 明确你需要“看见什么”:
    • 指令计数(总量/类别占比)、内存访问信息(地址、宽度)、分支行为(是否分歧)、原子/Barrier 使用等。
  • 先从“最小目标”起步(仅计数或仅内存指令),验证链路,再逐步加字段。

5.2 静态 vs 动态(两条路)

  • 静态插桩:在编译期修改 PTX/SASS(或用编译器 pass)。
    • 优点:开销可控、可定制性强。
    • 缺点:兼容性/维护成本高;适配不同架构麻烦。
  • 动态插桩(NVBit):运行时拦截驱动,对已加载的 CUfunction 动态加钩子。
    • 优点:对用户二进制零侵入、快速迭代。
    • 缺点:插入调用本身有额外成本,需要过滤/采样。

5.3 插桩颗粒度与插点策略

  • 粒度:函数级(仅启停)/ 指令级(精确到 LDG/STG/ATOM/BRA/...)。
  • 插点:IPOINT_BEFORE(指令执行前)或 IPOINT_AFTER(执行后)。
    • 读内存:多在 BEFORE(记录将要访问的地址)。
    • 写内存:多在 AFTER(可记录最终值,代价更高)。
  • 谓词执行:许多 SASS 指令有谓词屏蔽,务必把谓词传给设备端处理以过滤未执行的路径。

5.4 记录格式(Record)怎么设计?

  • 原则:结构体字段最小化、定长、cache 友好;避免可变长字符串。
1
2
3
4
5
6
7
struct __align__(16) MemRec {
unsigned smid; // 可选:SM id
unsigned warp; // 可选:Warp id(lane/warp 组合)
unsigned inst_tag; // 指令标识(host 侧映射助记符/位置)
unsigned flags; // 谓词/读写/空间等 bit 位
unsigned long long addr; // 访存地址(如需)
};
  • 如果只做计数:完全可以把 MemRec 简化成“按类别的计数器”放在全局/共享内存中,降低带宽。

5.5 设备端缓冲与背压(Ring Buffer)

  • 每条记录一条 printf 会拖垮性能,必须“先写缓冲 → 批量回传”。
  • 常见方案:全局环形缓冲 + 原子游标;必要时配合 Block 内共享内存做分层缓冲。
1
2
3
4
5
6
7
8
9
10
11
12
__device__ __managed__ MemRec gbuf[1<<20];
__device__ __managed__ unsigned ghead = 0;

extern "C" __device__ __noinline__ void dev_record_pred(int pred,
unsigned inst_tag,
unsigned flags,
unsigned long long addr) {
if (!pred) return; // 指令未执行,直接跳过
unsigned pos = atomicAdd(&ghead, 1);
pos &= (1<<20) - 1; // 环形
gbuf[pos] = { /*smid*/0u, /*warp*/0u, inst_tag, flags, addr };
}
  • 背压:当生产速度 > 消费速度,会覆盖旧数据。策略:
    • 增大缓冲(牺牲显存),
    • 提高回传频率(更多 DMA),
    • 采样/过滤(减少写入),
    • 分层缓冲(共享内存聚合,减少全局原子)。

5.6 Host ↔ Device 回传(批量且异步)

  • Host 侧维护 pinned host 缓冲 + stream,周期性 cudaMemcpyAsync 回传一批数据;双缓冲流水化:
1
2
3
4
// 伪代码
cudaMemcpyAsync(host_buf[cur], gbuf, bytes, cudaMemcpyDeviceToHost, stream);
cudaEventRecord(done[cur], stream);
// 轮转 cur,另一路并行解析/落盘
  • 落盘:优先二进制;解析器单独实现,避免在线文本格式化。

5.7 过滤与采样(把开销打下来)

  • Kernel 名过滤(正则/白名单)。
  • 指令类别过滤(只插 LDG/STG/ATOM/BRA/...)。
  • 比例采样(每 N 条插 1 条)或时间窗口采样(只在前 M 毫秒记录)。
  • 启停控制:通过环境变量在 nvbit_at_cuda_event 里仅对前 K 次 launch 开启。

5.8 正确性与验证(别被插桩“污染”)

  • 确保设备端处理函数 __noinline__,避免被内联改变控制流。
  • 避免在设备端大量 printf;必要时仅在小规模 debug 期开启。
  • 检查寄存器/共享内存占用(-Xptxas -v);插桩会增加压力,可能导致寄存器溢出到 Local,性能骤降。
  • compute-sanitizer 联合:先保证无越界/竞争/屏障问题,再看插桩结果。

5.9 最小插桩实战:统计全局内存读取次数

下面我们用三段代码,完整演示如何插桩一个 CUDA 程序来统计它执行了多少次全局内存读取(LDG 指令)。

1. Host 端工具 (tool.cpp)

这是插桩工具的核心逻辑,负责告诉 NVBit“在哪些指令前,插入哪些函数调用”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// file: tool.cpp
#include "nvbit.h"
#include <stdio.h>
#include <stdlib.h>

// 声明我们将在 Device 端定义的处理函数
extern "C" __device__ void count_ldg(int pred);

// NVBit 在加载每个 CUfunction 时会调用这个函数
void instrument_function(CUfunction func) {
// 遍历函数的所有基本块和指令
nvbit_iterator_instr cf_graph(func);
for (auto it = cf_graph.begin(); it != cf_graph.end(); ++it) {
for (auto instr : it->get_instrs()) {
// 如果是 LDG (Load Global) 指令
if (instr->get_opcode_name().find("LDG") != std::string::npos) {
// 在它执行前,插入对 dev_count_ldg 的调用
nvbit_insert_call(instr, "count_ldg", IPOINT_BEFORE);
// 把指令的谓词(是否执行)作为第一个参数传给 dev_count_ldg
nvbit_add_call_arg_guard_pred(instr);
}
}
}
}

// NVBit 工具加载时会调用
void nvbit_at_init() {
printf("[NVBit Tool] Initialized. Ready to instrument.\n");
// 注册我们的插桩函数
nvbit_register_callback(NVBIT_CB_TYPE_FUNCTION_LOAD, instrument_function);
}

2. Device 端处理 (dev.cu)

这是被插入到 GPU 代码流中的“钩子”函数,它在 GPU 上执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// file: dev.cu
#include <cuda.h>
#include <stdint.h>

// 定义一个全局计数器,__managed__ 让它在 Host/Device 间可见
__device__ __managed__ uint64_t ldg_counter = 0;

// Host 端插入的调用,最终会执行这个函数
extern "C" __device__ __noinline__ void count_ldg(int pred) {
// 只有当指令的谓词为 true (即指令确实会执行) 时,才计数
if (pred) {
atomicAdd((unsigned long long*)&ldg_counter, 1);
}
}

3. 编译与运行

假设我们有一个简单的 CUDA 程序 app.cu

编译

1
2
3
4
5
6
7
8
9
# 1. 编译 Device 端代码为对象文件
nvcc -dlink -Xptxas -v --shared dev.cu -o dev.o

# 2. 编译 Host 端工具,并把 Device 端代码链接进去
g++ -I$CUDA_HOME/include -shared -fPIC tool.cpp dev.o -o tool.so \
-L$CUDA_HOME/lib64 -lcuda

# 3. 编译你的目标 CUDA 程序
nvcc app.cu -o app

运行与验证

1
2
3
4
5
6
7
8
# 1. 设置 NVBIT_TOOL 环境变量,指向你的工具
export NVBIT_TOOL=$PWD/tool.so

# 2. 运行目标程序
./app

# 3. 检查计数器结果(需在 app 中加入打印 ldg_counter 的代码)
# 或者在 tool.cpp 的 nvbit_at_exit 回调中打印

省流总结 tool.cpp 像一个“手术规划师”,它决定了要在 LDG 指令这个“手术点”,插入 count_ldg 这个“探针”。dev.cu 定义了这个“探针”的具体动作(原子加)。nvccg++ 负责把这两部分打包成一个完整的插桩工具 tool.so,最后通过环境变量注入到目标程序中。