Python 辞書のメモリ使用量を実測する:sys.getsizeof の罠と pympler による再帰計測

Python の辞書はどれくらいのメモリを消費しているのか。sys.getsizeof() で調べようとして、想定外の結果に困惑した経験はないだろうか? この関数には「罠」がある。

sys.getsizeof() が返す値の正体

sys.getsizeof() はオブジェクト自体のメモリ使用量を返す。ただし、オブジェクトが参照している他のオブジェクトは含まれない。

import sys

d = {"name": "Alice", "age": 30}
print(sys.getsizeof(d))  # 184(環境により異なる)

この 184 バイトは辞書のハッシュテーブル構造だけを指している。キーの文字列 "name""age"、値の "Alice"30 は別のオブジェクトとしてメモリ上に存在する。つまり、辞書が「本当に」消費しているメモリはもっと大きい。

import sys

d = {"name": "Alice", "age": 30}

# 辞書本体
print(sys.getsizeof(d))           # 184

# キーと値を個別に計測
print(sys.getsizeof("name"))      # 53
print(sys.getsizeof("Alice"))     # 54
print(sys.getsizeof("age"))       # 52
print(sys.getsizeof(30))          # 28

すべてを足し合わせると 371 バイト。しかしこれでも正確ではない。ネストした辞書やリストがあれば、その中身も再帰的に計測しなければならない。

pympler による再帰的計測

pympler ライブラリの asizeof() は、オブジェクトが参照するすべてのオブジェクトを再帰的にたどり、合計サイズを返します。

from pympler import asizeof

d = {"name": "Alice", "age": 30}
print(asizeof.asizeof(d))  # 448(環境により異なる)

sys.getsizeof() の 184 バイトに対し、asizeof() は 448 バイト。2倍以上の差がある。ネストが深くなるほど、この差は広がっていく。

from pympler import asizeof

nested = {
    "user": {
        "profile": {
            "name": "Alice",
            "tags": ["python", "data", "ml"]
        }
    }
}

print(asizeof.asizeof(nested))  # 1000 前後

空の辞書はなぜ大きいのか

空の辞書でも一定のメモリを消費する。

import sys

empty = {}
print(sys.getsizeof(empty))  # 64

64 バイトは辞書オブジェクトのヘッダと、最小サイズのハッシュテーブルを確保するため。CPython は辞書を作成した時点で 8 スロット分の領域を用意している。

リサイズのタイミング

辞書に要素を追加していくと、ある時点でメモリ使用量が急増する。これはハッシュテーブルのリサイズが発生したタイミング。

import sys

d = {}
prev_size = sys.getsizeof(d)

for i in range(20):
    d[i] = i
    current_size = sys.getsizeof(d)
    if current_size != prev_size:
        print(f"要素数 {len(d)}: {prev_size} -> {current_size} バイト")
        prev_size = current_size

実行結果の例:

要素数 1: 64 -> 184 バイト
要素数 6: 184 -> 344 バイト
要素数 11: 344 -> 656 バイト

CPython は負荷率(使用スロット数 ÷ 全スロット数)が約 2/3 を超えるとリサイズを行う。リサイズ時にはテーブルサイズがおよそ 2 倍になり、すべてのエントリが再配置される。

リサイズ前

スロットの 2/3 が埋まっている。次の挿入でリサイズが発生する。

リサイズ後

テーブルサイズが 2 倍に拡張される。負荷率は約 1/3 に下がり、余裕ができる。

大量の小さな辞書を作るときの注意

辞書を大量に生成するとき、オーバーヘッドが無視できなくなる。

from pympler import asizeof

# 辞書 10000 個
dicts = [{"x": i, "y": i * 2} for i in range(10000)]
print(asizeof.asizeof(dicts) / 1024 / 1024, "MB")  # 約 4.5 MB

# 名前付きタプル 10000 個
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
tuples = [Point(i, i * 2) for i in range(10000)]
print(asizeof.asizeof(tuples) / 1024 / 1024, "MB")  # 約 1.2 MB

同じデータでも、辞書と名前付きタプルでは 4 倍近い差が出る。構造が固定されているなら、namedtupledataclass(slots=True) を検討する価値がある。

dict のメモリ効率が改善された歴史

Python 3.6 で辞書の内部実装が変わり、メモリ効率が大幅に改善された。

Python 3.5 以前
従来の実装

ハッシュテーブルに直接キー・値・ハッシュ値を格納。スロットが空でもフルサイズの領域を確保していた。

Python 3.6
Compact Dict 導入

インデックス配列とエントリ配列を分離。空スロットは小さなインデックスのみで済むようになり、メモリ使用量が 20〜25% 削減された。

Python 3.6 以降では挿入順序も保持されるようになった。これは副次的な効果で、Compact Dict の設計がたまたま順序を保つ構造だったため。

pympler のその他の機能

pympler には asizeof() 以外にも便利な機能がある。

from pympler import asizeof

d = {"a": [1, 2, 3], "b": {"nested": "value"}}

# 詳細な内訳を表示
asizeof.asized(d, detail=1).format()

asized() を使うと、どの部分がどれだけメモリを消費しているか内訳を確認できる。メモリ最適化の際に、ボトルネックを特定するのに役立ちます。

まとめ

sys.getsizeof() は参照先を含まない「浅い」計測しかできない。辞書の本当のメモリ使用量を知りたいなら pympler.asizeof() を使う。リサイズは負荷率 2/3 で発生し、テーブルサイズが倍増する。大量の辞書を扱うなら、namedtupledataclass への置き換えも検討してみてください。