ハッシュテーブルに直接キー・値・ハッシュ値を格納。スロットが空でもフルサイズの領域を確保していた。
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 倍近い差が出る。構造が固定されているなら、namedtuple や dataclass(slots=True) を検討する価値がある。
dict のメモリ効率が改善された歴史
Python 3.6 で辞書の内部実装が変わり、メモリ効率が大幅に改善された。
インデックス配列とエントリ配列を分離。空スロットは小さなインデックスのみで済むようになり、メモリ使用量が 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 で発生し、テーブルサイズが倍増する。大量の辞書を扱うなら、namedtuple や dataclass への置き換えも検討してみてください。