ICS2017 Programming Assignment
  • Introduction
  • PA0 - 世界诞生的前夜: 开发环境配置
    • Installing a GNU/Linux VM
    • First Exploration with GNU/Linux
    • Installing Tools
    • Configuring vim
    • More Exploration
    • Transferring Files between host and container
    • Acquiring Source Code for PAs
  • PA1 - 开天辟地的篇章: 最简单的计算机
    • 在开始愉快的PA之旅之前
    • 开天辟地的篇章
    • RTFSC
    • 基础设施
    • 表达式求值
    • 监视点
    • i386手册
  • PA2 - 简单复杂的机器: 冯诺依曼计算机系统
    • 不停计算的机器
    • RTFSC(2)
    • 程序, 运行时环境与AM
    • 基础设施(2)
    • 输入输出
  • PA3 - 穿越时空的旅程: 异常控制流
    • 更方便的运行时环境
    • 等级森严的制度
    • 穿越时空的旅程
    • 文件系统
    • 一切皆文件
  • PA4 - 虚实交错的魔法: 分时多任务
    • 虚实交错的魔法
    • 超越容量的界限
    • 分时多任务
    • 来自外部的声音
    • 编写不朽的传奇
  • PA5 - 从一到无穷大: 程序与性能
    • 浮点数的支持
    • 通往高速的次元
    • 天下武功唯快不破
  • 杂项
    • 为什么要学习计算机系统基础
    • 实验提交要求
    • Linux入门教程
    • man入门教程
    • git入门教程
    • i386手册指令集阅读指南
    • i386手册勘误
    • 指令执行例子
Powered by GitBook
On this page

Was this helpful?

  1. PA5 - 从一到无穷大: 程序与性能

天下武功唯快不破

Previous通往高速的次元Next杂项

Last updated 6 years ago

Was this helpful?

相信你也已经在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指令的效果一样吗?

这正是 的思想: 通过生成并执行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()如何工作, 可以参考.

即时编译(JIT)
基本块
这篇讲述QEMU中的JIT如何实现的文章