沒有什么內(nèi)存問題,是一行Python代碼解決不了的
大數(shù)據(jù)文摘出品
編譯:Javen、胡笳、云舟
內(nèi)存不足是項目開發(fā)過程中經(jīng)常碰到的問題,我和我的團隊在之前的一個項目中也遇到了這個問題,我們的項目需要存儲和處理一個相當(dāng)大的動態(tài)列表,測試人員經(jīng)常向我抱怨內(nèi)存不足。但是最終,我們通過添加一行簡單的代碼解決了這個問題。
結(jié)果如圖所示:
我將在下面解釋它的工作原理。
舉一個簡單的“learning”示例 - 創(chuàng)建一個DataItem類,在其中定義一些個人信息屬性,例如姓名,年齡和地址。
- class DataItem(object):
- def __init__(self, name, age, address):
- self.name = name
- self.age = age
- self.address = address
小測試——這樣一個對象會占用多少內(nèi)存?
首先讓我們嘗試下面這種測試方案:
- d1 = DataItem("Alex", 42, "-")
- print ("sys.getsizeof(d1):", sys.getsizeof(d1))
答案是56字節(jié)。看起來比較小,結(jié)果令人滿意。
但是,讓我們檢查另一個數(shù)據(jù)多一些的對象:
- d2 = DataItem("Boris", 24, "In the middle of nowhere")
- print ("sys.getsizeof(d2):", sys.getsizeof(d2))
答案仍然是56。這讓我們明白這個結(jié)果并不完全正確。
我們的直覺是對的,這個問題不是那么簡單。Python是一種非常靈活的語言,具有動態(tài)類型,它在工作時存儲了許多額外的數(shù)據(jù)。這些額外的數(shù)據(jù)本身就占了很多內(nèi)存。
例如,sys.getsizeof(“ ”)返回33,沒錯,每個空行就多達(dá)33字節(jié)!并且sys.getsizeof(1)將為此數(shù)字返回24-24個字節(jié)(我建議C程序員們現(xiàn)在點擊結(jié)束閱讀,以免對Python的美麗失去信心)。
對于更復(fù)雜的元素,例如字典,sys.getsizeof(dict())返回272個字節(jié),這還只是一個空字典。舉例到此為止,但事實已經(jīng)很清楚了,何況RAM的制造商也需要出售他們的芯片。
現(xiàn)在,讓我們回到回到我們的DataItem類和“小測試”問題。
這個類到底占多少內(nèi)存?
首先,我們將以較低級別輸出該類的全部內(nèi)容:
- def dump(obj):
- for attr in dir(obj):
- print(" obj.%s = %r" % (attr, getattr(obj, attr)))
這個函數(shù)將顯示隱藏在“隱身衣”下的內(nèi)容,以便所有Python函數(shù)(類型,繼承和其他包)都可以運行。
結(jié)果令人印象深刻:
它總共占用多少內(nèi)存呢?
在GitHub上,有一個函數(shù)可以計算實際大小,通過遞歸調(diào)用所有對象的getsizeof實現(xiàn)。
- def get_size(obj, seen=None):
- # From https://goshippo.com/blog/measure-real-size-any-python-object/
- # Recursively finds size of objects
- size = sys.getsizeof(obj)
- if seen is None:
- seen = set()
- obj_id = id(obj)
- if obj_id in seen:
- return 0
- # Important mark as seen *before* entering recursion to gracefully handle
- # self-referential objects
- seen.add(obj_id)
- if isinstance(obj, dict):
- size += sum([get_size(v, seen) for v in obj.values()])
- size += sum([get_size(k, seen) for k in obj.keys()])
- elif hasattr(obj, '__dict__'):
- size += get_size(obj.__dict__, seen)
- elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes, bytearray)):
- size += sum([get_size(i, seen) for i in obj])
- return size
讓我們試一下:
- d1 = DataItem("Alex", 42, "-")
- print ("get_size(d1):", get_size(d1))
- d2 = DataItem("Boris", 24, "In the middle of nowhere")
- print ("get_size(d2):", get_size(d2))
我們分別得到460和484字節(jié),這似乎更接近事實。
使用這個函數(shù),我們可以進(jìn)行一系列實驗。例如,我想知道如果DataItem放在列表中,數(shù)據(jù)將占用多少空間。
get_size([d1])函數(shù)返回532個字節(jié),顯然,這些是“原本的”460+一些額外開銷。但是get_size([d1,d2])返回863個字節(jié)—小于460+484。get_size([d1,d2,d1])的結(jié)果更加有趣,它產(chǎn)生了871個字節(jié),只是稍微多了一點,這說明Python很聰明,不會再為同一個對象分配內(nèi)存。
現(xiàn)在我們來看問題的第二部分。
是否有可能減少內(nèi)存消耗?
答案是肯定的。Python是一個解釋器,我們可以隨時擴展我們的類,例如,添加一個新字段:
- d1 = DataItem("Alex", 42, "-")
- print ("get_size(d1):", get_size(d1))
- d1.weight = 66
- print ("get_size(d1):", get_size(d1))
這是一個很棒的特點,但是如果我們不需要這個功能,我們可以強制解釋器使用__slots__指令來指定類屬性列表:
- class DataItem(object):
- __slots__ = ['name', 'age', 'address']
- def __init__(self, name, age, address):
- self.name = name
- self.age = age
- self.address = address
更多信息可以參考文檔中的“__dict__和__weakref__的部分。使用__dict__所節(jié)省的空間可能會很大”。
我們嘗試后發(fā)現(xiàn):get_size(d1)返回的是64字節(jié),對比460直接,減少約7倍。作為獎勵,對象的創(chuàng)建速度提高了約20%(請參閱文章的第一個屏幕截圖)。
真正使用如此大的內(nèi)存增益不會導(dǎo)致其他開銷成本。只需添加元素即可創(chuàng)建100,000個數(shù)組,并查看內(nèi)存消耗:
- data = []
- for p in range(100000):
- data.append(DataItem("Alex", 42, "middle of nowhere"))
- snapshot = tracemalloc.take_snapshot()
- top_stats = snapshot.statistics('lineno')
- total = sum(stat.size for stat in top_stats)
- print("Total allocated size: %.1f MB" % (total / (1024*1024)))
在沒有__slots__的情況結(jié)果為16.8MB,而使用__slots__時為6.9MB。當(dāng)然不是7倍,但考慮到代碼變化很小,它的表現(xiàn)依然出色。
現(xiàn)在討論一下這種方式的缺點。激活__slots__會禁止創(chuàng)建其他所有元素,包括__dict__,這意味著,例如,下面這種將結(jié)構(gòu)轉(zhuǎn)換為json的代碼將不起作用:
- def toJSON(self):
- return json.dumps(self.__dict__)
但這也很容易搞定,可以通過編程方式生成你的dict,遍歷循環(huán)中的所有元素:
- def toJSON(self):
- data = dict()
- for var in self.__slots__:
- data[var] = getattr(self, var)
- return json.dumps(data)
向類中動態(tài)添加新變量也是不可能的,但在我們的項目里,這不是必需的。
下面是最后一個小測試。來看看整個程序需要多少內(nèi)存。在程序末尾添加一個無限循環(huán),使其持續(xù)運行,并查看Windows任務(wù)管理器中的內(nèi)存消耗。
沒有__slots__時
69Mb變成27Mb......好吧,畢竟我們節(jié)省了內(nèi)存。對于只添加一行代碼的結(jié)果來說已經(jīng)很好了。
注意:tracemalloc調(diào)試庫使用了大量額外的內(nèi)存。顯然,它為每個創(chuàng)建的對象添加了額外的元素。如果你將其關(guān)閉,總內(nèi)存消耗將會少得多,截圖顯示了2個選項:
如何節(jié)省更多的內(nèi)存?
可以使用numpy庫,它允許你以C風(fēng)格創(chuàng)建結(jié)構(gòu),但在這個的項目中,它需要更深入地改進(jìn)代碼,所以對我來說第一種方法就足夠了。
奇怪的是,__slots__的使用從未在Habré上詳細(xì)分析過,我希望這篇文章能夠填補這一空白。
結(jié)論
這篇文章看起來似乎是反Python的廣告,但它根本不是。Python是非常可靠的(為了“刪除”Python中的程序,你必須非常努力),這是一種易于閱讀和方便編寫的語言。在許多情況下,這些優(yōu)點遠(yuǎn)勝過缺點,但如果你需要性能和效率的最大化,你可以使用numpy庫像C++一樣編寫代碼,它可以非??焖儆行У靥幚頂?shù)據(jù)。
最后,祝你編程愉快!
相關(guān)報道:
https://medium.com/@alexmaisiura/python-how-to-reduce-memory-consumption-by-half-by-adding-just-one-line-of-code-56be6443d524
【本文是51CTO專欄機構(gòu)大數(shù)據(jù)文摘的原創(chuàng)譯文,微信公眾號“大數(shù)據(jù)文摘( id: BigDataDigest)”】