forループで回すと遅い…でも「Pythonは遅い」で片付けていいのか?
この記事は、ダミーデータの“最小ベンチ”で どこが遅いのかをプロファイルし、NumPyベクトル化 と Numba で改善する流れを紹介します。
Google Colabで実行可能な 1セル→主要結果→実務に持ち帰る判断フロー を提供します。
TL;DR
- 「Pythonが遅い」ではなく、だいたい遅いのは Pythonレベルのループ(1要素ずつ処理)
- まずは プロファイル して「どこが遅いか」を当てずっぽうにしない
- 配列・数値計算なら最初の選択肢は NumPyのベクトル化(ufunc / np.where など)
- 分岐が多い・ループが必要なら Numbaで“ループのまま”コンパイル(ただし 初回はコンパイル分遅い)
- 実務では速度だけでなく メモリ(一時配列)・可読性・運用(監視) もセットで判断する
まず最小で動かす
以下を Colabのセル1つ に張り付けて実行してください。
!pip -q install numba
import numpy as np
import math, time, gc, typing
import matplotlib.pyplot as plt
try:
import coverage
if hasattr(coverage, "types"):
if not hasattr(coverage.types, "Tracer"):
coverage.types.Tracer = getattr(coverage.types, "TracerCore", object)
for _name in ["TShouldTraceFn", "TFileDisposition", "TShouldStartContextFn", "TWarnFn", "TTraceFn"]:
if not hasattr(coverage.types, _name):
setattr(coverage.types, _name, typing.Any)
except Exception:
pass
from numba import njit
# 1) ダミーデータ(要素ごと独立な特徴量計算的処理)
N = 1_000_000
THR = 0.5
rng = np.random.default_rng(0)
a = rng.random(N) # float64
b = rng.random(N)
# 2) 遅い版: Pythonレベルのループ(1要素ずつ処理)
def slow_python(a, b, thr=THR):
s = 0.0
for i in range(a.size):
x = float(a[i])
y = float(b[i])
if x > thr:
s += math.sin(x) + y * y
else:
s += math.cos(y) + x * x
return s
# 3) 速い版: NumPyベクトル化(ufunc + np.where)
# ※両方の枝(sin/cos)を先に配列で計算してから、whereで選ぶ
def numpy_vectorized(a, b, thr=THR):
return np.where(a > thr, np.sin(a) + b * b, np.cos(b) + a * a).sum()
# 4) 速い版: Numba(ループは残したまま、機械語にコンパイル)
@njit
def numba_loop(a, b, thr):
s = 0.0
for i in range(a.size):
x = a[i]
y = b[i]
if x > thr:
s += math.sin(x) + y * y
else:
s += math.cos(y) + x * x
return s
def time_once(fn, *args):
gc.collect()
t0 = time.perf_counter()
out = fn(*args)
t1 = time.perf_counter()
return t1 - t0, out
# 5) ベンチマーク(Numbaは 1回目=コンパイル込み、2回目=実行のみ)
t_py, r_py = time_once(slow_python, a, b)
t_np, r_np = time_once(numpy_vectorized, a, b)
t_nb1, r_nb1 = time_once(numba_loop, a, b, THR) # 1回目: コンパイル込み
t_nb2, r_nb2 = time_once(numba_loop, a, b, THR) # 2回目: 実行のみ
# 6) 正しさチェック(浮動小数なので誤差は許容)
assert np.isclose(r_py, r_np, rtol=1e-6, atol=1e-6)
assert np.isclose(r_py, r_nb2, rtol=1e-6, atol=1e-6)
rows = [
("Python loop", t_py),
("NumPy vectorization(np.where)", t_np),
("Numba 1st time (including compilation)", t_nb1),
("Numba 2nd time (execution only)", t_nb2),
]
print(f"N={N:,}")
for name, t in rows:
print(f"{name:28s}: {t*1000:9.1f} ms (x{t_py/t:6.1f} vs Python)")
# 7) 図1: 実行時間の比較(保存 + 表示)
labels = [r[0] for r in rows]
times_ms = [r[1] * 1000 for r in rows]
plt.figure(figsize=(10, 4))
plt.bar(labels, times_ms)
plt.yscale("log") # 桁が違うのでlogにすると見やすい
plt.ylabel("time [ms] (log scale)")
plt.xticks(rotation=20, ha="right")
plt.title("Minimal bench: Python loops / NumPy vectorization / Numba (first vs. second run)")
plt.tight_layout()
plt.savefig("fig1_runtime.png", dpi=200, bbox_inches="tight")
plt.show()
print("saved: fig1_runtime.png")
用語解説
- ベンチマーク: 実行時間などを測って比較すること(測らない最適化はだいたい外れます)
- プロファイル(profiling): 「どの関数/どの行が時間を使っているか」を特定すること
- ベクトル化(vectorization): Pythonのforループをやめて、NumPyの配列演算(C実装)に処理を移すこと
- ufunc:
np.sin,np.cosみたいな「配列に対してCで高速に回る関数」 - Numba: 数値計算向けに、Python関数を JIT(実行時コンパイル) して高速化するツール
最小実験の解説(なぜその結果になるか)
実験の入力・出力はこれだけ
- 入力: ランダムな配列
a, b(長さN) - 出力: 条件分岐つきの式を各要素に適用して sumしたスカラー値
-
a[i] > 0.5ならsin(a[i]) + b[i]^2 - それ以外なら
cos(b[i]) + a[i]^2
-
この形は「特徴量計算」「スコア計算」「シミュレーション」などでよく出ます。
ポイントは 要素同士が独立 で、同じ処理をN回繰り返す こと。
なぜPythonループは遅くなる?
1回のループでやっていることは小さくても、N回(例: 100万回)繰り返すと、
-
forの制御 -
if分岐 - 配列アクセス
- 動的型の処理(Pythonは「型が実行時に決まる」)
などの インタプリタのオーバーヘッドが積み上がる ためです。
なぜNumPyのベクトル化は速い?
np.sin(a) のような処理は、Pythonで1要素ずつ回すのではなく C実装のループ に一気に渡せます。
結果として「Pythonの処理回数」が激減し、CPUのSIMD最適化なども効きやすくなります。
今回の np.where(...) は、
- まず
np.sin(a)+b*bとnp.cos(b)+a*aを配列で作って -
a>thrの条件で選ぶ
というやり方です(※両方の枝を計算するので、処理内容によっては別アプローチが勝つこともあります)。
なぜNumbaはさらに速い?
Numbaは @njit をつけた関数を 機械語にコンパイル します。
つまり「Pythonのforループ」を「Cに近いループ」に変換できるので、
- ループやifのオーバーヘッドがほぼ消える
- 一時配列を作らず 逐次でsum できる(メモリ効率が良い)
が効いて速くなります。
ただし、初回だけコンパイル時間が上乗せ されます。
図1で「Numba 1回目(遅い)→ 2回目(速い)」になっているのはこのためです。
プロファイルして「遅い場所」を目で確認する
次のセル(セル2)を実行すると、slow_python を cProfile で測り、どの関数が時間を使っているか を確認できます。
セル2(任意): cProfileでボトルネック可視化
import cProfile, pstats, io
import matplotlib.pyplot as plt
# プロファイルは少し小さめのNで(傾向がわかればOK)
N_PROFILE = 200_000
a_p = a[:N_PROFILE]
b_p = b[:N_PROFILE]
pr = cProfile.Profile()
pr.enable()
_ = slow_python(a_p, b_p) # 対象は「遅い版」
pr.disable()
sio = io.StringIO()
ps = pstats.Stats(pr, stream=sio).strip_dirs().sort_stats("tottime")
ps.print_stats(12)
print(sio.getvalue())
# 上位を棒グラフに(tottime: 関数自身の時間)
items = []
for func, (cc, nc, tt, ct, callers) in ps.stats.items():
name = pstats.func_std_string(func)
# ノイズになりがちなIPython周りを軽く除外
if "ipython" in name.lower() or "interactiveshell" in name.lower():
continue
items.append((name, tt, nc))
items.sort(key=lambda x: x[1], reverse=True)
top = items[:6]
def pretty(name: str) -> str:
if name.endswith("(slow_python)"):
return "slow_python (loop)"
if "math.sin" in name:
return "math.sin"
if "math.cos" in name:
return "math.cos"
if name.startswith("{built-in method"):
return name.replace("{built-in method ", "").replace("}", "")
# "file:line(func)" っぽいのは func だけ残す
if "(" in name and name.endswith(")"):
return name.split("(")[-1][:-1]
return name
labels = [pretty(n) for n, tt, nc in top]
times = [tt for n, tt, nc in top]
plt.figure(figsize=(10, 4))
plt.bar(labels, times)
plt.ylabel("tottime [s]")
plt.title("cProfile (tottime): Top bottlenecks in slow_python")
plt.xticks(rotation=20, ha="right")
plt.tight_layout()
plt.savefig("fig2_profile_tottime.png", dpi=200, bbox_inches="tight")
plt.show()
print("saved: fig2_profile_tottime.png")
実務に持ち帰る(監視項目/設計手順/判断フロー)
監視するもの(「速くなった」で終わらせない)
- レイテンシ: 平均だけでなく p95 / p99(バッチなら総時間)
- スループット: 1秒あたり何件処理できるか
- メモリ: ピーク使用量(ベクトル化で一時配列が増えると跳ねやすい)
- CPU使用率: CPU-bound なのか I/O-bound なのか切り分け
- 入出力サイズN: Nが増えたとき線形に増える?(アルゴリズムの見直しが必要?)
設計手順(プロファイル→ベクトル化→Numba を“手順化”する)
-
測る(ベースライン)
-
time.perf_counter/%%timeitで「どれくらい遅いか」を数字で固定する
-
-
プロファイルする
-
cProfile(まず関数単位)→ 必要ならline_profiler(行単位)
-
-
ボトルネックを1つに絞る
- 体感ではなく「上位1箇所」を潰す(80/20が効きやすい)
-
ベクトル化できるか判断
- 要素ごと独立? →
np.where, ufunc, broadcasting,np.dotなど - 一時配列が巨大にならない?(
Nが大きいとメモリが先に死ぬ)
- 要素ごと独立? →
-
Numbaが刺さるか判断
- 分岐が多い / 逐次計算が必要 / 一時配列を作りたくない
- 数値配列(float/int)中心 で書ける
-
正しさを担保する
- 小さな入力で
np.isclose,np.allclose、境界値(thr周り)もチェック
- 小さな入力で
-
運用に組み込む
- 速度の回帰(遅くなったら気づく)をCIや監視に入れる
判断フロー(迷ったらこの順で)
遅い?
↓ まず測る(%%timeit / perf_counter)
↓ cProfileで場所特定
ボトルネックは「Pythonのループ」?
├─ NO → I/O, DB, ネットワーク, アルゴリズム(O(N^2)化) を疑う
└─ YES
↓ 要素ごと独立で配列演算に落とせる?
├─ YES → NumPyベクトル化(まずここ)
└─ NO → Numba(njit)でループをコンパイル
次に何をすればいいか(具体的な1歩)
あなたの手元の「遅い関数」を1つ選んで、この記事のセル2(cProfile)に 関数名だけ差し替えて 実行してください。
上位1つのボトルネックが見えたら、この記事の流れ通りに
- (ベクトル化できそうなら)NumPy化
- (分岐や逐次処理が強ければ)Numba化
のどちらかを試すのが次の一歩です。
チェックリスト(明日から使えるToDo)
-
まず
%%timeitorperf_counterでベースラインを取った -
cProfileで「上位1〜3個のボトルネック関数」を特定した - ボトルネックが Pythonループ か、I/Oやアルゴリズムか切り分けた
-
NumPyベクトル化を試した(
np.where, ufunc, broadcasting) - ベクトル化で一時配列が増えていないか(メモリ)確認した
-
Numbaを試した(
@njit、初回コンパイルを除いて測った) -
allclose/iscloseで正しさを確認した(境界値も) - 小さな回帰ベンチを残した(遅くなったら検知できるように)
よくある落とし穴(3つ)
- Numbaの「1回目」を測って遅いと判断する
- 初回はコンパイル時間が乗ります。比較するなら 2回目以降 を見る(図1の通り)。
- ベクトル化したのに遅くなる(メモリで負ける)
- ベクトル化は速い一方、巨大な一時配列を作るとメモリ帯域・確保コストで負けます。
-
np.whereは両方の枝を計算する点も注意(枝が重いなら、マスク分割やチャンク処理が有利な場合あり)。
- dtypeが
objectになって地獄を見る
- NumPyでもNumbaでも、
object配列やPythonオブジェクトが混ざると高速化が効きません。 - まず
a.dtype,b.dtypeを見る(float64/int64などになっているか)。
参考
- Python公式:
cProfile(プロファイリング) - NumPy公式: 概要 / ufunc / ベクトル化の考え方
- Numba公式ドキュメント(
njit/ supported features)



