實際專案中,pythoner更加關注的是Python的效能問題,之前也寫過一篇文章《Python效能優化》介紹Python效能優化的一些方法。而本文,關注的是Python的記憶體優化,一般說來,如果不發生記憶體洩露,執行在服務端的Python程式碼不用太關心記憶體,但是如果執行在客戶端(比如移動平臺上),那還是有優化的必要。具體而言,本文主要針對的Cpython,而且不涉及C擴充套件。
我們知道,Python使用引用技術和垃圾回收來管理記憶體,底層也有各種型別的記憶體池,那我們怎麼得知一段程式碼使用的記憶體情況呢?工欲善其事必先利其器,直接看windows下的工作管理員或者linux下的top肯定是不準的。
Pytracemalloc
對於基本型別,可以通過sys.getsizeof()來檢視物件佔用的記憶體大小。以下是在64位Linux下的一些結果:
1 2 3 4 5 6 7 8 9 10 11 12 |
>>> import sys >>> sys.getsizeof(1) 24 >>> sys.getsizeof([]) 72 >>> sys.getsizeof(()) 56 >>> sys.getsizeof({}) 280 >>> sys.getsizeof(True) 24 |
可以看到,即使是一個int型別(1)也需要佔用24個位元組,遠遠高於C語言中int的範圍。因為Python中一切都是物件,int也不例外(事實上是PyIntObject),除了真正儲存的數值,還需要儲存引用計數資訊、型別資訊,更具體的可以參見《Python原始碼剖析》。
而對於更復雜的組合型別,複雜的程式碼,使用getsizeof來檢視就不準確了,因為在Python中變數僅僅指向一個物件,這個時候就需要更高階的工具,比如guppy,pysizer,pytracemalloc,objgraph。在這裡重點介紹pytracemalloc。
在Python3.4中,已經支援了pytracemalloc,如果使用python2.7版本,則需要對原始碼打補丁,然後重新編譯。pytracemalloc在pep454中提出,主要有以下幾個特點:
- Traceback where an object was allocated
- Statistics on allocated memory blocks per filename and per line number: total size, number and average size of allocated memory blocks
- Compute the differences between two snapshots to detect memory leaks
簡單來說,pytracemalloc hook住了python申請和釋放記憶體的介面,從而能夠追蹤物件的分配和回收情況。對記憶體分配的統計資料可以精確到每個檔案、每一行程式碼,也可以按照呼叫棧做聚合分析。而且還支援快照(snapshot)功能,比較兩個快照之間的差異可以發現潛在的記憶體洩露。
下面通過一個例子來簡單介紹pytracemalloc的用法和介面,關於更詳細用法和API,可以參考這份詳盡的文件或者pytracemalloc的作者在pycon上的演講ppt。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import tracemalloc NUM_OF_ATTR = 10 NUM_OF_INSTANCE = 100 class Slots(object): __slots__ = ['attr%s'%i for i in range(NUM_OF_ATTR)] def __init__(self): value_lst = (1.0, True, [], {}, ()) for i in range(NUM_OF_ATTR): setattr(self, 'attr%s'%i, value_lst[i % len(value_lst)]) class NoSlots(object): def __init__(self): value_lst = (1.0, True, [], {}, ()) for i in range(NUM_OF_ATTR): setattr(self, 'attr%s'%i, value_lst[i % len(value_lst)]) def generate_some_objs(): lst = [] for i in range(NUM_OF_INSTANCE): o = Slots() if i % 2 else NoSlots() lst.append(o) return lst if __name__ == '__main__': tracemalloc.start(3) t = generate_some_objs() snapshot = tracemalloc.take_snapshot() top_stats = snapshot.statistics('lineno') # lineno filename traceback print(tracemalloc.get_traced_memory()) for stat in top_stats[:10]: print(stat) |
在上面的程式碼中,用到了pytracemalloc幾個核心的API:
1 |
start(nframe: int=1) |
pytracemalloc的一大好處就是可以隨時啟停,start函式即開始追蹤記憶體分配,相應的stop會停止追蹤。start函式有一個引數,nframes : 記憶體分配時記錄的棧的深度,這個值越大,pytracemalloc本身消耗的記憶體越多,在計算cumulative資料的時候有用。
1 |
get_traced_memory() |
返回值是擁有兩個元素的tuple,第一個元素是當前分配的記憶體,第二個元素是自記憶體追蹤啟動以來的記憶體峰值。
1 |
take_snapshot() |
返回當前記憶體分配快照,返回值是Snapshot物件,該物件可以按照單個檔案、單行、單個呼叫棧統計記憶體分配情況
執行環境:windows 64位python3.4
1 2 3 4 5 6 7 8 9 10 |
(62280, 62920) test_pytracemalloc_use_py3.4.py:10: size=16.8 KiB, count=144, average=120 B test_pytracemalloc_use_py3.4.py:17: size=16.7 KiB, count=142, average=120 B test_pytracemalloc_use_py3.4.py:19: size=9952 B, count=100, average=100 B test_pytracemalloc_use_py3.4.py:26: size=9792 B, count=102, average=96 B test_pytracemalloc_use_py3.4.py:27: size=848 B, count=1, average=848 B test_pytracemalloc_use_py3.4.py:34: size=456 B, count=1, average=456 B test_pytracemalloc_use_py3.4.py:36: size=448 B, count=1, average=448 B D:\Python3.4\lib\tracemalloc.py:474: size=64 B, count=1, average=64 B |
如果將第36行的“lineno“改成“filename”,那麼結果如下
1 2 3 4 |
(62136, 62764) test_pytracemalloc_use_py3.4.py:0: size=54.5 KiB, count=491, average=114 B D:\Python3.4\lib\tracemalloc.py:0: size=64 B, count=1, average=64 B |
有了Profile結果之後,可以看出來在哪個檔案中有大量的記憶體分配。與效能優化相同,造成瓶頸的有兩種情況:單個物件佔用了大量的記憶體;同時大量存在的小物件。對於前者,優化的手段並不多,惰性初始化屬性可能有一些幫助;而對於後者,當同樣型別的物件大量存在時,可以使用slots進行優化。
Slots
預設情況下,自定義的物件都使用dict來儲存屬性(通過obj.__dict__檢視),而python中的dict大小一般比實際儲存的元素個數要大(以此降低hash衝突概率),因此會浪費一定的空間。在新式類中使用__slots__,就是告訴Python虛擬機器,這種型別的物件只會用到這些屬性,因此虛擬機器預留足夠的空間就行了,如果宣告瞭__slots__,那麼物件就不會再有__dict__屬性。
使用slots到底能帶來多少記憶體優化呢,首先看看這篇文章,對於一個只有三個屬性的Image類,使用__slots__之後記憶體從25.5G下降到16.2G,節省了9G的空間!
到底能省多少,取決於類自身有多少屬性、屬性的型別,以及同時存在多少個類的例項。下面通過一段簡單程式碼測試一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# -*- coding: utf-8 -*- import sys import tracemalloc NUM_OF_ATTR = 3 #3 # 10 # 30 #90 NUM_OF_INSTANCE = 10 # 10 # 100 class Slots(object): __slots__ = ['attr%s'%i for i in range(NUM_OF_ATTR)] def __init__(self): value_lst = (1.0, True, [], {}, ()) for i in range(NUM_OF_ATTR): setattr(self, 'attr%s'%i, value_lst[i % len(value_lst)]) class NoSlots(object): def __init__(self): value_lst = (1.0, True, [], {}, ()) for i in range(NUM_OF_ATTR): setattr(self, 'attr%s'%i, value_lst[i % len(value_lst)]) if __name__ == '__main__': clz = Slots if len(sys.argv) > 1 else NoSlots tracemalloc.start() objs = [clz() for i in range(NUM_OF_INSTANCE)] print(tracemalloc.get_traced_memory()[0]) |
上面的程式碼,主要是在每個例項的屬性數目、併發存在的例項數目兩個維度進行測試,並沒有測試不同的屬性型別。結果如下表:
百分比為記憶體優化百分比,計算公式為(b – a) / b, 其中b為沒有使用__slots__時分配的記憶體, a為使用了__slots__時分配的記憶體。
注意事項
關於__slots__,Python文件有非常詳盡的介紹,這裡只強調幾點注意事項
第一:基類和子類都必須__slots__,即使基類或者子類沒有屬性
1 2 3 4 5 6 7 8 9 10 11 |
>>> class Base(object): ... pass ... >>> class Derived(Base): ... __slots__ = ('a', ) ... >>> d.__slots__ ('a',) >>> getattr(d, '__dict__', 'No Dict') {} |
從上面的示例可以看到,子類的物件還是有__dict__屬性,原因就在於基類沒有宣告__slots__。因此,可以通過看子類的例項有沒有__dict__屬性來判斷slots的使用是否正確
第二:子類會繼承基類的__slots__
更準確的說,如果訪問屬性的時候沒有在子類的__slots__找到,會繼續在基類的__slots__查詢,因為Python使用descriptor在類這個層級實現__slots__的,具體可以參見《python屬性查詢 深入理解》一文
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
>>> class Base(object): ... __slots__ = ('a',) ... >>> class Derived(Base): ... __slots__ = ('b', ) ... >>> d = Derived() >>> d.__slots__ ('b',) >>> getattr(d, '__dict__', 'No Dict') 'No Dict' >>> d.a = 1 >>> d.c = 0 Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'Derived' object has no attribute 'c' |
objgraph
在大型工程中,怎麼排查有哪些大量存在的物件呢,畢竟同一個型別存在的物件越多,優化越有效果。除了直接看程式碼,最好使的就是使用objgraph.py的show_most_common_types(N)函式,該函式返回Python gc管理的所有物件中,數目前N多的物件,在排除掉python builtin物件之後,剩下的就是可優化的物件。比如在最上面的程式碼中:在最後加上這麼兩句:
1 2 3 |
import objgraph objgraph.show_most_common_types(25) |
輸出如下:
再論Python dict
前面介紹slots的時候,就提到Python自定義的物件中通過dict來管理屬性。這種機制極大的提高了Python的靈活性 — 可以隨時給物件增加屬性,但是其實現機制也帶來了記憶體上的浪費。不管是python原始碼,還是Python程式,都大量使用了dict,因此這部分記憶體浪費不容小視。
python中的dict使用的是雜湊表(類似C++中的std::unordered_map),當計算出的hash值衝突的時候,採用開放地址法解決衝突(另一種常見的衝突解決演算法是連結串列法)。為了降低衝突概率,當裝填因子(實際儲存的元素與雜湊表長度的比值)超過2/3的時候就會對雜湊表進行擴容,因此雜湊表中一定會存在一些未使用的槽。
下面簡單看看PyDictObject的資料結構(python2.7.3 dictobject.h)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#define PyDict_MINSIZE 8 typedef struct { /* Cached hash code of me_key. Note that hash codes are C longs. * We have to use Py_ssize_t instead because dict_popitem() abuses * me_hash to hold a search finger. */ Py_ssize_t me_hash; PyObject *me_key; PyObject *me_value; } PyDictEntry; typedef struct _dictobject PyDictObject; struct _dictobject { PyObject_HEAD Py_ssize_t ma_fill; /* # Active + # Dummy */ Py_ssize_t ma_used; /* # Active */ /* The table contains ma_mask + 1 slots, and that's a power of 2. * We store the mask instead of the size because the mask is more * frequently needed. */ Py_ssize_t ma_mask; /* ma_table points to ma_smalltable for small tables, else to * additional malloc'ed memory. ma_table is never NULL! This rule * saves repeated runtime null-tests in the workhorse getitem and * setitem calls. */ PyDictEntry *ma_table; PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash); PyDictEntry ma_smalltable[PyDict_MINSIZE]; }; |
從定義可以看出,除了固定的部分(幾個Py_ssize_t),PyDictObject中主要是PyDictEntry物件,PyDictEntrty包含一個Py_ssize_t(int)和兩個指標。上面原始碼中的註釋(第26行)指出,當dict的元素比較少時,ma_table指向ma_smalltable,當元素增多時,ma_table會指向新申請的空間。ma_smalltable的作用在於Python(不管是原始碼還是程式碼)都大量使用dict,一般來說,儲存的元素也不會太多,因此Python就先開闢好PyDict_MINSIZE(預設為8)個空間。
為什麼說PyDictObject存在浪費呢,PyDictEntry在32位下也有12個位元組,那麼即使在ma_smalltable(ma_table)中大量的位置沒有被使用時,也要佔用這麼多位元組。用這篇文章中的例子:
假設有這麼一個dict:
1 |
d = {'timmy': 'red', 'barry': 'green', 'guido': 'blue'} |
在Python原始碼中的檢視就是這樣的:
1 2 3 4 5 6 7 8 9 10 |
# 下面的entries就是ma_smalltable entries = [['--', '--', '--'], [-8522787127447073495, 'barry', 'green'], ['--', '--', '--'], ['--', '--', '--'], ['--', '--', '--'], [-9092791511155847987, 'timmy', 'red'], ['--', '--', '--'], [-6480567542315338377, 'guido', 'blue']] |
然而,完全可以這麼儲存:
1 2 3 4 5 |
indices = [None, 1, None, None, None, 0, None, 2] entries = [[-9092791511155847987, 'timmy', 'red'], [-8522787127447073495, 'barry', 'green'], [-6480567542315338377, 'guido', 'blue']] |
indices的作用類似ma_smalltable,但只儲存一個陣列的索引值,陣列只儲存實際存在的元素(PyDictEntry),當dict中的元素越稀疏,相比上一種儲存方式使用的記憶體越少。而且,這種實現, dict就是有序的(按插入時間排序)
這就是python3.6中新的dict實現,Compact dict! Stackoverflow上也有相關討論。
總結
本文中介紹了Python記憶體優化的Profile工具,最有效的優化方法:使用slots,也介紹了在python3.6中新的dict實現。
當然,還有一些良好的編碼習慣。比如儘量使用immutable而不是mutable物件:使用tuple而不是list,使用frozenset而不是set;另外,就是儘量使用迭代器,比如python2.7中,使用xrange而不是range,dict的iterxx版本。