利用编译器得到程序更多的运行时信息(Get more run-time information using compilers)

编译器除了能将源代码编译成二进制文件.还可以根据程序员的需要,在生成的二进制文件中增加若干代码或者函数调用,满足某些需求。在编译器开发和调试中,静态插装(static instrumentation,或者Profiling)就是指这一过程。

本文试图介绍利用GCC、Pathcc/Open64、LLVM编译器等工具得到程序运行时信息的功能。

1 运行时能得到什么信息?

这个问题极具装13范儿的回答应该是:”你想要什么信息,就能得到什么信息“。计算机归根到底是人按照物理原理制造出来的机器,因此在各个阶段都可以做些修改、扩充。

从插上独立的HMTT电路板,捕获CPU实时访问DRAM的信息(参考链接8);到利用CPU内部的performance count捕捉诸如cache miss/hit,各种指令队列堵塞;再到直接修改代码利用printf打印某个变量在代码运行中的变化。总之,只要是程序运行中的,我们都可以想办法拿到。关键是用什么拿?怎么拿?怎么对程序的影响最小?带来的性能损失最少。

编译器因为完成代码到汇编、二进制文件的静态变换,因此更适合做一些静态的插装工作到运行时信息。众多编译器开发工程师们经过二十年的技术积累和反复尝试,找到了编译器适合的几个插装工作。主要是一些和源代码、程序语义相关的插装,因为是静态插入一些指令序列,这些序列将在运行时被执行,因此底层性能指标方面的信息无法简单的依赖编译器静态插装获得。

2 记录运行时的函数调用和退出信息(Function instrumentation)

Cygnus(已被Redhat收编) 在GCC中增加了函数插装支持。如下的代码,就是利用该功能的一段示例代码。

/* * Compile Command: gcc test.c -finstrument-functions */
#include <stdio.h>

static FILE *fp;

// Functions called before enter main function. void main_constructor(void) __attribute__ ((no_instrument_function, constructor));

// Funstion called after exit from main function void main_destructor(void) __attribute__ ((no_instrument_function, destructor));

// Function called immediately enter the calling function void __cyg_profile_func_enter(void *this_fn, void *call_site)
                              __attribute__((no_instrument_function));
// Function called immediately right before exit from the calling function. void __cyg_profile_func_exit(void *this_fn, void *call_site)
                             __attribute__((no_instrument_function));

void main_constructor(void) {
    fp = fopen( "trace.txt", "w");
    if(fp == NULL) exit(-1);
}
void main_destructor(void) {
        fclose(fp);
}
void __cyg_profile_func_enter(void *this_fn, void *call_site) {
  fprintf(fp, "ENTER: %p, from %p\n", this_fn, call_site);
}
void __cyg_profile_func_exit(void *this_fn, void *call_site) {
  fprintf(fp, "EXIT: %p, from %p\n", this_fn, call_site);
} 

int foo() {
  return 2;
}

int bar() {
  return 1;
}

int main(int argc, char** argv) {
  printf("foo=%d bar=%d\n", foo(), bar());
}

其中编译选项 -finstrument-functions, 告诉编译器要对函数的调用插装。打开此选项后,编译器会在所生成的汇编代码中,每个函数的入口处增加对函__cyg_profile_func_enter()
的调用;在每个函数退出时,增加对__cyg_profile_func_exit()的调用。上面的示例程序中仅仅记录了传入这两个函数参数的值,当然你还可以完成其他操作。

另外为了方便将上述信息记录到某个文件中。我们还控制编译器在进入main函数时,打开记录文件;在退出main函数时关闭该记录文件。函数main_constructor()和main_destructor()就是为此类功能而生的。

对于inline的函数,该选项在inline展开的地方,也会插入对__cyg_profile_func_XXX函数的调用。为了避免生成大量的此函数调用,抓住重点。GCC还提供了-finstrument-functions-exclude-file-list=file,file,… 和-finstrument-functions-exclude-function-list=sym,sym,…两个选项用来指定无需插装的函数。另外程序员也可以直接指定某个函数的属性为no_instrument_function,这样编译器就不会对该函数插装。

有了这一功能,程序员就能得到详细的运行时函数调用关系,还可以控制在函数调用和退出时,执行想要的代码。IBM有个网页利用这一功能绘制程序的函数调用图,见参考链接4.发挥你的想象,用它解决你的其他问题吧:)

3 利用编译器收集Profiling信息

这里的profiling信息,特指那些可以协助编译优化的程序运行时信息。为了能更好的提升性能,编译器一般都提供了相应的选项,生成能收集profiling信息的程序。并能够根据此类profiling信息重新优化程序,这一过程一般被成为feedback optimization.

就《编译点滴》目前所知,Pathcc/Open64编译器、GCC、LLVM都能生成可收集Profiling信息的程序,并能基于该信息做优化。具体的优化形式如下:

  1. 指定编译器编译生成可收集profiling信息的程序a.out。
  2. 执行a.out。在执行过程中,a.out会输出一个profiling文件B
  3. 利用编译器重新编译源代码,并指定B为输入的profiling文件
  4. 编译器重新生成新的可执行文件a.out.该程序已经基于profiling信息,做了优化。

因为需要首先收集profiling信息,再做优化。这样的编译过程实际需要两遍的编译/执行,耗费时间较长,因此这种优化技术只在特定场合,性能敏感应用调优时使用。不过随着Java、JavaScript之类的解释性语言越来越流行,再加上LLVM这种新型C/C++编译执行工具的兴起,profiling优化技术在即时编译器(Just-In-Time Compiler)中又有了新的活力。期待《编译点滴》能有机会为大伙介绍更多这方面的内容。

下面介绍一下静态编译器中的一些profiling信息获取。

3.1记录运行时分支的跳转信息(Edge Profiling)

Edge Profiling是主流工业级编译器提供的一种了解程序运行时分支跳转信息的插装功能,GCC、LLVM、Pathcc/Open64中都有对该功能的支持。主要指程序控制流图上各个基本块(每个跳转指令以及跳转指令的目标指令构成基本块的边界指令)的执行频率及各个控制边的执行概率情况。对于一些编译优化技术,如指令调度,这些信息对提升程序性能有很大帮助。

GCC中对应的选项是-fprofile-arcs,基于此选项编译得到可执行文件后。运行可执行文件,会生成一个gcda文件,记录边跳转信息。接下来再通过-fbranch-probabilities选项,编译器就会自动读入上面所得文件,并基于文件中记录的信息做优化。如果想自行读取gcda文件中的内容,可以参考GCC源代码中的gcc/gcov-dump.c文件。

3.2记录运行时程序中表达式值的信息(Value Profiling)

Value Profiling指收集程序运行时表示式值信息的profiling手段。在GCC中打开选项-fprofile-arcs的同时打开-fprofile-values,即可收集此信息。基于这些信息,编译器可以完成常数传播、函数特例化(基于某些表达式的特殊取值,对函数做针对性优化)等优化。

3.3记录运行时程序中的其他信息

除了上面的分支跳转和表达式值信息外,编译器还可以记录其他一些协助优化的信息,或者帮助程序员采集程序运行时信息。

比如Pathcc/Open64编译器中有Stride Profiling,用于记录程序变量取值的步长信息,编译器可以利用此信息实现预取(参考文献6)。GCC编译器收集一些表达式取值信息。比如gcc的-fvpt选项用来采集特定除法操作的分母值的信息,来协助做编译优化。

4 其他静态插装技术

在运行时动态收集程序的运行时信息都可以称为插装或者profiling操作。比如在程序进行特定的操作,如函数调用、分支跳转、访存操作、读写文件时,插入一些代码,记录该信息。

因此不仅仅是利用编译器的选项,任何能完成上面功能的变换都可以用于收集程序的运行时信息。所以你可以:

  • 利用编译器的选项实现。比如上面介绍的。
  • 修改编译器的源代码,实现任何你想要的插装支持。比如基于GCC也有的Edge Profiling或者value profiling修改。
  • 自己编译一个源代码到源代码的变换工具,直接在源代码层次插装。比如自己写Python脚本,CLang或者ANTLR实现。
  • 写一个二进制分析工具,在二进制文件上直接插装。比如基于PEBIL(参考链接7)、Pin、Valgrind实现。

5 动态插装工具

另外,还有很多工具,可以直接动态在程序中插装。比如Pin、Valgrind、Qemu都是常用的此类插装工具。

相比于静态插装,因为所有的事情都需要在运行时完成,动态插装工具都会带来严重的性能下降。平均在2x~10x左右。许多处在原型阶段的工具,比如学术界发表的论文中都采用动静结合的方法实现插装,这样兼顾效率,另外能同时得到更多的程序静态全局和动态运行时的信息做插装。同样,希望《编译点滴》日后能有机会为大伙多多介绍,也欢迎有相关经验的朋友投稿《编译点滴》。

9 thoughts on “利用编译器得到程序更多的运行时信息(Get more run-time information using compilers)

  1. bluesea147

    赞一个~~插装确实挺常用的。
    lz知道好多啊~这些干货都是从哪儿弄到的?能推荐下关于编译方面这样的站点和资源么。
    谢谢

    Reply
    1. erlv Post author

      编译方面的站点和资源很少。你可以多看看wikipedia。因为我一直在做编译器方面的事情,因此在这领域积累最多。其他领域就一无所知了

      Reply
      1. wuchengyang

        请问下,能否指点一下GCC(-O2优化)编译的64位程序的栈布局是什么样的呢?rbp寄存器不再记录栈基址吗?

        Reply
        1. erlv Post author

          不清楚你的栈布局指的是什么,但是rbp确实不再记录栈基址了。这一点,你可以从如下命令中看到:
          echo 'int main() {return 0;}' > test.c && gcc -v -Q -march=core2 -O2 test.c -o test && rm test.c test

          该命令能输出所有O2阶段打开的编译优化选项,可以看到-fomit-frame-pointer在其中被打开了。也就是说,这个选项已经起作用,rbp不再记录基址。我自己写的小例子O2也没有用rbp

          Reply
          1. wuchengyang

            博主,能否留一个联系方式?向你讨论并请教一下相关的内容,可以么?我的邮箱:wucywucy@gmail.com。如果不再用RBP记录栈基址,那么程序是如何知道函数的调用关系呢?

            Reply
            1. erlv Post author

              虽然不再使用帧指针寄存器rbp,但是栈指针寄存器依然存在,用来给出栈顶位置,有了该寄存器,就能在栈上分配内存空间。

              至于函数调用关系,你是不是想问,如果没有RBP的话,当函数返回时,如何弹出栈,以及GDB的backtrace如何使用? 这是因为C语言中,所有函数传参的大小在编译的时刻是固定的,这样就能根据传入的参数的类型和大小,以及返回值的类型和大小计算出栈帧的具体位置。说白了就是,能通过栈指针算出帧指针的值,因此rbp可以省下来干别的。(C++有异常处理的时候,会有些问题,其他时候基本都能计算出)。

              我的联系方式见 http://www.lingcc.com/about/

              Reply
              1. wuchengyang

                博主,您好,还有几个问题想请教一下,一些基础问题,但没有找到相关的资料,麻烦您了。64位程序加上-O2选项后,参数在7个以内是用的rdi,rsi,rdx,rax等寄存器来完成的,但是,我反汇编一个函数如下:
                push %r15
                xor %eax, &eax
                push %14
                push %13
                mov %edi, %r13d //
                push %r12
                push %rbp
                push %rbx
                ……..
                pop %rbx
                pop %rbp
                pop %r12
                pop %r13
                pop %r14
                pop %15
                …..
                麻烦博主能否讲一下,这样写实什么意思呢?多谢了。

                Reply
  2. wuchengyang

    博主,为什么GCC优化过后的汇编感觉格式各不相同?传参,寄存器的使用完全没有门道可以摸呢?可以大致讲讲GCC优化后的寄存器使用规则以及参数如何传递的么?多谢了。

    Reply
    1. erlv Post author

      这个问题很难回答,因为编译优化有很多种类。你的问题,看起来可能和寄存器传参有关。不知道你用的是什么指令集的处理器。一般的处理器都会利用寄存器直接传参数,避免压栈和弹栈操作。你可以看看是否是这个原因

      Reply

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>