GIL,即全局解释器锁(Global Interpreter Lock),是计算机程序设计语言解释器用于同步线程的工具,使得任何时刻仅有一个线程在执行。常见例子有CPython(JPython不使用GIL)与Ruby MRI
为何 PyPy 是趋势?
PyPy为优化和进一步的语言开发提供了更好的架构。对于大部分Python已有的问题,PyPy已经提供了解决方案:
- 先进的runtime和设计,在此文中作了介绍: The Architecture of Open Source Applications.
- 速度 - PyPy内置的JIT很棒,有时(其实很少)甚至可以与C相提并论。
- GIL问题 - PyPy引入了一个很棒的STM实现,在 Armins Rigo的 文章中对此作了介绍。
- 粘合代码 - 使用cffi可以简单的处理C库,甚至比CPython的ctypes还要快!
- 异步编程。这方面,PyPy内置的 greenlet 比CPython的C扩展更适合一些。实际上,非堆栈式的概念(也即greenlet)在PyPy中还在继续发展(参看https://ep2012.europython.eu/conference/talks/the-story-of-stackless-python)
- 沙盒技术
- 应用在web和移动中。这里有Dusty的一些文章:Pushing Python Past the Present
PyPy已经支持多平台 (x86, 64_x86, ARM)
PyPy同时还包含了一个优秀的现代的架构,在 Jim Huang 的演讲 中做了介绍,演讲的要点是:
- 解释性语言的框架
- 用于研究和产品的组件组合 (不同的数据模型,垃圾回收 - 这些可以在具体的应用场景进行改变)
- 构建在基于组件链的功能架构之上(翻译工具链)。每一个步骤都会延续/转换程序模型、引入特征、各种后端(JVM, JavaScript, LLVM, GCC IR等等)。来看一下翻译链的例子:python 代码 -> 字节码 -> 函数对象 -> 类型推断 -> 垃圾收集器 -> JIT
- 包含大量在架构的不同层次开发的现代的优化技术 (这个任务可以简化)
相信让所有软件支持PyPy需要付出艰巨的努力 - 需要在现有的库上做很多工作。不过使用新的工具,编写支持PyPy和CPython的软件会比采用C扩展的方式更简单一些(在我们能做什么一节有介绍)。
即时编译: PyPy和它的未来
我们已经有了一个使用C写的Python实现,一个用Java写的,一个用C#写的。接下来就是:用Python写的Python实现(有心人可能会注意这句话有点问题,是个死循环,^_^)
接下来我们看下什么地方容易搞混淆。首先,我们讨论下即时编译器JIT
JIT: 为什么会有这个?它的原理是什么?
大家都知道本地机器码的速度比字节码的速度快很多。那么,如果我们能将一些字节码直接编译成本地机器码再去运行它会怎样呢?我们必须花费一些代价(比如时间)在编译字节码到本地机器码上,如果最终的运行时间更快,那么这个代价就是值得的。这就是JIT编译器的动机,一种混合了解释器和编译器好处的技术。简单来讲,JIT就是想通过编译技术提升脚本解释器系统的速度。
例如, 被JIT(及时编译)采用的通用方法:
- 标识被经常执行的字节码。
- 把其编译成本地的机器码。
- 缓存该结果。
- 当同样的的字节码再次被执行的时候,会取预编译的机器码,得到好处(例如速度提升)。
这是关于PyPy的用处: 把JIT代入Python语言 (参看前面成果的附录).当然也有其他目的: PyPy 目标是成为一个跨平台,轻内存,支持stackless(译注:stackless为python提供微线程扩展,具有并发特性)。 但是及时编译才是它真正的卖点。 基于一系列时间测试的平均, 据说性能上能提高6.27倍. 停一下, 看看下面这个由PyPy Speed Center提供的图表:
PyPy is Hard to Understand
PyPy具有巨大的潜力,在这一点上,它与CPython高度兼容所以它能运行Flask,Django等等)。
但关于PyPy有许多困惑 (例如,荒谬的建议创造一种PyPyPy…语言). 按我的观点,那主要是因为PyPy实际上是两种东西:
一种用RPython (非Python (我之前撒谎了))编写的Python解释器。 RPython是Python的子集,具有静态类型。在Python里,最难严格推论类型 (为什么这么困难,考虑下下面的事实:
- 将是合法的Python代码 (归功于 Ademan). x的类型是什么? 我们怎么推出变量的类型,当类型还没有被严格实施?)通过RPython,你牺牲了一些灵活性, 但使得内存管理和优化大大的容易。
- 一个编译RPython代码为了各种目标和加入及时编译的编译器。 默认平台是C,也就是从RPython到C编译器,但你也可以瞄准JVM或者其他。
只为清晰,我将引用这些PyPy(1)和PyPy(2)。
为什么你在同一层面下同时需要这两者? 你可以这样想一下:PyPy(1)是一个用RPython写的解释器,因此它能加载用户的Python代码并将它编译成字节码。但是这个用RPython写的解释器本身要能运行,就必须要被另外一个Python实现去解释,对不?
我们可以直接用CPython去运行这个解释器。但是这个还不够快
取而代之,我们使用了PyPy(2)(参考 RPython的工具链)去编译这个PyPy的解释器,生成其他平台(比如C, JVM或CLI)代码在我们的机器上运行,并且还加入了JIT特性。这个很神奇:PyPy动态的将JIT加入一个解释器,生成它自己编译器!(这就是核心原理:我们在编译一个解释器,并同时加入了另外一个单独的编译器到里面去)。
最终结果就是一个融合了JIT优化特性的单独的可执行文件,用来解释执行我们的Python源代码。这就是我们之前想要达到的效果。这么讲可能比较拗口,下面这张图可能会解释的比较清楚点:
再次重申下,PyPy真正可贵之处在于我们可以利用RPython实现各种不同的Python解释器,不用去关心JIT(除了一些小的提示外)。PyPy到时候会利用RPython工具链/PyPy(2)为我们自动实现JIT
事实上,我们还可以更抽象一点,我们理论上可以写一个适用于任何语言的解释器,然后将它扔给PyPy,最后获得那种语言的JIT。原因是PyPy仅仅关心的是优化解释器,而不会去关心这个解释器到底解释的是什么语言。
理论上你自己可以写一个适用于任何语言的解释器,然后将这个解释器传给PyPy,最后你得到这个语言的一个JIT。一个简单的题外话,我这里想提一下,JIT本事是相当棒的。它使用了一种叫做跟踪的技术,按照下面的步骤执行:
- 执行解释器并解释执行所有代码(还没有加入JIT特性)
- 对被解释过的代码做一些记录
- 确认你已经执行过的操作
- 将确认过的这些代码编译成本地机器码
想获取更多信息,可以参考这篇文章,易于理解,并且非常有趣
最后收尾:我们使用PyPy的RPython-to-C(或者其他目标平台)编译器去编译PyPy的基于RPython实现的解释器。
结尾
为什么它如此的伟大?为什么这个疯狂的想法值得我们去追求?我想Alex Gaynor已经在他的博客上面做了很好的解释了:“[PyPy就是未来] 因为[它]提供了更快的速度,更大的灵活性,并且对于Python的成长也提供了一个更好的平台”
总之:
- 它很快,因为它将源代码编译成了本地机器码(使用了JIT)
- 它很灵活,因为除了极少数的额外工作需要做外,它就能将JIT加入你的解释器中
- 它还是很灵活,因为你能使用RPython实现你的解释器,这个比其他的(比如C语言)更易扩展。事实上,它是如此的简单,这里有一篇教程教你如何实现你自己的解释器。
Python的慢可是众所周知的。
来一个小程序试试看:
Python:
1
2
3
4
5
6
7
8
9
10
11
|
NUM
=
111181111
def
is_prime(n):
i
=
2
while
i < n:
if
n
%
i
=
=
0
:
return
False
i
+
=
1
return
True
print
is_prime(NUM)
|
等效的C程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
#include <stdio.h>
int
NUM = 111181111;
int
is_prime(
int
n) {
int
i;
for
(i = 2; i < n; i++) {
if
(n % i == 0) {
return
0;
}
}
return
1;
}
int
main() {
int
result;
result = is_prime(NUM);
printf
(
"%d\n"
, result);
return
0;
}
|
测试一个数是不是质数。我知道111181111是。
然后用Python2.7.6, Jython2.5.3, pypy2.2.1跑Python程序, gcc4.9.0分别用-O0和-O3编译C程序。然后试试速度。
1
2
3
4
5
|
python2 isprime.py 15.60s user 0.01s system 100% cpu 15.609 total
jython isprime.py 8.45s user 0.09s system 117% cpu 7.272 total
pypy isprime.py 1.43s user 0.03s system 99% cpu 1.461 total
./isprime-o0 0.62s user 0.00s system 99% cpu 0.624 total
./isprime-o3 0.60s user 0.00s system 99% cpu 0.605 total
|
用官方的Python解释器跑,速度比等效的C程序慢30倍?
Jython是把Python翻译成Java字节码然后在JVM上跑,有加速,但比起PyPy来说可以说效果不明显了。
PyPy可以将Python的速度加到C的一半左右。
不公平是吧,仅仅因为使用Python语言,速度就要比C语言慢?
或者,更好的问题是,为什么Python程序慢?
其实这个问题已经有人解答了。推荐来自IBM研究中心的Jose Castanos等人的论文:《On the Benefits and Pitfalls of Extending a Statically Typed Language JIT Compiler for Dynamic Scripting Languages》。文章分析了一个试图将Python运行在一个高性能虚拟机上的事例。他们提到了一些失败的例子和成功的例子,并阐述了成功的关键。
文章的主要观点就是: Python等动态类型语言之所以慢,就是因为每一个简单的操作都需要大量的指令才能完成。 他们的虚拟机拥有很强的优化器,却是为静态语言设计的。对Python几乎没有效果。而他们成功的尝试使用了别的技术,下文提到。
举一个例子。对于整数加法,C语言很简单,只要一个机器指令ADD就可以了,最多不过再加一些内存读写。
但是,对于Python来说,a+b这样的简单二元运算,可就真的很麻烦了。Python是动态语言,变量只是对象的引用,变量a和b本身都没有类型,而它们的值有类型。所以,在相“加”之前,必须先判断类型。
1. 判断a是否为整数,否则跳到第9步
2. 判断b是否为整数,否则跳到第9步
3. 将a指向的对象中的整数值读出来
4. 将b指向的对象中的整数值读出来
5. 进行整数相加
6. 生成一个新整数对象
7. 将运算结果存进去
8. 返回这个对象,完成!
9. 判断a是否为字符串,否则跳到第13步
10. 判断b是否为字符串,否则跳到第13步
11. 进行字符串串接操作,生成一个新字符串对象
12. 返回这个对象,完成!
13. 从a的字典里取出__add__方法
14. 调用这个方法,将a和b作为参数传入
15. 返回上述方法的返回值。
这还只是简化版的,实际中还要考虑溢出问题等。
可想而知,如果对于每一次加法运算,C语言只需要一个机器指令,而Python要做这么多操作,Python显然要比C慢得太多。再加上官方的CPython是一个解释器,还要加上每次读指令、指令译码的代价,就更慢了。
Jython只是加快了一些,从上述的例子中,从15秒降到8秒,但是仍然比C慢太多。这是因为Jython能做的只是把Python代码转换成JVM的代码,而Python中那些判断a,b是否为整数或者字符串的操作是不能省略的。毕竟Python是允许你写1+2同时也可以"hello"+"world"。这种运行时的类型检查并不能简单地通过编译而去除。
这里需要提到一个项目:谷歌的Unladen Swallow。它是一个很有雄心的项目,但是,在项目开始一年后就流产了。最后,加速效果也不过50%左右。它们使用的方法是朴素的“模板编译法”:看到Python的加法操作,就转换成一个C语言的函数调用,调用Python的PyNumber_Add函数。这个函数就是干类似上面一串的事。同样地,虽然去除了官方Python的解释器代价,但并没有消除运行时类型检查的代价。
那为什么PyPy能够优化到如此接近C呢?
PyPy使用了一种技巧,就是“类型推导”(Type Inference)。
PyPy的运行时编译器(Just-in-time Compiler,或者称JIT Compiler)的工作方式是,只优化循环,因为大量的时间都是消耗在少数循环上。当运行时检测到某个循环运行的次数很多的时候,就开启一个“录像机”,录制这个循环执行一次中,执行的所有操作的轨迹。这样以“轨迹”为单位的编译方式叫Tracing JIT。
如果给上述判断质数的程序录制“轨迹”,得到的应该是这样:
1. 判断i是整数
2. 判断n是整数
3. 对i和n进行整数“小于”操作
4. 如果不小于,就退出循环
5. 判断i是整数
6. 判断n是整数
7. 计算i除以n的余数
8. 判断上述结果是整数
9. 判断0是整数
10. 比较两者是否相等
11. 如果相等就返回False
12. 判断i是整数
13. 判断1是整数
14. 将i和1进行整数相加,结果赋值给i
15. 回到第1步。
可以显而易见地看出来,这段代码中,充斥着大量的“判断i为整数”和“判断n为整数”这样的操作。但是,很显然,如果i从一开始就是整数,而程序的过程中i从来没有变过,那么i就一直是整数。还有像0、1这样的常数显然是整数。
不仅如此,如果一次循环中i是整数,循环中i只是自加了1,那么下一次循环它还是整数。知道这些知识,优化器就可以大幅度地优化上述代码。
1. 判断i是整数,而且用32位整数装得下,否则跳回解释器执行。
2. 令i0为32位整数,值为i的值
3. 判断n是整数,而且用32位整数装得下,否则跳回解释器执行。
4. 令n0为32位整数,值为n0的值
5. 对i0和n0进行整数“小于”操作
6. 如果不小于,就退出循环
7. 计算i0除以n0的余数
8. 比较上述结果和整数0是否相等
9. 如果相等就返回False
10. 将i0和整数1进行整数相加,结果赋值给i0
11. 但是,如果i0溢出了,就跳回解释器执行。
12. 回到第5步。
看到,原来的循环是1-15,现在只有5-12了。其中涉及的i0和n0还有常数0和1都是32位小整数,都可以直接对应机器指令进行执行。程序起码在这一部分已经由动态的代码变成像C一样的静态类型代码了,而且数据类型很接近机器。将这一段代码编译成机器码,效率就可以和C相比了。
注意到,这其实是一种“猜测”:优化器“猜想”每次执行这段循环,i和n都是整数。这种猜测是可能出错的。万一程序员将一个字符串传入函数怎么办呢?所以,基于“猜测”(speculation)的优化必须考虑“猜错了”的情形。这就是优化过的代码的第1、3、11行的用途。1和3考虑万一i和n不是整数的情形,而15考虑了整数溢出的情况。在Python里,整数都是高精度整数,可以是任意大的,而不仅限于32位。(其实上述32位也只是假设,在64位机上,显然64位效率更高。)所以,如果猜错了(这种事经常会发生),就必须停止执行这段“优化”过的代码,而是老老实实回到解释器中,像传统的Python一样执行。
可以看出,带有类型推导功能的Tracing JIT编译器可以大幅度加快动态语言的速度。主要原因是:
1. 在运行时得到了变量的类型,并通过“猜测”,将这些类型转换成接近机器的类型。
2. 将简化的操作编译成机器码,去除了解释器的代价。
目前,PyPy是一个很活跃的项目。但是,毕竟是一个研究型的项目,PyPy也有自己的不足。如和官方Python并不完全兼容;PyPy本身的可执行文件很大;并不是运行所有的程序都快——PyPy虽然JIT Compiler很快,但它的解释器速度不如官方的Python,对于无法通过优化加速的程序来说,PyPy就不快了。