天下武功唯快不破

相信你也已经在NEMU中运行过microbench, 发现NEMU的性能连真机的1%都不到. 使用perf也没有发现能突破性能瓶颈的地方. 那NEMU究竟慢在哪里呢?

回想一下, 执行程序, 其实就是不断地执行程序的每一条指令: 取指, 译码, 执行, 更新PC... 在真机中, 这个过程是通过高速的门电路来实现的. 但NEMU毕竟是个模拟器, 只能用软件来实现"执行指令"的过程: 执行一次isa_exec_once(), 客户程序才执行一条指令, 但运行NEMU的真机却需要执行上百条native指令. 这也是NEMU性能不高的根本原因. 为了方便叙述, 我们将"客户程序的指令"称为"客户指令". 因此, 作为软件模拟器的NEMU注定无法摆脱"用n条native指令模拟一条客户指令"的命运. 要提高NEMU的性能, 我们就只能想办法减小n了.

事实上, 模拟器的这种工作方式称为解释执行: 每一条客户指令的执行都需要经历完整的指令生命周期. 但仔细回顾一下计算机的本质, 执行指令的最终结果就是改变计算机的状态(寄存器和内存), 而真正改变状态的动作, 只有指令生命周期中的"执行"阶段, 其它阶段都是为状态的改变作铺垫: 取指是为了取到指令本身, 译码是为了看指令究竟要怎么执行, 更新PC只是为了执行下一条指令. 而且, 每次解释执行的时候, 这些辅助阶段做的事情都是一样的. 例如

100000:    b8 34 12 00 00        mov    $0x1234,%eax

每次执行到这条指令的时候, 取指都是取到相同的比特串, 译码总是发现"要将0x1234移动到eax中", 更新PC后其值也总是0x100005. 反正执行客户指令的结果就是改变计算机的状态, 这不就和执行

mov $0x1234,(cpu.eax的地址)
mov $0x100005,(cpu.pc的地址)

这两条native指令的效果一样吗?

这正是即时编译(JIT)的思想: 通过生成并执行native指令来直接改变(被模拟)计算机的状态. 这里的编译不再是"生成机器指令"的含义了, 而是更广义的"语言转换": 把客户程序中执行的机器语言转换成与之行为等价的native机器语言. "即时"是指"这一编译的动作并非事先进行", 而是"在客户程序执行的过程中进行", 这样的好处是, 不会被执行到的客户指令, 就不需要进行编译.

为了生成native指令, 我们至少也要知道相应的客户指令要做什么. 因此, 我们至少也要对客户指令进行一次取指和译码. 通常情况下, 客户指令不会发生变化, 因此编译成的native指令也不会发生变化. 这意味着, 我们只需要对客户指令进行一次编译, 生成相应的native指令, 然后存放起来, 将来碰到相同的客户指令, 就不必重新编译, 而是可以找到之前编译的结果直接执行了. 这恰恰就是cache的思想: 我们将native指令序列组织成一个TB(translation block), 用客户程序的PC来索引; 这个cache由一系列的TB组成; 执行客户程序的时候, 先用PC索引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.pc);
  if ( cache miss ) {
    tb = jit_translate(cpu.pc); // translate a basic block
    update_cache(cpu.pc, tb);
  }

  jump_to(tb); // cpu.pc will be updated while executing tb
}

关于jit_translate()如何工作, 可以参考这篇讲述QEMU中的JIT如何实现的文章.

什么? 这就没有了?

事实上, 实现JIT的坑非常多, yzh也还没全踩完, 也就还没总结出好的要点. 即使把坑都踩过了, 拖延症患者yzh也不一定有时间把这些要点整理成讲义.

不过如果你看到这里, 相信你也有一定能力来面对这些坑了. 踩坑其实是非常非常宝贵的经验, 也是做这些项目的意义所在: 通过做项目, 知道了以前永远也不可能知道的东西. 上述文章提到, QEMU的早期版本可以做到只比真机性能慢4倍. 看着microbench的分数越来越高, 了解每一项技术带来的性能提升及其背后揭示的原理, 这些都是最好的回报, 也是系统方向科研人员所追求的奥义.

开源项目的大门已经向你敞开: 不妨尝试一下阅读QEMU的源代码 (虽然现在的QEMU已经不是上述那篇十几年前的文章所说的那个样子了). NEMU的架构也不够完美: 欢迎和yzh交流你实现JIT所踩过的坑.

这之后的讲义内容, 你, 也许就是作者.

results matching ""

    No results matching ""