浅谈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的动态性,通过一个例子来看:

class AOP:
    def save(self):
        print("saved")
old_save = AOP.save
def new_save(self):
    print("before save")
    old_save(self)
    print("after save")
AOP.save = new_save
a = AOP()
a.save()


1.4 老生常谈GIL

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

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

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

2. 优化


2.1 排序
python中内置的排序函数有两个:list.sort()和标准内置函数sorted(),两个函数接受同样的关键字参数key和reverse,先通过几个简单小例子来展示下排序函数的用法:

l = ['hello', 'Wor', 'H']
new_l = sorted(l, key=str.lower, reverse=True)
print("sorted:", new_l)
l.sort(key=str.lower, reverse=True)
print("list.sort:", l)
 
from operator import itemgetter
 
l = [("Tom", 88), ("Peter", 76), ("Jason", 82)]
#以元组中第二个元素作为排序的key
new_l = sorted(l, key=itemgetter(1))
print("sorted:", new_l)
l.sort(key=itemgetter(1))
print("list.sort:", l)

  


sorted函数会返回一个新的排好序的列表,而list.sort则是在原列表上就地排序,从这点上就可以看出list.sort的性能要好于sorted函数通过例子可以看到,两个排序函数的用法基本相同,但还是有区别的,下边是几点tips:

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的性能是要远远优于+拼接的。通过一个例子来对比下:

import timeit
import string
 
string_list = list(string.printable)
 
def plus_concat():
    s = ""
    for substring in string_list:
        s += substring
 
def join_concat():
    s = "".join(string_list)
 
t1 = timeit.repeat("plus_concat", setup="from __main__ import plus_concat", number=100000000,repeat=5)
t2 = timeit.repeat("join_concat", setup="from __main__ import join_concat", number=100000000,repeat=5)
print("plus_concat elapsed:", ["{:.4f}".format(t) for t in t1])
print("join_concat elapsed:", ["{:.4f}".format(t) for t in t2])
 
output:
plus_concat elapsed: ['1.3260', '1.2785', '1.2672', '1.2792', '1.2718']
join_concat elapsed: ['1.2658', '1.2698', '1.2665', '1.2739', '1.2648']

  


2.2.2  字符串格式化例子中的string_list不是很大,重复运行1亿次的情况下还是能看到略微的差距,这种差距在日常使用中几乎没有任何影响,但是在拼接字符串的量比较大的情况下还是会明显提升性能的。

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

避免使用:

避免使用:
out = "<html>" + head + body + footer"</html>"
尽量使用:
out = "<html>%s%s%s</html>" % (head, body, footer)
或者
out = "<html>%(head)s%(body)s%(footer)s</html>" % locals()
或者
out = "<html>{head}{body}{footer}</html>".format(locals())

  


2.3 循环优化
2.3.1 避开那个点

import random
 
class Base:
    l = []
    def add(n):
        l.append(n)
 
class A:
    pass
 
class D(A, Base):
    pass
 
n = 1000000000
d = D()
def dot(n):
    for _ in range(n):
        d.add(random.randint(1,10))
 
def no_dot(n):
    rand = random.randint
    add = d.add
    for _ in range(n):
        add(rand(1, 10))

  


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

2.3.2 内联代码替换函数调用

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

x = 0
def add(i):
    global x
    x = x + i
 
def test1():
    for i in range(10000):
        add(i)
 
def test2():
    global x
    for i in range(10000):
        x = x + i

  


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

2.3.3 避免常量计算

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

2.3.4 巧用map

newlist = []
for word in oldlist:
    newlist.append(word.upper())
替换为:
newlist = list(map(str.upper, oldlist))

  


2.3.5 巧用推导式

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

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

2.3.6 学会绕道而行

比如有这样一种情况,统计某个序列中的词频,一般情况下可能会这样实现:

wdict = {}
for word in words:
    if word not in wdict:
        wdict[word] = 0
    wdict[word] += 1

  


这样实现有一个很明显的弊端,那就是对于在words列表中的每个word都要经过一次判断,正常情况下words列表中应该是有大量重复单词的,这样做明显非常低效,稍微改进一下:

wdict = {}
for word in words:
    try:
        wdict[word] += 1
    except KeyError:
        wdict[word] = 1

  

改进后的版本只有word不存在于wdict时才会触发异常,通过这种方式巧妙的避开了大量的重复判断。很不幸的是,如果words列表中都是不重复的word,那这个版本还没有版本1高效,因为处理异常的开销是要大于条件判断的。很明显版本2不是很稳定,那再次改进一下:

wdict = {}
get = wdict.get
for word in words:
    wdict[word] = get(word, 0) + 1
或者:
from collections import defaultdict
 
wdict = defaultdict(int)
for word in words:
    wdict[word] += 1

  

2.4 尽量使用本地变量

不过从实际的效果来看,以上几个版本的运行效率都差不多,只是优化后的版本更加简洁,显得高大上了。

在函数内部,访问本地变量要比进行全局查找更加高效,因为python默认对变量查找的规则是local(局部作用域,比如当前函数内部)–>enclosing(比如闭包访问外层函数作用域,类内部作用域)–>global(模块级别的全局作用域)—>build-in(内建对象作用域)。

import operator
 
def global_lookup(vec1, vec2):
    return sum(map(operator.mul, vec1, vec2))
 
#替换为如下:
def local1(vec1, vec2, sum=sum, map=map, mul=operator.mul):
    return sum(map(mul, vec1, vec2))
def local2(vec1, vec2):
    sum = sum
    map = map
    mul = operator.mul
    return sum(map(mul, vec1, vec2))

  

2.5 关于import

把对应的内置函数从global lookup改为local variable,使查找一步命中,尤其是在大量的循环中,改为本地变量引用,更加高效。

在python解释器进程中,会维护一个导入模块的全局缓存,再次import已导入的模块会直接从缓存中查找,而不会再重新编译并导入该模块,通过sys.modules可以查看已经导入的模块。但有些特殊情况下,被导入的模块不是百分百会被用到,此时可以利用延迟导入,如下所示:

email = None
def parse_email():
    global email
    if email is None:
        import email
        pass

  

将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 空间换时间
不错,就是利用缓存以空间换时间,通过一个斐波那契数列的列子来看一下:

from time import time
from functools import lru_cache
 
def timer(func):
    @wraps(func)
    def wrapper(*args, **kwds):
        start = time()
        result = func(*args, **kwds)
        elapsed = int((time() - start)*1000)
        print("{} elapsed: {}ms".format(func.__name__, elapsed))
        return result
    return wrapper
 
def fib(n, f=1, s=1):
    if n == 1:
        result = f
    if n == 2:
        result = s
    elif n > 2:
        result = fib(n-2) + fib(n-1)
    return result
 
@lru_cache(maxsize=1024)
def fib_with_cache(n, f=1, s=1):
    if n == 1:
        return f
    if n == 2:
        return s
    if n > 2:
        return fib3(n-2) + fib3(n-1)
 
n = 30
timer(fib)(n)
timer(fib_with_cache)(n)
 
output:
fib elapsed: 814ms
fib_with_cache elapsed: 0ms

  

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

如果对程序性能有严苛的要求,可以借助目前维护最好的第三方解释器pypy,其最主要的特性是融入了JIT编译器,程序在执行过程中会经过热点代码(hotcode)监测,如果被JIT检测为热点代码,则会被编译为本地机器代码,下次再次执行到同样的代码块时,则直接执行编译好的本地代码,非热点代码继续解释执行。java虚拟机也是借助JIT编译器,解释与编译执行共存的模式,所以其性能可见一斑。对于cpu密集型的程序,其性能提升是很可观的。通过一个全排列计算的例子来比较一下,示例代码如下:

import time
import itertools
 
n = list(range(11))
start = time.time()
#返回一个迭代器
s = itertools.permutations(n)
num = 0
for i in s:
    num+=1
elapsed = int((time.time() - start)*1000)
print("permutations num: {}, elapsed: {}".format(num, elapsed))

  


出于好奇,笔者用java写了个递归版本的的全排列计算,比较一下python和java的性能差异,代码这里就不上了,在同一个机器上运行平均耗时660ms左右,这样的结果也是预料之中。在8核2.1GHz的CentOS虚拟机上,计算长度为11的列表全排列39916800项,平均耗时cpython:8500ms左右,pypy:2500ms左右,可见pypy对性能的提升还是比较强悍的。

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!
————————————————
版权声明:本文为CSDN博主「心之所在为西天」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/yinzhixin123/java/article/details/87894479

原文地址:https://www.cnblogs.com/mtfan01/p/13214926.html