3
3

CPU python とGPU CUDA の処理時間を比較。CUDAプログラミングにおけるスレッド数とブロック数の最適な構成とは。

Last updated at Posted at 2024-07-22

35e7825b-2b53-4193-9942-3c27aa0653d2.png

ショートストーリー: 「圧倒的なGPUのパワー」

エリックは学生時代からコンピューターサイエンスに興味を持っていたが、特にGPUの並列計算に心を奪われていた。大学の研究室で初めてCUDAプログラミングに触れたとき、その計算速度の違いに驚きを隠せなかった。彼は「GPUのパワーを引き出すにはどうすればいいのか?」という問いに対する答えを見つけるために、夜遅くまでコードを書き続けた。

ある日、エリックは新しい課題に取り組むことにした。それは、非常に大きな配列のベクトル加算を行うプログラムだった。彼は、CPUだけでなく、GPUも使って計算速度を比較することに決めた。

彼の最初の目標は、CUDAカーネルを使って単純なベクトル加算を行うことだった。数百万の要素を持つ配列に対して、各要素を加算するコードを書いた。しかし、ただ動作するだけでは満足できなかった。彼は最適なスレッドとブロックの設定を見つけることで、GPUのパフォーマンスを最大限に引き出したいと考えた。

エリックは様々なブロックサイズを試して、その処理時間を測定することにした。彼のノートブックには、以下のようなコードが書かれていた。

import pycuda.driver as cuda
import pycuda.autoinit
from pycuda.compiler import SourceModule
import numpy as np
import time

# CUDAカーネルのコード
kernel_code = """
__global__ void vector_add(float *a, float *b, float *c, int N) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    if (i < N) {
        c[i] = a[i] + b[i];
    }
}
"""

# CUDAカーネルのコンパイル
mod = SourceModule(kernel_code)
vector_add = mod.get_function("vector_add")

# 配列のサイズ
N = 1000000

# ホスト側のデータ
a = np.random.randn(N).astype(np.float32)
b = np.random.randn(N).astype(np.float32)
c = np.empty(N, dtype=np.float32)

# デバイスメモリの確保
d_a = cuda.mem_alloc(a.nbytes)
d_b = cuda.mem_alloc(b.nbytes)
d_c = cuda.mem_alloc(c.nbytes)

cuda.memcpy_htod(d_a, a)
cuda.memcpy_htod(d_b, b)

# スレッドとブロックの設定
for block_size in [128, 256, 512, 1024]:
    grid_size = (N + block_size - 1) // block_size
    start_time = time.time()

    # CUDAカーネルの実行
    vector_add(d_a, d_b, d_c, np.int32(N), block=(block_size, 1, 1), grid=(grid_size, 1, 1))

    cuda.memcpy_dtoh(c, d_c)
    end_time = time.time()

    print(f"Block Size: {block_size}, Time: {end_time - start_time:.5f} seconds")

エリックはこれを実行し、各ブロックサイズでの処理時間を比較した。結果は彼の予想を超えていた。最適なブロックサイズを見つけることで、処理時間は劇的に短縮され、彼の期待通りの結果が得られた。

彼は次のように考えた。「これがGPUの力だ。このパワーを最大限に引き出せれば、どんな計算もあっという間に終わる。」

エリックはその夜、研究室の窓から星空を眺めながら、将来の夢に思いを馳せた。彼の目には、無限の可能性が広がっていた。そして、圧倒的なGPUのパワーを手に入れたことで、彼の未来はさらに明るく輝いて見えた。

エリックの旅は始まったばかりだった。彼はもっと大きな挑戦に立ち向かい、GPUの力を最大限に活用して、新たな地平を切り開いていくことを心に誓った。

Pythonで書かれた数値計算のコードをCUDA GPUで実行できるように変換するのは、難易度が変わることがあります。具体的には、以下の要素が影響します:
  1. 基本的な理解
    CUDAの理解: CUDAプログラミングには、GPUのメモリ管理、スレッド管理、カーネルの設計などの知識が必要です。これらの基本を理解していると、変換は比較的スムーズになります。
  2. コードの構造
    直線的な計算: 行列の掛け算やベクトル演算など、計算が比較的単純で直線的な場合、CUDAへの移植は比較的簡単です。
    複雑なロジック: ループのネストが深い場合や、多くの条件分岐がある場合は、CUDAでの最適化が難しくなることがあります。
  3. データの取り扱い
    メモリの管理: GPUのメモリはCPUのメモリとは異なるため、データ転送やメモリ管理のコーディングが必要です。デバイスメモリとホストメモリ間でデータをコピーする処理を実装する必要があります。
  4. パフォーマンスの最適化
    スレッドとブロックの配置: CUDAでは、スレッドやブロックの配置によってパフォーマンスが大きく変わるため、最適な配置を見つけるための試行錯誤が必要です。
  5. ツールとライブラリの活用
    高レベルのライブラリ: CuPyやPyCUDAなどの高レベルのライブラリを利用することで、CUDAプログラミングの複雑さを軽減し、Pythonから簡単にGPU計算を実行できます。これらのライブラリは、CUDAの詳細を抽象化して扱いやすくします。
CPU python とGPU CUDA 行列掛け算の処理時間を比較。

1024x1024の行列をランダムに生成します(行列のサイズは適宜変更可能)。

CPUで行列掛け算:

NumPyの dot 関数を使ってCPU上で行列掛け算を実行し、処理時間を計測します。

GPUで行列掛け算:

PyCUDAを使ってGPU上で行列掛け算を実行し、処理時間を計測します。

結果の比較:

このコードを実行することで、CPUとGPUの処理時間を比較することができます。

CPU Time: 32.10769 seconds
GPU Time: 5.30780 seconds

!pip install pycuda


import pycuda.driver as cuda
import pycuda.autoinit
from pycuda.compiler import SourceModule
import numpy as np
import time

# CUDAカーネルコード
kernel_code = """
__global__ void matrixMultiply(float *A, float *B, float *C, int N) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;

    if (row < N && col < N) {
        float value = 0;
        for (int k = 0; k < N; ++k) {
            value += A[row * N + k] * B[k * N + col];
        }
        C[row * N + col] = value;
    }
}
"""

# カーネルをコンパイル
mod = SourceModule(kernel_code)
matrix_multiply = mod.get_function("matrixMultiply")

# 行列サイズ
N = 10000  # 例: 1024x1024行列

# ホスト側のデータ
h_A = np.random.randn(N, N).astype(np.float32)
h_B = np.random.randn(N, N).astype(np.float32)
h_C = np.empty((N, N), dtype=np.float32)

# CPUで行列掛け算を実行
start_cpu = time.time()
h_C_cpu = np.dot(h_A, h_B)
end_cpu = time.time()

print(f"CPU Time: {end_cpu - start_cpu:.5f} seconds")

# デバイス側のデータ
d_A = cuda.mem_alloc(h_A.nbytes)
d_B = cuda.mem_alloc(h_B.nbytes)
d_C = cuda.mem_alloc(h_C.nbytes)

cuda.memcpy_htod(d_A, h_A)
cuda.memcpy_htod(d_B, h_B)

# グリッドとブロックの設定
block_size = 16
grid_size = (N + block_size - 1) // block_size

# GPUで行列掛け算を実行
start_gpu = time.time()
matrix_multiply(d_A, d_B, d_C, np.int32(N), block=(block_size, block_size, 1), grid=(grid_size, grid_size, 1))
cuda.Context.synchronize()
end_gpu = time.time()

print(f"GPU Time: {end_gpu - start_gpu:.5f} seconds")

# 結果をホスト側にコピー
cuda.memcpy_dtoh(h_C, d_C)

# 結果の比較
if np.allclose(h_C, h_C_cpu):
    print("CPU and GPU results are the same.")
else:
    print("CPU and GPU results differ.")

# デバイスメモリの解放
d_A.free()
d_B.free()
d_C.free()

CUDAプログラミングにおけるスレッドとブロックの設定は、GPUの並列計算性能を最大限に引き出すための重要な要素です。以下に、スレッドとブロックの設定について詳しく説明します。

スレッドとブロックの基本概念
スレッド (Thread):

GPUの基本的な計算単位です。各スレッドは、CUDAカーネル内の独立した計算を担当します。
スレッドは、グローバルメモリのデータを操作し、計算を実行します。
ブロック (Block):

スレッドのグループであり、スレッドが一定の数だけ束ねられたものです。
ブロック内のスレッドは、共有メモリにアクセスでき、同期が可能です。
グリッド (Grid):

ブロックのグループであり、カーネル全体の構造を形成します。
グリッドのサイズは、計算を行う全てのブロックの配置を決定します。
スレッドとブロックの設定

  1. スレッド数とブロック数の決定
    スレッド数: 各ブロックに含まれるスレッドの数を決定します。スレッド数は、通常1次元、2次元、または3次元で設定できます。最も一般的なのは1次元または2次元です。
    ブロック数: グリッド内のブロックの数を決定します。これも1次元、2次元、または3次元で設定できます。
  2. ブロックサイズとグリッドサイズの設定
    ブロックサイズ: ブロック内のスレッド数を設定します。例えば、block=(256, 1, 1) とすると、1次元のブロック内に256スレッドが含まれます。
    グリッドサイズ: グリッド内のブロック数を設定します。例えば、grid=(N // block_size, 1, 1) とすると、1次元のグリッド内にブロックが配置されます。
    設定例
    以下のコードスニペットは、スレッドとブロックの設定例を示しています。
!pip install pycuda

import pycuda.driver as cuda
import pycuda.autoinit
from pycuda.compiler import SourceModule
import numpy as np

# CUDAカーネルのコード
kernel_code = """
__global__ void vector_add(float *a, float *b, float *c, int N) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    if (i < N) {
        c[i] = a[i] + b[i];
    }
}
"""

# CUDAカーネルのコンパイル
mod = SourceModule(kernel_code)
vector_add = mod.get_function("vector_add")

# 配列のサイズ
N = 1000

# ホスト側のデータ
a = np.random.randn(N).astype(np.float32)
b = np.random.randn(N).astype(np.float32)
c = np.empty(N, dtype=np.float32)

# デバイスメモリの確保
d_a = cuda.mem_alloc(a.nbytes)
d_b = cuda.mem_alloc(b.nbytes)
d_c = cuda.mem_alloc(c.nbytes)

# デバイスメモリにデータを転送
cuda.memcpy_htod(d_a, a)
cuda.memcpy_htod(d_b, b)

# スレッドとブロックの設定
block_size = 256  # 各ブロックに256スレッド
grid_size = (N + block_size - 1) // block_size  # グリッド内のブロック数

# CUDAカーネルの実行
vector_add(d_a, d_b, d_c, np.int32(N), block=(block_size, 1, 1), grid=(grid_size, 1, 1))

# 結果をホスト側に転送
cuda.memcpy_dtoh(c, d_c)

# 結果の表示
print("Result:", c)
詳細説明

block_size: 各ブロックに256スレッドを配置します。これは、256スレッドが並列に計算を行うことを意味します。
grid_size: グリッド内のブロック数を計算します。N + block_size - 1 の式は、データサイズ N に対して、全てのスレッドが処理できるようにブロックの数を計算するためのものです。この式は、データサイズがブロックサイズで割り切れない場合も考慮しています。
まとめ
スレッド: GPUで実行される計算の最小単位。
ブロック: スレッドのグループで、同じブロック内のスレッドは共有メモリにアクセスでき、同期が可能。
グリッド: ブロックのグループで、全体の計算を分割して実行します。
適切なスレッドとブロックの設定は、GPU計算の性能を最大化するための重要なステップです。計算の特性に合わせてこれらの設定を調整することで、効率的なGPUプログラミングが可能になります。

例えば、計算の内容によって、スレッド数及びブロック数を試行錯誤で試してみて、最も処理速度が速い、つまり処理時間が短いような設定を見つける作業が必要である、というようなことですか?

ChatGPT
はい、その通りです。スレッド数やブロック数の設定は、GPUプログラミングにおいて性能を最適化するために非常に重要です。これらの設定を試行錯誤して最適な構成を見つけることは、以下の理由から必要です:

  1. ハードウェアの特性
    GPUのアーキテクチャ: 各GPUには異なるアーキテクチャがあり、特定のスレッド数やブロック数が最も効率的に動作する場合があります。GPUのコア数、共有メモリ、スレッドの並列性などが性能に影響します。
  2. 計算の内容
    データのサイズ: データのサイズが異なると、最適なスレッド数やブロック数も変わります。たとえば、非常に大きなデータセットでは、多くのスレッドやブロックが必要になることがあります。
    計算の種類: 行列の掛け算、ベクトル演算、画像処理など、計算の種類によっても最適な設定が異なります。特に、計算の負荷やメモリの利用方法が違うと最適な設定が変わります。
  3. メモリの制約
    共有メモリ: ブロック内のスレッドが共有メモリを使用する場合、メモリの容量とアクセスパターンによって性能が大きく変わります。
    レジスタ数: 各スレッドが使用するレジスタの数も、スレッド数やブロック数の設定に影響を与えます。レジスタが不足すると、スレッドがオフロードされて性能が低下します。
  4. パフォーマンスの測定と調整
    実行時間の測定: 異なる設定でカーネルを実行し、処理時間を測定することで、どの設定が最も高速であるかを特定します。
    プロファイリングツールの利用: NVIDIAのNSightやCUDA Profilerなどのツールを使用して、詳細なパフォーマンスデータを収集し、最適化のためのインサイトを得ることができます。
例: スレッドとブロックの調整
以下のPythonコードスニペットは、スレッドとブロックの設定を試行錯誤して最適化するプロセスの一例です:
!pip install pycuda

import pycuda.driver as cuda
import pycuda.autoinit
from pycuda.compiler import SourceModule
import numpy as np
import time

# CUDAカーネルのコード
kernel_code = """
__global__ void vector_add(float *a, float *b, float *c, int N) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    if (i < N) {
        c[i] = a[i] + b[i];
    }
}
"""

# CUDAカーネルのコンパイル
mod = SourceModule(kernel_code)
vector_add = mod.get_function("vector_add")

# 配列のサイズ
N = 1000000

# ホスト側のデータ
a = np.random.randn(N).astype(np.float32)
b = np.random.randn(N).astype(np.float32)
c = np.empty(N, dtype=np.float32)

# デバイスメモリの確保
d_a = cuda.mem_alloc(a.nbytes)
d_b = cuda.mem_alloc(b.nbytes)
d_c = cuda.mem_alloc(c.nbytes)

cuda.memcpy_htod(d_a, a)
cuda.memcpy_htod(d_b, b)

# スレッドとブロックの設定
for block_size in [128, 256, 512, 1024]:
    grid_size = (N + block_size - 1) // block_size
    start_time = time.time()

    # CUDAカーネルの実行
    vector_add(d_a, d_b, d_c, np.int32(N), block=(block_size, 1, 1), grid=(grid_size, 1, 1))

    cuda.memcpy_dtoh(c, d_c)
    end_time = time.time()

    print(f"Block Size: {block_size}, Time: {end_time - start_time:.5f} seconds")

説明
異なるブロックサイズ: block_size を変更して、さまざまなスレッド数でカーネルを実行します。
グリッドサイズの計算: データのサイズに応じて適切なグリッドサイズを計算します。
処理時間の測定: 各設定で処理時間を測定し、どの設定が最も効率的かを確認します。
このプロセスを通じて、最適なスレッド数やブロック数を見つけることができ、GPUのパフォーマンスを最大限に引き出すことができます。

実行結果。
Block Size: 128, Time: 0.00182 seconds
Block Size: 256, Time: 0.01525 seconds
Block Size: 512, Time: 0.00130 seconds

Block Size: 1024, Time: 0.00117 seconds
3
3
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
3
3