【CuPy入門 第2回】GPU上でデータを操る!CuPy配列の基本操作とユニバーサル関数(ufunc) 🚀
「GPUにデータを送ったはいいけど、どうやって操作するの?」
「NumPyで覚えた配列の操作方法は、CuPyでも通用するんだろうか?」
「GPUって本当に速いの?そのパワーを実際に見てみたい!」
こんにちは! CuPy探検隊、隊長のPythonistaです! 前回の第1回では、CuPyの概要、大変な環境構築、そしてNumPyとCuPy間の重要なデータ転送について学びましたね。これで、あなたの手元にはGPUという超高速な計算機と、それを操るための基本的な準備が整いました。
シリーズ第2回の今回は、いよいよGPUメモリ上にあるCuPy配列を実際に操作していきます!そして、皆さんに素晴らしいニュースがあります。もしあなたがNumPyの基本的な操作を知っているなら、実はもうCuPyの基本操作のほとんどをマスターしているようなものなのです! この記事では、
- CuPy配列のインデックス参照とスライシング
- 形状変更、結合、分割といった配列操作
- GPUで高速に実行されるユニバーサル関数 (ufunc)
が、いかにNumPyとそっくりな感覚で使えるかを実証していきます。さらに記事の最後には、お待ちかねの【お楽しみ】速度比較プレビューで、CPUとGPUの圧倒的な性能差を体感してみましょう!
1. あなたのNumPyスキルはGPU上でも最強の武器になる!
CuPyの設計思想の素晴らしい点は、NumPyのAPI(関数の使い方や書き方)との互換性を非常に重視していることです。これにより、NumPyに慣れたユーザーは、最小限の学習コストでGPUコンピューティングの世界に足を踏み入れることができます。
まずは、これからのサンプルコードで使うモジュールをインポートし、基本的なCuPy配列を準備しておきましょう。
import cupy as cp
import numpy as np # 比較のためにNumPyもインポート
# テスト用の2次元CuPy配列を作成
cp_arr = cp.arange(12).reshape((3, 4))
print("--- テスト用CuPy配列 (cp_arr) ---")
print(cp_arr)
2. 配列へのアクセス:インデックス参照とスライシング
NumPy配列の特定の要素や部分を取り出すために使ったインデックス参照やスライシングは、CuPyでも全く同じように機能します。
# cp_arr が上記で定義済みとする
print("\n--- インデックス参照とスライシング ---")
# 特定の要素にアクセス (1行2列目の要素)
element = cp_arr[1, 2]
print(f"cp_arr[1, 2]: {element}")
print(f"型: {type(element)}") # cupy.ndarray (0次元配列、つまりスカラ)
# 特定の行全体を取得 (0行目)
row_slice = cp_arr[0] # または cp_arr[0, :]
print(f"cp_arr[0]: {row_slice}")
# 特定の列全体を取得 (1列目)
col_slice = cp_arr[:, 1]
print(f"cp_arr[:, 1]: {col_slice}")
# 部分配列を取得
sub_array = cp_arr[:2, 1:3]
print(f"cp_arr[:2, 1:3]:\n{sub_array}")
# NumPyと同様、スライスは元の配列のビュー(参照)です
sub_array[0, 0] = 999
print(f"\nスライスを変更した後、元の配列cp_arrも変わる:\n{cp_arr}")
NumPyで慣れ親しんだ書き方がそのまま使えるのは、非常に嬉しいポイントですね!
3. 配列構造の操作:形状変更、結合、分割
配列の形状を変えたり、複数の配列を合体させたり、一つの配列を分けたりといった構造的な操作も、NumPyとほぼ同じ関数・メソッドで行うことができます。
import cupy as cp
# --- 形状変更 (reshape) ---
arr1 = cp.arange(8)
arr1_reshaped = arr1.reshape((2, 4))
print(f"\n--- 形状変更 ---")
print(f"reshape後の配列:\n{arr1_reshaped}")
# --- 配列の結合 (concatenate) ---
arr2 = cp.array([[10, 11], [12, 13]])
arr3 = cp.array([[20, 21], [22, 23]])
arr_concat = cp.concatenate([arr2, arr3], axis=0) # 行方向(縦)に結合
print(f"\n--- 配列の結合 ---")
print(f"concatenate後の配列:\n{arr_concat}")
# vstack, hstackも同様に使えます
# arr_vstack = cp.vstack([arr2, arr3])
# arr_hstack = cp.hstack([arr2, arr3])
# --- 配列の分割 (split) ---
arr4 = cp.arange(9).reshape((3, 3))
arr_split = cp.split(arr4, 3, axis=0) # 行方向に3つに均等分割
print(f"\n--- 配列の分割 ---")
print(f"split後の配列 (リストで返る):")
for sub_arr in arr_split:
print(sub_arr)
このように、NumPyでの配列操作の知識と経験が、CuPyでも直接活かせるのです。
4. GPUで超高速計算!CuPyのユニバーサル関数 (ufunc)
NumPyの大きな魅力であった、配列の全要素に対して高速な演算を行うユニバーサル関数(ufunc)は、CuPyにも同様に用意されています。そして、これらはGPUの膨大な並列処理能力を使って実行されるため、非常に高速です。
import cupy as cp
cp_arr_math = cp.array([1, 4, 9, 16])
# 数学関数 (ufunc)
print(f"\n--- ユニバーサル関数 (ufunc) ---")
print(f"元の配列: {cp_arr_math}")
print(f"平方根 (cp.sqrt): {cp.sqrt(cp_arr_math)}")
print(f"サイン (cp.sin): {cp.sin(cp_arr_math)}")
# 集計関数
cp_arr_stats = cp.arange(1, 10).reshape((3, 3))
print(f"\n集計用の配列:\n{cp_arr_stats}")
print(f"全要素の合計 (cp.sum): {cp.sum(cp_arr_stats)}")
print(f"列ごとの平均 (cp.mean, axis=0): {cp.mean(cp_arr_stats, axis=0)}")
print(f"行ごとの最大値 (cp.max, axis=1): {cp.max(cp_arr_stats, axis=1)}")
書き方はNumPyと全く同じですが、これらの計算は全てGPU上で実行されています。
5. 【お楽しみ】GPUの真価を体感!NumPy vs CuPy 速度比較プレビュー ⚡
さて、お待たせしました!「本当にGPUは速いのか?」を体感してみましょう。ここでは、非常に大きな配列の各要素に対してsin関数を適用する処理時間を、NumPy (CPU) と CuPy (GPU) で比較します。
5.1. 比較の準備
処理時間を計測するために、標準ライブラリのtime
モジュールを使います。
import numpy as np
import cupy as cp
import time
size = 10**7 # 1000万要素の大きな配列
# NumPy配列をCPUで作成
np_arr = np.arange(size, dtype=np.float32)
# CuPy配列をGPUで作成
cp_arr = cp.arange(size, dtype=np.float32)
5.2. いざ、ベンチマーク!
重要ポイント: GPUでの処理は非同期に行われることがあるため、正確な時間を測るには、GPUの処理が終わるのを待つ必要があります。ここではcp.cuda.Stream.null.synchronize()
を使って処理の完了を待ち合わせます。
# --- NumPy (CPU) での実行時間 ---
start_cpu = time.time()
result_np = np.sin(np_arr)
end_cpu = time.time()
cpu_time = end_cpu - start_cpu
print(f"NumPy (CPU) での処理時間: {cpu_time:.4f} 秒")
# --- CuPy (GPU) での実行時間 ---
start_gpu = time.time()
result_cp = cp.sin(cp_arr)
cp.cuda.Stream.null.synchronize() # GPUでの処理が終わるのを待つ
end_gpu = time.time()
gpu_time = end_gpu - start_gpu
print(f"CuPy (GPU) での処理時間: {gpu_time:.4f} 秒")
print("-"*30)
if gpu_time > 0:
print(f"速度比較: CuPyはNumPyの約 {cpu_time / gpu_time:.2f} 倍高速です!")
実行結果 (例 - 環境によって大きく異なります):
NumPy (CPU) での処理時間: 0.1563 秒
CuPy (GPU) での処理時間: 0.0021 秒
------------------------------
速度比較: CuPyはNumPyの約 74.43 倍高速です!
どうでしょうか? お使いのGPUの性能にもよりますが、数十倍から場合によっては100倍以上の圧倒的な性能差が確認できたのではないでしょうか。これがGPUコンピューティングのパワーです!
5.3. ただし、忘れてはならない「データ転送コスト」
この驚異的な速度には一つ「ただし書き」があります。それは第1回で学んだCPUとGPU間のデータ転送コストです。実際のアプリケーションでは、この転送時間も考慮に入れる必要があります。
# CPU -> GPU への転送時間を計測
start_transfer_to_gpu = time.time()
cp_arr_from_np = cp.asarray(np_arr)
cp.cuda.Stream.null.synchronize()
end_transfer_to_gpu = time.time()
to_gpu_time = end_transfer_to_gpu - start_transfer_to_gpu
print(f"\nCPU -> GPU のデータ転送時間: {to_gpu_time:.4f} 秒")
# GPU -> CPU への転送時間を計測
start_transfer_to_cpu = time.time()
np_result_from_cp = result_cp.get()
end_transfer_to_cpu = time.time()
to_cpu_time = end_transfer_to_cpu - start_transfer_to_cpu
print(f"GPU -> CPU のデータ転送時間: {to_cpu_time:.4f} 秒")
total_time = to_gpu_time + gpu_time + to_cpu_time
print(f"\nデータ転送を含めた総時間: {total_time:.4f} 秒")
print(f"総時間で比較した場合の速度: CuPyはNumPyの約 {cpu_time / total_time:.2f} 倍高速です。")
データ転送を含めてもなおCuPyの方が速いことが多いですが、配列サイズが小さい場合や、転送が頻繁に発生する場合は、この転送コストが計算の高速化のメリットを上回ってしまうこともあります。このあたりの詳細な比較は、次回の性能比較編でじっくりと見ていきましょう!
まとめ:NumPyの知識はGPUでも通用する!
今回は、CuPy入門シリーズの第2回として、GPU上のCuPy配列の基本的な操作方法を探求しました。
- CuPy配列のインデックス参照、スライシング、形状変更、結合・分割は、NumPyと驚くほど同じように書けること。
cp.sin()
やcp.sum()
といったユニバーサル関数 (ufunc) もNumPyと同じ感覚で使え、それらがGPU上で高速に実行されること。- そして、大規模な配列での単純な演算では、CuPyがNumPyを圧倒的に凌駕するパフォーマンスを発揮すること。
- ただし、その性能を最大限に活かすには、CPUとGPU間のデータ転送コストを意識することが重要であること。
これまでのNumPyの経験が無駄になることなく、そのままGPUコンピューティングの世界で活かせるというのは、非常に心強いですね。
NumPyと同じ書き方でGPUのパワーを引き出せるCuPyの魅力、感じていただけたでしょうか。さて、cp.sin()
やcp.sqrt()
のような便利な関数はたくさんありますが、もし「配列の各要素に対して、if文で条件分岐するような、もう少し複雑な処理」をGPUの速度で実行したくなったらどうでしょう?Pythonのfor
ループではGPUの性能を活かせません。
次回はその答えとなる、CuPyのさらに一歩進んだ機能、「カスタムカーネル」に挑戦します!あなただけのオリジナルなGPU演算を定義し、CuPyを単なるライブラリから真のGPUプログラミングツールへと進化させる方法をご紹介します。お楽しみに!
コメント
コメントを投稿