利用Psyco提升Python运行速度
Psyco是严格地在Python运行时进行操作的。也就是说,Python源代码是通过python命令编译成字节码的,所用的方式和以前完全相同(除了为调用Psyco而添加的几个import语句和函数调用)。但是当Python解释器运行应用程序时,Psyco会不时地检查,看是否能用一些专门的机器代码去替换常规的Python字节码操作。这种专门的编译和Java即时编译器所进行的操作非常类似(一般地说,至少是这样),并且是特定于体系结构的。到现在为止,Psyco只可用于i386CPU体系结构。Psyco的妙处在于可以使用您一直在编写的Python代码(完全一样!),却可以让它运行得更快。
Psyco是如何工作的
要完全理解Psyco,您可能需要很好地掌握Python解释器的eval_frame()函数和i386汇编语言。遗憾的是,我自己不能对其中任何一项发表专家性的意见-但是我想我可以大致不差地概述Psyco。
在常规的Python中,eval_frame()函数是Python解释器的内循环。eval_frame()函数主要察看执行上下文中的当前字节码,并将控制向外切换到一个适合实现该字节码的函数。支持函数将做什么的具体细节通常取决于保存在内存中的各种Python对象的状态。简单点说,添加Python对象“2”和“3”和添加对象“5”和“6”会产生不同的结果,但是这两个操作都以类似的方式分派。
Psyco用复合求值单元替代eval_frame()函数。Psyco有几种方法可以用来改进Python所进行的操作。首先,Psyco将操作编译成有点优化的机器码;由于机器码需要完成的工作和Python的分派函数所要做的事一样,所以其本身只有些许改进。而且,Psyco编译中的“专门的”内容不仅仅是对Python字节码的选择,Psyco也要对执行上下文中已知的变量值进行专门化。例如,在类似于下面的代码中,变量x在循环持续时间内是可知的:
x=5 l=[] foriinrange(1000): l.append(x*i)
该段代码的优化版本不需要用“x变量/对象的内容”乘每个i,与之相比,简单地用5乘以每个i所用的开销较少,省略了查找/间接引用这一步。
除为小型操作创建特定于i386的代码之外,Psyco还高速缓存这个已编译的机器码以备今后重用。如果Psyco能够识别出特定的操作和早先所执行的(“专门化的”)操作一样,那么,它就能依靠这个高速缓存的代码而不需要再次编译代码段。这样就节省了一些时间。
但是,Psyco中真正省时的原因在于Psyco将操作分成三个不同的级别。对于Psyco,有“运行时”、“编译时”和“虚拟时”变量。Psyco根据需要提高和降低变量的级别。运行时变量只是常规Python解释器处理的原始字节码和对象结构。一旦Psyco将操作编译成机器码,那么编译时变量就会在机器寄存器和可直接访问的内存位置中表示。
最有意思的级别是虚拟时变量。在内部,一个Python变量就是一个有许多成员组成的完整结构-即使当对象只代表一个整数时也是如此。Psyco虚拟时变量代表了需要时可能会被构建的Python对象,但是这些对象的详细信息在它们成为Python对象之前是被忽略的。例如,考虑如下赋值:
x=15*(14+(13-(12/11)))
标准的Python会构建和破坏许多对象以计算这个值。构建一个完整的整数对象以保存(12/11)这个值;然后从临时对象的结构中“拉”出一个值并用它计算新的临时对象(13-PyInt)。而Psyco跳过这些对象,只计算这些值,因为它知道“如果需要”,可以从值创建一个对象。
使用Psyco
解释Psyco相对比较困难,但是使用Psyco就非常容易了。基本上,其全部内容就是告诉Psyco模块哪个函数/方法要“专门化”。任何Python函数和类本身的代码都不需进行更改。
有几种方法可以指定Psyco应该做什么。“猎枪(shotgun)”方法使得随处都可使用Psyco即时操作。要做到这点,把下列行置于模块顶端:
importpsyco;psyco.jit() frompsyco.classesimport*
第一行告诉Psyco对所有全局函数“发挥其魔力”。第二行(在Python2.2及以上版本中)告诉Psyco对类方法执行相同的操作。为了更精确地确定Psyco的行为,可以使用下列命令:
psyco.bind(somefunc)#ormethod,class
newname=psyco.proxy(func)
第二种形式把func作为标准的Python函数,但是优化了涉及newname的调用。除了测试和调试之外的几乎所有的情况下,您都将使用psyco.bind()形式。
Psyco的性能
尽管Psyco如此神奇,使用它仍然需要一点思考和测试。主要是要明白Psyco对于处理多次循环的块是很有用的,而且它知道如何优化涉及整数和浮点数的操作。对于非循环函数和其它类型对象的操作,Psyco多半只会增加其分析和内部编译的开销。而且,对于含有大量函数和类的应用程序来说,在整个应用程序范围启用Psyco,会在机器码编译和用于这一高速缓存的内存使用方面增加大量的负担。有选择性地绑定那些可以从Psyco的优化中获得最大收益的函数,这样会好得多。
我以十分幼稚的方式开始了我的测试过程。我仅仅考虑了我近来运行的、但还未考虑加速的应用程序。想到的第一个示例是用来将我即将出版的书稿(TextProcessinginPython)转换成LaTeX格式的文本操作程序。该应用程序使用了一些字符串方法、一些正则表达式和一些主要由正则表达式和字符串匹配所驱动的程序逻辑。实际上将它用作Psyco的测试候选是很糟的选择,但是我还是使用了,就这么开始了。
第一遍测试中,我所做的就是将psyco.jit()添加到脚本顶端。这做起来一点都不费力。遗憾的是,结果(意料当中)很令人失望。原先脚本运行要花费8.5秒,经过Psyco的“加速”后它大概要运行12秒。真差劲!我猜测大概是即时编译所需的启动开销拖累了运行时间。因此接下来我试着处理一个更大的输入文件(由原来那个输入文件的多个副本组成)。这次获得了小小的成功,将运行时间从120秒左右减到了110秒。几次运行中的加速效果比较一致,但是效果都不显著。
本处理候选项的第二遍测试中。我只添加了psyco.bind(main)这一行,而不是添加一个总的psyco.jit()调用,因为main()函数确实要循环多次(但是仅利用了最少的整数运算)。这里的结果名义上要比前面好。这种方法将正常的运行时间削减了十分之几秒,在较大的输入版本的情况下削减了数秒钟。但是仍然没有引入瞩目的结果发生(但也没产生什么害处)。
为进行更恰当的Psyco测试,我搜寻出我在以前的文章里编写的一些神经网络代码(请参阅“参考资料”)。这个“代码识别器(code_recognizer)”应用程序可以经“训练”用于识别不同编程语言编写的不同ASCII值的可能分布情况。类似于这样的东西可能在猜测文件类型方面(比方说丢失的网络信息包)将很有用;但是,关于“训练”些什么,代码实际上完全是通用的-它能很容易地学会识别面孔、声音或潮汐模式。任何情况下,“代码识别器”都基于Python库bpnn,Psyco4.0分发版也包含(以修正的形式)了该库作为测试用例。在本文中,对“代码识别器”要重点了解它做了许多浮点运算循环并花费了很长的运行时间。这里我们已经有了一个能用于Psyco测试的好的候选用例。
使用了一段时间后,我建立了有关Psyco用法的一些详细信息。对于这种只有少量类和函数的应用程序,使用即时绑定还是目标绑定没有太大区别。但最佳的结果是,通过有选择性地绑定最优化类,仍可得到几个百分点的改进。然而,更值得注意的是要理解Psyco绑定的作用域,这一点很重要。
code_recognizer.py脚本包括类似于下面的这些行:
从bpnn导入NN
classNN2(NN):
#customizedoutputmethods,mathcoreinherited
也就是说,从Psyco的观点来看,有趣的事情在类bpnn.NN之中。把psyco.jit()或psyco.bind(NN2)添加到code_recognizer.py脚本中起不了什么作用。要使Psyco进行期望的优化,需要将psyco.bind(NN)添加到code_recognizer.py或者将psyco.jit()添加到bpnn.py。与您可能假设的情况相反,即时优化不在创建实例时或方法运行时发生,而是在定义类的作用域内发生。另外,绑定派生类不会专门化其从其它地方继承的方法。
一旦找到适当的Psyco绑定的细微的详细信息,那么加速效果是相当明显的。使用参考文章中提供的相同测试用例和训练方法(500个训练模式,1000个训练迭代),神经网络训练时间从2000秒左右减到了600秒左右-提速了3倍多。将迭代次数降到10,加速的倍数也成比例降低(但对神经网络的识别能力无效),迭代的中间数值也会如此变化。
我发现使用两行新代码就能将运行时间从超过半小时减到10分钟左右,效果非常显著。这种加速仍可能比C编写的类似应用程序的速度慢,而且它肯定比几个独立的Psyco测试用例所反映出的100倍加速要慢。但是这种应用程序是相当“真实的”,而且在许多环境中这些改进已经是够显著的了。