python性能优化

背景

本文主要在python代码的性能分析优化方面进行讨论,旨在解决一些语言层面比较常见的性能瓶颈,是在平时工作中的一些积累和总结,会比较基础和全面,顺便也会介绍一些在服务架构上的优化经验。

一、Python语言背景

python简单易学以及在数据计算分析方面优异的特点催生了庞大的用户群体和活跃社区性,使得它在数据分析、机器学习等领域有着先天的优势, 同时由于其协程特性和广泛的第三方支持,python在在线服务上也有广泛的使用。但是python在性能问题上有所有动态解释型高级语言的通病,也是制约python进一步广泛应用的重要因素。这也是这类解释型脚本语言的通病:

image.png

二、业务架构背景

单独脱离具体的业务应用场景来看性能问题是比较片面的。下面以我们当前的后端架构来看下python性能瓶颈在业务应用上的具体表现。该系统是基于大数据和机器学习模型的风在线风控系统,它为大量金融机构提供风控服务,基于大量结构化和非结构化数据、外部数据源、超万维的特征、以及复杂的建模技术。些也导致我们基于python的服务性能面临着严峻考验 。下图是架构简图:

image.png

性能分析

对代码优化的前提是需要了解性能瓶颈在什么地方,程序运行的主要时间是消耗在哪里,常见的可以在日志中打点来统计运行时间,对于比较复杂的代码也可以借助一些工具来定位,python 内置了丰富的性能分析工具,能够描述程序运行时候的性能,并提供各种统计帮助用户定位程序的性能瓶颈。常见的 profilers:cProfile,profile,line_profile,pprofile 以及 hotshot等,当然一些IDE比如pycharm中也继承了完善的profiling。这里我们只介绍有代表性的几种性能分析方法:

一、利用装饰器实现函数耗时统计打点

装饰器就是通过闭包来给原有函数增加新功能,python可以用装饰器这种语法糖来给函数进行耗时统计,但是这仅限于一般的同步方法,在协程中,更一般地说在生成器函数中,因为yield会释放当前线程,耗时统计执行到yield处就会中断返回,导致统计的失效。
如下是一个包含两层闭包(因为要给装饰器传参)的装饰器:

def time_consumer(module_name='public_module'):

    def time_cost(func):
        #获取调用装饰器的函数路径
        filepath =sys._getframe(1).f_code.co_filename
        @wraps(func)
        def warpper(*args,**kwargs):
            t1 = time.time()
            res = func(*args,**kwargs)
            t2 = time.time()
            content={}
            try:
            
                content['time_cost'] = round(float(t2-t1),3)
                content['method'] = func.__name__
                content['file'] = filepath.split(os.sep)[-1]
                content['module'] = module_name
            
                content_res = json.dumps(content)
                time_cost_logger.info(content_res)
            
            except Exception as e:
                time_cost_logger.warning('%s detail: %s' % (str(e), traceback.format_exc()))
            return res
        return warpper
    return time_cost

二、函数级性能分析工具cprofile

cProfile自python2.5以来就是标准版Python解释器默认的性能分析器,它是一种确定性分析器,只测量CPU时间,并不关心内存消耗和其他与内存相关联的信息。

性能分析结果

image.png

字段含义

ncalls:函数被调用的次数。
tottime:函数内部消耗总时间。
percall:每次调用平均消耗时间。
cumtime:消费时间的累计和。
filename:lineno(function):被分析函数所在文件名、行号、函数名。

使用方法

1、针对单个文件的性能分析:

 python -m cProfile -s tottime test.py

2、针对某个方法的性能分析:

import cProfile
def function():
    pass
if __name__ == '__main__':
    cProfile.run("function()")

3、项目中针对实时服务的性能分析:

 # 一般需要绑定在服务框架的钩子函数中来实现,如下两个方法分别放在入口和出口钩子中;pstats格式化统计信息,并根据需要做排序分析处理。

 def _start_profile(self):
     import cProfile
     self.pr = cProfile.Profile()
     self.pr.enable()
     
 def _print_profile_result(self):
     if not self.pr:
         return
     self.pr.disable()
     import pstats
     import StringIO
     s = StringIO.StringIO()
     stats = pstats.Stats(self.pr, stream=s).strip_dirs().sort_stats('tottime')
     stats.print_stats(50)

三、行级分析工具pprofile/line_profile

使用line_profile需要引入_kernprof__,因此我们这里选用pprofile,虽然pprofile的效率没有line_profile高,但在做性能分析时这是可以忽略的。pprofile的用法和cprofile的用法三完全一致。

性能分析结果

image.png
image.png

字段含义

Line:行号
Hits:该行代码执行次数
Time:总执行耗时
Time per hit:单次执行耗时
%:耗时占比

四、性能分析总结

我们在做性能分析时,可以挑选任何方便易用的方法或工具进行分析。但总体的思路是由整体到具体的。例如可以通过cprofile寻找整个代码执行过程中的耗时较长的函数,然后再通过pprofile对这些函数进行逐行分析,最终将代码的性能瓶颈精确到行级。

python性能优化方法

一、优化思路

Python的性能优化方式有很多,可以从不同的角度出发考虑具体问题具体分析,但可以归结为两个思路:从服务架构和CPU效率层面,将CPU密集型向IO密集型优化。从代码执行和cpu利用率层面,要提高代码性能以及多核利用率。比如,基于此,python在线服务的优化思路可以从这几方面考虑:

image.png

二、代码优化

使用字典/集合等hash等数据结构

常用操作:检索、去重、交集、并集、差集
1、在字典/集合中查找(以下代码中均省略记时部分)

dic = {str(k):1 for k in xrange(1000000)}
if 'qth' in dic.keys():
    pass
if 'qth' in dic:
    pass

耗时:
0.0590000152588
0.0

2、使用集合求交集

list1=list(range(10000))
list2=[i*2 for i in list1]
s1=set(list1)
s2=set(list2)
list3 = []
# 列表求交集
for k in list1:
    if k in list2:
        list3.append(k)
# 集合求交集
s3 = s1&s2

耗时:
0.819999933243
0.001000165939

Ps:集合操作在去重、交并差集等方面性能突出:

image.png

使用生成器代替可迭代对象

节省内存和计算资源,不需要计算整个可迭代对象,只计算需要循环的部分。
1、使用xrange而不是range(python3中无区别)

for i in range(1000000):
    pass
for i in xrange(1000000):
    pass

耗时:
0.0829999446869
0.0320000648499

2、列表推导使用生成器

dic = {str(k):1 for k in xrange(100000)}
list1 = [k for k in dic.keys()]
list1 = (k for k in dic.keys())

耗时:
0.0130000114441
0.00300002098083

3、复杂逻辑产生的迭代对象使用生成器函数来代替

def fib(max):
    n,a,b =0,0,1
    list = []
    while n < max:
        a,b =b,a+b
        n = n+1
        list.append(b)
    return list
# 迭代列表
for i in fib(100000):
    pass
def fib2(max):
    n, a, b = 0, 0, 1
    while n < max:
        yield b
        a, b = b, a + b
        n = n + 1
# 迭代生成器
for  i in fib2(100000):
    pass

耗时:
0.713000059128
0.138999938965

减少循环和冗余

这部分比较容易理解就不再附上示例了。
i) 在循环中不要做和迭代对象无关的事。将无关代码提到循环上层。
ii) 使用列表解析和生成器表达式
iii) 对于and,应该把满足条件少的放在前面,对于or,把满足条件多的放在前面。
iv) 迭代器中的字符串操作:是有join不要使用+。
v) 尽量减少嵌套循环,不要超过三层,否则考虑优化代码逻辑、优化数据格式、使用dataframe代替循环等方式。

尝试pandas和numpy中的数据结构

一个NumPy数组基本上是由元数据(维数、形状、数据类型等)和实际数据构成。数据存储在一个均匀连续的内存块中,该内存在系统内存(随机存取存储器,或RAM)的一个特定地址处,被称为数据缓冲区。这是和list等纯Python结构的主要区别,list的元素在系统内存中是分散存储的。这是使NumPy数组如此高效的决定性因素。

import numpy as np
def pySum(n):
    a=list(range(n))
    b=list(range(0,5*n,5))
    c=[]
    for i in range(len(a)):
        c.append(a[i]**2+b[i]**3)
    return c
def npSum(n):
    a=np.arange(n)
    b=np.arange(0,5*n,5)
    c=a**2+b**3
    return c
a=pySum(100000)
b=npSum(100000)

耗时:
0.138999891281
0.007123823012

多进程

python多进程multiprocessing的目的是为了提高多核利用率,适用于cpu密集的代码。需要注意的两点是,Pytho的普通变量不是进程安全的,考虑同步互斥时,要使用共享变量类型;协程中可以包含多进程,但是多进程中不能包含协程,因为多进程中协程会在yield处释放cpu直接返回,导致该进程无法再恢复。从另一个角度理解,协程本身的特点也是在单进程中实现cpu调度。
1、进程通信、共享变量
python多进程提供了基本所有的共享变量类型,常用的包括:共享队列、共享字典、共享列表、简单变量等,因此也提供了锁机制。具体不在这里赘述,相关模块:from multiprocessing import Process,Manager,Queue

2、分片与合并
多进程在优化cpu密集的操作时,一般需要将列表、字典等进行分片操作,在多进程里分别处理,再通过共享变量merge到一起,达到利用多核的目的,注意根据具体逻辑来判断是否需要加锁。这里的处理其实类似于golang中的协程并发,只是它的协程可以分配到多核,同样也需要channel来进行通信 。

from multiprocessing import Pool
p = Pool(4)
# 对循环传入的参数做分片处理
for i in range(5):
    p.apply_async(long_time_task, args=(i,))
p.close()
p.join()

多线程

Python多线程一般适用于IO密集型的代码,IO阻塞可以释放GIL锁,其他线程可以继续执行,并且线程切换代价要小于进程切换。要注意的是python中time.sleep()可以阻塞进程,但不会阻塞线程

class ThreadObj():
    executor = ThreadPoolExecutor(16)

    @run_on_executor
    def function(self):
        # 模拟IO操作, time.sleep不会阻塞多线程,线程会发生切换
        time.sleep(5)

协程

协程可以简单地理解为一种特殊的程序调用,特殊的是在执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。如果熟知了python生成器,其实可以知道协程也是由生成器实现的,因此也可以将协程理解为生成器+调度策略。通过调度策略来驱动生成器的执行和调度,达到协程的目的。这里的调度策略可能有很多种,简单的例如忙轮循:while True,更简单的甚至是一个for循环。复杂的可能是基于epoll的事件循环。在python2的tornado中,以及python3的asyncio中,都对协程的用法做了更好的封装,通过yield和await就可以使用协程。但其基本实现仍然是这种生成器+调度策略的模式。使用协程可以在单线程内实现cpu的释放和调度,不再需要进程或线程切换,只是函数调用的消耗。在这里我们举一个简单的生产消费例子:

def consumer():
    r = ''
    while True:
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        r = '200 OK'

def produce(c):
    r=c.send(None)
    print r
    n = 0
    while n<5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()

c = consumer()
produce(c)

使用合适的python解释器

CPython:是用C语言实现Pyhon,是目前应用最广泛的解释器。最新的语言特性都是在这个上面先实现,基本包含了所有第三方库支持,但是CPython有几个缺陷,一是全局锁使Python在多线程效能上表现不佳,二是CPython无法支持JIT(即时编译),导致其执行速度不及Java和Javascipt等语言。于是出现了Pypy。

Pypy:是用Python自身实现的解释器。针对CPython的缺点进行了各方面的改良,性能得到很大的提升。最重要的一点就是Pypy集成了JIT。但是,Pypy无法支持官方的C/Python API,导致无法使用例如Numpy,Scipy等重要的第三方库。这也是现在Pypy没有被广泛使用的原因吧。

Jython:Jython是将python code在JVM上面跑和调用java code的解释器。

一些建议

合理使用copy与deepcopy

使用 join 合并迭代器中的字符串

使用最佳的反序列化方式 json>cPickle>eval。

不借助中间变量交换两个变量的值(有循环引用造成内存泄露的风险)。

不局限于python内置函数,一些情况下,内置函数的性能,远远不如自己写的。比如python的strptime方法,会生成一个9位的时间元祖,经常需要根据此元祖计算时间戳,该方法性能很差。我们完全可以自己将时间字符串转成split成需要的时间元祖。

用生成器改写直接返回列表的复杂函数,用列表推导替代简单函数,但是列表推导不要超过两个表达式。生成器> 列表推导>map/filter。

关键代码可以依赖于高性能的扩展包,因此有时候需要牺牲一些可移植性换取性能; 勇于尝试python新版本。

考虑优化的成本,一般先从数据结构和算法上优化,改善时间/空间复杂度,比如使用集合、分治、贪心、动态规划等,最后再从架构和整体框架上考虑。

Python代码的优化也需要具体问题具体分析,不局限于以上方式,但只要能够分析出性能瓶颈,问题就解决了一半。《约束理论与企业优化》中指出:“除了瓶颈之外,任何改进都是幻觉”。

三、优化实例

优化循环

将无关代码提到循环上层
image.png
去掉冗余循环
image.png
平均耗时由2.0239s提升到0.7896s,性能提升了61%

多进程

采用多进程将无关主进程的函数放到后台执行:
image.png
将列表分片到多进程中执行:
image.png
如图,1s内返回的请求比例提升了十个百分点,性能提升200ms左右,但不建议代码中过多使用,在业务高峰时会对机器负载造成压力。
image.png

时间元祖代替strptime方法

image.png
image.png
如图,模块平均耗时由123ms提升到79ms,提升35.7%,并且对一些badcase优化效果会更明显:
image.pngimage.png

应用集合

将复杂字典转成md5的可hash的字符串后,通过集合去重,性能提升60%以上。数据量越大,优化效果越好。
image.png

微服务业务解耦

将特征计算作为分布式微服务,实现IO与计算解耦,将cpu密集型转为IO密集,在框架和服务选用方面,我们分别测试了tornado协程、uwsgi多进程、import代码库、celery分布式计算等多种方式,在性能及可用性上tornado都有一定优势,上层nginx来代理做端口转发和负载均衡:
ab压测前后性能对比,虽然在单条请求上并没有优势,但是对高并发系统来说,并发量明显提升:
ab压测前后性能对比,虽然在单条请求上并没有优势,但是对高并发系统来说,并发量明显提升:
image.png
image.png

耗时模块pipeline实时计算:

image.png

image.png
image.png

命中pipeline实时特征后的性能提升:
image.png

python的高性能服务框架

虽然python的语言特性导致它在cpu密集型的代码中性能堪忧,但是python却很适合IO密集型的网络应用,加上它优异的数据分析处理能力以及广泛的第三方支持,python在服务框架上也应用广泛。

例如Django、flask、Tornado,如果考虑性能优先,就要选择高性能的服务框架。Python的高性能服务基本都是协程和基于epoll的事件循环实现的IO多路复用框架。tornado依靠强大的ioloop事件循环和gen封装的协程,让我们可以用yield关键字同步式地写出异步代码。

在python3.5+中,python引入原生的异步网络库asyncio,提供了原生的事件循环get_event_loop来支持协程。并用async/await对协程做了更好的封装。在tornado6.0中,ioloop已经已经实现了对asyncio事件循环的封装。除了标准库asyncio的事件循环,社区使用Cython实现了另外一个事件循环uvloop。用来取代标准库。号称是性能最好的python异步IO库。之前提到python的高性能服务实现都是基于协程和事件循环,因此我们可以尝试不同的协程和事件循环组合,对tornado服务进行改造,实现最优的性能搭配
image.png
篇幅原因这里不详细展开,我们可以简单看下在python2和python3中异步服务框架的性能表现,发现在服务端的事件循环中,python3优势明显。而且在三方库的兼容,其他异步性能库的支持上,以及在协程循环及关键字支持等语法上,还是推荐使用python3,在更加复杂的项目中,新版的优势会显而易见。但不论新旧版本的python,协程+事件循环的效率都要比多进程或线程高的多。这里顺便贴一个python3支持协程的异步IO库,基本支持了常见的中间件:https://github.com/aio-libs?p...
image.png
image.png
image.png
image.png

阅读 297

推荐阅读

后端研发工程师(python、golang),专注性能优化、架构设计、微服务等

679 人关注
5 篇文章
专栏主页