Python在計算內存時應該注意的問題

我以前的一篇文章,帶你們揭曉了 Python 在給內置對象分配內存時的 5 個奇怪而有趣的小祕密。文中使用了sys.getsizeof()來計算內存,可是用這個方法計算時,可能會出現意料不到的問題。html

文檔中關於這個方法的介紹有兩層意思:python

  • 該方法用於獲取一個對象的字節大小(bytes)
  • 它只計算直接佔用的內存,而不計算對象內所引用對象的內存

也就是說,getsizeof() 並非計算實際對象的字節大小,而是計算「佔位對象」的大小。若是你想計算全部屬性以及屬性的屬性的大小,getsizeof() 只會停留在第一層,這對於存在引用的對象,計算時就不許確。git

例如列表 [1,2],getsizeof() 不會把列表內兩個元素的實際大小算上,而只是計算了對它們的引用。app

舉一個形象的例子,咱們把列表想象成一個箱子,把它存儲的對象想象成一個個球,如今箱子裏有兩張紙條,寫上了球 1 和球 2 的地址(球不在箱子裏),getsizeof() 只是把整個箱子稱重(含紙條),而沒有根據紙條上地址,找到兩個球一塊兒稱重。post

一、計算的是什麼?

咱們先來看看列表對象的狀況:學習

如圖所示,單獨計算 a 和 b 列表的結果是 36 和 48,而後把它們做爲 c 列表的子元素時,該列表的計算結果卻僅僅才 36。(PS:我用的是 32 位解釋器)測試

若是不使用引用方式,而是直接把子列表寫進去,例如 「d = [[1,2],[1,2,3,4,5]]」,這樣計算 d 列表的結果也仍是 36,由於子列表是獨立的對象,在 d 列表中存儲的是它們的 id。網站

也就是說:getsizeof() 方法在計算列表大小時,其結果跟元素個數相關,但跟元素自己的大小無關。ui

下面再看看字典的例子:spa

明顯能夠看出,三個字典實際佔用的所有內存不可能相等,可是 getsizeof() 方法給出的結果卻相同,這意味着它只關心鍵的數量,而不關心實際的鍵值對是什麼內容,狀況跟列表類似。

二、「淺計算」與其它問題

有個概念叫「淺拷貝」,指的是 copy() 方法只拷貝引用對象的內存地址,而非實際的引用對象。類比於這個概念,咱們能夠認爲 getsizeof() 是一種「淺計算」。

「淺計算」不關心真實的對象,因此其計算結果只是一個假象。這是一個值得注意的問題,可是注意到這點還不夠,咱們還能夠發散地思考以下的問題:

  • 「淺計算」方法的底層實現是怎樣的?
  • 爲何 getsizeof() 會採用「淺計算」的方法?

關於第一個問題,getsizeof(x) 方法實際會調用 x 對象的__sizeof__() 魔術方法,對於內置對象來講,這個方法是經過 CPython 解釋器實現的。

我查到這篇文章《Python中對象的內存使用(一)》,它分析了 CPython 源碼,最終定位到的核心代碼是這一段:

/*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」 [1],指的應該是數字、字符串和布爾值之類的簡單對象。可是不包括列表、元組和字典等在內部存在引用關係的類型。

爲何不推廣到全部內置類型上呢?我未查到這方面的解釋,如有知情的同窗,煩請告知。

三、「深計算」與其它問題

與「淺計算」相對應,咱們能夠定義出一種「深計算」。對於前面的兩個例子,「深計算」應該遍歷每一個內部元素以及可能的子元素,累加計算它們的字節,最後算出總的內存大小。

那麼,咱們應該注意的問題有:

  • 是否存在「深計算」的方法/實現方案?
  • 實現「深計算」時應該注意什麼?

Stackoverflow 網站上有個年代久遠的問題「How do I determine the size of an object in Python?」 [2],實際上問的就是如何實現「深計算」的問題。

有不一樣的開發者貢獻了兩個項目:pympler  pysize :第一個項目已發佈在 Pypi 上,能夠「pip install pympler」安裝;第二個項目爛尾了,做者也沒發佈到 Pypi 上(注:Pypi 上已有個 pysize 庫,是用來作格式轉化的,不要混淆),可是能夠在 Github 上獲取到其源碼。

對於前面的兩個例子,咱們能夠拿這兩個項目分別測試一下:

單看數值的話,pympler 彷佛確實比 getsizeof() 合理多了。

再看看 pysize,直接看測試結果是(獲取其源碼過程略):

64
118
190
206
300281
30281

能夠看出,它比 pympler 計算的結果略小。就兩個項目的完整度、使用量與社區貢獻者規模來看,pympler 的結果彷佛更爲可信。

那麼,它們分別是怎麼實現的呢?那微小的差別是怎麼致使的?從它們的實現方案中,咱們能夠學習到什麼呢?

pysize 項目很簡單,只有一個核心方法:

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 所考慮的內容要多不少,入口在這:

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()。但代碼很複雜,繞來繞去像一座迷宮:

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 的邏輯在:

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,又考慮到了實際存儲時的字節對齊問題,因此它會顯得更加貼近現實。

四、小結

getsizeof() 方法的問題是顯而易見的,我創造了一個「淺計算」概念給它。這個概念借鑑自 copy() 方法的「淺拷貝」,同時對應於 deepcopy() 「深拷貝」,咱們還能推理出一個「深計算」。

前面展現了兩個試圖實現「深計算」的項目(pysize+pympler),二者在淺計算的基礎上,深刻地求解引用對象的大小。pympler 項目的完整度較高,代碼中有不少細節上的設計,好比字節對齊。

Python 官方團隊固然也知道 getsizeof() 方法的侷限性,他們甚至在文檔中加了一個連接 [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

相關文章
相關標籤/搜索