リスト内包表記とforループ、どちらが速いのでしょうか。ネットで調べると内包表記のほうが速いと書いてありますが、自分の手元でも本当にそうなのか気になりますよね。
ためしに time.time() で前後の時刻を引き算してみると、実行するたびに数字が変わってしまいます。これでは、どちらが速いのか判断できません。
そんなときに頼りになるのが、Pythonの標準ライブラリに入っているtimeitです。小さなコードを何度もくり返し実行して、ぶれの少ない形で時間を測ってくれます。
今回は、timeitの使い方と結果の読み方、測るときにやりがちな失敗まで、順番に見ていきます。手元のPython 3.11で実際に測った数字も載せているので、一緒に試しながら読んでみてください。
timeitとは何か¶
まずは、timeitがどんな道具なのかをつかんでおきましょう。ひとことで言うと、短いコードの実行時間を正確に比べるための計測係です。
処理時間を測る方法としては、timeモジュールの perf_counter() で前後の時刻を引き算するやり方もあります。この基本は、こちらの記事で紹介しています。【関連記事】Pythonのtimeモジュールとは?sleepで待つ・処理時間を測る基本を初心者向けに解説
ただ、1回だけの計測はとても不安定です。パソコンの裏では別のプログラムも動いているので、たまたま遅くなった1回を拾ってしまうことがあるからです。
timeitは、同じコードを何万回、何百万回とくり返して合計時間を測ります。さらにその計測を何セットか行うので、偶然のぶれに振り回されにくくなります。
標準ライブラリなので、pipでのインストールは不要です。Pythonが入っていれば、今すぐ使えます。
まずはコマンドラインで測ってみよう¶
いちばん手軽なのは、ターミナルから直接呼び出す方法です。Pythonファイルを作らなくても、1行で計測できます。
次のコマンドは、0から99までの合計を求める処理の時間を測ります。-m timeit のあとに、測りたいコードを文字列で渡すだけです。
python -m timeit "sum(range(100))"
手元の環境では、次のように表示されました。数字は環境によって変わるので、あなたの結果と違っていても問題ありません。
500000 loops, best of 5: 590 nsec per loop
この1行には、3つの情報が詰まっています。
| 表示 | 意味 |
|---|---|
| 500000 loops | 1セットで50万回くり返した |
| best of 5 | 5セット測って、いちばん速いセットを採用した |
| 590 nsec per loop | 1回あたり590ナノ秒かかった |
nsecはナノ秒で、10億分の1秒のことです。ほかにusec(マイクロ秒、100万分の1秒)やmsec(ミリ秒、1000分の1秒)も出てくるので、単位の大きさは覚えておくと読みやすくなります。
くり返す回数は自動で決まる¶
ここで不思議に思うのが、50万回という回数をどこで決めたのかという点ですよね。実は、コマンドラインのtimeitは回数を自動で調整してくれます。
1回、2回、5回、10回、20回、50回というように回数を増やしていき、合計が0.2秒以上になったところで止まります。-v を付けると、この探り方を実際に見られます。
1000000 loops -> 0.0233 secs
2000000 loops -> 0.0451 secs
5000000 loops -> 0.12 secs
10000000 loops -> 0.344 secs
raw times: 245 msec, 222 msec, 217 msec, 257 msec, 348 msec
これは集合から値を探す処理を測ったときの出力の一部です。0.2秒を超えた1000万回で回数が決まり、そのあと5セット分の合計時間が並んでいます。
よく使うオプション¶
コマンドラインのtimeitには、いくつかオプションがあります。最初は次の表のものだけ知っておけば十分です。
| オプション | 役割 |
|---|---|
| -s | 計測前に1回だけ実行する準備のコード |
| -n | 1セットでくり返す回数を自分で指定する |
| -r | 何セット測るか(既定は5) |
| -u | 表示の単位をnsec・usec・msec・secから選ぶ |
| -v | 回数の探り方や生の計測値も表示する |
中でもよく使うのが -s です。データの準備は計測に含めたくないので、-s に分けて書きます。
リストと集合の検索を比べてみる¶
では、実際に2つの書き方を比べてみましょう。1万個のデータから値を探すとき、リストと集合でどれだけ差が出るかを測ります。
準備のコードを -s に渡し、測りたい処理だけを最後に書きます。次の2行を順に実行しました。
python -m timeit -s "data = list(range(10000))" "9999 in data"
python -m timeit -s "data = set(range(10000))" "9999 in data"
結果は次のとおりです。リストは5000回、集合は1000万回のくり返しが自動で選ばれました。
5000 loops, best of 5: 54.4 usec per loop
10000000 loops, best of 5: 21.6 nsec per loop
リストは1回あたり54.4マイクロ秒、集合は21.6ナノ秒です。単位が違うので見落としがちですが、2000倍以上の差がついています。
リストは先頭から順番に探すのに対し、集合はハッシュという仕組みで一気に場所を見つけられるのが理由です。集合の仕組みは、こちらで詳しく解説しています。【関連記事】Pythonの集合(set)とは?重複の削除と高速な存在チェックを初心者向けに解説
Pythonのコードから使う方法¶
コマンドラインは手軽ですが、関数どうしを比べたいときはPythonのコードから呼ぶほうが便利です。timeitには、そのための関数が用意されています。
基本の形を次の表にまとめました。どちらも戻り値は秒数です。
| 関数 | 戻り値 | 既定の回数 |
|---|---|---|
| timeit.timeit() | 合計時間(1つの数値) | 100万回 |
| timeit.repeat() | セットごとの合計時間(リスト) | 100万回を5セット |
注意したいのは、戻り値が1回あたりの時間ではなく、くり返した合計の時間だという点です。1回あたりを知りたいときは、自分で回数で割ります。
ちなみに、repeatの既定のセット数はPython 3.7で3から5に変わっています。古い解説記事で3セットと書かれていても、それは間違いではありません。
関数どうしを比べるサンプルコード¶
それでは、冒頭の疑問だったforループとリスト内包表記を比べてみましょう。次のコードを、bench.pyという名前で保存して実行します。
import timeit
def with_loop(n):
result = []
for i in range(n):
result.append(i * 2)
return result
def with_comprehension(n):
return [i * 2 for i in range(n)]
for func in (with_loop, with_comprehension):
times = timeit.repeat(lambda: func(1000), number=10000, repeat=5)
best = min(times) / 10000
print(f"{func.__name__:20} {best * 1e6:.1f} usec")
timeitには文字列だけでなく、引数なしで呼べる関数も渡せます。ここではlambdaで包んで、引数1000を渡した呼び出しを測っています。
number=10000 で1セット1万回、repeat=5 で5セットです。いちばん速いセットの合計を1万で割り、マイクロ秒に直して表示しています。
手元での結果は次のとおりでした。
with_loop 26.3 usec
with_comprehension 23.2 usec
たしかに内包表記のほうが速いですね。ただし差は1割ほどで、集合とリストのような桁違いの差ではありません。
こうして数字で見ると、書き方を選ぶときの判断がしやすくなります。1割の差のために読みにくいコードを書くかどうかは、場面しだいと言えそうです。
結果の読み方は平均ではなく最小値¶
ここで、なぜ平均ではなく最小値を使うのか気になった方もいるかもしれません。コマンドラインの表示も、best ofという名前のとおり最小値を出しています。
Python公式ドキュメントでも、結果から平均や標準偏差を計算するのはあまり役に立たないと説明されています。遅いセットが出るのは、Pythonの速さがぶれるからではなく、ほかのプロセスが割り込んだせいであることが多いからです。
つまり、最小値はそのパソコンでそのコードを動かしたときの、もっとも素直な速さに近い値です。邪魔が入らなかったときの記録、と考えるとイメージしやすいでしょう。
ちなみに、コマンドラインのtimeitは、いちばん遅いセットが最速の4倍以上かかったときに警告を出します。結果が信頼できない可能性がある、という合図なので、その場合は測り直すのがおすすめです。
測るときにやりがちな失敗¶
timeitは便利ですが、使い方を間違えると見当違いの数字が出てしまいます。初心者がつまずきやすいポイントを先に知っておきましょう。
1つめは、準備の処理まで測ってしまうことです。たとえば list(range(10000)) を測りたいコード側に書くと、リストを作る時間が毎回足されてしまいます。データの準備は -s や setup 引数に分けましょう。
2つめは、戻り値を1回あたりの時間だと思いこむことです。timeit.timeit() は既定で100万回くり返した合計を返します。0.5という数字を見て、1回に0.5秒かかると勘違いしないように注意してください。
3つめは、ガベージコレクションの扱いです。ガベージコレクションとは、使われなくなったメモリを自動で片づける仕組みのことです。timeitは計測中、これを止めて測ります。
計測が安定する反面、大量のオブジェクトを作る処理では、実際より速く見えることがあります。実際の動きに近づけたいときは、setupに gc.enable() を書いてガベージコレクションを有効にした状態で測れます。
4つめは、条件をそろえずに比べることです。片方だけPythonのバージョンが違ったり、データの大きさが違ったりすると、比較の意味がなくなります。比べるときは、測りたい部分以外をそろえるのが鉄則です。
timeitが得意なことと苦手なこと¶
timeitは万能ではありません。得意な場面と苦手な場面を知っておくと、ほかの道具と使い分けられます。
| やりたいこと | 向いている道具 |
|---|---|
| 2つの書き方のどちらが速いか比べる | timeit |
| 小さな処理の速さを正確に知る | timeit |
| プログラム全体のどこが遅いか探す | cProfile |
| 処理全体に何秒かかったかざっくり知る | time.perf_counter() |
timeitが得意なのは、数行のコードを比べることです。逆に、大きなプログラムのどこが遅いのかを探すのは苦手です。
遅い場所を探すのは、cProfileというプロファイラの役目です。まずcProfileで遅い関数を見つけ、その関数の書き方をtimeitで比べる、という順番で使うと効率よく改善できます。【関連記事】PythonのcProfileとは?遅い原因を推測せずに計測で見つける方法を初心者向けに解説
なお、Jupyter Notebookを使っている方は、セルの先頭に %timeit と書くだけで同じような計測ができます。中身はIPythonの機能ですが、考え方はtimeitと同じです。
現場で感じた、測ることの大切さ¶
私は10年ほどエンジニアとして開発に関わってきましたが、こっちのほうが速いはずという思いこみで書き換えたコードが、測ってみるとほとんど変わらなかった経験が何度もあります。読みやすさを犠牲にした書き換えが、実はたった数パーセントの改善だったこともありました。
逆に、リストを集合に変えるだけで、夜間のバッチ処理が数時間から数分に縮んだこともあります。どちらの場合も、決め手になったのは感覚ではなく計測した数字でした。
速さの差には、1割程度の小さな差と、何千倍もの桁違いの差があります。後者は多くの場合、アルゴリズムやデータ構造の選び方から生まれます。計算量の考え方を知っておくと、どこを改善すべきか見当がつけやすくなります。【関連記事】「実行時間が終わらない…」を卒業する!あなたのコードを100倍速くする計算量の考え方
timeitで小さな差を見つけて喜ぶ前に、桁違いの差がどこかに隠れていないか考えてみる。それが、私が現場で身につけたいちばんの教訓です。
まとめ¶
timeitは、小さなコードを何度もくり返し実行して、ぶれの少ない形で時間を測れる標準ライブラリです。python -m timeit "コード" の1行から、気軽に試せます。
覚えておきたいのは、準備のコードは分けること、戻り値は合計時間であること、結果は最小値を見ることの3つです。この3点を押さえるだけで、計測の失敗はぐっと減ります。
どちらが速いのか迷ったら、ネットの情報をうのみにせず、まず自分の手元で測ってみましょう。数字で確かめる習慣は、きっとあなたのコードをより良くしてくれます。
ここまでお読みいただきありがとうございました。