大背景

在进入主题前,请先看看IBM Research以前做过的类似项目的经验:Fiorano项目。

Fiorano是IBM Research做的一次尝试,将IBM J9 JVM所使用的Testarossa(TR)编译器单独拿出来,插入到CPython运行时中为后者提供JIT编译服务。

传送门:

结果呢?当然Fiorano没有被整合到官方CPython里,不然现在大家就已经在用它了。但作为研究性项目它还是有点意思的——我觉得最重要的一点,是在一个原本没有打算与高性能JIT编译器搭配使用的runtime上,很难实现出特别有效的优化。在主流JVM上,JIT编译后的代码的速度可以轻易达到解释器速度的10x水平;而Fiorano带上了JIT却也就达到了纯解释执行的CPython的速度的1.2x~2.74x的水平范围,并没有给大家带来多少震撼…

Pyjion

开源许可证:MIT

是的,微软近期也加入了给CPython加JIT编译器的大混战。微软甚至还有一个寄身于Data Group in Azure组的Python研发组,最近开始对外宣传。

项目名是“Pyjion”,读作“pigeon”(鸽子),因为项目主力成员Dino大大想要有Python的Py音节、JIT的Ji音节的词…就找(sheng)到(zao)了这么个词出来。GJ!

说起项目主要成员之一的Dino Viehland大大,他以前是IronPython与DLR的主力开发之一,后来也参与了Python Tools for Visual Studio(PTVS)的开发。大家用Visual Studio / VS Express开发Python爽不?里面就有Dino大大的功劳。

可见他对Python那可是有深深的怨念…是真爱啊!

而Pyjion项目的另一个主要成员是Brett Cannon。他从2003年开始就是CPython的core commiter了。这也是真爱啊!

未来传送门:

言归正传,这Pyjion到底是啥呢?它是Brett和Dino做的实验产物,为了在保持完全兼容的前提下提升CPython的性能。目前基于的CPython版本是3.6 alpha 1。

项目官网的一句话说明是:"A JIT for Python based upon CoreCLR"。它目前的项目目标有三个:Add a C API to CPython for plugging in a JIT(代码) <- 最主要的目标

Develop a JIT module using CoreCLR utilizing the C API mentioned in goal #1(代码) <- 概念验证用

Develop a C++ framework

目标1很简单,就是给CPython添加一组新的C API及其实现,来为外部的JIT编译器提供接入CPython运行时的钩子。这部分目前设计和实现都很直观,看看上面的代码链接的patch就知道它是啥了——在解释器入口处添加钩子,当有JIT编译器注册进来时,一个函数在即将开始被解释执行时会先尝试JIT编译,如果成功以后就执行JIT出来的机器码;如果不成功就会把该函数标记为不可JIT编译,以后就不再尝试了。

目前这C API并不太灵活,只允许以Python函数为单元来编译,编译必须对整个函数成功,否则就得整个函数留在解释器里跑。这个API没有考虑到在函数中间跳进JIT编译的代码(On-Stack Replacement,OSR)或从JIT编译的代码中途跳回到解释器(deoptimization)之类的需求。

目标2的描述方式挺有趣的:把CoreCLR当作JIT编译器插入CPython。啥?难道为了JIT还得把整个CoreCLR都拉进来么?太可怕了!

实际上当然没那么糟糕。这个描述方式感觉是故意说得模糊一些。其实Pyjion只是要使用CoreCLR里带着的RyuJIT编译器来为CPython服务。但是当前的RyuJIT的实现依赖了CLR / CoreCLR提供的JIT编译器接口,所以要单独使用RyuJIT的话,得要把原本由CLR / CoreCLR提供的一些服务/接口给模拟出来才行。这个模拟层在Pyjion代码里就是CExecutionEngine、CorJitInfo等类。

换言之,Pyjion自身在pyjit.dll中,而它并不真的需要依赖整个CoreCLR(主体位于coreclr.dll),而只需要其中的RyuJIT(位于clrjit.dll)及其必须依赖的库(例如gcinfo),然后提供CExecutionEngine、CorJitInfo等类的实现给RyuJIT模拟出它所依赖CoreCLR的一些功能。

据说RyuJIT其实是希望未来与CLR / CoreCLR分离开,变得更独立,便于在诸如Pyjion这样的场景单独使用。目前RyuJIT与CLR确实不是由同一个组负责开发的,要分家也很合理。但未来会如何发展,外界也只能拭目以待了。

那么Pyjion是如何使用RyuJIT的呢?

它并没有实现一个RyuJIT的前端,直接把CPython字节码转换为RyuJIT的IR;而是把CPython字节码先转换为CLR的MSIL字节码,然后再让RyuJIT去把这MSIL编译成机器码,最后安装到CPython运行时里去运行。这种做法或许多少与项目组成员之前做IronPython的经历有关系,或者是与RyuJIT现在与CLR / CoreCLR的偶和有关系。

不过这里生成的MSIL只用了MSIL的指令集,而没有完全实现标准的Assembly格式;其元数据相关部分都是Pyjion用自己的数据结构模拟出来的,所以无法将生成的MSIL交给诸如ildasm之类的工具来查看。

具体的转换步骤是:CPython的解释器入口PyEval_EvalFrameEx()调用JIT编译器JitCompile()函数,传入CPython字节码。

JIT编译器入口JitCompile()创建AbstractInterpreter与PythonCompiler,调用AbstractInterpreter::compile()开始编译流程。

AbstractInterpreter类充当CPython字节码的解析器(parser),一边抽象解释CPython字节码一边调用PythonCompiler来生成MSIL。AbstractInterpreter::preprocess()先把CPython字节码里偷懒而设计的"Block"给预处理掉,把循环的跳转目标、异常处理块的边界给找出来并扁平化。可能有同学不理解“偷懒”是什么意思:Python的字节码编译器在处理循环和异常相关的控制流时,没有在编译器里处理嵌套关系,而是把“作用域栈”留到了解释器里。而正确的做法是在编译器里处理掉它,例如这样:如何对C语言的FOR语句给出一个生成中间代码的语法制导定义? - RednaxelaFX 的回答

AbstractInterpreter::interpret()遍历一遍整个CPython函数的字节码,找出基本块边界、异常处理块的边界,以及收集一些后续优化可能用到的信息。例如说它会做个很保守的逃逸分析来判断哪些值没有逃逸,后面就可以选择对它们做进一步特殊优化,例如下文提到的tagged pointer。

PythonCompiler会把每种CPython字节码的操作映射为合适的MSIL字节码序列。简单的CPython字节码可以直接映射为一条或多条MSIL字节码,而复杂的字节码则映射为Pyjion的intrinsic函数的调用。例如两个Python对象相加,会映射为对Pyjion提供的“PyJit_Add()”函数的调用,而这个函数会调用回到CPython运行时里的实现。

接下来就交给RyuJIT编译,得到编译好的机器码以及一些相关的元数据。

换句话说,Pyjion这种实现JIT编译的方式,实际的效果是把一个Python函数的字节码全部粘合到一起,去掉了解释器循环自身的开销,但是大部分复杂的操作还是调用回到CPython运行时去处理的。

要说在语义层面上的优化,Pyjion尝试了给CPython添加tagged pointer来减少小整数的内存开销,顺带提高运行性能(因为实际数据就伪装在指针里,离运算更近了)。但为了保证兼容性,tagged pointer只在被JIT编译的函数内部使用,一到return_value之类的要暴露(escape)出去的地方就还是装箱(box)回到原本的对象形态。对应的intrinsic实现在TAGGED_METHOD宏里(例如PyJit_Add_Int()就是这样来的)。

原本CPython解释器在解释执行每N条字节码指令后都会做些周期性检查,例如是否应该释放GIL来给别的线程机会执行。Pyjion把Python代码JIT编译后,这些周期性检查就安放在用户代码里的循环回跳(backedge)的地方。这跟HotSpot VM的JIT编译代码选择的放置safepoint polling的位置一样。

总体来说,Pyjion采用了一种非常保守的实现方式,很容易保证正确性,但能带来的性能提升也会非常有限。保守是否就意味着容易被接受呢?难说…搞不好会给人太多想像空间结果很失望orz

希望当前的保守设计只是一个过渡阶段。毕竟这个设计比Fiorano的做法还要保守,能带来的性能提升就更有限了。

在JIT编译之外,Pyjion还有没有向CPython注入任何其它东西呢?一点也没有。GIL、GC、监控之类的额外功能一概没碰。

IBM Python+OMR