python性能優化

背景

本文主要在python代碼的性能分析優化方面進行討論,旨在解決一些語言層面比較常見的性能瓶頸,是在平時工做中的一些積累和總結,會比較基礎和全面,順便也會介紹一些在服務架構上的優化經驗。java

1、Python語言背景

python簡單易學以及在數據計算分析方面優異的特色催生了龐大的用戶羣體和活躍社區性,使得它在數據分析、機器學習等領域有着先天的優點, 同時因爲其協程特性和普遍的第三方支持,python在在線服務上也有普遍的使用。可是python在性能問題上有全部動態解釋型高級語言的通病,也是制約python進一步普遍應用的重要因素。這也是這類解釋型腳本語言的通病:python

image.png

2、業務架構背景

單獨脫離具體的業務應用場景來看性能問題是比較片面的。下面以咱們當前的後端架構來看下python性能瓶頸在業務應用上的具體表現。該系統是基於大數據和機器學習模型的風在線風控系統,它爲大量金融機構提供風控服務,基於大量結構化和非結構化數據、外部數據源、超萬維的特徵、以及複雜的建模技術。些也致使咱們基於python的服務性能面臨着嚴峻考驗 。下圖是架構簡圖:nginx

image.png

性能分析

對代碼優化的前提是須要了解性能瓶頸在什麼地方,程序運行的主要時間是消耗在哪裏,常見的能夠在日誌中打點來統計運行時間,對於比較複雜的代碼也能夠藉助一些工具來定位,python 內置了豐富的性能分析工具,可以描述程序運行時候的性能,並提供各類統計幫助用戶定位程序的性能瓶頸。常見的 profilers:cProfile,profile,line_profile,pprofile 以及 hotshot等,固然一些IDE好比pycharm中也繼承了完善的profiling。這裏咱們只介紹有表明性的幾種性能分析方法:git

1、利用裝飾器實現函數耗時統計打點

裝飾器就是經過閉包來給原有函數增長新功能,python能夠用裝飾器這種語法糖來給函數進行耗時統計,可是這僅限於通常的同步方法,在協程中,更通常地說在生成器函數中,由於yield會釋放當前線程,耗時統計執行到yield處就會中斷返回,致使統計的失效。
以下是一個包含兩層閉包(由於要給裝飾器傳參)的裝飾器:github

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

2、函數級性能分析工具cprofile

cProfile自python2.5以來就是標準版Python解釋器默認的性能分析器,它是一種肯定性分析器,只測量CPU時間,並不關心內存消耗和其餘與內存相關聯的信息。golang

性能分析結果

image.png

字段含義

ncalls:函數被調用的次數。
tottime:函數內部消耗總時間。
percall:每次調用平均消耗時間。
cumtime:消費時間的累計和。
filename:lineno(function):被分析函數所在文件名、行號、函數名。算法

使用方法

一、針對單個文件的性能分析:json

python -m cProfile -s tottime test.py

二、針對某個方法的性能分析:flask

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

三、項目中針對實時服務的性能分析:後端

# 通常須要綁定在服務框架的鉤子函數中來實現,以下兩個方法分別放在入口和出口鉤子中;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)

3、行級分析工具pprofile/line_profile

使用line_profile須要引入_kernprof__,所以咱們這裏選用pprofile,雖然pprofile的效率沒有line_profile高,但在作性能分析時這是能夠忽略的。pprofile的用法和cprofile的用法三徹底一致。

性能分析結果

image.png
image.png

字段含義

Line:行號
Hits:該行代碼執行次數
Time:總執行耗時
Time per hit:單次執行耗時
%:耗時佔比

4、性能分析總結

咱們在作性能分析時,能夠挑選任何方便易用的方法或工具進行分析。但整體的思路是由總體到具體的。例如能夠經過cprofile尋找整個代碼執行過程當中的耗時較長的函數,而後再經過pprofile對這些函數進行逐行分析,最終將代碼的性能瓶頸精確到行級。

python性能優化方法

1、優化思路

Python的性能優化方式有不少,能夠從不一樣的角度出發考慮具體問題具體分析,但能夠歸結爲兩個思路:從服務架構和CPU效率層面,將CPU密集型向IO密集型優化。從代碼執行和cpu利用率層面,要提升代碼性能以及多核利用率。好比,基於此,python在線服務的優化思路能夠從這幾方面考慮:

image.png

2、代碼優化

使用字典/集合等hash等數據結構

經常使用操做:檢索、去重、交集、並集、差集
一、在字典/集合中查找(如下代碼中均省略記時部分)

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

耗時:
0.0590000152588
0.0

二、使用集合求交集

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

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

節省內存和計算資源,不須要計算整個可迭代對象,只計算須要循環的部分。
一、使用xrange而不是range(python3中無區別)

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

耗時:
0.0829999446869
0.0320000648499

二、列表推導使用生成器

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

三、複雜邏輯產生的迭代對象使用生成器函數來代替

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調度。
一、進程通訊、共享變量
python多進程提供了基本全部的共享變量類型,經常使用的包括:共享隊列、共享字典、共享列表、簡單變量等,所以也提供了鎖機制。具體不在這裏贅述,相關模塊:from multiprocessing import Process,Manager,Queue

二、分片與合併
多進程在優化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代碼的優化也須要具體問題具體分析,不侷限於以上方式,但只要可以分析出性能瓶頸,問題就解決了一半。《約束理論與企業優化》中指出:「除了瓶頸以外,任何改進都是幻覺」。

3、優化實例

優化循環

將無關代碼提到循環上層
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

相關文章
相關標籤/搜索