CuPy 入門

colab-logo

CuPy は NumPy と高い互換性を持つ数値計算ライブラリです。 NumPy で提供されている多くの関数を NVIDIA GPU (Graphics Processing Unit) で実行することで簡単に高速化できるように設計されています。

GPU とは

GPU (graphics processing unit) は 3D グラフィックスの描画や、画像処理を高速に計算できるように設計された演算装置です。 一方、一般的な計算で使用される CPU (central processing unit) は、幅広く様々な処理で利用されることを想定して設計されています。 そのため、GPU と CPU ではそれぞれ、得意な計算の種類が異なります。 GPU は、条件分岐を多用するような複雑な計算には向かない一方、行列計算のようなシンプルな計算を大量に並列処理する必要がある場合は、CPU よりもはるかに高速な場合があります。

特にディープラーニングでは行列計算を多く行うため、GPU との相性が良く、GPU を使用することでネットワークの訓練や推論を高速に行うことができます。

CuPy を使う準備

CuPy を使用するには NVIDIA GPU が必要です。 Colab ではノートブック上で GPU を使用することができます。 こちらを参考に GPU を有効にしてください。参考:GPU を使用する

CuPy は Colab 上にはデフォルトでインストールされているため、すぐに使い始めることができます。 Google Colaboratory 以外の環境で使用する場合には、CuPy の公式サイトを参考にインストールを行ってください。

また、CuPy の基本的な使い方は NumPy とほとんど同じです。 そのため NumPy の使い方を知っていれば、パッケージ名を numpy から cupy に置き換えるだけで、多くの関数が NumPy と同じ使い方で利用できます。

NumPy と CuPy の比較

単回帰分析と重回帰分析の章で説明を行った正規方程式による重みベクトルの計算を題材に、NumPy と CuPy との速度比較を行います。

重回帰分析では以下の正規方程式を解いて重みベクトルを決定しました。

\[{\bf w} = ({\bf X}^{T}{\bf X})^{-1}{\bf X}{\bf t}\]

この右辺の計算にかかる時間を、NumPy を用いる場合と、CuPy を用いる場合で比較します。

まず最初に NumPy と CuPy の両モジュールを読み込みましょう。 numpy モジュールを読み込む際に np という別名をつけて読み込むことが多いように、cupy はしばしば cp という別名をつけて読み込まれることが多いようです。

[1]:
import numpy as np
import cupy as cp

本節では、入力値の数が大きくなっていくにつれて NumPy と CuPy の間にどのくらいパフォーマンスの違いが現れるのかを調べるために、乱数を用いて作成した人工データを使用します。具体的には、適当な乱数を値に持つ行列 x の形が、

  • (10, 10)

  • (100, 100)

  • (1000, 1000)

  • (10000, 10000)

と 10 倍ずつ大きくなっていくにつれ、処理時間がどのように変化するかを調べます。

NumPy を使用した場合の処理時間測定

まず、正規方程式の計算を get_w_np() という関数にまとめます。

[2]:
def get_w_np(x, t):
    xx = np.dot(x.T, x)
    xx_inv = np.linalg.inv(xx)
    xt = np.dot(x.T, t)
    w = np.dot(xx_inv, xt)
    return w

次に、小さい行列を使ってget_w_np() の動作を確認してみます。

[3]:
# 一番小さいサイズの行列の準備
N = 10

x = np.random.rand(N, N)
t = np.random.rand(N, 1)
w = get_w_np(x, t)

# 求めた w を表示
print(w)
[[ 0.50049629]
 [ 0.86471569]
 [ 0.49414461]
 [-0.66620693]
 [-0.39075183]
 [ 0.14946165]
 [ 0.36332154]
 [-0.24633096]
 [ 0.4362624 ]
 [ 0.10785829]]

エラーなく結果が出力されることが確認できたので、 小さい行列を使用した場合の get_w_np() の経過時間を計測します。 経過時間の測定には time モジュールを使用します。

[4]:
import time

time.time() を実行時間を計測したい処理の前後で呼び、その返り値の差をとることで、おおまかな実行時間を測ることができます。

[5]:
time_start = time.time()

# - - - 処理 - - -
w = get_w_np(x, t)
# - - - - - - - - -

time_end = time.time()

elapsed_time = time_end - time_start  # 経過時間

print('{:.5f} sec'.format(elapsed_time))
0.00080 sec

次は、行列の形を大きくして処理時間を測定し、比較を行ってみましょう。

[6]:
times_cpu = []  # CPUの計算時間保存用

for N in [10, 100, 1000, 10000]:
    np.random.seed(0)
    x = np.random.rand(N, N)
    t = np.random.rand(N, 1)

    time_start = time.time()

    # - - - 処理 - - -
    w = get_w_np(x, t)
    # - - - - - - - - -

    time_end = time.time()

    elapsed_time = time_end - time_start  # 経過時間

    print('N={:>5}:{:>8.5f} sec'.format(N, elapsed_time))

    times_cpu.append(elapsed_time)
N=   10: 0.00012 sec
N=  100: 0.00299 sec
N= 1000: 0.19309 sec
N=10000:138.90935 sec

行列の形が大きくなるにつれて、処理にかかる時間が大幅に増えていることがわかります。

CuPy を使用した場合の処理時間測定

次に CuPy を使用して同様の実験を行ってみます。

最初に説明した通り、CuPy は NumPy と非常に互換性の高いインターフェースを持つように設計されています。 そのため、ソースコード中の npcp に置換するだけで計算を GPU 上で行うようコードに変更することができる場合もあります。

それでは、CuPy を使って、重みベクトルを計算する一連の処理をまとめた get_w_cp() という関数を定義してみます。

[7]:
def get_w_cp(x, t):
    xx = cp.dot(x.T, x)
    xx_inv = cp.linalg.inv(xx)
    xt = cp.dot(x.T, t)
    w = cp.dot(xx_inv, xt)
    return w

NumPy を使った関数 get_w_np() と CuPy を使った関数 get_w_cp() を見比べてみてください。 np というパッケージ名(の別名)を cp と置き換えた以外、何の変更も行われていないことが分かります。 これは、CuPy が NumPy と極めて高い互換性を保つように開発されているおかげです。

それでは、まずはこの CuPy を用いて計算を行う get_w_cp() が、本当に get_w_np() と同じ計算を行っているのかを確認してみましょう。

NumPy の関数の多くは、np.ndarray で表された多次元配列が与えられることが期待されています。 同様に CuPy の関数の多くは、cp.ndarray が渡されることが期待されています。 そこで、NumPy の np.random.rand() 関数を使って作成した np.ndarraycp.asarray() を使って cp.ndarray に変換して用います。

[8]:
# NumPy を用いた乱数生成
N = 10
x_np = np.random.rand(N, N)
t_np = np.random.rand(N, 1)
[9]:
# NumPy の ndarray から CuPy の ndarray へ変換
x_cp = cp.asarray(x_np)
t_cp = cp.asarray(t_np)

入力の準備ができたので、get_w_np()get_w_cp() を実行します。

[10]:
# NumPy
w_np = get_w_np(x_np, t_np)

# CuPy
w_cp = get_w_cp(x_cp, t_cp)

結果を見比べてみましょう。

[11]:
print('NumPy:\n', w_np)
print('\nCuPy:\n', w_cp)
NumPy:
 [[ 3.10913241]
 [-4.32028319]
 [ 1.09894125]
 [ 1.63321226]
 [ 1.25977854]
 [-0.89789306]
 [-0.87023945]
 [ 1.09654016]
 [ 1.19753311]
 [-1.3647516 ]]

CuPy:
 [[ 3.10913241]
 [-4.32028319]
 [ 1.09894125]
 [ 1.63321226]
 [ 1.25977854]
 [-0.89789306]
 [-0.87023945]
 [ 1.09654016]
 [ 1.19753311]
 [-1.3647516 ]]

結果の数値は、小数点以下 8 桁までしか表示されていませんが、ほぼ同じになっていることが確認できました。 CPU を用いて計算を行う NumPy と、GPU を用いて計算を行う CuPy で、同様の計算を行うことができました。

それでは、前節と同様に CuPy でもデータサイズを大きくしていった際に処理時間がどのように変化するかを調べてみましょう。

ここで、GPU 上で行われる計算の正しい実行時間を測定するには、time.time() の前に cp.cuda.Stream.null.synchronize() を実行する必要がある点に注意してください。 GPU の処理は基本的に CPU での処理とは非同期に行われるため、CPU 上で動いている Python インタプリタが time_end = time.time() を実行する時点では必ずしもその前に始まった GPU 上での計算が全て終了しているとは限りません。 しかし、cp.cuda.Stream.null.synchronize() を呼び出しておくことによって、GPU 上での処理が終わるまで待ってから、次の行へ処理を進めることができます。

[12]:
times_gpu = []  # GPUの計算時間保存用

for N in [10, 100, 1000, 10000]:
    cp.random.seed(0)
    x = cp.random.rand(N, N)
    t = cp.random.rand(N, 1)

    # GPU 上での処理が終わるのを待機
    cp.cuda.Stream.null.synchronize()

    time_start = time.time()

    # - - - 処理 - - -
    w = get_w_cp(x, t)
    # - - - - - - - - -

    # GPU 上での処理が終わるのを待機
    cp.cuda.Stream.null.synchronize()

    time_end = time.time()

    elapsed_time = time_end - time_start  # 経過時間

    print('N={:>5}:{:>8.5f} sec'.format(N, elapsed_time))

    times_gpu.append(elapsed_time)
N=   10: 0.00071 sec
N=  100: 0.00332 sec
N= 1000: 0.04541 sec
N=10000: 5.17372 sec

それでは NumPy と CuPy で、同サイズの配列を処理するのにどのくらい実行時間が異なっているのか、比較してみましょう。

[13]:
import tabulate

# N ごとの実行時間の差
N = [10, 100, 1000, 10000]
times_cpu = np.asarray(times_cpu)
times_gpu = np.asarray(times_gpu)
ratio = ['{:.2f} x'.format(r) for r in times_cpu / times_gpu]

# tabulate を用いてテーブルを作成
table = tabulate.tabulate(
    zip(N, times_cpu, times_gpu, ratio),
    headers=['N', 'NumPyでの実行時間 (sec)', 'CuPy での実行時間 (sec)', '高速化倍率'])

print(table)
    N    NumPyでの実行時間 (sec)    CuPy での実行時間 (sec)  高速化倍率
-----  -------------------------  -------------------------  ------------
   10                0.000119925                 0.00071311  0.17 x
  100                0.00298882                  0.00332189  0.90 x
 1000                0.193091                    0.0454121   4.25 x
10000              138.909                       5.17372     26.85 x

この結果からわかる通り、配列のサイズが小さい(例えば \(N=10\) のような場合)には、NumPy と CuPy の計算にほとんど違いがないか、もしくは NumPy の方が速い場合もあります。 一方、配列が大きくなるほど、CuPy を使用した場合のアドバンテージが大きくなっていくことが分かります。

Chainer では、デフォルトでは内部の計算に NumPy が使用され、GPU の使用を宣言した場合においては CuPy が使用されます。