0x00 前言
在Python中,可以使用py2exe或PyInstaller之类的工具将Python脚本编译成二进制文件,从而提升可移植性,并在一定程度上提升了性能。不过这类工具的实现只是将py文件编译成pyc或pyo,在安全性上还是弱了一些,存在被反编译的风险。
为了测试不同编译方式的性能差异,这里统一使用python2.7中提供的test/pystone.py作为执行脚本。由于这个脚本不支持python3,因此做了下python3的适配。完整的测试代码如下:
#! /usr/bin/env python""""PYSTONE" Benchmark ProgramVersion: Python/1.1 (corresponds to C/1.1 plus 2 Pystone fixes)Author: Reinhold P. Weicker, CACM Vol 27, No 10, 10/84 pg. 1013.Translated from ADA to C by Rick Richardson.Every method to preserve ADA-likeness has been used,at the expense of C-ness.Translated from C to Python by Guido van Rossum.Version History:Version 1.1 corrects two bugs in version 1.0:First, it leaked memory: in Proc1(), NextRecord endsup having a pointer to itself. I have corrected thisby zapping NextRecord.PtrComp at the end of Proc1().Second, Proc3() used the operator != to compare arecord to None. This is rather inefficient and nottrue to the intention of the original benchmark (wherea pointer comparison to None is intended; the !=operator attempts to find a method __cmp__ to do valuecomparison of the record). Version 1.1 runs 5-10percent faster than version 1.0, so benchmark figuresof different versions can't be compared directly."""LOOPS = 50000try:from time import perf_counter as clockexcept ImportError:from time import clock__version__ = "1.1"[Ident1, Ident2, Ident3, Ident4, Ident5] = range(1, 6)class Record:def __init__(self, PtrComp = None, Discr = 0, EnumComp = 0,IntComp = 0, StringComp = 0):self.PtrComp = PtrCompself.Discr = Discrself.EnumComp = EnumCompself.IntComp = IntCompself.StringComp = StringCompdef copy(self):return Record(self.PtrComp, self.Discr, self.EnumComp,self.IntComp, self.StringComp)TRUE = 1FALSE = 0def main(loops=LOOPS):benchtime, stones = pystones(loops)print("Pystone(%s) time for %d passes = %g" % \(__version__, loops, benchtime))print("This machine benchmarks at %g pystones/second" % stones)def pystones(loops=LOOPS):return Proc0(loops)IntGlob = 0BoolGlob = FALSEChar1Glob = '\0'Char2Glob = '\0'Array1Glob = [0]*51Array2Glob = list(map(lambda x: x[:], [Array1Glob]*51))PtrGlb = NonePtrGlbNext = Nonedef Proc0(loops=LOOPS):global IntGlobglobal BoolGlobglobal Char1Globglobal Char2Globglobal Array1Globglobal Array2Globglobal PtrGlbglobal PtrGlbNextstarttime = clock()for i in range(loops):passnulltime = clock() - starttimePtrGlbNext = Record()PtrGlb = Record()PtrGlb.PtrComp = PtrGlbNextPtrGlb.Discr = Ident1PtrGlb.EnumComp = Ident3PtrGlb.IntComp = 40PtrGlb.StringComp = "DHRYSTONE PROGRAM, SOME STRING"String1Loc = "DHRYSTONE PROGRAM, 1'ST STRING"Array2Glob[8][7] = 10starttime = clock()for i in range(loops):Proc5()Proc4()IntLoc1 = 2IntLoc2 = 3String2Loc = "DHRYSTONE PROGRAM, 2'ND STRING"EnumLoc = Ident2BoolGlob = not Func2(String1Loc, String2Loc)while IntLoc1 < IntLoc2:IntLoc3 = 5 * IntLoc1 - IntLoc2IntLoc3 = Proc7(IntLoc1, IntLoc2)IntLoc1 = IntLoc1 + 1Proc8(Array1Glob, Array2Glob, IntLoc1, IntLoc3)PtrGlb = Proc1(PtrGlb)CharIndex = 'A'while CharIndex <= Char2Glob:if EnumLoc == Func1(CharIndex, 'C'):EnumLoc = Proc6(Ident1)CharIndex = chr(ord(CharIndex)+1)IntLoc3 = IntLoc2 * IntLoc1IntLoc2 = IntLoc3 / IntLoc1IntLoc2 = 7 * (IntLoc3 - IntLoc2) - IntLoc1IntLoc1 = Proc2(IntLoc1)benchtime = clock() - starttime - nulltimeif benchtime == 0.0:loopsPerBenchtime = 0.0else:loopsPerBenchtime = (loops / benchtime)return benchtime, loopsPerBenchtimedef Proc1(PtrParIn):PtrParIn.PtrComp = NextRecord = PtrGlb.copy()PtrParIn.IntComp = 5NextRecord.IntComp = PtrParIn.IntCompNextRecord.PtrComp = PtrParIn.PtrCompNextRecord.PtrComp = Proc3(NextRecord.PtrComp)if NextRecord.Discr == Ident1:NextRecord.IntComp = 6NextRecord.EnumComp = Proc6(PtrParIn.EnumComp)NextRecord.PtrComp = PtrGlb.PtrCompNextRecord.IntComp = Proc7(NextRecord.IntComp, 10)else:PtrParIn = NextRecord.copy()NextRecord.PtrComp = Nonereturn PtrParIndef Proc2(IntParIO):IntLoc = IntParIO + 10while 1:if Char1Glob == 'A':IntLoc = IntLoc - 1IntParIO = IntLoc - IntGlobEnumLoc = Ident1if EnumLoc == Ident1:breakreturn IntParIOdef Proc3(PtrParOut):global IntGlobif PtrGlb is not None:PtrParOut = PtrGlb.PtrCompelse:IntGlob = 100PtrGlb.IntComp = Proc7(10, IntGlob)return PtrParOutdef Proc4():global Char2GlobBoolLoc = Char1Glob == 'A'BoolLoc = BoolLoc or BoolGlobChar2Glob = 'B'def Proc5():global Char1Globglobal BoolGlobChar1Glob = 'A'BoolGlob = FALSEdef Proc6(EnumParIn):EnumParOut = EnumParInif not Func3(EnumParIn):EnumParOut = Ident4if EnumParIn == Ident1:EnumParOut = Ident1elif EnumParIn == Ident2:if IntGlob > 100:EnumParOut = Ident1else:EnumParOut = Ident4elif EnumParIn == Ident3:EnumParOut = Ident2elif EnumParIn == Ident4:passelif EnumParIn == Ident5:EnumParOut = Ident3return EnumParOutdef Proc7(IntParI1, IntParI2):IntLoc = IntParI1 + 2IntParOut = IntParI2 + IntLocreturn IntParOutdef Proc8(Array1Par, Array2Par, IntParI1, IntParI2):global IntGlobIntLoc = IntParI1 + 5Array1Par[IntLoc] = IntParI2Array1Par[IntLoc+1] = Array1Par[IntLoc]Array1Par[IntLoc+30] = IntLocfor IntIndex in range(IntLoc, IntLoc+2):Array2Par[IntLoc][IntIndex] = IntLocArray2Par[IntLoc][IntLoc-1] = Array2Par[IntLoc][IntLoc-1] + 1Array2Par[IntLoc+20][IntLoc] = Array1Par[IntLoc]IntGlob = 5def Func1(CharPar1, CharPar2):CharLoc1 = CharPar1CharLoc2 = CharLoc1if CharLoc2 != CharPar2:return Ident1else:return Ident2def Func2(StrParI1, StrParI2):IntLoc = 1while IntLoc <= 1:if Func1(StrParI1[IntLoc], StrParI2[IntLoc+1]) == Ident1:CharLoc = 'A'IntLoc = IntLoc + 1if CharLoc >= 'W' and CharLoc <= 'Z':IntLoc = 7if CharLoc == 'X':return TRUEelse:if StrParI1 > StrParI2:IntLoc = IntLoc + 7return TRUEelse:return FALSEdef Func3(EnumParIn):EnumLoc = EnumParInif EnumLoc == Ident3: return TRUEreturn FALSEif __name__ == '__main__':import sysdef error(msg):print >>sys.stderr, msg,print >>sys.stderr, "usage: %s [number_of_loops]" % sys.argv[0]sys.exit(100)nargs = len(sys.argv) - 1if nargs > 1:error("%d arguments are too many;" % nargs)elif nargs == 1:try: loops = int(sys.argv[1])except ValueError:error("Invalid argument %r;" % sys.argv[1])else:loops = LOOPSmain(loops)COPY
以下测试数据均为连续执行3次,取最大值。
0x01 不同Python版本的测试数据
Python 2.7
Pystone(1.1) time for 50000 passes = 0.178948This machine benchmarks at 279411 pystones/second
COPYPython 3.7
Pystone(1.1) time for 50000 passes = 0.201795This machine benchmarks at 247777 pystones/second
COPYPython 3.8
Pystone(1.1) time for 50000 passes = 0.222014This machine benchmarks at 225211 pystones/second
COPYPython 3.9
Pystone(1.1) time for 50000 passes = 0.223407This machine benchmarks at 223807 pystones/second
COPYPython 3.10
Pystone(1.1) time for 50000 passes = 0.265725This machine benchmarks at 188164 pystones/second
COPYPython 3.11
Pystone(1.1) time for 50000 passes = 0.104691This machine benchmarks at 477596 pystones/second
COPY
可以看到,Python 3.11版本有了明显的性能提升,这个与官方的宣传也是一致的。
0x02 使用Cython编译python脚本
$ pip install cython$ cython -3 --embed pystone.py$ gcc -pthread -fPIC -fwrapv -O2 -Wall -fno-strict-aliasing -I/usr/include/python3.7 -l:libpython3.7m.so -o pystone pystone.c$ ls -l pystone-rwxrwxrwx 1 drunkdream drunkdream 178928 Sep 6 15:42 pystone$ readelf -d pystoneDynamic section at offset 0x1fd08 contains 26 entries:Tag Type Name/Value0x0000000000000001 (NEEDED) Shared library: [libpython3.7m.so.1.0]0x0000000000000001 (NEEDED) Shared library: [libpthread.so.0]0x0000000000000001 (NEEDED) Shared library: [libc.so.6]0x000000000000000c (INIT) 0x4030000x000000000000000d (FINI) 0x41b5140x0000000000000019 (INIT_ARRAY) 0x420cf80x000000000000001b (INIT_ARRAYSZ) 8 (bytes)0x000000000000001a (FINI_ARRAY) 0x420d000x000000000000001c (FINI_ARRAYSZ) 8 (bytes)0x000000006ffffef5 (GNU_HASH) 0x4003080x0000000000000005 (STRTAB) 0x4010780x0000000000000006 (SYMTAB) 0x4003280x000000000000000a (STRSZ) 2404 (bytes)0x000000000000000b (SYMENT) 24 (bytes)0x0000000000000015 (DEBUG) 0x00x0000000000000003 (PLTGOT) 0x4210000x0000000000000002 (PLTRELSZ) 2592 (bytes)0x0000000000000014 (PLTREL) RELA0x0000000000000017 (JMPREL) 0x401e300x0000000000000007 (RELA) 0x401b180x0000000000000008 (RELASZ) 792 (bytes)0x0000000000000009 (RELAENT) 24 (bytes)0x000000006ffffffe (VERNEED) 0x401af80x000000006fffffff (VERNEEDNUM) 10x000000006ffffff0 (VERSYM) 0x4019dc0x0000000000000000 (NULL) 0x0$ ./pystonePystone(1.1) time for 50000 passes = 0.171947This machine benchmarks at 290787 pystones/secondCOPY
可以看出,编译成二进制文件后,性能上略有提升,并且需要依赖libpython3.7m.so才能运行。下面是pystone.c文件的部分代码:
/* "pystone.py":73* return Proc0(loops)** IntGlob = 0 # <<<<<<<<<<<<<<* BoolGlob = FALSE* Char1Glob = '\0'*/if (PyDict_SetItem(__pyx_d, __pyx_n_s_IntGlob, __pyx_int_0) < 0) __PYX_ERR(0, 73, __pyx_L1_error)/* "pystone.py":74** IntGlob = 0* BoolGlob = FALSE # <<<<<<<<<<<<<<* Char1Glob = '\0'* Char2Glob = '\0'*/__Pyx_GetModuleGlobalName(__pyx_t_7, __pyx_n_s_FALSE); if (unlikely(!__pyx_t_7)) __PYX_ERR(0, 74, __pyx_L1_error)__Pyx_GOTREF(__pyx_t_7);if (PyDict_SetItem(__pyx_d, __pyx_n_s_BoolGlob, __pyx_t_7) < 0) __PYX_ERR(0, 74, __pyx_L1_error)__Pyx_DECREF(__pyx_t_7); __pyx_t_7 = 0;/* "pystone.py":75* IntGlob = 0* BoolGlob = FALSE* Char1Glob = '\0' # <<<<<<<<<<<<<<* Char2Glob = '\0'* Array1Glob = [0]*51*/if (PyDict_SetItem(__pyx_d, __pyx_n_s_Char1Glob, __pyx_kp_u__12) < 0) __PYX_ERR(0, 75, __pyx_L1_error)/* "pystone.py":76* BoolGlob = FALSE* Char1Glob = '\0'* Char2Glob = '\0' # <<<<<<<<<<<<<<* Array1Glob = [0]*51* Array2Glob = list(map(lambda x: x[:], [Array1Glob]*51))*/if (PyDict_SetItem(__pyx_d, __pyx_n_s_Char2Glob, __pyx_kp_u__12) < 0) __PYX_ERR(0, 76, __pyx_L1_error)/* "pystone.py":77* Char1Glob = '\0'* Char2Glob = '\0'* Array1Glob = [0]*51 # <<<<<<<<<<<<<<* Array2Glob = list(map(lambda x: x[:], [Array1Glob]*51))* PtrGlb = None*/__pyx_t_7 = PyList_New(1 * 51); if (unlikely(!__pyx_t_7)) __PYX_ERR(0, 77, __pyx_L1_error)__Pyx_GOTREF(__pyx_t_7);{ Py_ssize_t __pyx_temp;for (__pyx_temp=0; __pyx_temp < 51; __pyx_temp++) {__Pyx_INCREF(__pyx_int_0);__Pyx_GIVEREF(__pyx_int_0);PyList_SET_ITEM(__pyx_t_7, __pyx_temp, __pyx_int_0);}}if (PyDict_SetItem(__pyx_d, __pyx_n_s_Array1Glob, __pyx_t_7) < 0) __PYX_ERR(0, 77, __pyx_L1_error)__Pyx_DECREF(__pyx_t_7); __pyx_t_7 = 0;COPY
可以看出,C代码本身就已经很难读懂了,编译后的二进制文件基本是不可能还原出原始的python代码的。
不过,目前这种方式有个问题,就是只能编译单个文件。但很多时候,我们是想将多个包编译成一个独立的可执行文件。
0x03 使用Nuitka编译Python脚本
$ pip install nuitka$ nuitka pystone.pyNuitka-Options:INFO: Used command line options: pystone.pyNuitka-Options:WARNING: You did not specify to follow or include anything but main program. Check optionsNuitka-Options:WARNING: and make sure that is intended.Nuitka:WARNING: Using very slow fallback for ordered sets, please install 'orderedset' PyPI package for bestNuitka:WARNING: Python compile time performance.Nuitka:INFO: Starting Python compilation with Nuitka '1.1.8' on Python '3.7' commercial grade 'not installed'.Nuitka:INFO: Completed Python level compilation and optimization.Nuitka:INFO: Generating source code for C backend compiler.Nuitka:INFO: Running data composer tool for optimal constant value handling.Nuitka:INFO: Running C compilation via Scons.Nuitka-Scons:INFO: Backend C compiler: gcc (gcc).Nuitka-Scons:INFO: Backend linking program with 9 files (no progress information available).Nuitka-Scons:WARNING: You are not using ccache.Nuitka:INFO: Keeping build directory 'pystone.build'.Nuitka:INFO: Successfully created 'pystone.bin'.$ ls -l pystone.bin-rwxrwxrwx 1 drunkdream drunkdream 268440 Sep 6 20:57 pystone.bin$ ./pystone.binPystone(1.1) time for 50000 passes = 0.12965This machine benchmarks at 385654 pystones/secondCOPY
可以看到使用nuitka性能上明显比原生的Python要高出许多。
本来想在Python 3.11下测试下性能,不过发现目前最新版本的nuitka还没适配Python 3.11,编译会有报错。
nuitka还有些可选参数,比较重要的有:
-o FILENAME: 指定要生成的文件名--standalone: 将依赖库都编译到一个文件中,不过对于依赖的动态链接库,还是会以多个文件的形式存在--onefile: 这个参数可以解决--standalone参数会有多个文件的问题,保证最终生成的是一个可执行文件--nofollow-imports: 不编译import进来的第三方库--clang: 强制使用clang作为编译后端--static-libpython=yes: 静态链接libpython--show-scons: 显示编译C代码过程中的详细日志
通过观察可以发现,nuitka也是通过将python代码转换成C代码,然后编译成最终的可执行文件。使用--static-libpython=yes参数可以静态链接libpython库,使用的命令行如下:
$ gcc -o pystone.bin -fuse-linker-plugin -flto=8 -fpartial-inlining -freorder-functions -O2 -s -z noexecstack -Wl,-R,'/usr/lib' -Wl,--disable-new-dtags -Wl,-b -Wl,binary -Wl,./__constants.bin -Wl,-b -Wl,elf64-x86-64 -Wl,-defsym -Wl,constant_bin_data=_binary_____constants_bin_start @"./@link_input.txt" -L/usr/lib -ldl -lm /usr/lib/libpython3.7m.aCOPY
不过在实际执行时会有报错,原因是命令行中没有包含-lz -lpthread -lexpat -lutil等参数,针对这个问题有一个专门的issue。
0x04 结论
相比于py2exe、pyinstaller等方案,Cython和Nuitka采用了先生成C代码,再进行编译的方案,相对来说安全性和性能上都优于前两种方案。
而Nuitka相比Cython,可以同时编译多个Python脚本,功能上更加强大一些,性能也提升了不少。不过Nuitka使用--onefile参数生成的可执行文件大小会远大于pyinstaller生成的文件大小。
Be the first guy leaving a comment!