以JIT的方式执行C/C++程序

传统来说,我们编译c/c++程序的方式是:

  1. 先把每个源文件编译成obj文件

$(CXX) -c xxx.cpp -o xxx.o

  1. 然后把obj文件和其它静态库、动态库链接在一起成为一个可执行文件(ELF/PE)

$(CXX) -o xxx xxx1.o xxx2.o xxx3.o -lpthread -lxxxx

LLVM加入了一种与机器无关的中间语言IR,使得我们可以像Java一样,半编译半解释的执行C/C++程序。

把C/C++源文件编译成LLVM字节码

clang在加了-emit-llvm和-c之后,输出的是llvm的bytecode文件(*.bc)

例如:

$ clang++ -emit-llvm -stdlib=libc++ -fno-use-cxa-atexit -I/data/test2 -o gtest-all.cc.bc -c /data/test2/gtest-all.cc

$ clang++ -emit-llvm -stdlib=libc++ -fno-use-cxa-atexit -I/data/test2 -o test.cpp.bc -c /data/test2/test.cpp

llvm-link能把多个bytecode文件链接成一个bc文件。

$ llvm-link test.cpp.bc gtest-all.cc.bc -o hello

执行字节码

lli能直接执行bc文件。

$ lli -use-mcjit hello.bc

这么做的优点是,bytecode文件只需要生成一次,但是却可以在不同的硬件平台上执行。比如既能在intel x86的CPU上执行,又能在arm上执行。(我已经拿我的RK3188的板子试过)。
程序依赖的额外的动态库可以用-load参数加上。比如

$ lli -use-mcjit -load /lib/x86_64-linux-gnu/libssl.so.1.0.0 hello.bc

使用CMake编译

不能总停留在Hello World上。要找实际项目做测试。于是我找了curl。它可以通过cmake构建。我先从官网上下载了7.36.0的源代码,然后按正常流程先跑了一次cmake,让它把所有的Makefile和link.txt都生好。但是不执行make指令。

然后我修改它原来的CMakeLists.txt,修改其编译规则。

在最前面加上:

#原来在链接的时候会加上-rdynamic。但是llvm-link无法识别这个选项

set(CMAKE_SHARED_LIBRARY_LINK_CXX_FLAGS )

set(CMAKE_CXX_FLAGS "-emit-llvm -stdlib=libc++ -fno-use-cxa-atexit")

#修改静态库的构建规则。原来这里会调用ar

set(CMAKE_CXX_ARCHIVE_CREATE "/opt/llvm/bin/llvm-link -o \ \ \")

#原来默认会调用ranlib对ar生成的包再次打包。

set(CMAKE_CXX_ARCHIVE_FINISH "")

#原来默认这里会调用C/C++的compiler来完成链接工作。

set(CMAKE_CXX_LINK_EXECUTABLE "/opt/llvm/bin/llvm-link \ -o \ \")

然后再跑一次cmake。重新生成Makefile和link.txt。这一次运行cmake就不用再执行那些环境测试了(check_include_file等等),因为在这样的链接规则下无法生成ELF。

然后打开link.txt,稍微修改下。把所有的动态库都去掉,把各种多余的参数也都去掉。

然后make。

然后执行:

$ lli -load /lib/x86_64-linux-gnu/libssl.so.1.0.0 -use-mcjit src/curl http://www.baidu.com/

果然ok。

把字节码编译成native ELF

还有,以JIT方式执行的程序崩溃时,栈上没有符号。为了找到错误发生的地点,我们可以把它编译成native的可执行文件看看。

llc能把bytecode文件编译成asm文件(*.s)或者二进制的obj文件(*.o)。在命令行加上-filetype=asm或者-filetype=obj来指定类型。

$ llc -filetype=obj all_unitest

ld/lld能把obj文件链接在一起。

$ clang++ -stdlib=libc++ -o a all_unitest.o -lc++abi -pthread

总结:

这么做虽然解决了跨平台的问题,但是有一些限制:

  1. 不能依赖于条件编译(#ifdef)来解决不同平台的差异问题。因为我们只编译一次。

  2. 必须是同样的ABI。这一条要求必须采用同样的操作系统(因为android、linux、windows的ABI各不相同),同样的字节宽度(sizeof(void*) == ?)

但是我在我的x86_64 ubuntu下编译的hello world放在32位的ARM上也能正确执行。所以对于ABI这块的限制我需要再了解下。

其实mcjit也并非是真正意义上的JIT,它就是一个"静态编译器+动态链接器"。它的动态链接器用的就是llvm/lib/ExecutionEngine/RuntimeDyld这个目录下的实现。目前只支持ELF和MachO。

我做性能测试,一个程序用McJIT的方式运行,和用clang直接编译成ELF运行相比,效率无明显差异。我怀疑可能是因为后面的代码执行引擎都是同一套。

但是从理论上来说,JIT的一大好处是可以开启针对特定CPU的优化。比如在我的电脑上可以开corei7-avx优化。而一般来说rpm/deb包里的ELF,就不能这么做。

另外,理论上来说,JIT还可以做On-Stack Replacement:https://labs.vmware.com/vee2013/docs/p143.pdf

或者,Profile-guided optimizations(比如make optimization decisions based on code paths executed)

再或者,动态的通过Class Hierarchy Analysis把虚函数调用变成静态调用。

有人声称他测试JIT比静态编译快很多 http://stackoverflow.com/questions/5988444/why-is-the-llvm-execution-engine-faster-than-compiled-code ,但是我觉得他应该是眼花了。

另外两个很推荐的项目是:

  1. Google PNaCl
  2. asm.js

此博客中的热门博文

少写代码,多读别人写的代码

在windows下使用llvm+clang

tensorflow distributed runtime初窥