Python のプロファイリング実測:本当に速くなった最適化

遅い単語カウンターを timeit・cProfile・tracemalloc で計測し、4.39秒から31msまで段階的に改善。属性への str += が二乗になる罠と、実測では効かなかった定番の高速化も示します。

シリーズ:Python 講座 6/6(上級)。 Python のコードは動くけれど遅い、そして小技の一覧ではなく「手順」を知りたい人向けです。読み終えると、測る → 本当のホットスポットを見つける → 直す → 直ったことを証明する、という流れを自分で回せるようになり、無視してよい「高速化の定番」も見分けられます。

社内のログ集計スクリプトが夜間バッチの時間内に終わらない。速くしようとループを三つ書き直したのに、ほとんど変わらなかった。この講座はそういう経験のある人のためのものです。わざと遅く書いたプログラムを一つ用意し、プロファイルを取り、段階ごとに時間を測りながら直していきます。結果を先に書くと、変更一つでプログラム全体が78倍速くなり、有名な小技の多くは計測誤差程度しか動きませんでした。

計測はすべて Apple Silicon Mac(M1 Pro)の Python 3.9.6 です。手元には Python 3.11 もありますが、Rosetta 2 の変換下で動くビルドなので、時間の計測には一切使っていません。この点は最後にもう一度触れます。

遅いプログラム

単語の出現回数を数えるプログラムです。ランダムに作った5,000語の語彙から20万語を取り出し(seed 42 で固定)、小文字にそろえ、初出順に重複を除き、回数を数えて上位5件を表示します。最初のバージョンとしてよく見かける書き方にしてあります。

# wordfreq_slow.py
import random

def make_words(n=200_000, vocab=5_000, seed=42):
    rnd = random.Random(seed)
    letters = "abcdefghijklmnopqrstuvwxyz"
    pool = ["".join(rnd.choice(letters) for _ in range(rnd.randint(3, 9))) for _ in range(vocab)]
    return [rnd.choice(pool) for _ in range(n)]

def normalize(words):
    text = ""
    for w in words:
        text += w.lower() + " "          # string concat in a loop
    return text.split()

def unique_in_order(words):
    seen = []
    for w in words:
        if w not in seen:                 # list membership: O(n)
            seen.append(w)
    return seen

def count(words):
    counts = {}
    for w in words:
        if w in counts:
            counts[w] = counts[w] + 1
        else:
            counts[w] = 1
    return counts

def top(counts, k=5):
    return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:k]

def main():
    words = normalize(make_words())
    uniq = unique_in_order(words)
    counts = count(words)
    return len(words), len(uniq), top(counts)

if __name__ == "__main__":
    import time
    t = time.perf_counter()
    print(main())
    print(f"{time.perf_counter() - t:.2f} s")
(200000, 4985, [('mor', 96), ('jwc', 90), ('fyg', 89), ('tksm', 86), ('bef', 82)])
4.08 s

先に読む前に、どこが遅いか予想してみてください。多くの人は文字列連結を選びます。どの入門記事でも注意されているからです。

手順1:timeit で正しく測る

数秒かかる処理なら全体を time.perf_counter() で囲めば十分です。それより短いものは timeit を使います。文を何度も実行し、それを5回繰り返すので、最小値を採用します(最小値が他のプロセスの影響をいちばん受けていない回です)。

$ python3 -m timeit -s "from wordfreq_slow import make_words" "make_words()"
5 loops, best of 5: 81.7 msec per loop

よくある間違い:入力を書き換える処理を測る

$ S="import random; random.seed(1); data = [random.random() for _ in range(100_000)]"
$ python3 -m timeit -s "$S" "data.sort()"
1000 loops, best of 5: 215 usec per loop
$ python3 -m timeit -s "$S" "sorted(data)"
20 loops, best of 5: 12.4 msec per loop
$ python3 -m timeit -s "$S" "d = data[:]; d.sort()"
20 loops, best of 5: 12.5 msec per loop

実際に起きていること: setup は一度しか実行されません。最初の data.sort() がリストをその場で並べ替え、残りの4,999回は並べ替え済みのリストを相手にします。Timsort はそれを一回の走査で終えてしまうので、215µs という結果は58倍も楽観的です。直し方: 計測する文の中でコピーを作り(d = data[:]; d.sort())、必要ならコピーだけの時間(data[:] は150µs)を測って差し引きます。sort、pop、辞書の更新、キャッシュなど、入力を変える処理のベンチマークはすべてこの問題を抱えています。

手順2:cProfile と pstats でホットスポットを探す

$ python3 -m cProfile -s tottime wordfreq_slow.py
(200000, 4985, [('mor', 96), ('jwc', 90), ('fyg', 89), ('tksm', 86), ('bef', 82)])
4.33 s
         1562316 function calls (1562289 primitive calls) in 4.335 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    4.111    4.111    4.111    4.111 wordfreq_slow.py:16(unique_in_order)
   235049    0.055    0.000    0.074    0.000 random.py:237(_randbelow_with_getrandbits)
   230049    0.046    0.000    0.125    0.000 random.py:343(choice)
        1    0.024    0.024    0.037    0.037 wordfreq_slow.py:10(normalize)
        1    0.022    0.022    0.132    0.132 wordfreq_slow.py:8(<listcomp>)
        1    0.018    0.018    0.018    0.018 wordfreq_slow.py:23(count)

tottime は関数の中だけで使った時間、cumtime は呼び出した先も含めた時間です。unique_in_order という関数一つが 4.34秒のうち4.11秒(95%) を占めています。みんなが疑った文字列連結の normalize は全体で0.037秒です。最初にそこを「直して」いたら、4秒の処理から最大でも0.01〜0.04秒しか減らせなかった計算です。

コードの中から特定の処理だけをプロファイルし、累積時間で並べるには次のようにします。

import cProfile, pstats, wordfreq_slow as m
pr = cProfile.Profile(); pr.enable(); m.main(); pr.disable()
pstats.Stats(pr).strip_dirs().sort_stats("cumulative").print_stats(8)
         1560940 function calls in 4.153 seconds

   Ordered by: cumulative time
   List reduced from 28 to 8 due to restriction <8>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    4.153    4.153 wordfreq_slow.py:35(main)
        1    3.934    3.934    3.934    3.934 wordfreq_slow.py:16(unique_in_order)
        1    0.000    0.000    0.159    0.159 wordfreq_slow.py:4(make_words)
        1    0.022    0.022    0.131    0.131 wordfreq_slow.py:8(<listcomp>)
   230049    0.045    0.000    0.125    0.000 random.py:343(choice)

cProfile には Python レベルの関数呼び出しごとにオーバーヘッドがある点に注意してください。make_words はここでは0.159秒ですが、timeit では81.7msでした。プロファイラーは「どこか」を探す道具、timeit は「どれだけか」を測る道具と使い分けます。

フローチャート。perf_counter で全体を測る。cProfile を tottime 順で動かしてホットスポットを探す。そのホットスポットだけを直す。assert で出力が同じことを確認し、timeit で測り直す。十分速ければ終了。そうでなければホットスポットが移動しているので、もう一度プロファイルする。
この講座で使う最適化のループ。変更のたびに測り直し、出力が同じことを確かめてから次へ進む。

手順3:ホットスポットから順に直し、毎回測る

以下の各バージョンは、元のコードと重複除去の結果・カウントが完全に同じであることをスクリプト内の assert で確認したうえで、5回(v0 のみ3回)の中央値を取りました。単語の生成は含めていないので、処理部分だけの時間です。

バージョン 変更点 時間 v0 比
v0 元のコード 4,387.5ms 1倍
v1 重複除去をリストから set に 56.1ms 78倍
v2 大きな文字列を作らず、内包表記で小文字化 38.3ms 115倍
v3 重複除去に dict.fromkeys、集計に Counter 31.3ms 140倍
処理時間(ミリ秒)の棒グラフ。v0(リストで所属判定する元のコード):4,387.5。v1(set で判定):56.1。v2(文字列連結の代わりにリスト内包表記):38.3。v3(dict.fromkeys と Counter):31.3。
単語カウンターの処理時間を段階ごとに測ったもの(20万語、seed 42、5回の中央値、Python 3.9、Apple M1 Pro)。ほぼすべての効果は1段目だけで出た。

最終版は短くなります。

# wordfreq_fast.py
from collections import Counter
from wordfreq_slow import make_words

def main():
    words = [w.lower() for w in make_words()]
    uniq = list(dict.fromkeys(words))        # keeps first-seen order (3.7+)
    counts = Counter(words)
    top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:5]
    return len(words), len(uniq), top

単語の生成込みで0.12秒、出力は同じです。今度は make_words(82ms)がいちばん重い処理になったので、次にプロファイラーが指すのはそこです。

なぜ1段目だけで78倍なのか。リストに対する w not in seen は要素を一つずつ比較するので、20万語 × 最大4,985語で数億回の比較になります。set ならハッシュを一度計算するだけです。各段階のマイクロベンチマーク(timeit、5回の最小値):

--- dedupe 200k words (min of 5 repeats, 1 loops each)
  list membership                     4252.94 ms   x  1.00
  set membership                         3.87 ms   x1099.67
  dict.fromkeys                          4.03 ms   x1056.10
--- build text (min of 5 repeats, 20 loops each)
  str += in loop                        29.19 ms   x  1.00
  join(generator)                       22.31 ms   x  1.31
  join([list comp])                     19.33 ms   x  1.51
--- count words (min of 5 repeats, 20 loops each)
  if w in d                             13.18 ms   x  1.00
  d.get(w, 0) + 1                       13.77 ms   x  0.96
  try/except KeyError                   12.01 ms   x  1.10
  defaultdict(int)                      10.53 ms   x  1.25
  Counter(words)                         6.27 ms   x  2.10

join(ジェネレーター) より join([リスト内包表記]) のほうが速かったのは、str.join が受け取ったジェネレーターを結局リストにしてから処理するためで、ジェネレーターは手間が増えるだけです。Counter(words) が勝ったのは数えるループが C で動くからで、c[w] += 1 を手で回す使い方ではこの差は出ません。

よくある間違い:属性への += は二乗のコストになる

normalize の文字列連結が安かったことに驚く人は多いはずです。CPython には最適化があり、text += ... の左辺がただの変数で、その文字列を他に誰も参照していなければ、文字列をその場で拡張します。ただしこれは代入先が変数名のときだけです。属性に代入すると効かなくなります。

# concat_attr.py
import time
from wordfreq_slow import make_words

class Buf:
    def __init__(self): self.text = ""

def local_concat(words):
    text = ""
    for w in words:
        text += w + " "
    return text

def attr_concat(words):
    b = Buf()
    for w in words:
        b.text += w + " "        # attribute target: no in-place resize
    return b.text

for n in (50_000, 100_000, 200_000):
    words = make_words(n)
    for f in (local_concat, attr_concat):
        t = time.perf_counter(); f(words); dt = time.perf_counter() - t
        print(f"{f.__name__:<13} n={n:>7,}: {dt*1000:8.1f} ms")
local_concat  n= 50,000:      4.5 ms
attr_concat   n= 50,000:    203.8 ms
local_concat  n=100,000:      8.9 ms
attr_concat   n=100,000:    717.7 ms
local_concat  n=200,000:     17.7 ms
attr_concat   n=200,000:   2948.9 ms
ミリ秒の棒グラフ。ローカル変数:5万語4.5、10万語8.9、20万語17.7。属性 self.text:5万語203.8、10万語717.7、20万語2,948.9。
ループ内の str +=(Python 3.9)。ローカル変数なら CPython がその場で拡張するので時間は比例して伸びる。属性に代入すると += のたびに全体をコピーし、入力が2倍になると時間はおよそ4倍になる。

実際に起きていること: ローカル変数版は入力に比例して伸びます(4.5 → 8.9 → 17.7ms)。属性版は入力が2倍になるたびにおよそ4倍です(204 → 718 → 2,949ms)。+= のたびにそれまでの文字列全体をコピーしているからです。直し方: 部品をリストに集めて最後に一度だけ "".join(parts) するか、ローカル変数で組み立ててから最後に属性へ代入します。「join を使え」が今でも正しい助言なのはこのためです。速い経路は CPython の実装の詳細にすぎず、コードをクラスにリファクタリングした瞬間に失われます。

ローカル変数の参照、内包表記、map

--- 1M items: len(str(i)) (min of 5 repeats, 3 loops each)
  for + out.append (globals)           162.38 ms   x  1.00
  for + local aliases                  125.72 ms   x  1.29
  list comprehension                   139.07 ms   x  1.17
  map(len, map(str, ...))               93.13 ms   x  1.74
  map(lambda ...)                      132.86 ms   x  1.22

「ローカル変数に別名を付ける」小技(append = out.append、デフォルト引数で len=len)は本当に効き、3.9 で時間が22%減りました。ローカル変数の参照はグローバル名や組み込み名より安く、out.append を毎回取り直さなくて済むからです。内包表記はその効果の一部を自然に得られて、しかも読みやすい書き方です。組み込み関数を渡す map が最速だったのは、要素ごとに Python のフレームが動かないためで、lambda を渡すとこの利点は消えます。実際のコードなら内包表記を選びます。別名の小技で得られるのは100万件あたり13msで、その代わりに読みやすさを失います。

効かなかった「定番の高速化」(今回の計測では)

3.9 での実測から、この種のコードでは従う価値がなかった助言です。

定番の助言 実測 判定
ローカル変数のループで「str += を join に」 29 → 19ms。4.4秒のプログラムのうち0.04秒 やってよいが、ボトルネックではなかった
「d.get(k, 0) + 1 は if k in d より速い」 13.77ms 対 13.18ms むしろ少し遅い。読みやすいほうで
「確認するより try/except KeyError が速い」 12.01ms 対 13.18ms(9%) 小さく、キーが欠ける頻度次第
「map(lambda …) はループより速い」 132.9ms、内包表記は139.1ms 内包表記と誤差の範囲
「join(ジェネレーター) はメモリが少ないから速い」 22.3ms、リストなら19.3ms 遅い。join は内部でリストを作る

「定番の助言は全部ダメ」と結論しないように、効いたものも二つ挙げます。i ** 2 の代わりに i * i(100万回で214.50ms 対 34.97ms、6.1倍)と、while のカウンターの代わりに for i in range(N)(57.43ms 対 28.68ms)です。どちらもインタープリターのループ内の仕事を減らすもので、sum(range(N))(10.25ms)はループごと C に任せるので両方に勝ちます。傾向ははっきりしていて、処理を C 側に移す工夫は効き、Python の書き方を入れ替えるだけの工夫はたいてい効きません。

メモリ:tracemalloc

コストは時間だけではありません。normalize は全単語をつないだ大きな文字列を作り、そこからリストを作ります。tracemalloc で Python が確保したメモリを測ります。

# mem.py
import tracemalloc
from collections import Counter
from wordfreq_slow import make_words, normalize

words = make_words()

tracemalloc.start()
counts = Counter(normalize(words))            # builds one big string, then a list
cur, peak = tracemalloc.get_traced_memory()
print(f"normalize + Counter : peak {peak/1e6:5.1f} MB")
tracemalloc.stop()

tracemalloc.start()
counts2 = Counter(w.lower() for w in words)   # streams one word at a time
cur, peak = tracemalloc.get_traced_memory()
print(f"generator + Counter : peak {peak/1e6:5.1f} MB")
assert counts == counts2

snap = tracemalloc.take_snapshot()
for stat in snap.statistics("lineno")[:3]:
    print(stat)
tracemalloc.stop()
normalize + Counter : peak  14.0 MB
generator + Counter : peak   0.4 MB
~/demo/mem.py:15: size=268 KiB, count=4986, average=55 B
.../python3.9/collections/__init__.py:679: size=144 KiB, count=1, average=144 KiB
.../python3.9/abc.py:123: size=1459 B, count=16, average=91 B

ストリーミング版のピークは14MBではなく0.4MBです。スナップショットには、まだ生きているもの、つまり4,986個の小さな文字列(Counter のキーとして残った異なる単語)と Counter のハッシュテーブルが出ています。2GB のログファイルなら、これが「動く」と「OOM で落ちる」の差になります。ジェネレーターの詳しい話は第3回で扱いました。

判断表

症状 道具・直し方 上の根拠
「遅い」がどこか分からない python -m cProfile -s tottime script.py 関数一つが95%を占めていた
小さなコード片を比べたい python -m timeit -s setup stmt、最小値を見る 0.1ms 単位で結果が出る
ベンチマークが入力を書き換える 計測する文の中でコピー data.sort() は58倍楽観的だった
ループの中の x in 大きなリスト set か dict 4,253ms → 3.9ms
順序を保って重複除去 list(dict.fromkeys(items)) 4.03ms、リスト版と同じ順序
要素を数える Counter(iterable) 手書き13.18ms に対し6.27ms
属性やコンテナに文字列を組み立てる リストに集めて最後に "".join 2,949ms → 17.7ms
組み込み関数を回す内側のループ 内包表記か map(組み込み関数, …) 162 → 139 / 93ms
入力に比例してメモリが増える tracemalloc で確認し、ジェネレーターに 14.0MB → 0.4MB
ここまでやってもまだ遅い 処理を C に移す(NumPy、組み込み関数)かプロセスプール 第5回を参照

正直な限界とバージョンの注記

  • 時間はすべて Apple M1 Pro 上の Python 3.9.6 で測りました。 他の処理も動いている状態で、timeit は5回の最小値、全体の計測は5回の中央値です。絶対値は環境で変わるので、比べるべきは比率です。
  • Python 3.11 の「Faster CPython」の効果は測っていません。 3.11 のリリースノートは、pyperformance で 3.10 より平均約1.25倍速いとしています。手元の 3.11 は Rosetta 2 上で動く x86_64 ビルドなので、測ってもインタープリターではなく変換層の速さを測ることになります。上の差のいくつか(i ** 2 対 i * i、グローバル名参照のコストなど)は 3.11 以降で小さくなっている可能性がありますが、どの程度かは言えません。
  • str += のその場拡張は CPython の実装の詳細で、ここでは 3.9 で観測したものです。PyPy などの別実装では保証されません。
  • dict.fromkeys が挿入順を保つのは、dict の順序が 3.7 から言語仕様で保証されているためです。
  • cProfile の数値にはプロファイラーのオーバーヘッドが含まれます。make_words はプロファイラー下で0.159秒、timeit では81.7msでした。

前回: 第5回 スレッド・プロセス・asyncio と GIL ・ 最初から: 第1回 型とミュータビリティ

関連:イテレーター・ジェネレーターとメモリ(第3回) ・ Java の JIT・GC・メモリ ・ ソートアルゴリズム

よくある質問

遅い Python コードは、まずどこを直せばいいですか?

cProfile が指す場所です。今回の単語カウンターでは、リストで重複を除く関数一つが、プロファイル下の4.15秒のうち3.93秒を占めていました。そこを set に変えるだけで、処理全体が4.39秒から56msになりました。よく警告される文字列連結は全体の0.04秒程度でした。

d.get(key, 0) + 1 は if key in d より速いですか?

いいえ。Python 3.9 の実測では少し遅く、20万語で13.77ms対13.18msでした。defaultdict(int) は10.53ms、Counter(words) は数える処理が C で書かれているため6.27msでした。

ループ内の str += は本当に二乗のコストですか?

ローカル変数なら違います。CPython がその場で文字列を拡張するので、20万回で17.7msでした。属性(self.text += ...)に対してはこの最適化が効かず、同じ処理が2,949msかかり、入力が2倍になるたびにおよそ4倍に増えました。

  • #Python
  • #プロファイリング
  • #cProfile
  • #timeit
  • #tracemalloc
  • #パフォーマンス
  • #上級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑