CUDA Python 入門 (Intro to CUDA Python)

重點總覽

項目 重點
CUDA Python Ecosystem 一群 Python 工具/函式庫;cuda.core/cuda.compute/CuPy 控制 GPU 與跑現成 code,cuda.lang/cuda.tile 寫 kernel
優先用 libraries 現代 CUDA 幾乎總是優先用 GPU-accelerated libraries(專家調校);不足時才自己寫 kernel,Python 與 C++ 皆可
Getting Setup 多數元件在 PyPi,用 pip 安裝;只需最新 NVIDIA Driver通常不需要 CUDA Toolkit
執行方式 與一般 Python 程式相同:python3 app.py
指定 kernel from numba import cuda + 在函式上方加 @cuda.jit 裝飾器;第一次 launch 時對當前 GPU JIT 編譯
launch kernel 方括號 execution configuration:kernel[blocks, threads](args);等同 C++ 的 <<<...>>> 三角括號
多維 grid/block 1 維用整數,2/3 維用 tuple:function[(gx,gy), (bx,by)](...);每 block 上限 1024 threads
index intrinsics cuda.threadIdx/blockDim/blockIdx/gridDim,皆有 .x/.y/.z;shorthand cuda.grid(n)
GPU 上建立 array CuPy 以類 Numpy 介面在 GPU DRAM 建 ndarray;無指定型別預設 float32
host↔GPU 複製 H→D:cp.array(host);D→H:cp.asnumpy(device)不會隱式複製,需程式設計師明確控制
ndarray 型別 array 只存在 host GPU 其一,不能兩邊;攜帶 dimension extents,kernel 內自動邊界檢查
同步 kernel launch 對 host 非同步cp.synchronize() / cuda.synchronize() / device.synchronize() 做 device-wide 同步
Error Checking CUDA 錯誤在 Python 拋 exception,用 try/except 捕捉;如 block_size=2048 → CUDA_ERROR_INVALID_VALUE

本筆記是 C++ 版(2.1 節)的 Python 對應。CUDA programming model 的概念兩種語言相同,差別主要在語法與工具鏈。以下每節都會對照 C++ 寫法凸顯 Python 的等價作法。

CUDA Python Ecosystem

CUDA Python 是一整套讓 Python 進行 GPU 運算的工具與函式庫生態系,並非每個元件都是本章所需。它可分為三類:

主要元件(GPU 控制 / 執行 library 提供的 GPU code)

Kernel 撰寫元件

其他元件

Important

本章實際示範時,使用的是 numba.cuda(寫/launch SIMT kernel)搭配 CuPy(GPU 記憶體與 ndarray)。上面的官方生態系清單是讓你認識完整版圖,並非全部都會用到。

CUDA Python Ecosystem
├─ 控制 / 跑現成 GPU code ─ cuda.core | cuda.compute | CuPy
├─ 寫 kernel / device fn ── cuda.lang(SIMT) | cuda.coop | cuda.tile(Tile)
└─ 其他工具 ────────────── cuda.pathfinder | cuda.bindings(低階 C wrapper)

在 Python 使用 CUDA libraries

Tip

現代 CUDA 的最佳實務:只要 GPU-accelerated library 的表達力足夠,幾乎總是優先用 library(多由 GPU 運算專家調校過)。只有在 library 不存在或不足時,才自己寫 kernel 與 device function——這在 Python 與 C++ 中都辦得到。本章接下來講的就是「需要自己寫 GPU code」時的作法。

Getting Setup 與執行

python3 cuda-python-app.py

SIMT Kernels in Python

在 GPU 上執行、可從 host 呼叫的函式稱為 kernel。CUDA 提供兩種模型:SIMTCUDA tile。SIMT kernel 由大量平行 thread 同時執行——這個概念在 CUDA Python 與 CUDA C++ 完全一致。本章以 SIMT kernel 介紹 CUDA Python。

指定 kernel(Specifying Kernels)

先 import numba.cuda,再用 @cuda.jit 裝飾器把函式標記成 kernel:

from numba import cuda

@cuda.jit
def function(input_array, output_array):
    ...
Tip

對照 C++:@cuda.jit def f(...) 等同於 __global__ void f(...)。C++ 由 nvcc 提前(或 JIT)編譯成 PTX/cubin;Python 則由 @cuda.jit 在首次 launch 時 JIT。

Launching Kernels

執行 kernel 的 thread 數量在 launch 時指定,稱為 execution configuration;每次呼叫可有不同的 block size 或 block 數量。execution configuration 放在 kernel 名稱後、引數前的方括號 [ ] 內,引數順序與 C++ 三角括號相同:

from numba import cuda

@cuda.jit
def my_kernel(input, output):
    ...

## launch the kernel
my_kernel[num_thread_blocks, threads_per_block](in_array, out_array)
Warning

每個 thread block 有 thread 數上限:因為一個 block 的所有 thread 都駐在同一個 SM 上、共用該 SM 資源。目前 GPU 每個 block 最多 1024 threads。若資源允許,多個 block 可同時排程到同一 SM。

C++/Python launch 語法對照:

動作 C++ Python (numba.cuda)
launch(1 維) f<<<blocks, threads>>>(a) f[blocks, threads](a)
包住 config 的符號 三角括號 <<< >>> 方括號 [ ]
引數順序 blocks, threads blocks, threads(相同)

多維 grid 與 thread block

grid 與 thread block 可為 1、2 或 3 維。1 維時用整數;2/3 維時用 tuple:

@cuda.jit
def function(input, output):
    ...

## 2D grid 與 2D block:(gridX, gridY) 與 (blockX, blockY)
function[(gridX, gridY), (blockX, blockY)](in_array, out_array)

Thread/Grid Index Intrinsics

kernel 內可用下列變數判斷自己的身分(皆為 3 分量向量,具 .x / .y / .z):

Intrinsic 意義 C++ 對應
cuda.threadIdx.[xyz] thread 在其 block 內的 index threadIdx
cuda.blockDim.[xyz] thread block 的維度(來自 exec config) blockDim
cuda.blockIdx.[xyz] block 在 grid 內的 index blockIdx
cuda.gridDim.[xyz] grid 的維度(來自 exec config) gridDim

一個 element-wise 向量加法 kernel(C = A + B):

@cuda.jit
def vecadd(A, B, C):
    idx = cuda.threadIdx.x + cuda.blockIdx.x * cuda.blockDim.x
    C[idx] = A[idx] + B[idx]
## 以下兩行等價
idx = cuda.threadIdx.x + cuda.blockIdx.x * cuda.blockDim.x
idx = cuda.grid(1)
Important

上面的 kernel 沒有做 out-of-bounds 檢查。本章假設 A/B/C 是 CuPy 建立的 ndarray,其陣列型別會隱式做邊界檢查,因此當 thread 總數略大於資料長度時不需手動檢查越界。(對照 C++ 通常需自己寫 if (idx < N) 守門。)

grid (1D)                      thread global index
┌──────────┬──────────┬──────────┐
│ block 0  │ block 1  │ block 2  │   idx = threadIdx.x
│ t0..t255 │ t0..t255 │ t0..t255 │       + blockIdx.x * blockDim.x
└──────────┴──────────┴──────────┘       = cuda.grid(1)
   blockDim.x = 256,  gridDim.x = 3,  N = 3 * 256 = 768

Memory in GPU computing

GPU 有自己連接的 DRAM。kernel 要用的資料陣列一般必須先位於 GPU DRAM 才能被 kernel 存取。在 Python 中,控制資料位置(在 CPU↔GPU 間搬移)是程式設計師的責任——這與 C++ 的 explicit memory management 情況相同。

Warning

CuPy 等 Python 套件其實是直接呼叫 CUDA C++ API(如 2.1.3.2 節)來做 GPU 記憶體管理。多個套件提供各自的 wrapper,本指南只涵蓋 CuPy;多數套件行為與其 C++ 對應相似,除非另有說明。

在 GPU 上 instantiate arrays

CuPy 提供在 GPU 上建立指定型別/維度 ndarray 的函式,函式簽章與 Numpy 相似:

import cupy as cp
import numpy as np

## 在 GPU 上建立全零矩陣;未指定 datatype 時預設 float32
A_device = cp.zeros((1024, 1024))
## 在 GPU 上建立 2^20 個隨機 double
B_device = cp.rand.random((2**20), dtype=np.double)
## 建立與既有 array 同 shape、同 datatype 的全零 array
C_device = cp.zeros_like(A)
Tip

重點記憶:CuPy 未指定 datatype 時預設 float32cp.zeros / cp.zeros_like / cp.random.* 等對應 Numpy 同名函式,但結果落在 GPU 記憶體

host↔GPU 複製

import cupy as cp
import numpy as np

A_host = np.zeros((1024, 1024))   # host memory (Numpy)
A_device = cp.array(A_host)       # H→D:複製到 GPU

B_device = cp.rand.random((1024, 1024))  # GPU memory
B_host = cp.asnumpy(B_device)            # D→H:複製回 host

C++/Python 記憶體操作對照:

動作 C++ Python (CuPy)
配置 device 記憶體 cudaMalloc(...) cp.zeros(...) / cp.random.*
H→D 複製 cudaMemcpy(..., H2D) cp.array(host_arr)
D→H 複製 cudaMemcpy(..., D2H) cp.asnumpy(dev_arr)

ndarray 物件型別

        Numpy ndarray            CuPy ndarray
        (host memory)            (GPU/device memory)
            │                          │
   cp.array(host) ─────────────────────▶  (H→D)
            ◀───────────────────── cp.asnumpy(dev)  (D→H)

   傳給 kernel:只接受 GPU 上的 ndarray(host array → error)
   傳給一般 Python fn:只接受 host array(GPU array → error)

Synchronizing the CPU and the GPU

與 C++ 相同,CUDA Python 的 kernel launch 對 host thread 是非同步的:host code 在 launch 後即繼續往下執行,不保證 kernel 已完成、甚至已開始。要確保 GPU kernel 完成,host thread 必須做某種同步。

最簡單的形式是同步整個 GPU(device-wide synchronization),由 CUDA driver 提供,CuPy 與 numba.cuda 都以 synchronize() 方法暴露:

import cupy as cp
from numba import cuda

## 等待所有先前發出的 GPU 工作完成(CuPy 介面)
cp.synchronize()
## 等待所有先前發出的 GPU 工作完成(numba.cuda 介面)
cuda.synchronize()
Tip

需要更細粒度的同步時,使用 CUDA streams(見 2.5 節)。在 Python 中建議用 cuda.core 建立 stream,並只對特定 stream 做必要同步。

host thread:  launch kernel ──▶ (繼續執行 host code,不等 GPU)
                                       │  synchronize()
GPU:          [====== kernel 執行中 ======]
host thread:                            └──▶ 阻塞直到 GPU 全部完成,再往下走

Putting it All Together

完整的平行向量加法(Python 版),刻意讓 vector_size 不是 2 的次方、也不是 block_size 的倍數,以示範自動邊界檢查:

import numpy as np
from numba import cuda
import cupy as cp

@cuda.jit
def vecadd(A, B, C):
    work_index = cuda.grid(1)
    C[work_index] = A[work_index] + B[work_index]

vector_size = 2**24 + 11          # 非 2 次方、非 block_size 倍數
device = cp.cuda.Device()

## 直接在 GPU 上建立輸入(隨機 float32)與輸出(全零)
a = cp.random.uniform(-1, 1, vector_size)
b = cp.random.uniform(-1, 1, vector_size)
c = cp.zeros_like(a)

block_size = 256
grid_size = int(np.ceil(vector_size / block_size))
vecadd[grid_size, block_size](a, b, c)

## 顯式同步示範好習慣;其實下面的 asnumpy 複製也會隱式等待 kernel 完成
device.synchronize()

## 把 3 個 array 複製回 CPU 做驗證
a_np = cp.asnumpy(a)
b_np = cp.asnumpy(b)
c_np = cp.asnumpy(c)
expected = a_np + b_np
np.testing.assert_array_almost_equal(c_np, expected)
print("Test succeeded")
Important

注意 cp.cuda.Device() 物件也提供 synchronize()(即 device.synchronize()),與 cp.synchronize() / cuda.synchronize() 同為 device-wide 同步。


Error Checking in CUDA Python

任何影響 GPU 的操作(記憶體配置、複製、kernel launch)都可能引發錯誤;如同 C++(2.1.7 節),確認過程中未發生錯誤是 best practice。

在 Python,CUDA 錯誤會拋出 exception,若未捕捉會終止程式;可用一般 Python 的 try/except 捕捉。下例把 block_size 設成 2048(超過任何現行 GPU 的 1024 上限),使 kernel launch 失敗並拋 exception

try:
    vector_size = 2**24 + 11
    device = cp.cuda.Device()
    a = cp.random.uniform(-1, 1, vector_size)
    b = cp.random.uniform(-1, 1, vector_size)
    c = cp.zeros_like(a)
    block_size = 2048                       # 對任何現行 GPU 都過大
    grid_size = int(np.ceil(vector_size / block_size))
    vecadd[grid_size, block_size](a, b, c)  # Error: 無效的 block size
    device.synchronize()
    print("Test did not encounter any errors")
except Exception as e:
    print(f"Exception occurred: {e}")

執行輸出:

$ python3 vecadd_error.py
Exception occurred: CUDA_ERROR_INVALID_VALUE: This indicates that one or more
of the parameters passed to the API call is not within an acceptable range of values.
Tip

C++/Python 錯誤處理對照:C++ 多數 runtime 呼叫回傳 cudaError_t,需主動檢查;Python 則把錯誤轉成 exception,用 try/except 攔截,更貼近 Python 慣用法。block_size > 1024 這類「參數超出範圍」會得到 CUDA_ERROR_INVALID_VALUE


考試/測驗重點

情境/關鍵字 答案
本章用哪兩個套件示範 SIMT? numba.cuda(寫/launch kernel)+ CuPy(GPU 記憶體與 ndarray)
把函式標記為 kernel 的語法? 在函式上方加 @cuda.jit 裝飾器
@cuda.jit 何時編譯? 第一次 launch 時對當前 GPU JIT 編譯
Python launch 語法?對應 C++ 什麼? kernel[blocks, threads](args);對應 C++ 三角括號 <<<blocks, threads>>>
execution configuration 放哪?引數順序? kernel 名稱後的方括號 [ ];順序 blocks, threads(與 C++ 相同)
每個 thread block 最多幾 threads?為何受限? 1024;因一個 block 所有 thread 駐在同一 SM、共用 SM 資源
多維 grid/block 怎麼指定? 1 維用整數;2/3 維用 tuple [(gx,gy),(bx,by)]
四個 index intrinsic? cuda.threadIdx / cuda.blockDim / cuda.blockIdx / cuda.gridDim(皆 .xyz
未指定維度時 intrinsic 預設值? Dim 預設 1,Idx 預設 0
計算 1D global index 的 shorthand? cuda.grid(1) = threadIdx.x + blockIdx.x * blockDim.x
N(grid 內總 thread 數,1D)= ? cuda.gridDim.x * cuda.blockDim.x
為何 vecadd kernel 不寫越界檢查? CuPy ndarray,array 攜帶 extent,自動邊界檢查
CuPy 未指定 datatype 的預設型別? float32
H→D / D→H 複製函式? H→D:cp.array(host);D→H:cp.asnumpy(device)
H→D / D→H 對應的 C++ API? cudaMemcpy(... H2D/D2H);配置對應 cudaMalloccp.zeros
把 host array 傳給 kernel 會怎樣? error(kernel 只接受 GPU 上的 ndarray)
把 GPU array 傳給一般 Python 函式? error(非 kernel 只接受 host array)
CuPy 會自動在 CPU↔GPU 複製嗎? 不會;複製昂貴,須程式設計師明確控制
kernel launch 對 host 同步還是非同步? 非同步;不保證已完成甚至已開始
device-wide 同步的三種寫法? cp.synchronize() / cuda.synchronize() / device.synchronize()
device-wide 同步對應 C++? cudaDeviceSynchronize()
更細粒度同步用什麼?Python 建議用哪個建 stream? CUDA streams;建議用 cuda.core 建立 stream
為何範例仍呼叫 device.synchronize() 示範好習慣;其實後面 cp.asnumpy 複製已隱式等待 kernel 完成
CUDA Python 怎麼回報錯誤?怎麼接? exception;用 try/except 捕捉
block_size=2048 會得到什麼錯誤? CUDA_ERROR_INVALID_VALUE(參數超出可接受範圍)
安裝需要什麼?需要 CUDA Toolkit 嗎? 需最新 NVIDIA Driver通常不需要 CUDA Toolkit
多數 CUDA Python 元件從哪安裝? PyPipip
cuda.core 之於 Python 等同什麼之於 C++? 等同 CUDA Runtime 之於 CUDA C++
現代 CUDA 寫 GPU code 的優先順序? 先用 GPU-accelerated library,不足時才自己寫 kernel