1. 程式人生 > >Python在計算記憶體時應該注意的問題?

Python在計算記憶體時應該注意的問題?

我之前的[一篇文章](https://mp.weixin.qq.com/s/8f259oIGCQtY6KFSx4KW6Q),帶大家揭曉了 Python 在給內建物件分配記憶體時的 5 個奇怪而有趣的小祕密。文中使用了`sys.getsizeof()`來計算記憶體,但是用這個方法計算時,可能會出現意料不到的問題。 文件中關於這個方法的介紹有兩層意思: - 該方法用於獲取一個物件的位元組大小(bytes) - 它只計算直接佔用的記憶體,而不計算物件內所引用物件的記憶體 也就是說,getsizeof() 並不是計算實際物件的位元組大小,而是計算“佔位物件”的大小。如果你想計算所有屬性以及屬性的屬性的大小,getsizeof() 只會停留在第一層,這對於存在引用的物件,計算時就不準確。 例如列表 [1,2],getsizeof() 不會把列表內兩個元素的實際大小算上,而只是計算了對它們的引用。 舉一個形象的例子,我們把列表想象成一個箱子,把它儲存的物件想象成一個個球,現在箱子裡有兩張紙條,寫上了球 1 和球 2 的地址(球不在箱子裡),getsizeof() 只是把整個箱子稱重(含紙條),而沒有根據紙條上地址,找到兩個球一起稱重。 ## 1、計算的是什麼? 我們先來看看列表物件的情況: ![](https://img2020.cnblogs.com/blog/1573275/202003/1573275-20200302175429512-285033354.jpg) 如圖所示,單獨計算 a 和 b 列表的結果是 36 和 48,然後把它們作為 c 列表的子元素時,該列表的計算結果卻僅僅才 36。(PS:我用的是 32 位直譯器) 如果不使用引用方式,而是直接把子列表寫進去,例如 “d = [[1,2],[1,2,3,4,5]]”,這樣計算 d 列表的結果也還是 36,因為子列表是獨立的物件,在 d 列表中儲存的是它們的 id。 也就是說:getsizeof() 方法在計算列表大小時,其結果跟元素個數相關,但跟元素本身的大小無關。 下面再看看字典的例子: ![](https://img2020.cnblogs.com/blog/1573275/202003/1573275-20200302175430628-1066859013.jpg) 明顯可以看出,三個字典實際佔用的全部記憶體不可能相等,但是 getsizeof() 方法給出的結果卻相同,這意味著它只關心鍵的數量,而不關心實際的鍵值對是什麼內容,情況跟列表相似。 ## 2、“淺計算”與其它問題 有個概念叫“淺拷貝”,指的是 copy() 方法只拷貝引用物件的記憶體地址,而非實際的引用物件。類比於這個概念,我們可以認為 getsizeof() 是一種“淺計算”。 “淺計算”不關心真實的物件,所以其計算結果只是一個假象。這是一個值得注意的問題,但是注意到這點還不夠,我們還可以發散地思考如下的問題: - “淺計算”方法的底層實現是怎樣的? - 為什麼 getsizeof() 會採用“淺計算”的方法? 關於第一個問題,getsizeof(x) 方法實際會呼叫 x 物件的`__sizeof__()` 魔術方法,對於內建物件來說,這個方法是通過 CPython 直譯器實現的。 我查到這篇文章《[Python中物件的記憶體使用(一)](https://www.dongwm.com/post/python-memory-usage-1/)》,它分析了 CPython 原始碼,最終定位到的核心程式碼是這一段: ```c /*longobject.c*/ static Py_ssize_t int___sizeof___impl(PyObject *self) { Py_ssize_t res; res = offsetof(PyLongObject, ob_digit) + Py_ABS(Py_SIZE(self))*sizeof(digit); return res; } ``` 我看不懂這段程式碼,但是可以知道的是,它在計算 Python 物件的大小時,只跟該物件的結構體的屬性相關,而沒有進一步作“深度計算”。 對於 CPython 的這種實現,我們可以注意到兩個層面上的區別: - 位元組增大:int 型別在 C 語言中只佔到 4 個位元組,但是在 Python 中,int 其實是被封裝成了一個物件,所以在計算其大小時,會包含物件結構體的大小。在 32 位直譯器中,getsizeof(1) 的結果是 14 個位元組,比數字本身的 4 位元組增大了。 - 位元組減少:對於相對複雜的物件,例如列表和字典,這套計算機制由於沒有累加內部元素的佔用量,就會出現比真實佔用記憶體小的結果。 由此,我有一個不成熟的猜測:基於“一切皆是物件”的設計原則,int 及其它基礎的 C 資料型別在 Python 中被套上了一層“殼”,所以需要一個方法來計算它們的大小,也即是 getsizeof()。 官方文件中說“[All built-in objects will return correct results](https://docs.python.org/3/library/sys.html#sys.getsizeof)” [1],指的應該是數字、字串和布林值之類的簡單物件。但是不包括列表、元組和字典等在內部存在引用關係的型別。 為什麼不推廣到所有內建型別上呢?我未查到這方面的解釋,若有知情的同學,煩請告知。 ## 3、“深計算”與其它問題 與“淺計算”相對應,我們可以定義出一種“深計算”。對於前面的兩個例子,“深計算”應該遍歷每個內部元素以及可能的子元素,累加計算它們的位元組,最後算出總的記憶體大小。 那麼,我們應該注意的問題有: - 是否存在“深計算”的方法/實現方案? - 實現“深計算”時應該注意什麼? Stackoverflow 網站上有個年代久遠的問題“[How do I determine the size of an object in Python?](https://dwz.cn/5m83JStN)” [2],實際上問的就是如何實現“深計算”的問題。 有不同的開發者貢獻了兩個專案:`pympler` 和 `pysize` :第一個專案已釋出在 Pypi 上,可以“pip install pympler”安裝;第二個專案爛尾了,作者也沒釋出到 Pypi 上(注:Pypi 上已有個 pysize 庫,是用來做格式轉化的,不要混淆),但是可以在 Github 上獲取到其原始碼。 對於前面的兩個例子,我們可以拿這兩個專案分別測試一下: ![](https://img2020.cnblogs.com/blog/1573275/202003/1573275-20200302175432033-426685609.jpg) 單看數值的話,pympler 似乎確實比 getsizeof() 合理多了。 再看看 pysize,直接看測試結果是(獲取其原始碼過程略): ```python 64 118 190 206 300281 30281 ``` 可以看出,它比 pympler 計算的結果略小。就兩個專案的完整度、使用量與社群貢獻者規模來看,pympler 的結果似乎更為可信。 那麼,它們分別是怎麼實現的呢?那微小的差異是怎麼導致的?從它們的實現方案中,我們可以學習到什麼呢? pysize 專案很簡單,只有一個核心方法: ```python def get_size(obj, seen=None): """Recursively finds size of objects in bytes""" 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 hasattr(obj, '__dict__'): for cls in obj.__class__.__mro__: if '__dict__' in cls.__dict__: d = cls.__dict__['__dict__'] if inspect.isgetsetdescriptor(d) or inspect.ismemberdescriptor(d): size += get_size(obj.__dict__, seen) break 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, '__iter__') and not isinstance(obj, (str, bytes, bytearray)): size += sum((get_size(i, seen) for i in obj)) if hasattr(obj, '__slots__'): # can have __slots__ with __dict__ size += sum(get_size(getattr(obj, s), seen) for s in obj.__slots__ if hasattr(obj, s)) return size ``` 除去判斷`__dict__` 和 `__slots__` 屬性的部分(針對類物件),它主要是對字典型別及可迭代物件(除字串、bytes、bytearray)作遞迴的計算,邏輯並不複雜。 以 [1,2] 這個列表為例,它先用 sys.getsizeof() 算出 36 位元組,再計算內部的兩個元素得 14*2=28 位元組,最後相加得到 64 位元組。 相比之下,pympler 所考慮的內容要多很多,入口在這: ```python def asizeof(self, *objs, **opts): '''Return the combined size of the given objects (with modified options, see method **set**). ''' if opts: self.set(**opts) self.exclude_refs(*objs) # skip refs to objs return sum(self._sizer(o, 0, 0, None) for o in objs) ``` 它可以接受多個引數,再用 sum() 方法合併。所以核心的計算方法其實是 \_sizer()。但程式碼很複雜,繞來繞去像一座迷宮: ```python def _sizer(self, obj, pid, deep, sized): # MCCABE 19 '''Size an object, recursively. ''' s, f, i = 0, 0, id(obj) if i not in self._seen: self._seen[i] = 1 elif deep or self._seen[i]: # skip obj if seen before # or if ref of a given obj self._seen.again(i) if sized: s = sized(s, f, name=self._nameof(obj)) self.exclude_objs(s) return s # zero else: # deep == seen[i] == 0 self._seen.again(i) try: k, rs = _objkey(obj), [] if k in self._excl_d: self._excl_d[k] += 1 else: v = _typedefs.get(k, None) if not v: # new typedef _typedefs[k] = v = _typedef(obj, derive=self._derive_, frames=self._frames_, infer=self._infer_) if (v.both or self._code_) and v.kind is not self._ign_d: # 貓注:這裡計算 flat size s = f = v.flat(obj, self._mask) # flat size if self._profile: # profile based on *flat* size self._prof(k).update(obj, s) # recurse, but not for nested modules if v.refs and deep < self._limit_ \ and not (deep and ismodule(obj)): # add sizes of referents z, d = self._sizer, deep + 1 if sized and deep < self._detail_: # use named referents self.exclude_objs(rs) for o in v.refs(obj, True): if isinstance(o, _NamedRef): r = z(o.ref, i, d, sized) r.name = o.name else: r = z(o, i, d, sized) r.name = self._nameof(o) rs.append(r) s += r.size else: # just size and accumulate for o in v.refs(obj, False): # 貓注:這裡遞迴計算 item size s += z(o, i, d, None) # deepest recursion reached if self._depth < d: self._depth = d if self._stats_ and s > self._above_ > 0: # rank based on *total* size self._rank(k, obj, s, deep, pid) except RuntimeError: # XXX RecursionLimitExceeded: self._missed += 1 if not deep: self._total += s # accumulate if sized: s = sized(s, f, name=self._nameof(obj), refs=rs) self.exclude_objs(s) return s ``` 它的核心邏輯是把每個物件的 size 分為兩部分:flat size 和 item size。 計算 flat size 的邏輯在: ```python def flat(self, obj, mask=0): '''Return the aligned flat size. ''' s = self.base if self.leng and self.item > 0: # include items s += self.leng(obj) * self.item # workaround sys.getsizeof (and numpy?) bug ... some # types are incorrectly sized in some Python versions # (note, isinstance(obj, ()) == False) # 貓注:不可 sys.getsizeof 的,則用上面邏輯,可以的,則用下面邏輯 if not isinstance(obj, _getsizeof_excls): s = _getsizeof(obj, s) if mask: # align s = (s + mask) & ~mask return s ``` 這裡出現的 mask 是為了作位元組對齊,預設值是 7,該計算公式表示按 8 個位元組對齊。對於 [1,2] 列表,會算出 (36+7)&~7=40 位元組。同理,對於單個的 item,比如列表中的數字 1,sys.getsizeof(1) 等於 14,而 pympler 會算成對齊的數值 16,所以彙總起來是 40+16+16=72 位元組。這就解釋了為什麼 pympler 算的結果比 pysize 大。 位元組對齊一般由具體的編譯器實現,而且不同的編譯器還會有不同的策略,理論上 Python 不應關心這麼底層的細節,內建的 getsizeof() 方法就沒有考慮位元組對齊。 在不考慮其它 edge cases 的情況下,可以認為 pympler 是在 getsizeof() 的基礎上,既考慮了遍歷取引用物件的 size,又考慮到了實際儲存時的位元組對齊問題,所以它會顯得更加貼近現實。 ## 4、小結 getsizeof() 方法的問題是顯而易見的,我創造了一個“淺計算”概念給它。這個概念借鑑自 copy() 方法的“淺拷貝”,同時對應於 deepcopy() “深拷貝”,我們還能推理出一個“深計算”。 前面展示了兩個試圖實現“深計算”的專案(pysize+pympler),兩者在淺計算的基礎上,深入地求解引用物件的大小。pympler 專案的完整度較高,程式碼中有很多細節上的設計,比如位元組對齊。 Python 官方團隊當然也知道 getsizeof() 方法的侷限性,他們甚至在文件中加了一個[連結](https://code.activestate.com/recipes/577504) [3],指向了一份實現深計算的示例程式碼。那份程式碼比 pysize 還要簡單(沒有考慮類物件的情況)。 未來 Python 中是否會出現深計算的方法,假設命名為 getdeepsizeof() 呢?這不得而知了。 本文的目的是加深對 getsizeof() 方法的理解,區分淺計算與深計算,分析兩個深計算專案的實現思路,指出幾個值得注意的問題。 讀完這裡,希望你也能有所收穫。若有什麼想法,歡迎一起交流。 ### 相關連結 Python 記憶體分配時的小祕密:https://dwz.cn/AoSdCZfo Python中物件的記憶體使用(一):https://dwz.cn/SXGtXklz [1] https://dwz.cn/yxg72lyS [2] https://dwz.cn/5m83JStN [3] https://code.activestate.com/recipes/577504 ![](https://img2020.cnblogs.com/blog/1573275/202003/1573275-20200302175434398-866300729.jpg) 公眾號【**Python貓**】, 本號連載優質的系列文章,有喵星哲學貓系列、Python進階系列、好書推薦系列、技術寫作、優質英文推薦與翻譯等等,歡迎關