浅谈python性能与优化

浅谈python性能与优化

楔子

程序猿李狗蛋利用python3.6花了5分钟写了个数据处理的脚本,心中甚是喜悦,果然高效啊,但随着时间一分一秒过去了,任务还在处理中,李狗蛋拍案而起,冲着python怒道:你咋运行的这么慢!python一脸无辜:容~容我解释一下!,李狗蛋:你解释个毛线!闻此言,python也怒了:爷就这样,不喜欢滚犊子!李狗蛋:…..

1. 为什么python运行慢

1.1 解释执行

python程序在执行前会先编译为pyc字节码文件,里边包含的是python虚拟机指令,真正执行的时候再把虚拟指令解释为本地机器指令从而开始执行,每次执行都要经过这样一个过程,即使是相同的代码,然后从虚拟指令解释为机器指令这一过程又是相当耗时的,这也是python运行慢的一大原因。

1.2 弱类型

python在定义变量或函数时不会声明类型,即使在编译为pyc字节码后变量的类型以及函数返回类型都是未知的,必须要在运行时(也就是将虚拟指令解释为机器指令的过程)通过上下文推算出实际的类型,比如a + b 先要通过复杂的上下文推荐得出a和b的实际类型,进而再转换为对应的机器指令,不像其他强类型语言,比如java,所有数据类型在编译为class文件时都已经确定了,不需要额外耗时去做类型推算。

1.3 一切皆对象

这其实体现的是python的动态性,通过一个例子来看:
通过python这个特性可以轻松实现AOP编程,但是python是通过引用计数来管理对象的,这个一切皆对象的特性无疑又增加了虚拟机额外的负担,对运行效率产生影响,可谓是一把双刃剑。

1.4 老生常谈GIL

地球人都知道,由于GIL(全局解释器锁)的存在,导致python无法运用真正的多线程,使并行处理能力稍显逊色。

以上这几点都是python的硬伤,目前暂时无法改变其现状。但还是可以通过代码层面的优化,提高程序的运行效率,接下来就谈谈从哪些方面下手以及注意点。

2. 优化

2.1 排序

python中内置的排序函数有两个:list.sort()和标准内置函数sorted(),两个函数接受同样的关键字参数key和reverse,先通过几个简单小例子来展示下排序函数的用法:
通过例子可以看到,两个排序函数的用法基本相同,但还是有区别的,下边是几点tips:
  • sorted函数会返回一个新的排好序的列表,而list.sort则是在原列表上就地排序,从这点上就可以看出list.sort的性能要好于sorted函数
  • list.sort只能用于list上,这点很明显。而sorted则可以对任何可迭代的对象排序,通用性更强。
  • 对于关键字参数key的定义尽量使用内置函数或方法,例如1-2中也可以用自定义的函数key=lambda item:item[1],但是其性能不如itemgetter好
python2版本(2.4-2.7)还提供了一个名为cmp的关键字参数,用来指定自定义的排序算法,但一般情况下不推荐使用自定义的算法,因为性能大多数情况下都比较低。python默认使用的排序算法为Timsort,该算法是一种做了大量优化的归并排序,其最好的时间复杂度为O(n),平均和最差时间复杂度都为O(nlogn),排序效率已然很强了,基本可以满足大部分排序需求了,所以python3中为了简化和统一排序的使用将cmp去除掉了。

2.2 字符串拼接,远离+

2.2.1  大量字符串拼接

避免使用s += substring,要尽量使用s = “”.join(list)来替代+或+=的拼接方式。由于字符串是不可变对象,无法在原字符串上进行插入或修改的操作,每次使用+连接操作都会重新开辟内存,通过复制substring创建新的字符串,如果list中有n个子串,其时间复杂度为O(n**2),而通过””.join(list)的方式只需要创建一个新的字符串并进行n次复制,其时间复杂度为O(n),所以如果要操作大量的字符串的话,join的性能是要远远优于+拼接的。通过一个例子来对比下:
例子中的string_list不是很大,重复运行1亿次的情况下还是能看到略微的差距,这种差距在日常使用中几乎没有任何影响,但是在拼接字符串的量比较大的情况下还是会明显提升性能的。

2.2.2  字符串格式化

在字符串格式化的时候也要尽量避免使用+,以python内置的格式化语法或函数来替代,不仅效率更高,也更加便于阅读和理解。

2.3 循环优化

2.3.1 避开那个点.

如例子中所示的d.add和random.randint都是方法引用,需要经过MRO属性查找,如果放到循环中,每次循环都会重新查找计算;放到循环外边,通过一个变量直接指向该方法,每次循环直接调用对应的方法,省去了查找计算的过程。不过笔者实际测试发现,两种方式方式基本无性能差异,从时间复杂度上来讲就是O(1)与O(n):此处n表示父类个数,而实际中n值一般都比较小,所以基本上时间上差别不大。该种优化方式也可以说是理论上的优化,收益甚微,并不实用。

2.3.2 内联代码替换函数调用

一般来讲函数调用的消耗是比较大的,所以理论上来讲test1要慢于test2的,最为典型的就是能用循环的就尽量避免递归,尤其是对于python来说,递归的性能实在不敢恭维。不过如果程序对性能要求不高,递归倒是使代码更易理解和维护。

2.3.3 避免常量计算

避免在循环中使用a=2+1,直接a=3,这没啥好说的,正常人一般不会那么干

2.3.4 巧用map

map是一个高阶函数,底层是c实现的,其运行效率要高于普通的for循环,而且还可以使代码看起来更简单。

2.3.5 巧用推导式

  • 如果你需要一个列表那就使用列表推导式newlist = [s.upper() for s in oldlist]
  • 如果你不需要随机访问,只想顺序迭代,那就使用生成器表达式iterator = (s.upper() for s in oldlist)
以上两种推导式的效率都要高于普通的for循环,而且对于简单的循环结构,还可以是代码更加简洁。

2.3.6 学会绕道而行

比如有这样一种情况,统计某个序列中的词频,一般情况下可能会这样实现:
这样实现有一个很明显的弊端,那就是对于在words列表中的每个word都要经过一次判断,正常情况下words列表中应该是有大量重复单词的,这样做明显非常低效,稍微改进一下:
改进后的版本只有word不存在于wdict时才会触发异常,通过这种方式巧妙的避开了大量的重复判断。很不幸的是,如果words列表中都是不重复的word,那这个版本还没有版本1高效,因为处理异常的开销是要大于条件判断的。很明显版本2不是很稳定,那再次改进一下:
不过从实际的效果来看,以上几个版本的运行效率都差不多,只是优化后的版本更加简洁,显得高大上了。

2.4 尽量使用本地变量

在函数内部,访问本地变量要比进行全局查找更加高效,因为python默认对变量查找的规则是local(局部作用域,比如当前函数内部)–>enclosing(比如闭包访问外层函数作用域,类内部作用域)–>global(模块级别的全局作用域)—>build-in(内建对象作用域)。
把对应的内置函数从global lookup改为local variable,使查找一步命中,尤其是在大量的循环中,改为本地变量引用,更加高效。

2.5 关于import

在python解释器进程中,会维护一个导入模块的全局缓存,再次import已导入的模块会直接从缓存中查找,而不会再重新编译并导入该模块,通过sys.modules可以查看已经导入的模块。但有些特殊情况下,被导入的模块不是百分百会被用到,此时可以利用延迟导入,如下所示:
将import放到函数内部,只有在真正需要的时候才会被导入,这样可以节省进程的启动时间。

2.6 降低解释器内部轮询间隔

python解释器默认会阶段性的执行一些检查,比如线程切换、是否有新的signal需要处理等,默认是每100个python字节码指令检查一次,每次检查都会将当前正在执行的任务暂停。如果程序只是单线程执行的而且也不需要处理什么signal,则可以通过sys.setcheckinterval(interval)设置一个较大的间隔,这样会提升解释器的性能。不过在python3.2版本之后sys.setcheckinterval已经废弃了,需要通过sys.setswitchinterval(interval)来修改。

2.7 并发处理

  • CPU密集型:由于GIL的存在,python是没有真正意义上的多线程的,如果想要利用机器的多核cpu的计算能力,则需要用到多进程multiprocessing
  • IO密集型:处理IO方面,可以借助基于event loop模型的框架,比如gevent,python3.4新增了asyncio模块,也是基于eventloop的,支持多种协议TCP, UDP, SSL,pipe等,这种基于事件循环的框架都可以利用协程来异步处理IO任务,相比与线程对系统资源的占用要更低,而且更高效。

2.8 空间换时间

不错,就是利用缓存以空间换时间,通过一个斐波那契数列的列子来看一下:

可以看到增加缓存后的运行效率有了极大的提升,感觉要上天。当然使用缓存也是有一定条件的,例子中的fib传入固定的参数每次都会返回预期的结果,这样缓存才有意义,所以在使用缓存方案时也要考虑实际场景是否适合,否则可能会造成性能不增反降。

2.9 他山之石

如果对程序性能有严苛的要求,可以借助目前维护最好的第三方解释器pypy,其最主要的特性是融入了JIT编译器,程序在执行过程中会经过热点代码(hotcode)监测,如果被JIT检测为热点代码,则会被编译为本地机器代码,下次再次执行到同样的代码块时,则直接执行编译好的本地代码,非热点代码继续解释执行。java虚拟机也是借助JIT编译器,解释与编译执行共存的模式,所以其性能可见一斑。对于cpu密集型的程序,其性能提升是很可观的。通过一个全排列计算的例子来比较一下,示例代码如下:
在8核2.1GHz的CentOS虚拟机上,计算长度为11的列表全排列39916800项,平均耗时cpython:8500ms左右,pypy:2500ms左右,可见pypy对性能的提升还是比较强悍的。
出于好奇,笔者用java写了个递归版本的的全排列计算,比较一下python和java的性能差异,代码这里就不上了,在同一个机器上运行平均耗时660ms左右,这样的结果也是预料之中。

2.10 其他优化点

  • python内置的函数运行要快于自定义函数的,比如map(operator.add, v1, v2)是要快于map(lambda x,y: x+y, v1, v2)
  • 判断成员包含关系a in b时,要尽量使用时间复杂度为O(1)的set和dict,避免使用O(n)list和tuple
  • 多重赋值x,y=a,b要慢于单独赋值x=a,y=b;但是交换值时x,y=y,x是要快于t=x, x=y, y=t的。其实这两种方式的性能差别也不大,使用多重赋值的方式才更加pythonic,更符合python之道,所以不必太过care。
  • 链式比较x < y < z是要快于x < y and y < z的

3. 总结

对于上边提到的优化点,可能除了使用缓存和pypy会大幅度提升性能外,其他的相比而言收效甚微。其实对于python这门语言来讲,由于那几个硬伤的存在,就决定了其性能好不到哪去,过度的或不切实际的优化,都是一种资源浪费(时间、人力)。如果真的追求高性能,可以利用基于C的扩展包来提升效率或者完全可以使用其他语言代替,比如java、C++。对于python来讲,笔者觉得其最大的优势在于开发效率高,使用起来让人觉得非常舒服。这也是为什么python在科学计算,机器学习等领域使用率居高不下。比如:TensorFlow大部分内核并不是用Python编写的,它是高度优化了C++和CUDA(Nvidia用于编程GPU的语言)的组合。Python作为表达和控制模型训练,该模型由快速C++代码执行,并且在大多数情况下,操作之间的数据不会被复制回Python代码,所以Python的性能并不重要,它更多的是为了让开发变得更舒适高效。正应了Bruce Eckel那句经典:Life is short, you need Python!

说点什么

avatar
  订阅  
提醒