PyPy 为什么会比 CPython 还要快?

关于PyPy的性能网上有很多资料,如[1];在oolps2009会议的论文[2]里也有对性能的说明(我没有完全看懂),大意是讲:原生的解释器无法获得程序的一些信息,无从优化,而PyPy就可以。我的问题是PyPy为什么比CPython还要快? [1] speed.pypy.org/ [2] codespeak.net/pypy/extr [3] codespeak.net/pypy/extr 谢谢,江疆的回答,但我仍然有疑问。 CPython也有几个带JIT的分支,如Unladen Swallow[4],那么Unladen Swallow或者类似的方…
关注者
1300
被浏览
158177
有个名词在现有的回答下面都没人提到——partial evaluation。
这是PyPy的实现机制中的一个核心思想。
Truffle/Graal和PyPy是应用了partial evaluation的现代编译器/运行时项目的代表作,不过它们的具体做法有许多有趣的差异,by design。

这里面参与的部分是:
  1. 用户写的Python应用代码
  2. 用RPython写的Python解释器
  3. 用RPython写的一个实现了partial evaluation机制的tracing JIT编译器
(3)会在运行时对(2)做编译,并且把运行时发现的(1)当作(2)的输入来作特化,最终达到把(1)编译成机器码的结果。
相对其它一些常见的tracing JIT编译器(例如TraceMonkey、LuaJIT2等),PyPy里的(3)最特别的地方就是它不只需要trace一层(用RPython写的解释器)并特化,而必须trace两层(再加上用户的Python代码)并高度特化。这种trace两层的做法叫做meta-tracing

这种做法的好处是,当有人想写一个新的编程语言的实现时,只要在PyPy框架下用RPython编写一个对应上面(2)的语言解释器,就可以借助作为meta-compiler的(3)的部分,得到一个能支持把(1)JIT编译到机器码的高性能实现。

重要的事情说三遍:
写解释器,得到JIT编译器。
写解释器,得到JIT编译器。
写解释器,得到JIT编译器。

例如说,基于PyPy框架实现的Ruby语言项目——Topaz,一上来就比当时最快的Ruby实现——JRuby的默认模式要快得多了。
JRuby的性能依赖于它底下运行的JVM的性能。首先JRuby要把Ruby代码“JIT”编译到Java字节码,然后依赖JVM里的JIT编译器把字节码再编译到机器码。这样JRuby就不用自己实现完整的JIT编译器也能得到不错的性能了。
而实现Topaz通用并不需要自己实现JIT编译器,只要按照PyPy框架的指引,用RPython实现一个带有足够annotation的解释器,就自动得到了高性能的带JIT编译器的实现。何乐而不为。

(关于Ruby实现之间的性能比较,请参考这个benchmark页面:jruby.org/bench9000/ 。如果想看清楚一点的话请把最后一项(Graal那个)去掉,因为它实在快太多导致别的实现的竖线都被压矮了…)

而CPython则是写解释器,得到解释器。在执行机制上PyPy就已经有潜在优势了。

===========================================

不过要应题的话,其实PyPy除了因为有JIT编译器而比纯解释执行的CPython快之外,其实更重要的是PyPy在实现Python的时候采用了更多runtime方面的优化,例如说更优化的对象布局、更优化的虚方法查找等。
这些其实才是更重要的。而那些试图基于CPython runtime加上JIT的项目(例如Unladen Swallow和Pyjion)之所以效果都一般,也正是因为被CPython糟糕的runtime设计而绑住了手脚,无可奈何。

===========================================

下面开些额外的废话,对实现细节不关心的同学可以忽略。

评论区里 @bhuztez 大大提醒说PyPy用的是meta-tracing而不是partial evaluation。嗯这个要严谨地说的话是对的。
但其实meta-tracing的本质思路和目标跟原本Futamura第一映射是完全吻合的,要说meta-tracing是广义上的partial evaluation实现我觉得是OK的,所以本回答开头会一开始就提到“partial evaluation”。

要知道trace-based compilation跟profiling+partial compilation / regional compilation其实最终效果可以非常非常的相似,只是中间所走的路线略微不同而已。随着trace-based compilation技术的发展,大家渐渐意识到了两者的相似之处,所以两者比较新的设计也渐渐变得相似。
例如说trace-based compiler中常见的IR,经历了这样的发展:
  • 开始应用在高级编程语言的实现上的时候,用的是许多单独的直线形trace——IR只有直线形代码。多个trace有可能可以通过patching粘合在一起,例如一个trace的某个side-exit可以粘合到另一个trace的入口。现在还有一些简易的trace-based compiler这样做的…效果其实并不是很好orz
  • 后来开始向trace-tree发展——把入口相同的多条trace合并在一起编译。这样IR所能表现的就是从某个位置开始的多条执行路径。但它们只共享开头而不共享后面的部分,即便后面的部分在多条trace上其实都代表源程序的同一块代码
  • 再后来向trace-graph发现——把入口相同的多条trace合并再一起编译,如果这些trace中包含重复的代码的话,则把这些代码也合并在一起。也就是说头和尾都有可能合并在一起。这样有利于在一次编译中看到更多上下文,让编译器可以做更智能的上下文判断。
发展到trace-graph之后,其实就跟一个带profiling的传统JIT编译器,在profile一段时间后选择只编译“热”的部分,从结果上看是非常非常非常相似的。开始收集trace的过程就权当跟传统系统中的profiler的作用一样,只不过可以看作是把profile嵌入在一小块可执行的代码里而已。

关于PyPy的meta-tracing与Truffle/Graal所实现的partial evaluation的联系与差异,请参考一篇于OOPSLA 2015发表的考察论文:Tracing vs. Partial Evaluation

之前也有一些比较论文或文章,例如: