【CuPy性能比較編 第4回】ベンチマーク対決!NumPy vs CuPy - 基本演算と正しい速度測定法 ⚡
「CuPyって本当に速いの?どれくらい速いの?」
「GPUを使えば、どんな計算も必ず速くなるの?」
「性能を測ってみたけど、なぜか一番小さいデータだけ結果が遅い…これってなぜ?」
こんにちは! CuPy探検隊、隊長のPythonistaです! CuPy入門編では、その基本的な使い方からカスタムカーネルによる独自の高速化まで、CuPyの機能と可能性を探求してきましたね。GPUの圧倒的なパワーの一端を垣間見た方も多いと思います。
今回から始まる「性能比較編」では、その速さの秘密と、CuPyを使いこなすための勘所を、具体的なベンチマーク(性能測定)を通じて徹底的に解き明かしていきます。第4回となる今回は、最も基本的な要素ごとの算術演算やユニバーサル関数(ufunc)を取り上げ、CPUで動作するNumPyとGPUで動作するCuPyの性能を比較します。さらに、単に速さを比べるだけでなく、GPUプログラミングにおける「正しい速度測定のお作法」についても詳しく解説します。この比較を通して、「どのような条件下でCuPyが真価を発揮するのか」を定量的に理解していきましょう!
1. ベンチマーク101:公正な比較のための準備
パフォーマンスを比較する際は、どのような環境で測定したかを明確にすることが非常に重要です。結果は、お使いのPCのハードウェア(CPU, GPU, メモリなど)やソフトウェアのバージョンによって大きく異なるためです。この記事の測定結果はあくまで一例として、ぜひご自身の環境でも試してみてください。
1.1. 私のベンチマーク環境(例)
この記事のコードは以下の環境で実行しました。(ご自身の環境に合わせて書き換えてください)
- CPU: AMD Ryzen 7 5800H
- GPU: NVIDIA GeForce GTX 1650 Mobile
- メモリ(RAM): 16GB DDR4
- OS: Ubuntu 24.04.2 LTS
- Python: 3.11.13
- NumPy: 2.2.6
- CuPy: 13.4.1
- CUDA Toolkit: 12.8
1.2. GPUベンチマークの重要なお作法
GPUの性能を正確に測るには、知っておくべき2つの重要なお作法があります。
お作法1:GPUの処理完了を待つ (同期)
CPUがGPUに「この計算をせよ!」と命令を出した後、GPUが計算を終えるのを待たずにCPUは次の処理に進んでしまうことがあります(非同期実行)。そのため、time.time()
だけで処理時間を挟んでも、GPUの真の計算時間は測れません。そこで、cp.cuda.Stream.null.synchronize()
を呼び出して、GPU側の処理が全て完了するのをCPUに待たせる必要があります。
お作法2:ウォームアップ処理を行う
プログラムの中で一番最初にGPUにアクセスする際、一度だけ「準備運動」の時間(初期化コスト)がかかります。これには、GPUとの通信チャネルの確立や、内部リソースの割り当てなどが含まれ、数十〜数百ミリ秒を要します。この初期化コストを計測に含めないために、本格的なベンチマークの前に、小さなダミーデータで一度GPUを動かしておく「ウォームアップ」を行うのが一般的です。
2. 見過ごせないコスト:CPU⇔GPU間のデータ転送時間
GPUでの計算がいくら速くても、その前にデータをCPUからGPUへ送り、計算後に結果をCPUへ戻す時間が必要です。この「転送コスト」が性能にどう影響するか見てみましょう。ここでは、先ほどのお作法に従い、ウォームアップ処理を先に行います。
import numpy as np
import cupy as cp
import time
import matplotlib.pyplot as plt
# --- ウォームアップ処理 ---
print("GPUをウォームアップ中...")
try:
cp.asarray(np.zeros(1)).get() # ダミーデータでGPUにアクセス
cp.cuda.Stream.null.synchronize()
print("ウォームアップ完了。\n")
except Exception as e:
print(f"CuPyの初期化中にエラー: {e}\nGPUが正しく設定されているか確認してください。")
exit()
# --- データ転送時間の計測 ---
array_sizes = [int(s) for s in np.logspace(3, 7, 5)]
transfer_times_to_gpu = []
transfer_times_from_gpu = []
for size in array_sizes:
np_arr = np.random.rand(size).astype(np.float32)
# CPU -> GPU
start = time.time()
cp_arr = cp.asarray(np_arr)
cp.cuda.Stream.null.synchronize()
transfer_times_to_gpu.append(time.time() - start)
# GPU -> CPU
start = time.time()
_ = cp_arr.get()
transfer_times_from_gpu.append(time.time() - start)
# 結果をプロット
plt.figure(figsize=(10, 5))
plt.plot(array_sizes, transfer_times_to_gpu, marker='o', label='CPU -> GPU (cp.asarray)')
plt.plot(array_sizes, transfer_times_from_gpu, marker='x', label='GPU -> CPU (.get())')
plt.xscale('log')
plt.yscale('log')
plt.title('Data Transfer Time between CPU and GPU (After Warmup)')
plt.xlabel('Array Size (Number of Elements)')
plt.ylabel('Time (seconds)')
plt.legend()
plt.grid(True, which="both", ls="--")
plt.show()
ウォームアップを行ったことで、データサイズが小さいところから大きいところまで、転送時間が滑らかに増加する、直感に合ったグラフが得られました。この転送時間が、CuPyを利用する上での「固定費」のようなものになります。
3. ベンチマーク対決!基本演算とユニバーサル関数
それでは、本題のベンチマークです。要素ごとの単純な演算について、NumPyとCuPyの処理時間を比較します。ここでは、以下の3つの時間を計測します。
- NumPy CPU Time: NumPy配列をCPUで計算する時間。
- CuPy GPU Compute Time: CuPy配列をGPUで計算する純粋な時間。
- CuPy Total Time: データをCPUからGPUに転送し、GPUで計算し、結果をCPUに戻すまでの総時間。
3.1. ベンチマーク用のコード (高精度版)
GPUでの純粋な計算時間をより正確に測定するため、CuPyが提供するcp.cuda.Event()
を使います。これはGPUのコマンドストリームに直接タイムスタンプを記録し、GPU上での純粋な経過時間をミリ秒単位で取得できる、より専門的な方法です。
import numpy as np
import cupy as cp
import time
import matplotlib.pyplot as plt
def benchmark_operation(op_name, np_op, cp_op, sizes):
np_times = []
cp_compute_times = []
cp_total_times = []
# ウォームアップ処理
print(f"\n--- {op_name} のベンチマーク開始 ---")
dummy_gpu_array = cp.array([0], dtype=np.float32)
_ = cp_op(dummy_gpu_array)
cp.cuda.Stream.null.synchronize()
print("ウォームアップ完了。")
for size in sizes:
print(f"配列サイズ: {size}")
np_arr = np.random.rand(size).astype(np.float32)
# NumPy (CPU) time
start_cpu = time.time()
_ = np_op(np_arr)
np_times.append(time.time() - start_cpu)
# --- CuPy (GPU) ---
# トータル時間(転送+計算+転送)の計測開始
start_total = time.time()
cp_arr = cp.asarray(np_arr)
# GPUでの純粋な計算時間を、Eventを使ってより正確に計測
start_compute = cp.cuda.Event()
end_compute = cp.cuda.Event()
start_compute.record()
_ = cp_op(cp_arr) # 計測したいGPU上の処理
end_compute.record()
end_compute.synchronize() # GPU処理の完了を待つ
# 結果をCPUへ戻す
_ = cp_arr.get()
# トータル時間の計測終了
end_total = time.time()
# 経過時間をリストに追加
cp_compute_times.append(cp.cuda.get_elapsed_time(start_compute, end_compute) / 1000.0) # ミリ秒を秒に変換
cp_total_times.append(end_total - start_total)
return np_times, cp_compute_times, cp_total_times
# テストする配列サイズ
array_sizes_to_test = [int(s) for s in np.logspace(3, 7, 5)] # 10^3 から 10^7 まで5点
# sin関数のベンチマーク
np_sin_times, cp_sin_compute, cp_sin_total = benchmark_operation(
"sin",
lambda x: np.sin(x),
lambda x: cp.sin(x),
array_sizes_to_test
)
# 結果をプロット
plt.figure(figsize=(10, 6))
plt.plot(array_sizes_to_test, np_sin_times, marker='o', label='NumPy CPU Time')
plt.plot(array_sizes_to_test, cp_sin_total, marker='x', label='CuPy Total Time (incl. transfer)')
plt.plot(array_sizes_to_test, cp_sin_compute, marker='s', label='CuPy GPU Compute Time')
plt.xscale('log')
plt.yscale('log')
plt.title('Performance Comparison: np.sin vs cp.sin')
plt.xlabel('Array Size')
plt.ylabel('Time (seconds)')
plt.legend()
plt.grid(True, which="both", ls="--")
plt.show()
このベンチマーク関数benchmark_operation
は、様々な演算(np_op
とcp_op
)に対して再利用できるように作られています。
3.2. 結果の可視化と考察
このグラフを見ると、いくつかの重要なことが分かります。
- CuPyの計算自体は常に高速: 緑の線(CuPy GPU Compute Time)は、常に青の線(NumPy CPU Time)よりも遥か下にあります。これは、GPUでの計算そのものが非常に速いことを示しています。
- 小さい配列ではNumPyが有利: グラフの左側、配列サイズが小さい領域では、赤の線(CuPy Total Time)が青の線(NumPy CPU Time)よりも上に来ています。これは、計算時間の短縮効果よりも、CPU⇔GPU間のデータ転送コストの方が大きくなってしまい、結果的にNumPyを使った方が速いことを意味します。
- ブレークイーブンポイント(損益分岐点)の存在: 青の線と赤の線が交差する点、これが「ブレークイーブンポイント」です。この点よりも配列サイズが大きくなると、データ転送コストを考慮してもなお、CuPyを使った方が全体として高速になります。
- 大きい配列ではCuPyが圧勝: グラフの右側、配列サイズが大きくなるにつれて、青の線と赤の線の差はどんどん開いていきます。これは、大規模なデータになるほど、データ転送コストの割合が相対的に小さくなり、GPUの圧倒的な計算パワーの恩恵が大きくなることを示しています。
同様のベンチマークを、要素ごとの足し算 (lambda x: x + x
) や、集計関数 (lambda x: x.sum()
) などで試してみると、演算の種類によってブレークイーブンポイントが変わることも確認でき、面白い発見があるでしょう。
まとめ:CuPyは「いつ、何を」計算するかが重要!
今回の性能比較を通して、以下の重要な知見が得られました。
- CuPy (GPU) での計算そのものは、NumPy (CPU) よりも圧倒的に高速である。
- しかし、その恩恵を受けるにはCPUとGPU間のデータ転送コストという「諸経費」がかかる。
- そのため、配列サイズが小さい場合は、転送コストが上回り、NumPyの方が速いことがある。
- CuPyが真価を発揮するのは、データ転送コストを吸収してなお余りあるほどの計算メリットが得られる、大規模な配列を扱う場合である。
「GPUは常に速い」と盲信するのではなく、「GPUにデータを送ってまで計算する価値があるか?」を見極めることが、CuPyを使いこなすための第一歩です。
次回、性能比較編の第2回では、GPUがさらに得意とする行列積などの線形代数演算や、より複雑な操作についてベンチマーク対決を行います。基本演算以上にドラマチックな性能差が見られるはずです。お楽しみに!
コメント
コメントを投稿