【Python】tracemallocでメモリが増えた場所を調べる

PythonのTopに戻る

結論:処理前後のスナップショットを比較する

Pythonの処理を繰り返すとメモリが増える場合、tracemallocで前後のスナップショットを取り、compare_to(..., "lineno")で増加した割り当て元を調べる。単にプロセスの使用量を見るより、どの行から作られたオブジェクトが残っているかへ近づける。まず同じ入力の短い処理で調べ、増加箇所を絞ってから大きな実験へ進むと扱いやすい。

ここで追うのはトレース対象になっているメモリブロックであり、プロセス全体のRSSではない。拡張ライブラリが独自に確保した領域、OSのキャッシュ、マップしたファイルなどが同じように見えるとは限らない。NumPyを含むライブラリ側の対応にも依存するため、「tracemallocで増えていないから全メモリが増えていない」とは判断できない。

そのまま動かせる例

例では1 KiBのbytearrayを200個作ってリストに保持し、増加した行を調べる。その後でリストを空にし、同じ割り当て箇所のメモリが減ることも確認する。標準ライブラリだけで動き、大量のメモリをわざと消費しない。コードをexample.pyという名前で保存して実行してほしい。自分のファイル名へ変えるなら、フィルターの条件も合わせる。

表示されるバイト数やブロック数は、Pythonの版や実行状態で変わり得る。掲載した値はこの環境での実測値で、メモリ消費量の一般的な保証ではない。200×1024バイトの内容だけでなく、Pythonオブジェクトや参照を管理する費用が含まれる点を見る。行番号は保存したファイルの行配置に対応する。

import gc
import tracemalloc
from pathlib import Path

tracemalloc.start(10)
try:
    before = tracemalloc.take_snapshot()
    held = [bytearray(1024) for _ in range(200)]
    after = tracemalloc.take_snapshot()
    changes = after.compare_to(before, "lineno")
    local = [item for item in changes
             if Path(item.traceback[0].filename).name == "example.py"
             and item.size_diff > 0]
    biggest = max(local, key=lambda item: item.size_diff)
    frame = biggest.traceback[0]
    print(f"largest increase: {Path(frame.filename).name}:{frame.lineno}")
    print("size_diff bytes:", biggest.size_diff)
    print("count_diff:", biggest.count_diff)
    assert biggest.size_diff >= 200 * 1024
    assert biggest.count_diff > 0
    current, peak = tracemalloc.get_traced_memory()
    assert peak >= current
    print("peak >= current:", peak >= current)
    held.clear()
    gc.collect()
    released = tracemalloc.take_snapshot()
    shrink = released.compare_to(after, "lineno")
    assert any(item.size_diff < -200 * 1024 and item.traceback[0] == frame
               for item in shrink)
    print("allocation released at same source line: True")
finally:
    tracemalloc.stop()

実行結果(バイト数・ブロック数は実測値で環境に依存する)

largest increase: example.py:8
size_diff bytes: 217800
count_diff: 401
peak >= current: True
allocation released at same source line: True

size_diffとcount_diffの読み方

size_diffは比較前後で保持されているトレース対象のバイト数の差、count_diffはブロック数の差である。大きな正の差があればその場所で増え、負なら減っている。オブジェクトの個数とメモリブロック数が必ず一対一になるわけではない。この例でもbytearray一つの管理部分と内容などの確保が関係するので、count_diffをそのまま試料数へ読み替えないこと。

二時点の比較は、その間に一時的に作られて既に解放された領域を全て列挙するものではない。瞬間的な最大使用量を知りたい場合はget_traced_memoryが返すpeakも手掛かりになる。ただしそれもトレース対象内のピークである。継続して残る増加と、処理中だけ発生する一時的なピークを区別すると、対処方法を選びやすい。

割り当て元と保持している原因は別

スナップショットの行はオブジェクトが割り当てられた場所を示す。そこにバグがあるとは限らず、別の関数が結果をキャッシュへ入れ続けたり、リストへ蓄積したりしている可能性がある。増加した行を見つけたら、その値を誰が参照しているのか、いつ不要になるはずなのかを確認しよう。正常なキャッシュの成長も、メモリリークのように見える場合がある。

tracemalloc.start(10)は割り当て時の呼び出し履歴を最大10フレーム保存する指定である。行ごとの集約で分からなければtraceback単位で比較し、どの呼び出し経路から来たかを見る。深い履歴ほど追跡の費用も増えるので、必要以上に大きくしない。追跡を始める前の割り当ては同じように調べられないため、問題の処理より前に開始する。

測定条件をそろえて再確認する

測定のたびに新しいデータや別のキャッシュを作ると、差分が本来の問題以外に埋もれる。同じ入力、同じ回数、同じ処理段階でスナップショットを取るのが基本である。初回だけのimportや初期化は別に扱い、繰り返し後に増加が収まるかを見る。スナップショットや出力結果自体を大量に保持しないよう、調査コードの負荷にも注意したい。

例のgc.collectは解放の確認をしやすくするために呼んでいるが、参照が残っている値を強制的に不要にする機能ではない。毎回呼べば根本原因が直るというものでもない。リストを空にした後に割り当てが減っても、OSへメモリが直ちに戻るとは限らないので、RSSの変化と区別しよう。原因を直したら追跡なしでも処理が正しく動くことを確かめ、必要な範囲だけの測定へ戻すとよい。

確認環境と参考資料

例はLinux・CPython 3.12.14で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。

関連項目:大量の図を保存するときのメモリ増加を防ぐ / 巨大なNumPy配列を必要な範囲だけ読む / cProfileで遅い処理を探す:tottimeとcumtimeの読み方

PythonのTopに戻る