GPU编程(CUDA)
GPU(图形处理单元),多核系统,而现今的大多数CPU也属于多核系统,但它们之间还是存在很大的区别:
- CPU适合执行复杂的逻辑,比如多分支,其核心比较重(复杂)
- GPU适合执行简单的逻辑,大量的数据计算,其吞吐量更高,但是核心比较轻(结构简单)
GPU本职任务时做图形图像的,将数据处理成图像,再显示。其最初是不可编程的,但后面有hacker为了实现规模较大的运算,开始研究着色语言或图形处理原语来和GPU对话,英伟达发现是商机就开发了一套平台 CUDA 专门用于GPU编程,然后深度学习火了,CUDA就越来越火,再到现在的AIGC的普及化,利用CUDA优化大规模计算基本是共识了。 - 异构运算
处理器不是单一类型组成的,比如仅有CPU,都是异构架构,反之就是同构架构。
异构架构比同构架构运算量更大,但是其应用复杂度也更高,控制、传输都需要人为干预,而同构架构,硬件部分自己完成控制,不需要人为设计。
在CPU+GPU的异构架构里
- 左图 一个4核CPU 一般有4个ALU,DRAM是内存,CPU通过总线访问内存,Cache 高速缓冲存储器
- 右图 GPU 一个绿色小方块就是一个ALU,而红框标记的SM表示这一组的ALU公用一个Control单元和Cache,就相当于一个多核CPU,但ALU更多,且control变小,导致计算力提升,控制能力减弱,所以对于控制逻辑复杂的程序,一个GPU的SM是没办法和CPU比较的,但对于逻辑简单,数据量大的任务,GPU更加高效。
CPU和GPU之间通过PCle总线连接,用于传递指令和数据(这也是决定性能瓶颈的地方之一)
一个在异构平台上的运行的程序,应该将低并行复杂逻辑的部分在CPU上跑,将高并行简单逻辑的部分在GPU上跑
CPU和GPU的区别:
-
CPU线程是重量级实体,操作系统交替执行线程,线程上下文切换花销很大
-
GPU线程是轻量级的,GPU的应用一般包含成千上万的线程,多数都在排队状态,线程之间切换基本没有开销
-
CPU的核被设计用来尽可能减少一个或两个线程运行时间的延迟,而GPU核则是大量线程,最大幅度提高吞吐量
cuda:一种异构计算平台
CUDA是建立在Nvidia GPU上的一整套平台,支持多种语言
CUDA的API有两种: -
低级API 驱动API 使用困难
-
高级API 运行时API 使用简单 其实现基于驱动API
两种API是互斥的,两者间的函数不能混合调用。
一个CUDA程序 -
分配GPU内存
-
拷贝内存到设备
-
调用CUDA内核函数来执行计算
-
把计算完成司机拷贝回主机端
-
内存销毁
其关键点在于内存和线程的管理
- 内存
分为共享内存(shared Memory)和全局内存(global Memory)
共享内存在块内,线程间共享
全局内存在grid内共享
- 线程
GPU中的线程层次结构为grid-block-thread
一个核函数只能有一个grid,一个grid可以有很多个块,每个块可以有很多线程。
不同块内的线程间不能相互影响,它们是物理隔离的
所有CUDA核函数的启动都是异步的
如何计算线程下标
譬如上图 一个grid中有4个block 一个block有8个thread
所以gridDim=4 blockDim=8 线程数=blockDim*gridDim=32
threadIdx.x 取值是0-7
而线程下标如何映射为数组下标呢?一个数组从主机传递到GPU的内存上,现在是并行计算,就不可能用for去逐个获取i值,就得依照以下公式来获取每一个i
i=threadIdx.x+blockDim.x*blockIdx.x
同时也容易注意到,我们线程是32,但如果数组小于32,比如20,就会有线程溢出的问题,这时该如何解决呢?方案就是,不要做任何事情!
#以python的numba库举例
@cuda.jit
def addArray(a,b,c):
i = cuda.threadIdx.x+cuda.blockDim.x*cuda.blockIdx.x
if i<a.size:#线程溢出的部分就不去管它了
c[i] = a[i]+b[i]
N = 20
a = np.arange(N,dtype=np.float32)
b = np.arange(N,dtype=np.float32)
dev_c = cuda.device_array_like(a)
addArray[4,8](a,b,dev_c)
c = dev_c.copy_to_host()
"""
注:也可以使用i=cuda.grid(1) 1表示获取1维线程索引,2表示2维,3表示3维
"""
因为硬件的限制,GPU并不能运行任意数量的线程和块,通常每个块不能超过1024个线程,一个网格不能超过 2 16 − 1 = 65535 2^{16}-1=65535 216−1=65535个块,同时还要考虑内存量。
在numba中硬件限制可以通过cuda.get_current_device()获取
Grid-stride循环
当每个网格的块数超过硬件设置,但显存还有剩余,我们可以使用一个线程来处理数组中的多个元素,这被称为Grid-stride。
就是在核函数里加一个循环来处理多个输入元素。
@cuda.jit
def addArrayGS(a,b,c):
i_start = cuda.grid(1)
thread_per_grid = cuda.blockDim.x*cuda.gridDim.x
for i in range(i_start,a.size,threads_per_grid):
"""这样设置能够重复利用线程,但并不是一个线程内运行0-a.size-1 而是负责其中的一部分
比如 当threads_per_grid = 46 a.size=89时
i_start = 0 i会取值0,46 可以以完整取完0-88的索引
"""
c[i]=a[i]+b[i]
threads_per_block = 256
block_per_grid_gs = 32*80
addArrayGS[block_per_grid_gs,threads_per_block](dev_a,dev_b,dev_c)
cuda内核的计算时间
因为GPU和CPU是不通信的,因此在内核启动前后分别调用time.time() 只能获得内核启动需要的时间,而不是计算运行需要的时间。
所以为了获取准确的时间,就得调用cuda.synchronize()函数,这个函数将停止主机执行任何其他代码,直到GPU完成所有核函数。(注:numba是动态编译的,第一调用核函数会计时编译步骤,要获取计算的时间 需要二次调用才是准确的)
from time import perf_counter_ns
# Compile and then clear GPU from tasks
addArray[blocks_per_grid, threads_per_block](dev_a, dev_b, dev_c)
cuda.synchronize()
timing = np.empty(101)
for i in range(timing.size):
tic = perf_counter_ns()
addArray[blocks_per_grid, threads_per_block](dev_a, dev_b, dev_c)
cuda.synchronize()
toc = perf_counter_ns()
timing[i] = toc - tic
timing *= 1e-3 # convert to μs
print(f"Elapsed time: {timing.mean():.0f} ± {timing.std():.0f} μs")
# Elapsed time: 201 ± 109 μs
# Compile and then clear GPU from tasks
addArrayGS[blocks_per_grid_gs, threads_per_block](dev_a, dev_b, dev_c)
cuda.synchronize()
timing_gs = np.empty(101)
for i in range(timing_gs.size):
tic = perf_counter_ns()
addArrayGS[blocks_per_grid_gs, threads_per_block](dev_a, dev_b, dev_c)
cuda.synchronize()
toc = perf_counter_ns()
timing_gs[i] = toc - tic
timing_gs *= 1e-3 # convert to μs
print(f"Elapsed time: {timing_gs.mean():.0f} ± {timing_gs.std():.0f} μs")
# Elapsed time: 178 ± 141 μs
"""对于简单的内核,还可以测量算法的整个过程获得每秒浮点运算的数量。通常以GFLOP/s (giga-FLOP /s)为度量单位。加法操作只包含一个触发器:加法。因此,吞吐量由:"""
# G * FLOP / timing in s
gflops = 1e-9 * dev_a.size * 1e6 / timing.mean()
gflops_gs = 1e-9 * dev_a.size * 1e6 / timing_gs.mean()
print(f"GFLOP/s (algo 1): {gflops:.2f}")
print(f"GFLOP/s (algo 2): {gflops_gs:.2f}")
# GFLOP/s (algo 1): 4.99
# GFLOP/s (algo 2): 5.63
一个二维的例子
给定一个值在0-1之间的图像I(x,y) 进行对数校正
I
z
(
x
,
y
)
=
λ
l
o
g
2
(
1
+
I
(
x
,
y
)
)
I_z(x,y) = \lambda log_2(1+I(x,y))
Iz(x,y)=λlog2(1+I(x,y))
import matplotlib.pyplot as plt
from skimage import data
import math
moon = data.moon().astype(np.float32) / 255.
fig, ax = plt.subplots()
im = ax.imshow(moon, cmap="gist_earth")
ax.set_xticks([])
ax.set_yticks([])
ax.set_xticklabels([])
ax.set_yticklabels([])
fig.colorbar(im)
fig.tight_layout()
# Example 1.5: 2D kernel
@cuda.jit
def adjustLog(inp, gain, out):
ix, iy = cuda.grid(2) # The first index is the fastest dimension
threads_per_grid_x, threads_per_grid_y = cuda.gridsize(2) # threads per grid dimension
n0, n1 = inp.shape # The last index is the fastest dimension
# Stride each dimension independently
for i0 in range(iy, n0, threads_per_grid_y):
for i1 in range(ix, n1, threads_per_grid_x):
out[i0, i1] = gain * math.log2(1 + inp[i0, i1])
#设计二维的线程结构
threads_per_block_2d = (16, 16) # 256 threads total
blocks_per_grid_2d = (64, 64)
moon_gpu = cuda.to_device(moon)
moon_corr_gpu = cuda.device_array_like(moon_gpu)
adjustLog[blocks_per_grid_2d, threads_per_block_2d](moon_gpu, 1.0, moon_corr_gpu)
moon_corr = moon_corr_gpu.copy_to_host()
fig, (ax1, ax2) = plt.subplots(1, 2)
ax1.imshow(moon, cmap="gist_earth")
ax2.imshow(moon_corr, cmap="gist_earth")
ax1.set(title="Original image")
ax2.set(title="Log-corrected image")
for ax in (ax1, ax2):
ax.set_xticks([])
ax.set_yticks([])
ax.set_xticklabels([])
ax.set_yticklabels([])
fig.tight_layout()
参考
- 谭升的博客
- 从头开始CUJDA编程:Numba并行编程的基本概念
- numba文档