結局 Python は遅いのか Part2 〜 sin() を外して比較してみた〜
この記事では
- 被積分関数の計算負荷を段階的に変えて,実装方式ごとの速度差を比較
- 「Python の遅さ」がどこに起因するかを定量的に考察
を行います.
※追記(2026/03/28):過去記事(結局 Python は遅いのか)もぜひご覧ください
背景
前回の記事では,モンテカルロ法で $\int_0^1 \sin(x),dx$ の近似を行い,Python の各実装と C++ の速度を比較しました.
結果として,Python の素朴な for-loop は C++ の約 24 倍遅い という結論でした.
この記事に対して,「sin() は C のライブラリ(libm)を呼んでいるのだから,実質 sin() の速度を測っているだけでは?」という趣旨のコメントをいただきました.
確かに,math.sin() の計算本体は C で実行されるため,sin() の計算コストが支配的であれば,Python 側のオーバーヘッドの寄与が見えにくくなっていた可能性があります.
そこで今回は,被積分関数の計算負荷を段階的に変えて,Python のオーバーヘッドがどの程度影響しているかを検証します.
問題設定
前回と同じモンテカルロ積分の枠組みを使い,被積分関数 $f(x)$ だけを差し替えて 4 パターンを比較します.
I = \int^{1}_{0} f(x) \, dx \approx \frac{1}{N} \sum^{N}_{i=1} f(x_i), \quad x_i \sim U(0, 1)
4 つのパターン
| パターン | $f(x)$ | 真値 | 狙い |
|---|---|---|---|
| A | $x$ | $1/2$ | 最軽量ケース |
| B | $x^2 + x$ | $5/6$ | 基本的な算術演算 |
| C | $x^5 + 3x^3 + 2x^2 + x$ | $25/12$ | 演算回数を増やした場合 |
| D | $\sin(x)$ | $1 - \cos(1)$ | 前回と同じ(C ライブラリ呼出) |
ポイント: A〜C は math.sin() などの C ライブラリ関数を使わず,D は前回と同一条件です.
各実装(for-loop / NumPy / Numba / C++)で実装方法は異なりますが,計算する被積分関数は全実装で統一しています.
条件
前回と同一:
- 乱数は事前生成(seed=42)
- 単一スレッド
- $N$ = $1 \times 10^5$,$1 \times 10^6$,$1 \times 10^7$
- 各 $N$ ごとに:ウォームアップ 2 回 → 本測定 200 回
- 中央値を代表値とする
実行環境
| 項目 | 内容 |
|---|---|
| OS | macOS |
| CPU | Intel Core i5-8257U CPU @ 1.40GHz |
| メモリ | 8 GB |
| Python | 3.10.19 |
| NumPy | 2.2.6 |
| Numba | 0.63.1 |
| C++ | clang++ 21.1.8(-O3) |
比較対象
今回は,前回のリスト内包表記・generator を省略し,以下の 4 実装で比較します.
| Exp | 実装 | 説明 |
|---|---|---|
| 1 | Python for-loop | 純粋な Python ループ |
| 2 | NumPy ベクトル化 | 内部は C で実装されたベクトル演算 |
| 3 | Numba JIT | LLVM による JIT コンパイル |
| 4 | C++ (-O3) | ネイティブ実装 |
実験結果
計算速度
以下に,200 回実行した処理時間の中央値(単位:ms)を示します.
Pattern A:
$ f(x) = x $ (単純加算)
| Exp | 実装 | $1 \times 10^5$ | $1 \times 10^6$ | $1 \times 10^7$ |
|---|---|---|---|---|
| 1 | Python for-loop | 8.920 | 105.988 | 989.969 |
| 2 | NumPy | 0.053 | 0.452 | 4.236 |
| 3 | Numba JIT | 0.110 | 1.152 | 12.545 |
| 4 | C++ (-O3) | 0.112 | 1.281 | 13.301 |
Pattern B:
$f(x) = x^2 + x$(軽量多項式)
| Exp | 実装 | $1 \times 10^5$ | $1 \times 10^6$ | $1 \times 10^7$ |
|---|---|---|---|---|
| 1 | Python for-loop | 19.684 | 203.179 | 2054.601 |
| 2 | NumPy | 0.113 | 2.328 | 35.656 |
| 3 | Numba JIT | 0.109 | 1.531 | 15.240 |
| 4 | C++ (-O3) | 0.108 | 1.354 | 13.845 |
Pattern C:
$f(x) = x^5 + 3x^3 + 2x^2 + x$(重い多項式)
| Exp | 実装 | $1 \times 10^5$ | $1 \times 10^6$ | $1 \times 10^7$ |
|---|---|---|---|---|
| 1 | Python for-loop | 64.838 | 664.547 | 5751.686 |
| 2 | NumPy | 0.398 | 9.939 | 149.944 |
| 3 | Numba JIT | 0.142 | 1.869 | 17.790 |
| 4 | C++ (-O3) | 0.155 | 1.725 | 17.018 |
Pattern D:
$f(x) = \sin(x)$(C ライブラリ呼出し)
| Exp | 実装 | $1 \times 10^5$ | $1 \times 10^6$ | $1 \times 10^7$ |
|---|---|---|---|---|
| 1 | Python for-loop | 13.430 | 134.462 | 1426.537 |
| 2 | NumPy | 0.577 | 6.835 | 66.855 |
| 3 | Numba JIT | 0.631 | 6.693 | 64.912 |
| 4 | C++ (-O3) | 0.514 | 5.723 | 62.715 |
相対速度(C++ = 1.00)
ここが本題です.C++ を基準(1.00)としたときの Python for-loop の相対速度を,パターンごとに比較します.
Python for-loop / C++ の相対速度
| パターン | $f(x)$ | $1 \times 10^5$ | $1 \times 10^6$ | $1 \times 10^7$ |
|---|---|---|---|---|
| A | $x$ | 79.37 | 82.76 | 74.43 |
| B | $x^2 + x$ | 181.51 | 149.97 | 148.37 |
| C | $x^5 + 3x^3 + 2x^2 + x$ | 419.53 | 385.29 | 337.99 |
| D | $\sin(x)$ | 26.12 | 23.49 | 22.74 |
A〜C と比べて D の差が明確に小さいことがわかります.
前回の記事では 約 24 倍 という結果でした.これ自体は正しい計測結果ですが,sin() が C ライブラリ呼出しであることにより,Python 側のオーバーヘッドの寄与が相対的に目立たなくなっていたことが示唆されます.
C ライブラリ関数を使わない場合(A〜C),C++ との差は 74〜338 倍にまで広がりました.
なぜパターンによってこれほど差が出るのか
結果を整理すると,下記の傾向が見えてきます($N = 10^7$ 基準):
軽い処理ほど Python のオーバーヘッドが相対的に目立つ傾向があり,また C ライブラリ関数呼出しのように計算本体が重い場合には,その影響が相対的に見えにくくなる
| パターン | 処理内容 | Python for / C++ |
|---|---|---|
| A | 読み出し・加算・除算(最軽量) | ~74x |
| B | 乗算 + 加算 | ~148x |
| C | 複数の乗算 + 加算 | ~338x |
| D | C ライブラリ関数呼出(sin) | ~23x |
上記の表を見ると,A -> B -> C と進むにつれて Python / C++ の差は大きく拡大し,一方で D(sin)ではその差がむしろ小さくなっている.
つまり,処理内容(特に 1 要素あたりの計算量)によって,Python と C++ の相対差の現れ方は大きく異なることがわかる.
まず A〜C では,演算回数が増えるにつれて Python / C++ の差が拡大しています.
もし差の主因がループ制御の固定オーバーヘッドだけであれば,倍率は大きくは変わらないと考えられます.
実際には A から C にかけて差が大きく広がっていることから,ループ制御だけでなく,各演算ごとに発生する Python 特有のオーバーヘッド も寄与していると考えられます.
もちろん,メモリアクセスパターンや C++ 側のコンパイラ最適化(演算融合やベクトル化など)の影響も排除はできませんが,傾向としては演算回数との相関が明確に出ていると考えられます.
一方で D(sin)では,A〜C と比べて Python / C++ の差が小さいことがわかります.
これは Python のオーバーヘッドが消えたからではなく,sin の計算本体が Python 側・C++ 側の両方で重く,全体時間に占める Python 固有オーバーヘッドの割合が相対的に小さく見えるためと考えられます.
Python で 1 演算ごとに起きていること
CPython(標準の Python 実装)では,x + x のような単純な演算でも,概ね以下のような処理が発生します:
specialize 前:
- BINARY_OP
- _PyEval_BinaryOps[NB_ADD]
- PyNumber_Add
- binary_op1
- float_as_number.nb_add
- float_add
- CONVERT_TO_DOUBLE(v)
- CONVERT_TO_DOUBLE(w)
- a + b
- PyFloat_FromDouble
specialize 後:
- _GUARD_TOS_FLOAT
- _GUARD_NOS_FLOAT
- _BINARY_OP_ADD_FLOAT
- left->ob_fval + right->ob_fval
- PyFloat_FromDouble
要点としては Python の * や + の 1 回 1 回に「C 演算 + オブジェクト生成 + メモリ管理」相当のコストがかかります.
演算回数が多い Pattern C では,このオーバーヘッドがループのたびに積み重なるため,差が大きくなります.
sin() で差が小さい理由
一方,math.sin(x) は C の libm を直接呼び出す ため:
- CALL / CALL_BUILTIN_O
- math_sin
- math_1(arg, sin, ...)
- PyFloat_AsDouble(arg)
- sin(x)
- PyFloat_FromDouble(result)
sin の内部計算は C レベルで完結 するため,sin 内部の計算過程では Python オブジェクトの生成・破棄は発生しません(呼出し前後の変換コストのみ).
そのため,sin 本体の計算コストが支配的になり,Python 側の呼出しオーバーヘッド(関数呼出し境界のコストなど)は相対的に小さくなります.結果として,Python と C++ の相対速度の差が小さくなります.
Numba・NumPy について
Numba JIT
Numba は全パターンで C++ とほぼ同等の性能を示しています:
| パターン | Numba / C++ ($N = 10^7$) |
|---|---|
| A | 0.94 |
| B | 1.10 |
| C | 1.05 |
| D | 1.04 |
Numba は Python コードを LLVM で機械語にコンパイルするため,演算前後のオーバーヘッドが排除されます.パターンに関係なく安定して高速です.
パターン A に関しては,Numba と clang++ ではコード生成・最適化パス,CPU target,SIMD 化やループ展開のされ方が違うため,この程度の差は十分ありうる差と考えられます.
NumPy
NumPy は C でベクトル演算を行いますが,パターンによって傾向が異なります:
| パターン | NumPy / C++ ($N = 10^7$) |
|---|---|
| A | 0.32 |
| B | 2.57 |
| C | 8.81 |
| D | 1.07 |
Pattern A では C++ より速く,D でもほぼ同等です.一方,B・C では C++ より遅くなっています.
これは xs**2 + xs のような式が演算ごとに中間配列を生成するためで,式が複雑になるほどメモリ確保・アクセスのコストが増加します.
考察
今回の検証で,前回のベンチマークでは sin() の計算コストが支配項となっており,Python のオーバーヘッドが相対的に目立ちにくくなっていたことが確認できました.
前回の結果で Python の遅さが見えていなかったわけではありませんが,支配項の切り分けが十分ではなかったと言えます.
被積分関数を算術演算のみに置き換えると:
- Python for-loop と C++ の差は 24 倍 → 74〜338 倍 に拡大
- 演算回数が増えるほど差が広がることから,ループ制御だけでなく各演算のオーバーヘッドも寄与が大きい
前回の結論の補足
前回の記事では,
Python は遅いのではなく,「Python のループ」が遅い
としました.この結論自体は間違いではありませんが,今回の検証を踏まえると,より正確にはこう言えます:
Python は「ループ制御」だけでなく「各演算」にもオーバーヘッドがある.
sin()のような C ライブラリ呼出を使う場合,計算本体のコストが支配的になり,そのオーバーヘッドが相対的に目立ちにくくなっていた
まとめ
-
math.sin()は C ライブラリ(libm)を呼び出しており,計算本体は C で実行される - 前回の sin() ベンチマークでは,sin() の計算コストが支配的で,Python のオーバーヘッドの寄与が相対的に目立ちにくかった
- C ライブラリ関数を使わない算術演算では,Python for-loop は C++ の 74〜338 倍 遅い
- Numba を使うと,様々なパターンで C++ と同等の性能が得られた
- NumPy は中間配列の生成により,複雑な式では若干遅くなった
さいごに
今回は被積分関数を変えることで,Python のオーバーヘッドの内訳をもう少し掘り下げることができました.
これは他の動的型付け・インタプリタ実行の言語でも同様かもしれません.
ただ,結局のところ,言語選択は速度だけで決まるものではありません.Python の強みはエコシステムの広さ,書きやすさ,プロトタイピングの速さにあると思います.速度が必要な箇所には Numba や NumPy のような道具をうまく使うのもスキルかと思います.
要件を満たすなら好きな言語で書くのが一番 だと思います.
最後に,前回コメントをくださった方にも感謝いたします.
Appendix:CPython の 1 演算あたりのコスト内訳
CPython の C API を呼出して,float 加算 1 回に含まれる(いくつかの)各処理のコストを計測しました(CPython 3.15.0a7+,-O2).
| 処理 | 時間 (ns/iter) | 備考 |
|---|---|---|
| PyFloat_FromDouble(boxing) | 14.39 | 新しい float オブジェクトを生成 |
| PyFloat_AS_DOUBLE(unbox) | 0.35 | オブジェクトから C double を取得 |
| Py_INCREF + Py_DECREF | 1.32 | 参照カウント操作 |
| Raw C double add | 0.35 | C レベルの加算そのもの |
| Full float add(合計) | 15.69 | unbox×2 + C add + box + decref |
1 回の a + b で約 15.7 ns かかり,そのうち C の加算は 0.35 ns(約 2%)です.残りの約 98% は boxing(オブジェクト生成)や参照カウント管理などの Python ランタイムのコストです.
なお,この計測は CPython の C API を直接呼び出したもので,実際の Python コード実行ではさらにバイトコードディスパッチのコストが加わります.
この結果からも,Python における演算コストの大半が実際の計算そのものではなく,オブジェクト操作などのランタイム処理に起因していることがわかります.