天下武功唯快不破
相信你也已经在NEMU中运行过microbench, 发现NEMU的性能连真机的1%都不到.
使用perf
也没有发现能突破性能瓶颈的地方.
那NEMU究竟慢在哪里呢?
回想一下, 执行程序, 其实就是不断地执行程序的每一条指令:
取指, 译码, 执行, 更新eip
...
在真机中, 这个过程是通过高速的门电路来实现的.
但NEMU毕竟是个模拟器, 只能用软件来实现"执行指令"的过程:
执行一次exec_wrapper()
, 客户程序才执行一条指令, 但运行NEMU的真机却需要执行上百条native指令.
这也是NEMU性能不高的根本原因.
为了方便叙述, 我们将"客户程序的指令"称为"客户指令".
因此, 作为软件模拟器的NEMU注定无法摆脱"用n条native指令模拟一条客户指令"的命运.
要提高NEMU的性能, 我们就只能想办法减小n
了.
事实上, 模拟器的这种工作方式称为解释执行:
每一条客户指令的执行都需要经历完整的指令生命周期.
但仔细回顾一下计算机的本质, 执行指令的最终结果就是改变计算机的状态(寄存器和内存),
而真正改变状态的动作, 只有指令生命周期中的"执行"阶段, 其它阶段都是为状态的改变作铺垫:
取指是为了取到指令本身, 译码是为了看指令究竟要怎么执行, 更新eip
只是为了执行下一条指令.
而且, 每次解释执行的时候, 这些辅助阶段做的事情都是一样的.
例如
100000: b8 34 12 00 00 mov $0x1234,%eax
每次执行到这条指令的时候, 取指都是取到相同的比特串,
译码总是发现"要将0x1234移动到eax
中", 更新eip
后其值也总是0x100005
.
反正执行客户指令的结果就是改变计算机的状态, 这不就和执行
mov $0x1234,(cpu.eax的地址)
这条native指令的效果一样吗?
这正是即时编译(JIT) 的思想: 通过生成并执行native指令来直接改变(被模拟)计算机的状态. 这里的编译不再是"生成机器指令"的含义了, 而是更广义的"语言转换": 把客户程序中执行的机器语言转换成与之行为等价的native机器语言. "即时"是指"这一编译的动作并非事先进行", 而是"在客户程序执行的过程中进行", 这样的好处是, 不会被执行到的客户指令, 就不需要进行编译.
为了生成native指令, 我们至少也要知道相应的客户指令要做什么.
因此, 我们至少也要对客户指令进行一次取指和译码.
通常情况下, 客户指令不会发生变化, 因此编译成的native指令也不会发生变化.
这意味着, 我们只需要对客户指令进行一次编译, 生成相应的native指令, 然后存放起来,
将来碰到相同的客户指令, 就不必重新编译, 而是可以找到之前编译的结果直接执行了.
这恰恰就是cache的思想:
我们将native指令序列组织成一个TB(translation block), 用客户程序的eip
来索引;
这个cache由一系列的TB组成; 执行客户程序的时候, 先用eip
索引cache,
若命中, 说明相应的客户指令已经被编译过了, 此时可以不必重新编译, 直接取出相应的TB并执行;
若缺失, 说明相应的客户指令还没有被编译过, 此时才需要对客户指令进行取指和译码,
并编译成相应的TB, 更新cache, 以便于将来多次执行.
一个值得考虑的问题是, 每次编译多少条客户指令比较合适? 若一次编译一条客户指令, 则会导致每执行完一条客户指令就需要重新对cache进行索引, 查看下一条客户执行是否被编译过. 细心的你会发现, 在即时编译模式中, 一条客户指令被编译过, 当且仅当它被执行过. 也就是说, NEMU在执行完一条客户执行之后, 都会去检查下一条指令有没有被执行过. 我们知道, 顺序执行是程序最基本的执行流之一. 我们很容易想到, 在一个顺序执行的模块中, 如果其中的一条指令被执行过, 那就意味着整个模块的每一条指令都已经被执行过. 这说明, 若一次编译一条客户指令, "检查下一条指令有没有被执行过"大多数时候是一个冗余的动作. 为了避免这些冗余的动作, 我们可以一次编译一个顺序执行的模块, 这样的模块称为基本块.
要如何进行编译呢? 我们知道, x86的指令集非常复杂, 如果要考虑每一条x86客户指令如何编译到native指令, 就太麻烦了. 嘿, 我们在PA2中引入的RTL就是用来解决这个问题的: RTL只有少数的基本指令, 我们只需要考虑如何将少数的RTL基本指令编译到native指令就可以了! 引入RTL还有另一个好处, 就是方便NEMU的移植:
+-----+
x86 ---> | | ---> mips
mips ---> | RTL | ---> arm
riscv ---> | | ---> x86
+-----+
| | | |
+ front-end + + back-end +
以RTL为分界, 我们可以把即时编译模式的NEMU分为两部分: 前端用于将客户程序的机器语言编译成RTL, 后端负责将RTL编译成native机器语言. 这样以后, 要在NEMU中支持一种新的客户程序架构x, 只需要增加相应的前端模块来将x编译成RTL即可; 要让NEMU运行在一种新的架构y, 只需要增加相应的后端模块来将RTL编译成y即可.
于是, 即时编译模式的NEMU的工作方式如下:
while (1) {
tb = query_cache(cpu.eip);
if ( cache miss ) {
tb = jit_translate(cpu.eip); // translate a basic block
update_cache(cpu.eip, tb);
}
jump_to(tb); // cpu.eip will be updated while executing tb
}
关于jit_translate()
如何工作, 可以参考这篇讲述QEMU中的JIT如何实现的文章.
事实上, 实现JIT的坑非常多, yzh也还没全踩完, 也就还没总结出好的要点. 即使把坑都踩过了, 拖延症患者yzh也不一定有时间把这些要点整理成讲义.
不过如果你看到这里, 相信你也有一定能力来面对这些坑了. 踩坑其实是非常非常宝贵的经验, 也是做这些项目的意义所在: 通过做项目, 知道了以前永远也不可能知道的东西. 上述文章提到, QEMU的早期版本可以做到只比真机性能慢4倍. 看着microbench的分数越来越高, 了解每一项技术带来的性能提升及其背后揭示的原理, 这些都是最好的回报, 也是系统方向科研人员所追求的奥义.
开源项目的大门已经向你敞开: 不妨尝试一下阅读QEMU的源代码 (虽然现在的QEMU已经不是上述那篇十几年前的文章所说的那个样子了). NEMU的架构也不够完美: 欢迎和yzh交流你实现JIT所踩过的坑.
这道蓝框题之后的讲义内容, 你, 也许就是作者.