# GPU 计算快速入门


本例中需要加载的包:

using BenchmarkTools
using CUDA
using Test

# 为什么使用 GPU 计算?

GPU,作为一种通用可编程的加速器,最初设计是用来进行图形处理和渲染功能,但是从 2007 年开始,英伟达(NVIDIA)公司提出了第一个可编程通用计算平台,同时提出了 CUDA 框架,从此开启了 GPU 用于通用计算的新纪元。

此后,不计其数的科研人员和开发者,对各种不同类型的算法用 CUDA 进行(部分)改写,从而达到几倍到数百倍的加速效果。尤其是在机器学习和深度学习的浪潮来临后,GPU 加速已经是各类工具实现的基本底层构架之一。

Julia 在 NVIDIA GPU 下的编程支持功能完善,可达到媲美 C 的性能。使用CUDA.jl将 Julia 数组转换为CuArray数组,即可简单地让 Julia 利用 GPU 的加速能力。

# CUDA 调用

# 概览

上图介绍了 GPU 加速系统的关键硬件组件。这是使用专用 GPU 的 GPU 加速系统框架图。CPU 和 GPU 分别带有自己的内存。CPU 和 GPU 通过 PCI 总线进行通信。

由于 CPU 和 GPU 内存的分离,我们需要CuArray的数据类型,这种类型的数据计算会发生在 GPU 上。CPU 计算上Base.Array类型支持的接口基本都可以在 GPU 的CuArray类型上使用。

# Julia 具体实现

# CUDA.jl 安装和调用

Syslab 用户目前需要手动下载 CUDA.jl,同时也需要 Nvidia GPU 设备才能使用 GPU 加速计算。

需要安装 CUDA.jl 包后才能使用 GPU 计算:

## 安装 CUDA.jl 包
using Pkg
Pkg.add("CUDA")

Julia CUDA 需要 Nvidia 驱动,但不需要手动下载整个 CUDA toolkit。在第一次使用该软件包时,会自动下载。

##(第一次运行会下载 CUDA toolkit)
using CUDA

判断是否可以正常运行:

CUDA.functional(true)

如果 GPU 环境正确,会返回:

true

CUDA.functional(true)true作为参数传递,如果环境错误,会显示可能失败的原因。

如果我们的机器上有多块 GPU,希望检测 GPU 数并指定 GPU 运行,可以使用以下方法:

# 查看当前机器GPU
collect(devices())
8-element Array{CuDevice,1}:
 CuDevice(0): GeForce GTX 1080 Ti
 CuDevice(1): GeForce GTX 1080 Ti
 CuDevice(2): GeForce GTX 1080 Ti
 CuDevice(3): GeForce GTX 1080 Ti
 CuDevice(4): GeForce GTX 1080 Ti
 CuDevice(5): GeForce GTX 1080 Ti
 CuDevice(6): GeForce GTX 1080 Ti
 CuDevice(7): GeForce GTX 1080 Ti
# 指定设备ID为5的为GPU环境
device!(5)
# 查看当前使用GPU设备
device()
CuDevice(5): GeForce GTX 1080 Ti

注意,以上 GPU 设备展示仅为说明多卡使用,不代表文档中测试使用设备。对于文档中性能测试数据,我们使用的是单张 RTX 4060 显卡。

# CuArray 示例

在这里,我们用简单的向量加法来演示 GPU 在数值计算加速方面的优势。

我们将对比 y=y+x分别在串行,多线程,GPU 三个版本的性能,从而得到一个直观的性能对比。

先演示一个简单的 CPU 上的 demo,是关于向量加法的实现。

在物理核心数大于 4 的机器上用 Julia -t 4 启动一个 4 线程的 Julia REPL。

下面测试串行向量加法和 4 线程并行向量加法的性能:

# 串行相加
N = 2^20
x = fill(1.0f0, N)  # 生成一个用Float32 1.0填充的向量
y = fill(2.0f0, N) # 生成一个用2.0填充的向量
## 下面是一个朴素的向量加法实现
function sequential_add!(y, x)
    for i in eachindex(y, x)
        @inbounds y[i] += x[i]
    end
    return nothing
end
@btime sequential_add!($y, $x)# 测试串行计算时间
  142.200 μs (0 allocations: 0 bytes)

我们是以Julia -t 4 启动,所以下列程序也是以 4 线程运行,可以看到 4 线程对应的性能提升:

function parallel_add!(y, x)
    Threads.@threads for i in eachindex(y, x)
        @inbounds y[i] += x[i]
    end
    return nothing
end
fill!(y, 2)
@btime parallel_add!($y, $x)# 测试多线程计算时间
  37.900 μs (20 allocations: 2.42 KiB)

使用 CUDA.jl 生成CuArray类型数组:

x_d=CUDA.fill(1.0f0,N)
y_d=CUDA.fill(2.0f0,N)
# 查看x_d类型
typeof(x_d)
CuArray{Float32, 1, CUDA.Mem.DeviceBuffer}

验证一下计算是否正确:

y_d .+= x_d
@test all(Array(y_d) .== 3.0f0)
Test Passed
  Expression: all(Array(y_d) .== 3.0f0)

测试使用CuArray进行 GPU 计算的时间:

function add_broadcast!(y, x)
    CUDA.@sync y .+= x
    return
end
x_d=CUDA.fill(1.0f0,N)
y_d=CUDA.fill(2.0f0,N)
@btime add_broadcast!($y_d, $x_d)
  15.400 μs (29 allocations: 1.62 KiB)

对 CPU 上串行和并行实现和 GPU 上直接使用CuArray的部分做做性能比较:

@btime 测试 时间
GPU 计算 15.400 μs
CPU 多线程并行 37.900 μs
CPU 串行 142.200 μs

注意,具体运行时间与机器性能相关,包括但不限于 CPU 性能、GPU 性能、内存频率等。

其中可以注意到 CUDA.@sync。CPU 可以将任务作业分配给 GPU,然后在 GPU 完成任务前去执行其他任务。而将执行语句包装在 CUDA.@sync 块里则将使 CPU 阻塞,直到完成执行队列中的 GPU 任务。如果没有CUDA.@sync同步,测量的会是启动计算所需的时间,而不是执行计算的时间。

可以看到 CUDA GPU 计算速度明显快于单线程 CPU 计算,使用多个 CPU 线程也使 CPU 实现具有性能提升。以上的测试结果会根据硬件差异而不同。

我们需要注意,由于 Nvidia 显卡设备的单精度运算能力显著优于双精度运算能力,所以CUDA.randCUDA.onesCUDA.zeros等函数创建的矩阵的默认类型为单精度浮点数 Float32

在精度可接受的情况下,推荐使用Float32类型而非Float64来进行 GPU 计算。

# 避免 GPU 上进行标量循环计算

另外,在 GPU 上并不适合做标量循环,下面的这个计算使用CuArray类型的x_dy_d是性能很差的。需要注意这一点。

function addcol_scalar!(a,b)
    for j in axes(a,2)
        for i in axes(a,1)
            @inbounds a[i,j]=b[i,j+1]+b[i,j]
        end
    end
end
a=CUDA.ones(Float32,10000,99)
b=CUDA.ones(Float32,10000,100)
@btime addcol_scalar!($a,$b) samples=2
  90.827 s (9079779 allocations: 1.34 GiB)

建议使用如下向量化方法计算,避免在 GPU 上使用标量循环:

function addcol_broadcast!(a,b)
    a.=@views b[:,2:end].+b[:,1:end-1]
end
@btime addcol_broadcast!($a,$b)
  141.600 μs (0 allocations: 0 bytes)