Python性能優化指南

1、使用生成器和列表解析


一個普遍被忽略的內存優化是生成器的使用。生成器讓我們創建一個函數一次只返回一條記錄,而不是一次返回所有的記錄,如果你正在使用python2.x,這就是你爲啥使用xrange替代range或者使用ifilter替代filter的原因。一個很好地例子就是創建一個很大的列表並將它們拼合在一起。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import timeit  
  2. import random  
  3.   
  4. def generate(num):  
  5.     while num:  
  6.         yield random.randrange(10)  
  7.         num -= 1  
  8.   
  9. def create_list(num):  
  10.     numbers = []  
  11.     while num:  
  12.         numbers.append(random.randrange(10))  
  13.         num -= 1  
  14.     return numbers  
  15.           
  16. print(timeit.timeit("sum(generate(999))", setup="from __main__ import generate", number=1000))  
  17. print(timeit.timeit("sum(create_list(999))", setup="from __main__ import create_list", number=1000))  
輸出:

1.00842191191
0.933518458666

列表解析要比在循環中重新構建一個新的 list 更爲高效,因此我們可以利用這一特性來提高運行的效率。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. list = ['a','b','is','python','jason','hello','hill','with','phone','test',  
  4. 'dfdf','apple','pddf','ind','basic','none','baecr','var','bana','dd','wrd']  
  5. total=[]  
  6. for i in range(1000000):  
  7.     for w in list:  
  8.         total.append(w)  
  9. print "total run time:"  
  10. print time()-t  
使用列表解析:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. for i in range(1000000):  
  2.      a = [w for w in list]  
上述代碼直接運行大概需要 17s,而改爲使用列表解析後 ,運行時間縮短爲 9.29s。將近提高了一半。生成器表達式則是在 2.4 中引入的新內容,語法和列表解析類似,但是在大數據量處理時,生成器表達式的優勢較爲明顯,它並不創建一個列表,只是返回一個生成器,因此效率較高。在上述例子上中代碼 a = [w for w in list] 修改爲 a = (w for w in list),運行時間進一步減少,縮短約爲 2.98s。

2、Ctypes的介紹


對於關鍵性的性能代碼python本身也提供給我們一個API來調用C方法,主要通過 ctypes來實現,你可以不寫任何C代碼來利用ctypes。默認情況下python提供了預編譯的標準c庫,我們再回到生成器的例子,看看使用ctypes實現花費多少時間。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import timeit  
  2. from ctypes import cdll  
  3.   
  4. def generate_c(num):  
  5.     #Load standard C library  
  6.     #libc = cdll.LoadLibrary("libc.so.6") #Linux  
  7.     libc = cdll.msvcrt #Windows  
  8.     while num:  
  9.         yield libc.rand() % 10  
  10.         num -= 1  
  11.   
  12. print(timeit.timeit("sum(generate_c(999))", setup="from __main__ import generate_c", number=1000))  
輸出:

0.404974439902

僅僅換成了c的隨機函數,運行時間減了大半!現在如果我告訴你我們還能做得更好,你信嗎?


3、Cython的介紹


Cython 是python的一個超集,允許我們調用C函數以及聲明變量來提高性能。嘗試使用之前我們需要先安裝Cython.

sudo pip install cython

Cython 本質上是另一個不再開發的類似類庫Pyrex的分支,它將我們的類Python代碼編譯成C庫,我們可以在一個python文件中調用。對於你的python文件使用.pyx後綴替代.py後綴,讓我們看一下使用Cython如何來運行我們的生成器代碼。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. #cython_generator.pyx  
  2. import random  
  3.   
  4. def generate(num):  
  5.     while num:  
  6.         yield random.randrange(10)  
  7.         num -= 1  
我們需要創建個setup.py以便我們能獲取到Cython來編譯我們的函數。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from distutils.core import setup  
  2. from distutils.extension import Extension  
  3. from Cython.Distutils import build_ext  
  4.   
  5. setup(  
  6.     cmdclass = {'build_ext': build_ext},  
  7.     ext_modules = [Extension("generator", ["cython_generator.pyx"])]  
  8. )  
編譯使用:

python setup.py build_ext--inplace

你應該可以看到兩個文件cython_generator.c 文件和generator.so文件,我們使用下面方法測試我們的程序:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import timeit  
  2. print(timeit.timeit("sum(generator.generate(999))", setup="import generator", number=1000))  
  3. >>> 0.835658073425  
還不賴,讓我們看看是否還有可以改進的地方。我們可以先聲明“num”爲整形,接着我們可以導入標準的C庫來負責我們的隨機函數。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. #cython_generator.pyx  
  2. cdef extern from "stdlib.h":  
  3.     int c_libc_rand "rand"()  
  4.   
  5. def generate(int num):  
  6.     while num:  
  7.         yield c_libc_rand() % 10  
  8.         num -= 1  
如果我們再次編譯運行我們會看到這一串驚人的數字。

>>> 0.033586025238
僅僅的幾個改變帶來了不賴的結果。然而,有時這個改變很乏味,因此讓我們來看看如何使用規則的python來實現吧。


4、PyPy的介紹


PyPy 是一個Python2.7.3的即時編譯器(JIT),通俗地說這意味着讓你的代碼運行的更快。Quora在生產環境中使用了PyPy。PyPy在它們的下載頁面有一些安裝說明,但是如果你使用的Ubuntu系統,你可以通過apt-get來安裝。它的運行方式是立即可用的,因此沒有瘋狂的bash或者運行腳本,只需下載然後運行即可。讓我們看看我們原始的生成器代碼在PyPy下的性能如何。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import timeit  
  2. import random  
  3.   
  4. def generate(num):  
  5.     while num:  
  6.         yield random.randrange(10)  
  7.         num -= 1  
  8.   
  9. def create_list(num):  
  10.     numbers = []  
  11.     while num:  
  12.         numbers.append(random.randrange(10))  
  13.         num -= 1  
  14.     return numbers  
  15.           
  16. print(timeit.timeit("sum(generate(999))", setup="from __main__ import generate", number=1000))  
  17. >>> 0.115154981613 #PyPy 1.9  
  18. >>> 0.118431091309 #PyPy 2.0b1  
  19. print(timeit.timeit("sum(create_list(999))", setup="from __main__ import create_list", number=1000))  
  20. >>> 0.140175104141 #PyPy 1.9  
  21. >>> 0.140514850616 #PyPy 2.0b1  


5、進一步測試


爲什麼還要進一步研究?PyPy是冠軍!並不全對。雖然大多數程序可以運行在PyPy上,但是還是有一些庫沒有被完全支持。而且,爲你的項目寫C的擴展相比換一個編譯器更加容易。讓我們更加深入一些,看看ctypes如何讓我們使用C來寫庫。我們來測試一下歸併排序和計算斐波那契數列的速度。下面是我們要用到的C代碼(functions.c):

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. /* functions.c */  
  2. #include "stdio.h"  
  3. #include "stdlib.h"  
  4. #include "string.h"  
  5.   
  6. /* http://rosettacode.org/wiki/Sorting_algorithms/Merge_sort#C */  
  7. inline  
  8. void merge(int *left, int l_len, int *right, int r_len, int *out)  
  9. {  
  10.     int i, j, k;  
  11.     for (i = j = k = 0; i < l_len && j < r_len; )  
  12.         out[k++] = left[i] < right[j] ? left[i++] : right[j++];  
  13.    
  14.     while (i < l_len) out[k++] = left[i++];  
  15.     while (j < r_len) out[k++] = right[j++];  
  16. }  
  17.    
  18. /* inner recursion of merge sort */  
  19. void recur(int *buf, int *tmp, int len)  
  20. {  
  21.     int l = len / 2;  
  22.     if (len <= 1return;  
  23.    
  24.     /* note that buf and tmp are swapped */  
  25.     recur(tmp, buf, l);  
  26.     recur(tmp + l, buf + l, len - l);  
  27.    
  28.     merge(tmp, l, tmp + l, len - l, buf);  
  29. }  
  30.    
  31. /* preparation work before recursion */  
  32. void merge_sort(int *buf, int len)  
  33. {  
  34.     /* call alloc, copy and free only once */  
  35.     int *tmp = malloc(sizeof(int) * len);  
  36.     memcpy(tmp, buf, sizeof(int) * len);  
  37.    
  38.     recur(buf, tmp, len);  
  39.    
  40.     free(tmp);  
  41. }  
  42.   
  43. int fibRec(int n){  
  44.     if(n < 2)  
  45.         return n;  
  46.     else  
  47.         return fibRec(n-1) + fibRec(n-2);  
  48. }  
在Linux平臺,我們可以用下面的方法把它編譯成一個共享庫:

gcc -Wall-fPIC-c functions.c
gcc -shared-o libfunctions.so functions.o

使用ctypes, 通過加載”libfunctions.so”這個共享庫,就像我們前邊對標準C庫所作的那樣,就可以使用這個庫了。這裏我們將要比較Python實現和C實現。現在我們開始計算斐波那契數列:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. #functions.py  
  2. from ctypes import *  
  3. import time  
  4.   
  5. libfunctions = cdll.LoadLibrary("./libfunctions.so")  
  6.   
  7. def fibRec(n):  
  8.     if n < 2:  
  9.         return n  
  10.     else:  
  11.         return fibRec(n-1) + fibRec(n-2)  
  12.   
  13. start = time.time()   
  14. fibRec(32)  
  15. finish = time.time()  
  16. print("Python: " + str(finish - start))  
  17.   
  18. #C Fibonacci  
  19. start = time.time()   
  20. x = libfunctions.fibRec(32)  
  21. finish = time.time()  
  22. print("C: " + str(finish - start))  
[plain] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. Python: 1.18783187866 #Python 2.7  
  2. Python: 1.272292137145996 #Python 3.2  
  3. Python: 0.563600063324 #PyPy 1.9  
  4. Python: 0.567229032516 #PyPy 2.0b1  
  5. C: 0.043830871582 #Python 2.7 + ctypes  
  6. C: 0.04574108123779297 #Python 3.2 + ctypes  
  7. C: 0.0481240749359 #PyPy 1.9 + ctypes  
  8. C: 0.046403169632 #PyPy 2.0b1 + ctypes  

正如我們預料的那樣,C比Python和PyPy更快。我們也可以用同樣的方式比較歸併排序。

我們還沒有深挖Cypes庫,所以這些例子並沒有反映python強大的一面,Cypes庫只有少量的標準類型限制,比如int型,char數組,float型,字節(bytes)等等。默認情況下,沒有整形數組,然而通過與c_int相乘(ctype爲int類型)我們可以間接獲得這樣的數組。這也是代碼第7行所要呈現的。我們創建了一個c_int數組,有關我們數字的數組並分解打包到c_int數組中

主要的是c語言不能這樣做,而且你也不想。我們用指針來修改函數體。爲了通過我們的c_numbers的數列,我們必須通過引用傳遞merge_sort功能。運行merge_sort後,我們利用c_numbers數組進行排序,我已經把下面的代碼加到我的functions.py文件中了。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. #Python Merge Sort  
  2. from random import shuffle, sample  
  3.   
  4. #Generate 9999 random numbers between 0 and 100000  
  5. numbers = sample(range(100000), 9999)  
  6. shuffle(numbers)  
  7. c_numbers = (c_int * len(numbers))(*numbers)  
  8.   
  9. from heapq import merge  
  10.    
  11. def merge_sort(m):  
  12.     if len(m) <= 1:  
  13.         return m  
  14.    
  15.     middle = len(m) // 2  
  16.     left = m[:middle]  
  17.     right = m[middle:]  
  18.    
  19.     left = merge_sort(left)  
  20.     right = merge_sort(right)  
  21.     return list(merge(left, right))  
  22.   
  23. start = time.time()  
  24. numbers = merge_sort(numbers)  
  25. finish = time.time()  
  26. print("Python: " + str(finish - start))  
  27.   
  28. #C Merge Sort  
  29. start = time.time()  
  30. libfunctions.merge_sort(byref(c_numbers), len(numbers))  
  31. finish = time.time()  
  32. print("C: " + str(finish - start))  
[plain] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. Python: 0.190635919571 #Python 2.7  
  2. Python: 0.11785483360290527 #Python 3.2  
  3. Python: 0.266992092133 #PyPy 1.9  
  4. Python: 0.265724897385 #PyPy 2.0b1  
  5. C: 0.00201296806335 #Python 2.7 + ctypes  
  6. C: 0.0019741058349609375 #Python 3.2 + ctypes  
  7. C: 0.0029308795929 #PyPy 1.9 + ctypes  
  8. C: 0.00287103652954 #PyPy 2.0b1 + ctypes  
這兒通過表格和圖標來比較不同的結果。


  Merge Sort Fibonacci
Python 2.7 0.191 1.187
Python 2.7 + ctypes 0.002 0.044
Python 3.2 0.118 1.272
Python 3.2 + ctypes 0.002 0.046
PyPy 1.9 0.267 0.564
PyPy 1.9 + ctypes 0.003 0.048
PyPy 2.0b1 0.266 0.567
PyPy 2.0b1 + ctypes 0.003 0.046

代碼優化能夠讓程序運行更快,它是在不改變程序運行結果的情況下使得程序的運行效率更高,根據 80/20 原則,實現程序的重構、優化、擴展以及文檔相關的事情通常需要消耗 80% 的工作量。優化通常包含兩方面的內容:減小代碼的體積,提高代碼的運行效率。


6、改進算法,選擇合適的數據結構


一個良好的算法能夠對性能起到關鍵作用,因此性能改進的首要點是對算法的改進。在算法的時間複雜度排序上依次是:

O(1) -> O(lg n) -> O(n lg n) -> O(n^2) -> O(n^3) -> O(n^k) -> O(k^n) -> O(n!)

因此如果能夠在時間複雜度上對算法進行一定的改進,對性能的提高不言而喻。但對具體算法的改進不屬於本文討論的範圍,讀者可以自行參考這方面資料。下面的內容將集中討論數據結構的選擇。

● 字典 (dictionary) 與列表 (list)

Python 字典中使用了hash table,因此查找操作的複雜度爲 O(1),而 list 實際是個數組,在 list 中,查找需要遍歷整個 list,其複雜度爲 O(n),因此對成員的查找訪問等操作字典要比 list 更快。

清單 1. 代碼 dict.py

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. list = ['a','b','is','python','jason','hello','hill','with','phone','test',  
  4. 'dfdf','apple','pddf','ind','basic','none','baecr','var','bana','dd','wrd']  
  5. #list = dict.fromkeys(list,True)  
  6. print list  
  7. filter = []  
  8. for i in range (1000000):  
  9.     for find in ['is','hat','new','list','old','.']:  
  10.         if find not in list:  
  11.             filter.append(find)  
  12. print "total run time:"  
  13. print time()-t  

上述代碼運行大概需要 16.09seconds。如果去掉行 #list = dict.fromkeys(list,True) 的註釋,將 list 轉換爲字典之後再運行,時間大約爲 8.375 seconds,效率大概提高了一半。因此在需要多數據成員進行頻繁的查找或者訪問的時候,使用 dict 而不是 list 是一個較好的選擇。

● 集合 (set) 與列表 (list)

set 的 union, intersection,difference 操作要比 list 的迭代要快。因此如果涉及到求 list 交集,並集或者差的問題可以轉換爲 set 來操作。

清單 2. 求 list 的交集:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. lista=[1,2,3,4,5,6,7,8,9,13,34,53,42,44]  
  4. listb=[2,4,6,9,23]  
  5. intersection=[]  
  6. for i in range (1000000):  
  7.     for a in lista:  
  8.         for b in listb:  
  9.             if a == b:  
  10.                 intersection.append(a)  
  11.    
  12. print "total run time:"  
  13. print time()-t  
上述程序的運行時間大概爲:

total run time: 
 38.4070000648
清單 3. 使用 set 求交集

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. lista=[1,2,3,4,5,6,7,8,9,13,34,53,42,44]  
  4. listb=[2,4,6,9,23]  
  5. intersection=[]  
  6. for i in range (1000000):  
  7.     list(set(lista)&set(listb))  
  8. print "total run time:"  
  9. print time()-t  

改爲 set 後程序的運行時間縮減爲 8.75,提高了 4 倍多,運行時間大大縮短。讀者可以自行使用表 1 其他的操作進行測試。

表 1. set 常見用法

語法 操作 說明
set(list1) | set(list2) union 包含 list1 和 list2 所有數據的新集合
set(list1) & set(list2) intersection 包含 list1 和 list2 中共同元素的新集合
set(list1) - set(list2) difference 在 list1 中出現但不在 list2 中出現的元素的集合


7、對循環的優化


對循環的優化所遵循的原則是儘量減少循環過程中的計算量,有多重循環的儘量將內層的計算提到上一層。 下面通過實例來對比循環優化後所帶來的性能的提高。程序清單 4 中,如果不進行循環優化,其大概的運行時間約爲 132.375。

清單 4. 爲進行循環優化前

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. lista = [1,2,3,4,5,6,7,8,9,10]  
  4. listb =[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,0.01]  
  5. for i in range (1000000):  
  6.     for a in range(len(lista)):  
  7.         for b in range(len(listb)):  
  8.             x=lista[a]+listb[b]  
  9. print "total run time:"  
  10. print time()-t  

現在進行如下優化,將長度計算提到循環外,range 用 xrange 代替,同時將第三層的計算 lista[a] 提到循環的第二層。

清單 5. 循環優化後

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. lista = [1,2,3,4,5,6,7,8,9,10]  
  4. listb =[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,0.01]  
  5. len1=len(lista)  
  6. len2=len(listb)  
  7. for i in xrange (1000000):  
  8.     for a in xrange(len1):  
  9.         temp=lista[a]  
  10.         for b in xrange(len2):  
  11.             x=temp+listb[b]  
  12. print "total run time:"  
  13. print time()-t  
上述優化後的程序其運行時間縮短爲 102.171999931。在清單 4 中 lista[a] 被計算的次數爲 1000000*10*10,而在優化後的代碼中被計算的次數爲 1000000*10,計算次數大幅度縮短,因此性能有所提升。


8、充分利用 Lazy if-evaluation 的特性


python 中條件表達式是 lazy evaluation 的,也就是說如果存在條件表達式 if x and y,在 x 爲 false 的情況下 y 表達式的值將不再計算。因此可以利用該特性在一定程度上提高程序效率。

清單 6. 利用 Lazy if-evaluation 的特性

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. abbreviations = ['cf.''e.g.''ex.''etc.''fig.''i.e.''Mr.''vs.']  
  4. for i in range (1000000):  
  5.     for w in ('Mr.''Hat''is''chasing''the''black''cat''.'):  
  6.         if w in abbreviations:  
  7.         #if w[-1] == '.' and w in abbreviations:  
  8.             pass  
  9. print "total run time:"  
  10. print time()-t  

在未進行優化之前程序的運行時間大概爲 8.84,如果使用註釋行代替第一個 if,運行的時間大概爲 6.17。


9、字符串的優化


python 中的字符串對象是不可改變的,因此對任何字符串的操作如拼接,修改等都將產生一個新的字符串對象,而不是基於原字符串,因此這種持續的 copy 會在一定程度上影響 python 的性能。對字符串的優化也是改善性能的一個重要的方面,特別是在處理文本較多的情況下。字符串的優化主要集中在以下幾個方面:

1、在字符串連接的使用盡量使用 join() 而不是 +:在代碼清單 7 中使用 + 進行字符串連接大概需要 0.125 s,而使用 join 縮短爲 0.016s。因此在字符的操作上 join 比 + 要快,因此要儘量使用 join 而不是 +。

清單 7. 使用 join 而不是 + 連接字符串

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from time import time  
  2. t = time()  
  3. s = ""  
  4. list = ['a','b','b','d','e','f','g','h','i','j','k','l','m','n']  
  5. for i in range (10000):  
  6.  for substr in list:  
  7.      s+= substr  
  8. print "total run time:"  
  9. print time()-t  
同時要避免:
[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. s = ""  
  2. for x in list:   
  3.     s += func(x)  
而是要使用:
[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. slist = [func(elt) for elt in somelist]  
  2. s = "".join(slist)  

2、當對字符串可以使用正則表達式或者內置函數來處理的時候,選擇內置函數。如 str.isalpha(),str.isdigit(),str.startswith((‘x’, ‘yz’)),str.endswith((‘x’, ‘yz’))

3、對字符進行格式化比直接串聯讀取要快,因此要使用

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. out = "%s%s%s%s" % (head, prologue, query, tail)  
而避免
[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. out = "" + head + prologue + query + tail + ""  

10、其他優化技巧


1、如果需要交換兩個變量的值使用 a,b=b,a 而不是藉助中間變量 t=a;a=b;b=t;

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. >>> from timeit import Timer  
  2. >>> Timer("t=a;a=b;b=t","a=1;b=2").timeit()  
  3. 0.25154118749729365  
  4. >>> Timer("a,b=b,a","a=1;b=2").timeit()  
  5. 0.17156677734181258  
  6. >>>  

2、在循環的時候使用 xrange 而不是 range。使用 xrange 可以節省大量的系統內存,因爲 xrange() 在序列中每次調用只產生一個整數元素。而 range() 將直接返回完整的元素列表,用於循環時會有不必要的開銷。在 python3 中 xrange 不再存在,裏面 range 提供一個可以遍歷任意長度的範圍的 iterator。

3、使用局部變量,避免”global” 關鍵字。python 訪問局部變量會比全局變量要快得多,因 此可以利用這一特性提升性能。

4、if done is not None 比語句 if done != None 更快,讀者可以自行驗證;

5、在耗時較多的循環中,可以把函數的調用改爲內聯的方式;

6、使用級聯比較 “x < y < z” 而不是 “x < y and y < z”;

7、while 1 要比 while True 更快(當然後者的可讀性更好);

8、build in 函數通常較快,add(a,b) 要優於 a+b。


11、定位程序性能瓶頸


對代碼優化的前提是需要了解性能瓶頸在什麼地方,程序運行的主要時間是消耗在哪裏,對於比較複雜的代碼可以藉助一些工具來定位,python 內置了豐富的性能分析工具,如 profile,cProfile 與 hotshot 等。其中 Profiler 是 python 自帶的一組程序,能夠描述程序運行時候的性能,並提供各種統計幫助用戶定位程序的性能瓶頸。Python 標準模塊提供三種 profilers:cProfile,profile 以及 hotshot。

profile 的使用非常簡單,只需要在使用之前進行 import 即可。具體實例如下:

清單 8. 使用 profile 進行性能分析

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import profile  
  2. def profileTest():  
  3.    Total =1;  
  4.    for i in range(10):  
  5.        Total=Total*(i+1)  
  6.        print Total  
  7.    return Total  
  8. if __name__ == "__main__":  
  9.    profile.run("profileTest()")  
程序的運行結果如下:
[plain] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. 1  
  2. 2  
  3. 6  
  4. 24  
  5. 120  
  6. 720  
  7. 5040  
  8. 40320  
  9. 362880  
  10. 3628800  
  11.          5 function calls in 0.015 seconds  
  12.   
  13.    Ordered by: standard name  
  14.   
  15.    ncalls  tottime  percall  cumtime  percall filename:lineno(function)  
  16.         1    0.000    0.000    0.000    0.000 :0(range)  
  17.         1    0.015    0.015    0.015    0.015 :0(setprofile)  
  18.         1    0.000    0.000    0.000    0.000 <string>:1(<module>)  
  19.         1    0.000    0.000    0.000    0.000 performance.py:2(profileTest)  
  20.         1    0.000    0.000    0.015    0.015 profile:0(profileTest())  
  21.         0    0.000             0.000          profile:0(profiler)  

其中輸出每列的具體解釋如下:

●ncalls:表示函數調用的次數;

●tottime:表示指定函數的總的運行時間,除掉函數中調用子函數的運行時間;

●percall:(第一個 percall)等於 tottime/ncalls;

●cumtime:表示該函數及其所有子函數的調用運行的時間,即函數開始調用到返回的時間;

●percall:(第二個 percall)即函數運行一次的平均時間,等於 cumtime/ncalls;

●filename:lineno(function):每個函數調用的具體信息;

如果需要將輸出以日誌的形式保存,只需要在調用的時候加入另外一個參數。如 profile.run(“profileTest()”,”testprof”)。

對於 profile 的剖析數據,如果以二進制文件的時候保存結果的時候,可以通過 pstats 模塊進行文本報表分析,它支持多種形式的報表輸出,是文本界面下一個較爲實用的工具。使用非常簡單:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import pstats  
  2. p = pstats.Stats('testprof')  
  3. p.sort_stats("name").print_stats()  

其中 sort_stats() 方法能夠對剖分數據進行排序, 可以接受多個排序字段,如 sort_stats(‘name’, ‘file’) 將首先按照函數名稱進行排序,然後再按照文件名進行排序。常見的排序字段有 calls( 被調用的次數 ),time(函數內部運行時間),cumulative(運行的總時間)等。此外 pstats 也提供了命令行交互工具,執行 python – m pstats 後可以通過 help 瞭解更多使用方式。

對於大型應用程序,如果能夠將性能分析的結果以圖形的方式呈現,將會非常實用和直觀,常見的可視化工具有 Gprof2Dot,visualpytune,KCacheGrind 等。


12、性能分析的基本思路


儘管並非每個你寫的Python程序都需要嚴格的性能分析,但瞭解一下Python的生態系統中很多優秀的在你需要做性能分析的時候可以使用的工具仍然是一件值得去做的事。

分析一個程序的性能,最終都歸結爲回答4個基本的問題:

  1. 程序運行速度有多快?
  2. 運行速度瓶頸在哪兒?
  3. 程序使用了多少內存?
  4. 內存泄露發生在哪裏?
下面,我們將使用一些優秀的工具深入回答這些問題。

使用time工具粗糙定時

首先,我們可以使用快速然而粗糙的工具:古老的unix工具time,來爲我們的代碼檢測運行時間。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. $ time python yourprogram.py  
  2.   
  3. real    0m1.028s  
  4. user    0m0.001s  
  5. sys     0m0.003s  
上面三個輸入變量的意義在文章 stackoverflow article 中有詳細介紹。簡單的說:

  • real - 表示實際的程序運行時間
  • user - 表示程序在用戶態的cpu總時間
  • sys - 表示在內核態的cpu總時間

通過sysuser時間的求和,你可以直觀的得到系統上沒有其他程序運行時你的程序運行所需要的CPU週期。

sysuser時間之和遠遠少於real時間,那麼你可以猜測你的程序的主要性能問題很可能與IO等待相關。

使用計時上下文管理器進行細粒度計時

我們的下一個技術涉及訪問細粒度計時信息的直接代碼指令。這是一小段代碼,我發現使用專門的計時測量是非常重要的:

timer.py

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import time  
  2.   
  3. class Timer(object):  
  4.     def __init__(self, verbose=False):  
  5.         self.verbose = verbose  
  6.   
  7.     def __enter__(self):  
  8.         self.start = time.time()  
  9.         return self  
  10.   
  11.     def __exit__(self, *args):  
  12.         self.end = time.time()  
  13.         self.secs = self.end - self.start  
  14.         self.msecs = self.secs * 1000  # millisecs  
  15.         if self.verbose:  
  16.             print 'elapsed time: %f ms' % self.msecs  

爲了使用它,你需要用Python的with關鍵字和Timer上下文管理器包裝想要計時的代碼塊。它將會在你的代碼塊開始執行的時候啓動計時器,在你的代碼塊結束的時候停止計時器。

這是一個使用上述代碼片段的例子:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. from timer import Timer  
  2. from redis import Redis  
  3. rdb = Redis()  
  4.   
  5. with Timer() as t:  
  6.     rdb.lpush("foo""bar")  
  7. print "=> elasped lpush: %s s" % t.secs  
  8.   
  9. with Timer() as t:  
  10.     rdb.lpop("foo")  
  11. print "=> elasped lpop: %s s" % t.secs  

我經常將這些計時器的輸出記錄到文件中,這樣就可以觀察我的程序的性能如何隨着時間進化。

使用分析器逐行統計時間和執行頻率

Robert Kern有一個稱作line_profiler的不錯的項目,我經常使用它查看我的腳步中每行代碼多快多頻繁的被執行。

想要使用它,你需要通過pip安裝該python包:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. $ pip install line_profiler  

一旦安裝完成,你將會使用一個稱做“line_profiler”的新模組和一個“kernprof.py”可執行腳本。

想要使用該工具,首先修改你的源代碼,在想要測量的函數上裝飾@profile裝飾器。不要擔心,你不需要導入任何模組。kernprof.py腳本將會在執行的時候將它自動地注入到你的腳步的運行時。

primes.py

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. @profile  
  2. def primes(n):   
  3.     if n==2:  
  4.         return [2]  
  5.     elif n<2:  
  6.         return []  
  7.     s=range(3,n+1,2)  
  8.     mroot = n ** 0.5  
  9.     half=(n+1)/2-1  
  10.     i=0  
  11.     m=3  
  12.     while m <= mroot:  
  13.         if s[i]:  
  14.             j=(m*m-3)/2  
  15.             s[j]=0  
  16.             while j<half:  
  17.                 s[j]=0  
  18.                 j+=m  
  19.         i=i+1  
  20.         m=2*i+3  
  21.     return [2]+[x for x in s if x]  
  22. primes(100)  
一旦你已經設置好了@profile裝飾器,使用kernprof.py執行你的腳步。

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. kernprof.py -l -v fib.py  
-l選項通知kernprof注入@profile裝飾器到你的腳步的內建函數,-v選項通知kernprof在腳本執行完畢的時候顯示計時信息。上述腳本的輸出看起來像這樣:

[plain] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. Wrote profile results to primes.py.lprof  
  2. Timer unit: 1e-06 s  
  3.   
  4. File: primes.py  
  5. Function: primes at line 2  
  6. Total time: 0.00019 s  
  7.   
  8. Line #      Hits         Time  Per Hit   % Time  Line Contents  
  9. ==============================================================  
  10.      2                                           @profile  
  11.      3                                           def primes(n):   
  12.      4         1            2      2.0      1.1      if n==2:  
  13.      5                                                   return [2]  
  14.      6         1            1      1.0      0.5      elif n<2:  
  15.      7                                                   return []  
  16.      8         1            4      4.0      2.1      s=range(3,n+1,2)  
  17.      9         1           10     10.0      5.3      mroot = n ** 0.5  
  18.     10         1            2      2.0      1.1      half=(n+1)/2-1  
  19.     11         1            1      1.0      0.5      i=0  
  20.     12         1            1      1.0      0.5      m=3  
  21.     13         5            7      1.4      3.7      while m <= mroot:  
  22.     14         4            4      1.0      2.1          if s[i]:  
  23.     15         3            4      1.3      2.1              j=(m*m-3)/2  
  24.     16         3            4      1.3      2.1              s[j]=0  
  25.     17        31           31      1.0     16.3              while j<half:  
  26.     18        28           28      1.0     14.7                  s[j]=0  
  27.     19        28           29      1.0     15.3                  j+=m  
  28.     20         4            4      1.0      2.1          i=i+1  
  29.     21         4            4      1.0      2.1          m=2*i+3  
  30.     22        50           54      1.1     28.4      return [2]+[x for x in s if x]  
尋找具有高Hits值或高Time值的行。這些就是可以通過優化帶來最大改善的地方。

程序使用了多少內存?

現在我們對計時有了較好的理解,那麼讓我們繼續弄清楚程序使用了多少內存。我們很幸運,Fabian Pedregosa模仿Robert Kern的line_profiler實現了一個不錯的內存分析器

首先使用pip安裝:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. $ pip install -U memory_profiler  
  2. $ pip install psutil  

(這裏建議安裝psutil包,因爲它可以大大改善memory_profiler的性能)。

就像line_profiler,memory_profiler也需要在感興趣的函數上面裝飾@profile裝飾器:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. @profile  
  2. def primes(n):   
  3.     ...  
  4.     ...  
想要觀察你的函數使用了多少內存,像下面這樣執行:
[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. $ python -m memory_profiler primes.py  
一旦程序退出,你將會看到看起來像這樣的輸出:
[plain] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. Filename: primes.py  
  2.   
  3. Line #    Mem usage  Increment   Line Contents  
  4. ==============================================  
  5.      2                           @profile  
  6.      3    7.9219 MB  0.0000 MB   def primes(n):   
  7.      4    7.9219 MB  0.0000 MB       if n==2:  
  8.      5                                   return [2]  
  9.      6    7.9219 MB  0.0000 MB       elif n<2:  
  10.      7                                   return []  
  11.      8    7.9219 MB  0.0000 MB       s=range(3,n+1,2)  
  12.      9    7.9258 MB  0.0039 MB       mroot = n ** 0.5  
  13.     10    7.9258 MB  0.0000 MB       half=(n+1)/2-1  
  14.     11    7.9258 MB  0.0000 MB       i=0  
  15.     12    7.9258 MB  0.0000 MB       m=3  
  16.     13    7.9297 MB  0.0039 MB       while m <= mroot:  
  17.     14    7.9297 MB  0.0000 MB           if s[i]:  
  18.     15    7.9297 MB  0.0000 MB               j=(m*m-3)/2  
  19.     16    7.9258 MB -0.0039 MB               s[j]=0  
  20.     17    7.9297 MB  0.0039 MB               while j<half:  
  21.     18    7.9297 MB  0.0000 MB                   s[j]=0  
  22.     19    7.9297 MB  0.0000 MB                   j+=m  
  23.     20    7.9297 MB  0.0000 MB           i=i+1  
  24.     21    7.9297 MB  0.0000 MB           m=2*i+3  
  25.     22    7.9297 MB  0.0000 MB       return [2]+[x for x in s if x]  

line_profiler和memory_profiler的IPython快捷方式

memory_profiler和line_profiler有一個鮮爲人知的小竅門,兩者都有在IPython中的快捷命令。你需要做的就是在IPython會話中輸入以下內容:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. %load_ext memory_profiler  
  2. %load_ext line_profiler  
在這樣做的時候你需要訪問魔法命令%lprun和%mprun,它們的行爲類似於他們的命令行形式。主要區別是你不需要使用@profiledecorator來修飾你要分析的函數。只需要在IPython會話中像先前一樣直接運行分析:
[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. In [1]: from primes import primes  
  2. In [2]: %mprun -f primes primes(1000)  
  3. In [3]: %lprun -f primes primes(1000)  
這樣可以節省你很多時間和精力,因爲你的源代碼不需要爲使用這些分析命令而進行修改。

內存泄漏在哪裏?

cPython解釋器使用引用計數做爲記錄內存使用的主要方法。這意味着每個對象包含一個計數器,當某處對該對象的引用被存儲時計數器增加,當引用被刪除時計數器遞減。當計數器到達零時,cPython解釋器就知道該對象不再被使用,所以刪除對象,釋放佔用的內存。

如果程序中不再被使用的對象的引用一直被佔有,那麼就經常發生內存泄漏。

查找這種“內存泄漏”最快的方式是使用Marius Gedminas編寫的objgraph,這是一個極好的工具。該工具允許你查看內存中對象的數量,定位含有該對象的引用的所有代碼的位置。

一開始,首先安裝objgraph:

pip install objgraph
一旦你已經安裝了這個工具,在你的代碼中插入一行聲明調用調試器:
[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. import pdb; pdb.set_trace()  
最普遍的對象是哪些?

在運行的時候,你可以通過執行下述指令查看程序中前20個最普遍的對象:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. (pdb) import objgraph  
  2. (pdb) objgraph.show_most_common_types()  
  3.   
  4. MyBigFatObject             20000  
  5. tuple                      16938  
  6. function                   4310  
  7. dict                       2790  
  8. wrapper_descriptor         1181  
  9. builtin_function_or_method 934  
  10. weakref                    764  
  11. list                       634  
  12. method_descriptor          507  
  13. getset_descriptor          451  
  14. type                       439  
哪些對象已經被添加或刪除?

我們也可以查看兩個時間點之間那些對象已經被添加或刪除:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. (pdb) import objgraph  
  2. (pdb) objgraph.show_growth()  
  3. .  
  4. .  
  5. .  
  6. (pdb) objgraph.show_growth()   # this only shows objects that has been added or deleted since last show_growth() call  
  7.   
  8. traceback                4        +2  
  9. KeyboardInterrupt        1        +1  
  10. frame                   24        +1  
  11. list                   667        +1  
  12. tuple                16969        +1  
誰引用着泄漏的對象?

繼續,你還可以查看哪裏包含給定對象的引用。讓我們以下述簡單的程序做爲一個例子:

[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. x = [1]  
  2. y = [x, [x], {"a":x}]  
  3. import pdb; pdb.set_trace()  
想要看看哪裏包含變量x的引用,執行objgraph.show_backref()函數:
[python] view plaincopy在CODE上查看代碼片派生到我的代碼片
  1. (pdb) import objgraph  
  2. (pdb) objgraph.show_backref([x], filename="/tmp/backrefs.png")  
該命令的輸出應該是一副PNG圖像,保存在/tmp/backrefs.png,它看起來是像這樣:


最下面有紅字的盒子是我們感興趣的對象。我們可以看到,它被符號x引用了一次,被列表y引用了三次。如果是x引起了一個內存泄漏,我們可以使用這個方法,通過跟蹤它的所有引用,來檢查爲什麼它沒有自動的被釋放。

回顧一下,objgraph 使我們可以:

  • 顯示佔據python程序內存的頭N個對象
  • 顯示一段時間以後哪些對象被刪除活增加了
  • 在我們的腳本中顯示某個給定對象的所有引用

努力與精度

在本帖中,我給你顯示了怎樣用幾個工具來分析python程序的性能。通過這些工具與技術的武裝,你可以獲得所有需要的信息,來跟蹤一個python程序中大多數的內存泄漏,以及識別出其速度瓶頸。

對許多其他觀點來說,運行一次性能分析就意味着在努力目標與事實精度之間做出平衡。如果感到困惑,那麼就實現能適應你目前需求的最簡單的解決方案。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章