最近看到一篇文章 Do you Know LLVM XRay?,是用来做函数调用跟踪分析的,简单写了一下关于这套系统的笔记。

1、XRay

谷歌研发了 XRay 作为轻量级的 C/C++ 函数调用跟踪系统(a function call tracing system),可以在函数入口和出口(entry/exit) 处记录精确的时间点。这套系统可以部署到生产环境,在运行时动态地开启或关闭。

XRay 主要是为了解决在生产环境很难调试分析延迟和缓慢(latency) 的问题。现有的采样方案只能大致近似,无法精确,而手工跟踪又非常耗时和容易出错。一些采样方式还需要有系统级别的支持才行。

XRay 包含有多个部分:

  • 编译期插桩(compiler-inserted instrumentation)。
  • 程序探测的架构(instrumentation framework)。
  • 运行时记录(tracing runtime)。
  • 分析记录文件的一套工具(a set of tools for analysing traces)。

2、compiler-inserted instrumentation

XRay 会在编译期插入 no-op 空操作指令串到函数的入口和出口,在运行时进行修改来进行函数级别的跟踪,然后有运行时库在必要时进行数据整合和保存。

  • 不开启 XRay 时,这些 no-op 指令几乎没有执行的消耗。
  • 开启 XRay 时,其运行时库重写这些指令,修改为插桩(instrumentation) 记录函数信息到内存当中。

XRay 采用启发式(heuristic) 来判断哪些函数需要插桩记录。

  • 函数需要满足一定的大小,可以通过 -fxray-instruction-threshold= <value> 手动设置,默认为 200。
    • 一般认为没有循环的小函数不会占用很多的执行时间,根据性价比而言,不值得插桩记录。
  • 包含有 non-trivial loops(非标准术语,一个 trivial loop 是编译器可以不附加不利条件(增加 block、消除 inline 的机会等) 地进行全展开(fully unroll)。通常认为 trivial loop 是简单易懂的循环,第二部分判断中与值对比的变量i < maxLen,也是第三部分递增的i++)。
    • 一般认为 non-trivial loops 这种循环会引入可变的执行时间,用户会希望 XRay 可以记录下这种变化。
  • 还有可以通过 __attribute__((xray_always_instrument)) 设置函数开启。

XRay 大概会导致 20%~40% 的 CPU 使用率增加,200MB 左右的内存增长,而二进制大小增加 2% 左右。

3、instrumentation framework

在二进制产物当中,会发现有 XRay 相关 compiler-rt 的库被链接进来,比如我本地的 /usr/lib/llvm-14/lib/clang/14.0.0/lib/linux/libclang_rt.xray-fdr-x86_64.a,这块属于 XRay 的框架。它需要满足以下几点:

  • 修改指令的插桩(Patching/Unpatching) 不能把进程或线程停掉。
  • 开启或关闭功能(Installing/Uninstalling handlers) 需要保证原子性(atomic)。保证开启时,所有线程都是一致开启的。
  • 只修改必要的插桩点(instrumentation points)。

在二进制的 section 当中会存有插桩的 map 表记录重要信息(存有为每个要 patch 的函数计算得到的唯一 id):

patch 的过程就会是,设置寄存器为对应的函数 id,调用对应的跳板(trampoline)。

1
2
3
4
5
6
7
8
// compiler-rt/lib/xray/xray_init.cpp
// __xray_init() will do the actual loading of the current process' memory map
// and then proceed to look for the .xray_instr_map section/segment.
// 在这里会获取到 section xray_instr_map 的内容

// compiler-rt/lib/xray/xray_interface.cpp
// mprotectAndPatchFunction
// 在这里会采用 mprotect 修改内存页为可写可执行,进行 patch,修改完成后改为可读可执行。

3.1、如何保证修改过程是线程安全的?

可以看到,我们是需要重写前面的指令 jmp +9nopmovcall。这里修改前后都是 11 个字节。

修改过程非常巧妙:

首先是先把后面的 5 个字节重写为跳转到对应的 trampoline,这个顺序很重要,因为前面的 jmp 还是可以越过这里的修改。

然后 jmp +9 需要是 2 字节对齐的(为了保证 cache line 的一致性),因为我们要先处理后面的几个字节,这样不会影响前面的 jmp 指令。把 mov N, %r10d 的右半部分先写入到第 3-6 个字节。

最后才改最前面的两个字节。

关闭 patch 就容易多了,直接写入 jmp 在入口,ret 在出口即可。

3.2、handlers

XRay 提供了显式的 APIs 供用户手动设置,主要在 compiler-rt/include/xray/xray_interface.h 文件中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/// Provide a function to invoke for when instrumentation points are hit. 回调
extern  int  __xray_set_handler(void (*entry)(int32_t, XRayEntryType));

/// This tells XRay to patch the instrumentation points. See XRayPatchingStatus
/// for possible result values.
extern  XRayPatchingStatus  __xray_patch();

/// Reverses the effect of __xray_patch(). See XRayPatchingStatus for possible
/// result values.
extern  XRayPatchingStatus  __xray_unpatch();

/// This patches a specific function id. See XRayPatchingStatus for possible
/// result values.
extern  XRayPatchingStatus  __xray_patch_function(int32_t  FuncId);

/// This unpatches a specific function id. See XRayPatchingStatus for possible
/// result values.
extern  XRayPatchingStatus  __xray_unpatch_function(int32_t  FuncId);

4、tracing runtime

记录有两种模式:

  • Basic(naive) mode。直接把所有事件都写入到记录当中。适合执行时间短的应用。
  • Flight Data Recorder(FDR) mode。用固定大小的环形队列缓存,在内存中像飞机的黑匣子一样不断淘汰旧的,写入新的数据,直到需要时,才会写到磁盘。适合长时间运行的应用和服务。

执行二进制的时候,可以通过设置环境变量来控制 XRay 的行为模式:

5、tools

分析记录文件使用的是 llvm-xray 工具。这部分更多是如何对记录文件进行查看和分析,官方文档和工具的 manual 文档都比较详细了,不做展开。

还可以生成图的形式,看执行次数和运行时间。

还能生成火焰图,在浏览器进行查看。

参考