即时编译器真心大冒险之解释器
一,前言
本文是关于即时编译器系列文章的首篇,目的是为一种简单的程序语言,用逐步推进深入的方式 ,开发解释器(interpreter)和即时编译器(jit compilation)。在本系列文章结束的时候,读者朋友将会对如何开发即时编译器有更多的认识,并掌握若干相关工具软件的使用。
贯穿本系列文章的程序语言是BrainFuck,简称BF,该语言去繁化简,只保留程序语言精髓,它拥有可以直接映射到C语言控制结构的指针和循环,以及其他符合现代主流编程语言的语义。
我将会使用C++作为它的实现语言,历史的原因,多数编译器都使用C++(OR C)实现,因此有丰富的流行的底层代码生成库可供选择使用,另外还会使用若干C++库,但不会使用C++的高级特性以此使代码简单易懂。
二,BF语言简介
BF的文法简单,可以参考附录的链接或者维基百科了解它的定义,这里不作详细说明。我先给出它的一个示例作为开胃菜,下面的BF程序在屏幕上打印数字1到5:
++++++++ ++++++++ ++++++++ ++++++++ ++++++++ ++++++++
>+++++
[<+.>-]
说明:
.第一行初始化内存0单元为48,对应字符'0'的ASCII码。
.第二行初始化内存1单元为5,作为循环计数器。
.第三行是循环体,逐步递增0单元的值并打印,然后逐步递减1单元的值,检查1单元的值,倘若为0,循环终止。
三,一个简单的解释器
为了让读者对BF语言有初步的认识并有一个实际的解释器源码实现,我们开始了一个简单的解释器项目,它将一次读取源码文件的一个字符,然后做处理。
选择BF语言的一个重要原因是它足够简单,互联网上很多声称帮助初学者的解释器或者编译器的文章,将百分之90的时间用于词法分析和语法解析,而我认为编译的后继其它的阶段更为有趣且更有价值,我们的解释器的前端解析就象下面这样简单,
struct Program {
std::string instructions;
};
Program parse_from_stream(std::istream& stream) {
Program program;
for (std::string line; std::getline(stream, line);) {
for (auto c : line) {
if (c == '>' || c == '<' || c == '+' || c == '-' || c == '.'|| c == ',' || c == '[' || c == ']') {
program.instructions.push_back(c);
}
}
return program;
}
请注意,根据BF的文法定义,这是一个合理的实现,除了定义的8个字符,其他字符都作为注释而忽略。这个共识将伴随我们贯穿始终,有了这个前提,一个具体的解释器如下:
constexprint MEMORY_SIZE = 30000;
void simpleinterp(const Program& p, bool verbose) {
// Initialize state.
std::vector<uint8_t> memory(MEMORY_SIZE, 0);
size_t pc = 0;
size_t dataptr = 0;
while (pc < p.instructions.size()) {
char instruction = p.instructions[pc];
switch (instruction) {
case'>':
dataptr++;
break;
case'<':
dataptr--;
break;
case'+':
memory[dataptr]++;
break;
case'-':
memory[dataptr]--;
break;
case'.':
std::cout.put(memory[dataptr]);
break;
case',':
memory[dataptr] = std::cin.get();
break;
// [...]
看起来特别简单,其中最有趣的是控制流指令[和]。先看[,如果当前数据单元的值为0,则跳过循环不执行,这可以用来跳过循环结构或者实现if语义。
case'[':
if (memory[dataptr] == 0) {
int bracket_nesting = 1;
size_t saved_pc = pc;
while (bracket_nesting && ++pc < p.instructions.size()) {
if (p.instructions[pc] == ']') {
bracket_nesting--;
}elseif (p.instructions[pc] == '[') {
bracket_nesting++;
}
}
if (!bracket_nesting) {
break;
}else {
DIE << "unmatched '[' at pc=" << saved_pc;
}
}
break;
你已经注意到,BF中的[和]可以嵌套,当语义指示跳过[时,就应当找到匹配的括号],这在运行的时候看起来是没有意义。是的,不过请继续往下。
类似的,若当前的数据单元值不为0,从]跳转到先前的[处。
case']':
if (memory[dataptr] != 0) {
int bracket_nesting = 1;
size_t saved_pc = pc;
while (bracket_nesting && pc > 0) {
pc--;
if (p.instructions[pc] == '[') {
bracket_nesting--;
}elseif (p.instructions[pc] == ']') {
bracket_nesting++;
}
}
if (!bracket_nesting) {
break;
}else {
DIE << "unmatched ']' at pc=" << saved_pc;
}
}
break;
解释器循环还包括:
default: { DIE << "bad char '" << instruction << "' at pc=" << pc; }
}
pc++;
}
四,BF程序测试
对于解释器和编译器开发来说,执行速度是重要的参数。编译器开发者通常使用一个基准测试套件来测量获取参数。对于BF,我使用一系列程序测量我们的解释器的执行速度,一个程序是Mandelbrot生成器,另外一个是斐波那契factorization程序。
前面介绍的简单解释器,mandelbrot的测试结果为38.6秒,factor的测试结果为16.5秒。我们将示例如何改善这些参数。
五,优化解释器,第一步
程序在每次遇到[或者]时,都需要费力的逐个搜索查找匹配的括号,时间复杂度为(n),很明显的这有优化的空间。想象一下,在一个实际的程序中,一个热点循环在执行中重复数十亿次,如果每次循环都需要逐个搜索匹配的括号,那么将会是一个非常耗时的事情。BF程序在运行中不会修改运行指令,那么我们就可以程序运行之前,预先准备好匹配的括号位置信息。
这就是第一个优化解释器optinterp.cpp的思想。大部分源码和先前的简单解释器相同,关键的一步是下面这个预处理函数,该函数在解释器载入源码后实际运行程序之前执行。
std::vector<size_t> compute_jumptable(const Program& p) {
size_t pc = 0;
size_t program_size = p.instructions.size();
std::vector<size_t> jumptable(program_size, 0);
while (pc < program_size) {
char instruction = p.instructions[pc];
if (instruction == '[') {
int bracket_nesting = 1;
size_t seek = pc;
while (bracket_nesting && ++seek < program_size) {
if (p.instructions[seek] == ']') {
bracket_nesting--;
}elseif (p.instructions[seek] == '[') {
bracket_nesting++;
}
}
if (!bracket_nesting) {
jumptable[pc] = seek;
jumptable[seek] = pc;
}else {
DIE << "unmatched '[' at pc=" << pc;
}
}
pc++;
}
return jumptable;
}
它计算出所有的[和]指令对应的跳转位置,构造出jumptable表,对于每个在偏移处i的[或者],jumptable[i]指示其对应的括号的指令偏移位置,而其它的非括号的指令,jumptable[i]为0,表示不执行跳转。Optinterp的主循环和simpleinterp除了括号的处理外都是相同的。
case'[':
if (memory[dataptr] == 0) {
pc = jumptable[pc];
}
break;
case']':
if (memory[dataptr] != 0) {
pc = jumptable[pc];
}
break;
如你所料,optinterp快了许多,执行mandelbrot耗时18.4秒, 执行factor耗时6.7秒,比之前的时间快了2倍多。
六,解释器优化,第二步
上一部分所做的优化很简单,但是效果明显。如果我们能够在编译时做处理,那么就应当避免在运行时处理。除此之外,我们可以做更多的创造性的工作使得解释器运行的更快。
优化程序的首要之处在于获取相关的性能分析数据,而过往的经验可以帮助我们减少不必要的工作。譬如说,解释器的时间百分之百的都在运行一个单独的函数,那么对程序的函数调用分析就无裨益。
解释器的主循环非常短小,第一眼看上去好像也没有优化的地方,我们知道这个循环会不断的执行从BF源码中读取的指令,重复很多次。那么我们可以跟踪BF程序的执行并将操作指令分为若干类,optinterp已经加入了这个功能,但是该功能是很影响性能的,所以通过宏BFTRACE来启用,正常情况下不启用。
下面是在质数179424691执行斐波那契factor基准测试的数据,最左边的是操作类型,右边的是解释器执行该操作的数目,
. --> 21
, --> 10
+ --> 212,428,900
] --> 242,695,606
< --> 1,220,387,704
- --> 212,328,376
> --> 1,220,387,724
[ --> 118,341,127
.. Total: 3,226,569,468
从这些数据中很容易得出这样的结论:
1. 各种操作类型的数目之和非常巨大,在主循环中执行了约30亿次。毫无疑问,使用C++实现解释器是明智的,使用其他的更高级的语言来实现的话,执行时间将是一个噩梦。
2. 移动指针的指令条数在总执行指令数目的比例很高,大约执行了2.42个循环操作([数目),但是执行了24亿个指令指针移动(<和>).
我们希望每一个热点循环能够短小精悍,不需要做过多的事情。粗略的看一下factor.bf的源码,下面是其中一个代码片段:
<<<<<<<<<<<<<<<<<[<<<<<<<<<<]>>>>>>>>>>
[>>>>>>>>[-]<<[->+<]<[->>>+<<<]>>>>>]<<<<<<<<<<
[+>>>>>>>[-<<<<<<<+>>>>>>>[-<<<<<<<->>>>>>+>
[-<<<<<<<+>>>>>>>[-<<<<<<<->>>>>>+>
[-<<<<<<<+>>>>>>>[-<<<<<<<->>>>>>+>
[-<<<<<<<+>>>>>>>[-<<<<<<<->>>>>>+>
[-<<<<<<<+>>>>>>>]]]]]]]]]<<<<<<<
[->>>>>>>+<<<<<<<]-<<<<<<<<<<]
>>>>>>>
[-<<<<<<<<<<<+>>>>>>>>>>>]
>>>[>>>>>>>[-<<<<<<<<<<<+++++>>>>>>>>>>>]>>>]<<<<<<<<<<
[+>>>>>>>>[-<<<<<<<<+>>>>>>>>[-<<<<<<<<->>>>>+>>>
[-<<<<<<<<+>>>>>>>>[-<<<<<<<<->>>>>+>>>
[-<<<<<<<<+>>>>>>>>[-<<<<<<<<->>>>>+>>>
[-<<<<<<<<+>>>>>>>>[-<<<<<<<<->>>>>+>>>
[-<<<<<<<<+>>>>>>>>]]]]]]]]]<<<<<<<<
[->>>>>>>>+<<<<<<<<]-<<<<<<<<<<]
>>>>>>>>[-<<<<<<<<<<<<<+>>>>>>>>>>>>>]>>
[>>>>>>>>[-<<<<<<<<<<<<<+++++>>>>>>>>>>>>>]>>]<<<<<<<<<<
[<<<<<<<<<<]>>>>>>>>>>
>>>>>>
]
<<<<<<
注意这些很长的指令序列<<<<<<<, >>>>>>>,根据BF的定义稍作思考就会明白,我们需要在数据存储单元之间来回移动来更新数据。
现在,我们思考如何才能在解释器中执行<<<<<<<这样的语句序列,我们的主循环执行7次,每次执行操作:
1.移动pc指针并和程序的大小比较
2.读取pc指针处的指令
3.根据指令的值,选择合适的处理单元
4.执行该处理单元
这样的执行方式是非常占用资源的,假如我们可以压缩所有这样的<<<<<<<长指令序列,效果如何?对于单个的<指令,我们的操作是,
case'<':
dataptr--;
break;
那么对于7个<,就应当执行,
case ... something representing 7 <s ...:
dataptr -= 7;
break;
这很容易推广到一般情况,我们可以在BF源码中检测到连续的指令序列,然后编码: the operaton, repetition count,当运行时,我们只需要按要求重复若干次相应的指令即可。
完整的解释器代码是optinterp2.cpp.在前面,我们在输入的源程序中为匹配的[和]指令生成了单独的跳转表,现在我们由于需要获取每条BF指令的额外信息,因此将Program转换成一个操作类型的操作序列,
enumclassBfOpKind {
INVALID_OP = 0,
INC_PTR,
DEC_PTR,
INC_DATA,
DEC_DATA,
READ_STDIN,
WRITE_STDOUT,
JUMP_IF_DATA_ZERO,
JUMP_IF_DATA_NOT_ZERO
};
// Every op has a single numeric argument. For JUMP_* ops it's the offset to
// which a jump should be made; for all other ops, it's the number of times the
// op is to be repeated.
struct BfOp {
BfOp(BfOpKind kind_param, size_t argument_param)
: kind(kind_param), argument(argument_param) {}
BfOpKind kind = BfOpKind::INVALID_OP;
size_t argument = 0;
};
解释分两个阶段执行,首先运行translate_program读取源文件生成std::vector<BfOp>, 这个转换非常直接了当,它检测到操作的重复次数就将它们编码在argument域。
解释器的主循环如下所示,
switch (kind) {
case BfOpKind::INC_PTR:
dataptr += op.argument;
break;
case BfOpKind::DEC_PTR:
dataptr -= op.argument;
break;
case BfOpKind::INC_DATA:
memory[dataptr] += op.argument;
break;
case BfOpKind::DEC_DATA:
memory[dataptr] -= op.argument;
break;
// [...] etc.
现在它运行有多快呢?Mandelbrot基准测试耗时11.9秒,而factor耗时3.7秒,又减少了大约百分之40的运行时间。
七,解释器优化-第三步
我们优化过的解释器运行mandelbrot测试性能比原始的版本提高3x,那还能做的更好吗?
我们来看下optinterp2的指令追踪记录,重复前一个实验:
. --> 21
] --> 242,695,606
, --> 10
+ --> 191,440,613
< --> 214,595,790
- --> 205,040,514
> --> 270,123,690
[ --> 118,341,127
.. Total: 1,242,237,371
总的指令数目大约减少了3倍多,而BF循环执行次数比其他的指令数目还要多,这意味着我们在每个循环中做的工作不是太多。这正是我们重复指令优化的目标。接下来我们再看看其他的优化途径。一个需要注意的问题是每个BF循环在做些什么工作呢,我们是不是在这上面做些优化工作呢。这些信息的获取需要更复杂的跟踪机制,optinterp2已经在源码中添加了这样的功能。这个机制跟踪记录程序在每轮循环中所做的指令寻列,将它们按照出现数目排序,下面就是斐波那契测试的记录结果,
-1<10+1>10 --> 32,276,219
-1 --> 28,538,377
-1<4+1>4 --> 15,701,515
-1>3+1>1+1<4 --> 12,581,941
-1>3+1>2+1<5 --> 9,579,970
-1<3+1>3 --> 9,004,028
>3 --> 8,911,600
-1<1-1>1 --> 6,093,976
-1>3+1<3 --> 6,085,735
-1<1+1<3+1>4 --> 5,853,530
-1>3+2<3 --> 5,586,229
>2 --> 5,416,630
-1>1+1<1 --> 5,104,333
这些数据揭示了什么呢?先从第一个记录开始,可以这样来理解:
1. 将当前数据单元值减少1.
2. 移动到左边的第十个单元
3. 将当前的单元增加1
4. 移动到右边的第十个单元
这个循环执行了3200万次,类似的,第二个循环执行将当前单元值减少1的逻辑2800万次。如果你阅读factor.bf的源文件,容易看出程序的语义。第一个是[-<<<<<<<<<<+>>>>>>>>>>],第二个是[-].
我们可以从整体上优化这些循环吗?从高级语言来讲看,这些循环的所做的工作比较简单,[-]将当前的单元设置为0,[-<<<<<<<<<<+>>>>>>>>>>]复杂一些,将当前单元的值加到左边第十个单元。上面的跟踪记录结果揭示了大量这样类型的循环,以及像[>>>]这样以3为单位向右移动直到遇到非0单元。
在optinterp2的基础上,我们增加高级操作到解释器,来优化上面操作类型的循环,即optinterp3,它增加了一些循环操作的通用类型,
enumclassBfOpKind {
INVALID_OP = 0,
INC_PTR,
DEC_PTR,
INC_DATA,
DEC_DATA,
READ_STDIN,
WRITE_STDOUT,
LOOP_SET_TO_ZERO,
LOOP_MOVE_PTR,
LOOP_MOVE_DATA,
JUMP_IF_DATA_ZERO,
JUMP_IF_DATA_NOT_ZERO
};
LOOP_SET_TO_ZERO表示[-],LOOP_MOVE_PTR表示[>>>],LOOP_MOVE_DATA表示[-<<<+>>>]。我们需要一个更加复杂的转换步骤来从源文件输入中监测到这些循环,然后生成相应的LOOP_*指令。我们举一个转换[-]的例子,
std::vector<BfOp> optimize_loop(const std::vector<BfOp>& ops,
size_t loop_start) {
std::vector<BfOp> new_ops;
if (ops.size() - loop_start == 2) {
BfOp repeated_op = ops[loop_start + 1];
if (repeated_op.kind == BfOpKind::INC_DATA ||
repeated_op.kind == BfOpKind::DEC_DATA) {
new_ops.push_back(BfOp(BfOpKind::LOOP_SET_TO_ZERO, 0));
// [...]
这个函数在转换BF程序到指令序列是执行,loop_start是最近一次循环的指令位置。上面示例的源码展现了如何检测到只有一个-或者+指令的循环,并将该循环替换为一个LOOP_SET_TO_ZERO指令。当解释器运行到LOOP_SET_TO_ZERO指令时,会执行定义的语义:
case BfOpKind::LOOP_SET_TO_ZERO:
memory[dataptr] = 0;
break;
其它的循环优化相对复杂一些,但是思想是一样的。我们希望这种优化效果显著,因为将程序中的热点循环转换为一个精简指令。从下图看出, optinterp3确实相当快,mandelbrot耗时3.9秒而factor耗时1.97秒。
程序的执行速度获得惊人的提升,optinterp3几乎相对simpleinterp提升10x的效率。将来我们能将解释器的效率提升到更高的水平,不过目前的优化对我们来说已经足够。
编译器,字节码,及时编译器
首先,我们从编译器和解释器的差别谈起,参考维基百科,一个编译器定义如下:
a computer program (or a set of programs) that transforms source code written in a programming language (the source language) into another computer language (the target language), with the latter often having a binary form known as object code.
gcc是编译器的典型,它将C或者C++源码转换为Intel CPU的汇编语言。除此之外,还有许多其他种类的编译器,Chicken将Scheme编译成C;Rhino将javascript编译为JVM字节码;Clang将C++编译为LLVM IR。Python的官方实现CPython,将python源码转换为字节码,等等等等。通常来说,术语字节码Bytecode是指任意一种中间表示或者虚拟机指令集。
基于该定义,simpleinterp确实是一个BF解释器,而后来的优化的解释器则是编译器+字节码解释器。比如,optinterp3,源输入语言是BF,目标语言是遵从下面规范的字节码解释器,
enumclassBfOpKind {
INVALID_OP = 0,
INC_PTR,
DEC_PTR,
INC_DATA,
DEC_DATA,
READ_STDIN,
WRITE_STDOUT,
LOOP_SET_TO_ZERO,
LOOP_MOVE_PTR,
LOOP_MOVE_DATA,
JUMP_IF_DATA_ZERO,
JUMP_IF_DATA_NOT_ZERO
};
这里的每个指令有一个参数。Optinterp3先将BF转换为字节码,然后执行字节码。不严格的来说,这已经是一个即时编译器了,目标代码不是可执行的机器码,而是特定的字节码。我们在后继的部分中将实行一个真正的即时编译器JIT.
最后,需要指出的是在optinterp3中的循环优化时即时编译器的静态版本,我们在基准测试中观察记录频繁执行的循环,然后优化这些循环。我们优化的循环操作在绝大多数的BF程序中具有普遍性,将来还可以做更多的工作。我们一开始只优化频繁执行的具备普遍性的循环,然后我们将考虑测试中的更特殊的执行路径情况。
为了更符合一个通常意义上的即时编译器,我们将延迟优化至运行时。一个即时编译器解释执行输入的源代码,对运行中热点循环进行跟踪记录,当热点循环的执行次数超过一个设定的阈值时,将循环优化并且编译成更高效的机器码。
八,后记
在这篇文章中,我们见证了BF解释器一个原始简陋的实现逐步的演进到即时编译字节码的复杂实现,执行效率提高10X倍。从这个过程中,我们希望读者能从中认识到实际解释器优化过程中的妥协与折中。
在文章的下一部分,我会展现一个实际的BF即时编译器,它在运行时编译BF到紧凑的X64机器码并且执行。我会展示如何从零开始构建一个即时编译器,出于简易开发的目的,我们只使用系统的标准库,以及汇编代码生成库。
【1】细心的读者会注意到我应该使用switch或者查表法提高效率。我遵从“除非你有测量数据,否则它就不慢”的理念,BF在实际程序的解析阶段花费的时间忽略不计,几乎没有必要去优化这部分。在我机器上最大的BF程序(Mandelbrot)大约是11000个指令,花费约360微秒,其中绝大部分是文件读取时间。
【2】我使用了两个不同的程序来防止针对特定基准测试的过度拟合,专业的测试会使用一整套的基准测试,但是本文只是业余博客,因此不会做的那么专业。实际上,现实中大型工程的基准测试套件的逼真程度不是特别好。
【3】本系列文章的所有性能数据都是在HaswellLinux(Ubuntu14.04)盒子上收集的;C++代码默认使用gcc4.8.4编译。在某些条件下,使用Clang会得到略微不同的结果,而本文的目的不是比较C++编译器,因此限定使用gcc。
需要注意的是这些执行时间是二进制文件的整个的端到端的时间,包括加载解释器可执行文件,读取BF文件,源文件的预处理,优化,代码发射和执行时间。对于运行时间超过100ms情况,和实际的基准测试运行时间相比,其他的因素都是忽略不计。
【4】全部的源码请移步https://github.com/eliben/code-for-blog/blob/master/2017/bfjit。
【4】本文翻译作者skywen,email:66629317@qq.com