使用Pyrex来扩展和加速Python程序的教程
Pyrex是一种专门设计用来编写Python扩展模块的语言。根据PyrexWeb站点的介绍,“它被设计用来在友好易用的高级Python世界和凌乱的低级C世界之间搭建一个桥梁。”虽然几乎所有的Python代码都可以作为有效的Pyrex代码使用,但是您可以在Pyrex代码中添加可选的静态类型声明,从而使得这些声明过的对象以C语言的速度运行。
加速Python
从某种意义上来说,Pyrex只是不断发展的Python类语言系列的一个部分:Jython、IronPython、Prothon、Boo、Vyper(现在没人用了)、StacklessPython(以一种方式)或Parrotruntime(以另外一种方式)。按照语言的术语来说,Pyrex本质上是在Python中添加了类型声明。它的另外几个变化没有这么重要(不过对for循环的扩展很漂亮)。
然而,您真正希望使用Pyrex的原因是它编写的模块比纯Python运行得更快,可能会快很多。
实际上,Pyrex会从Pyrex代码生成一个C程序。中间文件module.c依然可以用于手工处理。然而对于“普通的”Pyrex用户来说,没有什么理由需要修改所生成的C模块。Pyrex本身可以让您访问那些对速度至关重要的C级代码,而节省了编写内存分配、回收、指针运算、函数原型等的工作。Pyrex还可以无缝地处理Python级对象的所有接口;通常它都是通过在必要的地方将变量声明为PyObject结构并使用PythonC-API调用进行内存处理和类型转换而实现的。
对于大部分情况来说,Pyrex不需要不断对简单数据类型变量进行装箱(box)和拆箱(unbox)操作,因此速度比Python更快。例如,Python中的int类型是一个具有很多方法的对象。它有一个继承树,自己有一个计算好的“方法解析顺序(mothodresolutionorder,MRO)”。它有分配和回收方法可以用于内存处理。它知道何时将自己转换为一个long类型,以及如何对其他类型的值进行数值运算。所有这些额外的功能都意味着在使用int对象进行处理时需要经过更多级的间接处理或条件检查。另外一方面,C或Pyrex的int变量只是内存中各个位设置为1或0的一个区域。使用C/Pyrex的int类型进行处理不需要涉及任何间接操作或条件检查。一个CPU“加”操作在硅芯片中就可以执行完了。
在仔细选择的情况中,Pyrex模块的速度可以比Python版本的相同模块的运行速度快40到50倍。但是与使用C本身编写的模块相比,Pyrex版本的模块几乎都不会比Python版本的模块更长,代码更类似于Python,而不是C。
当然,当您开始谈论加速(类)Python模块时,Pyrex并不是惟一可用的工具。在Python开发者的选择中,也可以使用Psyco。Psyco可以保持代码非常简短;它是(x86)机器代码中的一个JITPython代码编译器。与Pyrex不同,Psyco并不会精确地限定变量的类型,而是根据数据可能是哪种类型的每种假设为每个Python代码块创建几种可能的机器代码。如果在一个给定的代码段中数据是是简单类型,例如int,那么这段代码(如果是一个循环,这种情况就更为突出)就可以很快地运行。例如,x在一个执行一百万次的循环中可以是int类型,但是在循环结束时可以依然是一个float类型的值。Psyco可以使用与在Pyrex中显式指定的类型相同的类型来加速循环。
虽然Pyrex也并不难,但是Psyco更加简单易用。使用Psyco不过是在模块的末尾加上几行;实际上,如果加上正确的代码,那么即使在Psyco不可用时,模块也可以同样运行(只是速度较慢)。
清单1.只有在Psyco可用时才使用Psyco
#ImportPsycoifavailable try: importpsyco psyco.full() exceptImportError: pass
要使用Pyrex,需要对代码进行的修改会更多(但也不过是多一点而已),系统中还需要安装一个C编译器,并正确对生成Pyrex模块的系统进行配置。虽然您可以分发二进制的Pyrex模块,但是为了能使您的模块在其他地方也可以运行,Python的版本、架构和终端用户需要的优化选项必须匹配。
速度初体验
我最近为developerWorks的文章Beatspamusinghashcash创建了一个纯Python的hashcash实现,但是基本上来说,hashcash是一种使用SHA-1提供CPU工作的技术。Python有一个标准的模块sha,这使得编写hashcash非常简单。
与我编写的95%的Python程序不同,hashcash模块缓慢的速度让我心烦,至少有那么一点点心烦。按照设计,这个协议就是要吃光所有的CPU周期,因此运行效率非常关键。hashcash.c的ANSIC二进制文件运行的速度是这个hashcash.py脚本的10倍。而且启用了PPC/Altivec的优化后的hashcash.c二进制文件的速度是普通的ANSIC版本的4倍(1Ghz的G4/Altivec在处理hashcash/SHA操作时的速度相当于3Ghz的Pentium4?/MMX;G5的速度会更快)。因此在我的TiPowerbook上的测试显示,这个模块的速度比优化后的C版本速度慢40倍(不过在x86上的差距没有这么大)。
由于这个模块的运行速度很慢,可能Pyrex会是一个比较好的加速方法。至少我认为是如此。“Pyrex化”hashcash.py的第一件事情(当然是在安装Pyrex之后)是简单地将其拷贝为hashcash_pyx.pyx,并试图这样处理:
$pyrexchashcash_pyx.pyx
创建二进制模块
运行这个命令会生成一个hashcash.c文件(这会对源文件进行一些微小的改动)。不幸的是,调整gcc开关刚好适合我的平台需要点技巧,因此我决定采用推荐的捷径,让distutils为我做一些工作。标准的Python安装知道如何在模块安装过程中使用本地的C编译器,以及如何使用distutils来简化Pyrex模块的共享。我创建了一个setup_hashcash.py脚本,如下所示:
清单2.setup_hashcash.py脚本
fromdistutils.coreimportsetup fromdistutils.extensionimportExtension fromPyrex.Distutilsimportbuild_ext setup( name="hashcash_pyx", ext_modules=[ Extension("hashcash_pyx",["hashcash_pyx.pyx"],libraries=[]) ], cmdclass={'build_ext':build_ext} )
运行下面的命令,完整地编译一个基于C的扩展模块hashcash:
$python2.3prime_setup.pybuild_ext--inplace
代码修改
我把从hashcash.pyx生成基于C的模块的工作有些简化了。实际上,我需要对源代码进行两处修改;通过查找pyrexc抱怨的位置来找到要修改的位置。在代码中,我使用了一个不支持的列表,将其放入一个普通的for循环。这非常简单。我还将增量赋值从counter+=1修改为counter=counter+1。
就这么多了。这就是我的第一个Pyrex模块。
测试速度
为了可以简单地测试要开发的模块的速度提高情况,我编写了一个简单的测试程序来运行不同版本的模块:
清单3.测试程序hashcash_test.py
#!/usr/bin/envpython2.3 importtime,sys,optparse hashcash=__import__(sys.argv[1]) start=time.time() printhashcash.mint('mertz@gnosis.cx',bits=20) timer=time.time()-start sys.stderr.write("%0.4fseconds(%dhashespersecond)\n"% (timer,hashcash.tries[0]/timer))
令人兴奋的是,我决定来看一下只通过Pyrex编译可以怎样提高速度。注意在下面所有的例子中,真实的时间变化很大,都是随机的。我们要看的内容是“hashespersecond”,它可以精确可靠地测量速度。因此比较一下纯粹的Python和Pyrex:
清单4.纯Python和“纯Pyrex”的比较
$./hashcash_test.pyhashcash 1:20:041003:mertz@gnosis.cx::I+lyNUpV:167dca 13.7879seconds(106904hashespersecond) $./hashcash_test.pyhashcash_pyx>/dev/null 6.0695seconds(89239hashespersecond)
噢!使用Pyrex几乎慢了20%。这并不是我期望的。现在应该来分析一下代码可能加速的地方了。下面这个简短的函数会试图消耗所有的时间:
清单5.hashcash.py中的函数
def_mint(challenge,bits): "Answera'generalizedhashcash'challenge'" counter=0 hex_digits=int(ceil(bits/4.)) zeros='0'*hex_digits hash=sha while1: digest=hash(challenge+hex(counter)[2:]).hexdigest() ifdigest[:hex_digits]==zeros: tries[0]=counter returnhex(counter)[2:] counter+=1
我需要利用Pyrex变量声明的优点来进行加速。有些变量显然是整数,另外一些变量显然是字符串——我们可以指定这些类型。在进行修改时,我将使用Pyrex的经过改进的for循环:
清单6.经过最低限度Pyrex改进的mint函数
cdef_mint(challenge,intbits): #Answera'generalizedhashcash'challenge'" cdefintcounter,hex_digits,i cdefchar*digest hex_digits=int(ceil(bits/4.)) hash=sha forcounterfrom0<=counter<sys.maxint: py_digest=hash(challenge+hex(counter)[2:]).hexdigest() digest=py_digest forifrom0<=i<hex_digits: ifdigest[i]!=c'0':break else: tries[0]=counter returnhex(counter)[2:]
到现在为止一切都非常简单。我只声明了早已知道的一些变量类型,并使用最干净的Pyrexcounter循环。一个小技巧是将py_digest(一个Python字符串)赋值给digest(一个C/Pyrex字符串),目的是确定其类型。经过实验,我还发现循环字符串比较操作速度都非常快。这些会带来什么好处呢?
清单7.Pyrex化mint函数的速度结果
$./hashcash_test.pyhashcash_pyx2>/dev/null 20.3749seconds(116636hashespersecond)
这下好多了。我已经对原有的Python进行了一些细微的改进,这可以稍微提高最初的Pyrex模块的速度。不过效果还不明显,仅仅提高了很少的百分比。
剖析
有些东西似乎不对。速度提高几个百分比和Pyrex主页(以及很多Pyrex用户)那样提高40倍有很大的差距。现在应该来看一下这个Python_mint()函数中哪些地方真正消耗了时间。有一个quick脚本(此处没有给出)可以分解复杂操作sha(challenge+hex(counter)[2:]).hexdigest():
清单8.hashcash的mint函数的时间消耗
1000000emptyloops:0.559 ------------------------------ 1000000sha()s:2.332 1000000hex()[2:]s:3.151 justhex()s:<2.471> 1000000concatenations:0.855 1000000hexdigest()s:3.742 ------------------------------ Total:10.079
显然,我并不能将这个循环从_mint()函数中删除。虽然Pyrex改进后的for循环可能有一点加速,但是整个函数主要是一个循环。我也不能删除对sha()的调用,除非要使用Pyrex重新实现SHA-1(即使我要这样做,也没有自信自己可以比Python标准的sha模块的作者做得更好)。而且,如果我希望得到一个sha.SHA对象的hash值,就只能调用.hexdigest()或.digest();前者的速度更快。
现在真正要解决的是hex()对counter变量的转换,以及结果中时间片的消耗情况。我可能需要使用Pyrex/C的字符串连接操作,而不是Python的字符串对象。然而,我见过的惟一一种避免hex()转换的方法是手工在嵌套循环之外构建一个后缀。虽然这样做可以避免int到char类型的转换,但是需要生成更多代码:
清单9.完全Pyrex优化过的mint函数
cdef_mint(char*challenge,intbits): cdefinthex_digits,i0,i1,i2,i3,i4,i5 cdefchar*ab,*digest,*trial,*suffix suffix='******' ab=alphabet hex_digits=int(ceil(bits/4.)) hash=sha fori0from0<=i0<55: suffix[0]=ab[i0] fori1from0<=i1<55: suffix[1]=ab[i1] fori2from0<=i2<55: suffix[2]=ab[i2] fori3from0<=i3<55: suffix[3]=ab[i3] fori4from0<=i4<55: suffix[4]=ab[i4] fori5from0<=i5<55: suffix[5]=ab[i5] py_digest=hash(challenge+suffix).hexdigest() digest=py_digest forifrom0<=i<hex_digits: ifdigest[i]!=c'0':break else: returnsuffix
虽然这个Pyrex函数看起来仍然比对应的C函数更加简单易读,但是它实际上最初的纯Python的版本更为复杂。通过这种方式,在纯Python中展开后缀生成与最初的版本相比会对总体速度有些负面的影响。在Pyrex中,正如您期望的一样,这些嵌套的循环都是很少花费时间的,因而我节省了转换和分时调度的代价:
清单10.mint函数Pyrex化优化后的速度结果
$./hashcash_test.pyhashcash_pyx3>/dev/null 13.2270seconds(166125hashespersecond)
当然,这比我开始的时候好多了。但是速度提高也不过是两倍。大部分时间的问题是(此处也是)消耗了太多的时间在对Python库的调用上,而我并不能对这些调用编写代码来提高速度。
令人失望的比较
速度提高50%到60%似乎是值得的。达到这个目标我并没有编写多少代码。但是如果您认为是在原来的Python版本中添加两条语句importpsyco;psyco.bind(_mint),那么这种加速方法就不会给您多深的印象:
清单11.mint函数Psyco化的加速结果
$./hashcash_test.pyhashcash_psyco>/dev/null 15.2300seconds(157550hashespersecond)
换而言之,Psyco之不过添加了两行通用的代码,就几乎能实现相同的目标。当然,Psyco只能用于x86平台,而Pyrex可以在具有C编译器的所有环境上执行。但是对于这个特定的例子来说,os.popen('hashcash-m'+options)的速度会比Pyrex和Psyco都快很多倍(当然,假设可以使用C工具hashcash)。