Kodokon kodokon.com

パフォーマンス:プロファイリング、GIL、ジェネレーター対リスト、メモリの落とし穴

最適化の前に測定しましょう。timeitとcProfile、GILが本当に妨げているもの、ジェネレーターの倹約さ、そして文字列とスライスに潜むメモリの落とし穴です。

11 分 · 3 問

このレッスンを Kodokon で開く

推測は決してせず、測りましょう。timeitは信頼できるマイクロベンチマークを切り出します。ガベージコレクターを無効にし、実行を繰り返してノイズをならすのです。cProfileはプログラム全体の呼び出しを地図にします - cumtime(サブ呼び出しを含む累積時間)で並べ替えて、本当の犯人を特定しましょう。あらゆるパフォーマンス作業の黄金律はこうです。遅い行は、あなたが疑っている行であることはまずありません。

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
timeitの-sオプションは測定の外で文脈を準備する

GIL(Global Interpreter Lock)は、一つのプロセスの中で同時にPythonのバイトコードを実行するスレッドは一つだけであることを保証します。その帰結として、スレッドは純粋な計算を速くしません。とはいえI/Oには依然として最適です。ネットワークやディスクの読み込みのあいだ、そしてNumPyのような多くのCライブラリによって、GILは解放されるからです。複数のコアを計算で埋め尽くすには、multiprocessingconcurrent.futures.ProcessPoolExecutorを経由しましょう。Python 3.13にはGILのない実験的な「フリースレッド」版がありますが、本番ではまだ稀です。

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
リストはおよそ8MB、ジェネレーターはおよそ200バイト

ジェネレーターは、列の長さがどうであれ数百バイトしかありません。保存するのは自分の実行状態だけで、各値は求められたときに生み出します。パイプラインとしてつなげれば - sum(x * x for x in data) - 中間のリストを一度も実体化せずにストリームを処理できます。承知しておくべきトレードオフもあります。ジェネレーターは使い切りです。いったん尽きたら、それ以上は何も生み出さず、しかも黙ったままです - 二度目の反復が「空」に見える、というバグの典型的な原因です。

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
自分のマシンで測ろう。joinは移植性のある安全な選択であり続ける

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. GILは正確には何を妨げていますか?
    • multiprocessingを含む、Pythonにおけるあらゆる形の並列処理
    • 同じプロセスの複数のスレッドによるPythonバイトコードの同時実行 - I/Oや多くのCコードはGILを解放する
    • 一つのプログラムの中で複数のスレッドを使うこと
    • 同じ変数への並行した書き込み
  2. なぜジェネレーターのsys.getsizeofは、同等のリストに比べてごく小さいままなのですか?
    • ジェネレーターは要素を一つも保存しないから。自分の実行状態だけを保持し、各値は求められたときに計算する
    • getsizeofはジェネレーターを測れないから
    • ジェネレーターは内部でデータを圧縮しているから
  3. なぜ''.join(parts)は、ループの中で繰り返されるout += partに勝るのですか?
    • joinは複数のスレッドを使って文字列を組み立てるから
    • それは俗説で、Python 3以降は性能は同じだから
    • strはイミュータブルなので、+=のたびに文字列全体をコピーし直す(二次のコスト)。joinは最終的なサイズを計算して一度だけ確保する
    • joinはジェネレーターの中間的なstrオブジェクトの生成を避けるから