CUDAとOpenCLによるGPUを用いた行列-行列積(GEMM)の実装テスト備忘録。OpenMPを用いたCPUのマルチスレッド型の並列実装とも比較する



実装コード


実装したコード、環境構築用スクリプトはGitHubリポジトリ参照



評価環境


CPU Intel Core i7-9700K
GPU NVIDIA GeForce RTX 2060 Super
DDR Memory DDR 16GB 2666MHz
OS Ubuntu 24.04.4 LTS
Compiler CUDA: nvcc V12.0.140
OpenCL: gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
Option CUDA: -O2 -arch=sm_75
OpenCL: -O2 -lOpenCL -mcmodel=medium
Time measure CUDA: cudaEventRecord
OpenCL: clGetEventProfilingInfo


環境構築


詳細はGithub上の "env"ディレクトリと、READMEファイル参照

パッケージリスト更新等
sudo apt update -y
sudo apt upgrade -y
sudo apt autoremove -y

GPU認識確認コマンド:
lspci | grep -i nvidia


CUDA環境構築


ドライバインストール コマンド
ubuntu-drivers devices
バージョン指定ドライバインストール:
sudo apt install nvidia-driver-580-open
※ 下記ドライバの確認コマンドで確認したドライバのバージョンを指定してインストールする

再起動:
sudo reboot
CUDAツールキットインストール:
sudo apt install nvidia-cuda-toolkit



<環境確認コマンド>

ドライバ確認:
ubuntu-drivers devices
GPU動作・認識確認:
nvidia-smi
CUDA Toolkit 確認:
nvcc --version


OpenCL


ドライバーインストール:
sudo apt install nvidia-driver-580-open nvidia-opencl-dev -y
OpenCL プラットフォーム インストール:
sudo apt install clinfo -y



<環境確認コマンド>

プラットフォーム確認:
clinfo


CPU-GPU間のデータ受け渡し

CUDA


GPUメモリの動的確保
cudaMalloc(void** devPtr, size_t size)
ホスト(CPU)からGPUへデータコピー
cudaMemcpy(void* dst , const void* src , size_t size , cudaMemcpyKind cudaMemcpyHostToDevice)
GPUからホスト(CPU)へデータコピー
cudaMemcpy(void* dst , const void* src , size_t size , cudaMemcpyKind cudaMemcpyDeviceToHost)


OpenCL


GPUメモリ == Bufferオブジェクト
として表現されているため、下記キュー型の実行を行う

clCreateBuffer

clEnqueueWriteBuffer

kernel実行

clEnqueueReadBuffer


GPUメモリの動的確保
cl_mem clCreateBuffer( cl_context context, cl_mem_flags flags, size_t size, void* host_ptr, cl_int* errcode_ret )
ホスト(CPU)からGPUへデータコピー
cl_int clEnqueueWriteBuffer( cl_command_queue command_queue, cl_mem buffer, cl_bool blocking_write, size_t offset, size_t size, const void* ptr, cl_uint num_events_in_wait_list, const cl_event* event_wait_list, cl_event* event )
GPUからホスト(CPU)へデータコピー
cl_int clEnqueueReadBuffer( cl_command_queue command_queue, cl_mem buffer, cl_bool blocking_read, size_t offset, size_t size, void* ptr, cl_uint num_events_in_wait_list, const cl_event* event_wait_list, cl_event* event )



GPUアーキテクチャの勉強


評価に使用したRTX2060 Superは、2018年後半のNvidia Turing(チューリング)アーキテクチャを採用しています。ですので、今回はこのアーキテクチャをベースとして、CUDA実装をベースにGPUの構成を見ていきます。

参照:


GPUアーキテクチャの構成要素


CUDAで高性能な行列演算を実装するために、次の要素が重要

  • CUDA Core
  • Warp
  • Shared Memory
  • Tensor Core
  • WMMA API


GPU アーキテクチャの基本構造


GPUは以下の階層構造で構成される。

GPU
 ├ GPC (Graphics Processing Cluster)
 │   ├ TPC (Texture Processing Cluster)
 │   │   ├ SM (Streaming Multiprocessor)
 │   │   │   ├ CUDA Core
 │   │   │   ├ Tensor Core
 │   │   │   ├ Shared Memory
 │   │   │   └ Warp Scheduler

CUDAプログラムは最終的に SM (Streaming Multiprocessor) 上で実行される。

Turing世代のSMは以下のような構成

  • 64 FP32 CUDA cores
  • 64 INT32 cores
  • 8 Tensor Cores
  • Warp Scheduler

Turingでは FP32 と INT32 を同時実行可能で、浮動小数演算整数演算を並列に処理可能


Warp(GPUスレッドの実行単位)


CUDAではスレッドは1つずつ実行されるのではなく、 Warp単位で実行

1 Warp = 32 threads

今回のコードも Warp を前提とした実装

int tid = threadIdx.x;
int warpId = tid / 32;
int laneId = tid % 32;
変数意味
tidスレッドID
warpIdWarp番号
laneIdWarp内スレッドID

GPUは32スレッドを同時に実行するSIMD方式のため、 Warpの概念はCUDA最適化の基本


メモリ階層


CUDAの性能はメモリアクセスに大きく依存


主なメモリ階層

Global Memory   (GPU DRAM)
Shared Memory   (SM内部)
Register

速度の順序

Register > Shared Memory > Global Memory

そのためCUDA最適化の基本戦略は

Global → Shared → Register

という順序でデータを移動すること

今回のコードでも Shared Memory を利用

__shared__ half As[BLOCK_M][BLOCK_K];
__shared__ half Bs[BLOCK_K][BLOCK_N];


Shared Memory Tiling


GPUで行列演算を高速化する代表的なテクニックの Tiling


行列積

C = A × B

を小さなブロックに分割して計算

今回のコードでは次のタイルサイズを使用

BLOCK_M = 128
BLOCK_N = 128
BLOCK_K = 16

行列は以下のように分割

C matrix

+---------+---------+
| block00 | block01 |
+---------+---------+
| block10 | block11 |
+---------+---------+


Tensor Core


Tensor Core は行列演算専用のハードウェアユニット

Tensor Core は次の演算を高速に実行

D = A × B + C

これは FMA (Fused Multiply Add) と呼ばれる。


Turing GPU の Tensor Core は

16 × 16 × 16

サイズの行列演算を1命令で処理可能。


Turing GPUでは

  • 1 SM に 8 Tensor Core
  • Tensor Core は FP16 FMA を高速実行

という構成



WMMA (Warp Matrix Multiply Accumulate)


CUDAからTensor Coreを利用するAPIが WMMA

CUDAでは

nvcuda::wmma

という名前空間で提供されている


今回のコードでも使用

wmma::fragment<wmma::matrix_a, WMMA_M, WMMA_N, WMMA_K, half, wmma::row_major> a_frag;

wmma::fragment<wmma::matrix_b, WMMA_M, WMMA_N, WMMA_K, half, wmma::row_major> b_frag;

wmma::fragment<wmma::accumulator, WMMA_M, WMMA_N, WMMA_K, float> c_frag;

fragment は Tensor Core 用のレジスタ行列



Tensor Core 演算の流れ

Tensor Core演算は次の流れで実行

Global Memory
   ↓
Shared Memory
   ↓
WMMA fragment
   ↓
Tensor Core
   ↓
Accumulator

今回のコードでも以下の3ステップで実行。

wmma::load_matrix_sync(a_frag,&As[warpRow][k],BLOCK_K);

wmma::load_matrix_sync(b_frag,&Bs[k][warpCol],BLOCK_N);

wmma::mma_sync(c_frag,a_frag,b_frag,c_frag);


Warp Tiling

Tensor Coreの性能を最大化するために 計算を以下の階層で分割

block
  └ warp
      └ tensor core

今回のコードでは

  • block = 128 × 128
  • warp = 64 × 64
  • tensor core = 16 × 16

という構造で計算を分割



Mixed Precision (混合精度)

Tensor Coreでは Mixed Precision が使用される。

FP16 × FP16 → FP32

今回のコードでも

half *a
half *b
float *c

という構成


これは

  • FP16で高速計算
  • FP32で精度保持

を両立するための設計



CUDA Kernel Execution


Kernelの起動は次の形式

tensor_gemm<<<blocks,threads>>>(dA,dB,dC);

今回の設定は

threads = 128
block = 128×128 tile

つまり

1 block = 4 warps

という構成



まとめ

今回実装したCUDA Tensor Core GEMMコードを理解するために、 以下の概念が重要

  • GPU階層構造 (SM)
  • Warp execution
  • メモリ階層 (Global / Shared)
  • Tiling
  • Tensor Core
  • WMMA API
  • Mixed Precision
  • Warp tiling

これらを組み合わせることで GPUでCPUより高速な行列演算を実現可能





評価に用いる行列-行列積(GEMM)


こちらでOpenMPで評価している行列-行列積と同じ演算を行います。



Kernel部分の実装部分

全ての要素に値を入れた密な行列AとBを入力として行列の積(General Matrix Multiply)を計算し、結果として行列Cを出力する処理を考えます。例を示すと以下のような処理です。


前提条件として「行列Aの列数=行列Bの行数」となっていることが挙げられます。得られる結果の行列Cの大きさは、「行×列」=「行列Aの行数×行列Bの列数」です。



簡易速度評価


演算精度や、評価環境など厳密に条件をそろえて比較等しておりません。簡易的なテストだと思ってみてください。

実装環境

CUDA


makefile:
matrix_gemm: ../src/matrix_gemm.cu
	nvcc ../src/matrix_gemm.cu -O2 -arch=sm_75 -o matrix_gemm

時間測定

GPU上でのカーネル実行時間を測定しています。
CPU-GPU間のメモリ転送やCPU側の処理時間は含めません。
float total_ms=0.0f;
cudaEvent_t start,stop;

cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord(start);

// kernel launch
tensor_gemm<<>>(dA,dB,dC);

CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());

cudaEventRecord(stop);
cudaEventSynchronize(stop);

float ms;
cudaEventElapsedTime(&ms,start,stop);
total_ms+=ms;

cudaEventDestroy(start);
cudaEventDestroy(stop);

cudaEventRecord()で、GPUのコマンドキューへイベントを追加しています。
cudaEventRecord()のstartと、stopの間にメモリ転送を含めると転送時間を含む時間の測定をすることも可能です。


OpenCL


makefile:
matrix_gemm: ../src/matrix_gemm.cpp
	g++ ../src/matrix_gemm.cpp -O2 -lOpenCL -mcmodel=medium -o matrix_gemm

時間測定
double total_host_ms    = 0.0; // CPU上の時間
double total_device_ms  = 0.0; // GPU内部の時間

cl_event event;
auto start = std::chrono::high_resolution_clock::now(); // CPU測定START

err = clEnqueueNDRangeKernel(queue, kernel, 2, NULL, global, local, 0, NULL, &event);
clFinish(queue);

auto end = std::chrono::high_resolution_clock::now();   // CPU測定END

total_host_ms += std::chrono::duration(end - start).count();

// Device時間取得 (GPU)
cl_ulong start_time, end_time;
clGetEventProfilingInfo(event, CL_PROFILING_COMMAND_START, sizeof(cl_ulong), &start_time, NULL);
clGetEventProfilingInfo(event, CL_PROFILING_COMMAND_END, sizeof(cl_ulong), &end_time, NULL);
total_device_ms += (end_time - start_time) * 1.0e-6;

clReleaseEvent(event);



時間測定




実行時間 [ms]
行列サイズCPU(Serial)OpenMPCUDAOpenCL
1,000292580.6112.27
2,00024604671.3999.77
3,000808515475.78268.05
4,00018908390310.471036.03


CPUで実行するSerial版や、OpenMP版と比べると、GPUで実行しているCUDAや、OpenCL版のほうが高速に演算できています。

CUDAと比較してOpenCLでそれほど性能を出せていません。もう少し改良の余地はありそうです。




参考:

このエントリーをはてなブックマークに追加
コメントを閉じる

コメント

コメントフォーム
記事の評価
  • リセット
  • リセット