8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

結局Pythonは遅いのか?を“最小ベンチ”で決着:プロファイル→ベクトル化→Numba

8
Last updated at Posted at 2026-02-20

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")

スクリーンショット 2026-02-20 194803.png

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*bnp.cos(b)+a*a を配列で作って
  • a>thr の条件で選ぶ

というやり方です(※両方の枝を計算するので、処理内容によっては別アプローチが勝つこともあります)。


なぜNumbaはさらに速い?

Numbaは @njit をつけた関数を 機械語にコンパイル します。
つまり「Pythonのforループ」を「Cに近いループ」に変換できるので、

  • ループやifのオーバーヘッドがほぼ消える
  • 一時配列を作らず 逐次でsum できる(メモリ効率が良い)

が効いて速くなります。

ただし、初回だけコンパイル時間が上乗せ されます。
図1で「Numba 1回目(遅い)→ 2回目(速い)」になっているのはこのためです。


プロファイルして「遅い場所」を目で確認する

次のセル(セル2)を実行すると、slow_pythoncProfile で測り、どの関数が時間を使っているか を確認できます。

スクリーンショット 2026-02-20 195246.png
fig2_profile_tottime.png

セル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 を“手順化”する)

  1. 測る(ベースライン)
    • time.perf_counter / %%timeit で「どれくらい遅いか」を数字で固定する
  2. プロファイルする
    • cProfile(まず関数単位)→ 必要なら line_profiler(行単位)
  3. ボトルネックを1つに絞る
    • 体感ではなく「上位1箇所」を潰す(80/20が効きやすい)
  4. ベクトル化できるか判断
    • 要素ごと独立? → np.where, ufunc, broadcasting, np.dot など
    • 一時配列が巨大にならない?(N が大きいとメモリが先に死ぬ)
  5. Numbaが刺さるか判断
    • 分岐が多い / 逐次計算が必要 / 一時配列を作りたくない
    • 数値配列(float/int)中心 で書ける
  6. 正しさを担保する
    • 小さな入力で np.isclose, np.allclose、境界値(thr周り)もチェック
  7. 運用に組み込む
    • 速度の回帰(遅くなったら気づく)を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)

  • まず %%timeit or perf_counter でベースラインを取った
  • cProfile で「上位1〜3個のボトルネック関数」を特定した
  • ボトルネックが Pythonループ か、I/Oやアルゴリズムか切り分けた
  • NumPyベクトル化を試した(np.where, ufunc, broadcasting)
  • ベクトル化で一時配列が増えていないか(メモリ)確認した
  • Numbaを試した(@njit、初回コンパイルを除いて測った)
  • allclose/isclose で正しさを確認した(境界値も)
  • 小さな回帰ベンチを残した(遅くなったら検知できるように)

よくある落とし穴(3つ)

  1. Numbaの「1回目」を測って遅いと判断する
  • 初回はコンパイル時間が乗ります。比較するなら 2回目以降 を見る(図1の通り)。
  1. ベクトル化したのに遅くなる(メモリで負ける)
  • ベクトル化は速い一方、巨大な一時配列を作るとメモリ帯域・確保コストで負けます。
  • np.where は両方の枝を計算する点も注意(枝が重いなら、マスク分割やチャンク処理が有利な場合あり)。
  1. dtypeが object になって地獄を見る
  • NumPyでもNumbaでも、object 配列やPythonオブジェクトが混ざると高速化が効きません。
  • まず a.dtype, b.dtype を見る(float64 / int64 などになっているか)。

参考

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?