CUDA 学习分享

1. Ubuntu CUDA Installation

# 1. install the latest graphic driver by ppa:graphic-drivers/ppa
#    note: some new released kernel may not be supported
# 2. install cuda, just follow the instruction in webpage that you download the .deb

export PATH = /usr/local/cuda/bin${PATH:+:${PATH}}

sudo apt-get install linux-headers-$(uname -r)

# test
$ cat /proc/driver/nvidia/version
nvcc -V
cuda-install-samples
cd sample_dir
make
/bin/devicequerry /bin/bandwidthtest

2. CUDA 学习指南

  • 一个比较合适的学习路线是 “Professional CUDA C Programing Guide” -> “Programing Guide” -> “Tuning Guide” -> “Cuda Handbook 2nd” -> “XXX Whitepaper”等
    • 这样一轮学习后,你就已经基本入门CUDA了,最起码可以听懂别人在说什么.经过一些实践/代码阅读后,在大部分场景下都能写出性能不错的代码
    • 官方的Best Practice Guide 真的没什么卵用.
    • 官方的”Compatibility Guide”和”PTX”都可以在需要的时候再看.
    • 官方的其余Reference可以在用的时候再看, 各种调试/profile工具可以在有一定工程积累后再看
    • 尽管”Professional CUDA C”已经是Kepler时代的资料, 但是CUDA编程的核心策略仍然是保持不变的, 到目前为止(Turing),新的架构仅仅是添加一些新的特性,或者调整一些参数,并没有革命性的改变CUDA编程的一般规则.
  • 快速上手的话,Pascal以后的硬件上,可以(也推荐)直接使用”managed memory”进行编程,此时,只要看到Programing Guide的矩阵乘法即可.
  • 目前来看,CUDA越来越像真正的众核CPU了,现代CPU中一些高级特性也被逐渐的应用到新的架构上.

3. 基础概念

  • CUDA的device编程语言基本完整的支持”C++”,但是总的来说, 在不了解编译器行为的时候, 大部分时候还是用”C With Class”比较好, 避免一些隐含的坑.
  • CUDA额外提供了一些限定符,如__device__等,这些限定符在不同的场合有不同的意义.
    • 我们要尽可能的使用const__restrict__这两个关键字,这对于指导编译器优化是非常有意义的.
  • 如果一个函数在deivce上运行,那么
    • No support for a variable number of arguments
    • No support for static variables
    • No support for function pointers
  • CUDA中Launch代码时,形参一般是通过const memory传递的
    • 也就是说会使用const memory cache
    • 由于const memory尺寸有限,所以形参的尺寸也是有限的,一般限制为不超过48KB.
  • CUDA的硬件代码和Shader类似, 是以二进制形式(或PTX源码形式)直接存储在Host端中的, 在运行期通过CUDA Driver装载到设备中.
  • CUDA高性能编程的核心策略:
    • 增加代码的并行水平.
    • 在尽可能降低内存IO的基础上,充分利用各种形式的CACHE提高IO性能.
  • CUDA Context相当于传统的”进程”概念,它对应了一组硬件资源,每个CUDA Context都有自己的虚拟地址空间.
    • 执行Launch时,相当于对Context加载动态库,并执行动态库里的函数
    • 如果CUDA API 没有明确的Context参数,那么使用的就是最近被激活的那个Context
    • CUDA Context的切换开销非常大,应当尽量避免.
    • Runtime API中会自动创建/销毁CUDA Context

3.1. Compute Capability (or hardware version, sm version, arch version )

  • Compute Capability是指硬件的Capability, 可以近似认为”Compute Capability”和架构号是一一对应的.
  • 在nvcc编译中,总是先编译得到PTX代码,再编译为CUBIN代码
    • -gencode arch=xxx,code=yyy用于指定PTX使用的ISA,以及cubin文件对应的架构.
    • -arch=sm_35选项是gencode的缩写,它可以同时设定PTX和cubin的架构号
    • PTX使用JIT编译,可以保证向后兼容.cubin文件只能用于对应的硬件架构

3.2. CUDA 相关的库

  • “CUDA Driver”: “libcuda.so”, 该库是随着显卡驱动一起安装的,所以一般不必关注,保持为最新的即可.
    • 如果仅仅使用”CUDA Driver API”进行开发,那么,没有任何额外依赖,仅仅安装好显卡驱动就行了.
  • “CUDA Runtime API”: “libcudart.so”,该库是”CUDA Driver API”的一个”Warpper”,且附带了很多其他功能, 它并不随着显卡驱动分发,必须由应用开发者自行部署.
    • 由于一定的设计问题, cudart最好是以静态链接的形式链接进入程序, 这一方面便于分发,另一方面可以避免复杂的兼容性问题.
    • CUDA 6 之后,所有CUDA Toolkit自带的库默认都是静态链接的.

3.3. 存储器

  • 可供编程的有”Global Memory”, “Const Memory”, “Texture Memory”, “Shared Memory”, “Register File”
  • 除了L2 Cache外,其他Cache都是片上Cache.
  • “Global Memory” : 一般认为只使用L2 Cache, 性能比较差, 可以通过编译选项启用L1
  • “Texture Memroy”: 2D内存空间(不是线性的), 可以进行插值访问, 其Cache也是2D形式存储的
  • “Const Memory”: 空间有限的存储器,一般为64KB, 其Cache一个CLK只能吐出一个4B值,但是性能与Register相同.
  • “Shared Memory”: 片上可编程Cache,一般总量为96K,但每个Block只能至多使用48K,每个CLK能吐出32个4B值.
  • “Register File”: 在没有读写依赖时,可以近似为0延迟的存储器, 在有读写依赖时,latency大致为24个CLK.一般每个线程在编译期被限制为只能使用255个4B的reg, 超出这个数值后, register spilling 会导致 编译器去使用global memory.

  • 在编译期, kernel 对 device mem 内对象的读写将会被翻译为一条或多条指令. 在SM执行时,一个warp内的32个线程在执行同一个IO指令时, 将先查询cache, 如果出现了cache miss, 则将cache miss的部分组合为一个或多个 memory transaction,再发出gst/gld,并等待数据返回.

  • 对device的IO指令所操作指令有5种,用于操作1,2,4,8,16 Bytes的对象
    • NVIDIA CUDA中,指令访问的地址必须是对齐的, 也就是1x,2x,4x,8x,16x 型地址,否则会IO出错误值
  • 实践中,为了充分利用Cache, 应当避免生成1B,2B的IO指令, 避免生成过短的request, 进而避免带宽浪费. 例如 使用char4作为连续int8_t容器一般更好.
  • 在sm35之后, device mem transaction, Cache Line大小基本都是128B, 所以一个warp内每个线程的单次IO最好都是以4B为标准(不足4B时凑足4B), 不要多,也不要少.
    • 自然, 我们也必须保证所有128B的Transaction都是存储在128x对齐的地址上

3.3.2. Managed Memory ( Unified Memory)

  • 可以近似的认为,Managed Memory是可以直接在Host和Device端访问的主存.
    • 在Device端, 和Gloabl Memory性能相同
    • 在Host端, 和常规的Memory性能相同.
  • 从逻辑上看, 数值会自动在Device端和Host端同步, 但是仅仅在Pascal架构之后,这种同步才被认为是比较高效的. 在Pascal架构之前,必须通过合理的编程,尽可能避免Device端和Host端的频繁同步.
  • 从物理上说,Pascal以后的架构支持了完整的49位虚拟空间,以及Page Fault机制, Host端和Device端交换数据时,是以Page为单位的,而不是整段内存,所以效率变高了.
  • Host端的主存从逻辑上变为了Device主存的Swap空间, 所以 CUDA Context 可以超额认购主存了,例如在3GB显存的设备上,直接创建6GB大小的Managed Memory. ( 注意,只有Managed Memory 才会把Host主存当做Swap空间)

3.4. CUDA Stream

  • 每一个device都有一个唯一的NULL Stream
  • 大部分不指定Stream的API一般会使用DefaultStream, 少部分API会直接使用”NULL Stream”

    • --default-stream legacy DefaultStream是对应Context的 NULL stream.
    • --default-stream per-threadDefaultStream是thread_local的stream(非NULL)
  • 隐式同步:在foo()上触发隐式同步后,在同一个Context中, 点foo()之前的所有stream都必须执行完成后,foo()才return
    • cudaMalloc,cudaMallocHost,cudaMemset一定会触发隐式同步.
    • 任何发送到NULL Stream的API都会造成隐式同步
      • 注意, 很多没有指明Stream的API都会发往Default Stream, 如果DefaultStream是Null Stream的话,就会造成隐式同步. 例如,cudaMemcpy默认就是发往DefaultStream的
      • 注意,如果cudaMemcpyAsync中使用的不是Pinned Memory,那么相当于发送到了DefaultStream
    • L1 Cache的运行期配置一定会导致隐式同步.

4. CUDA 硬件基础

4.1. 执行特性

  • 在CUDA Launch之后,同一个Stream内的Grid会进入同一个 Device Connection,等待被调度,当资源足够时,会将这组Grid内的Block依照BlockID顺序的分配给某一个SM.之后,各个SM就可以认为是彼此独立工作的了.
    • 注意:尽管分配的顺序是按BlockID来的,但是由于SM具体执行的任务不可预知,各个block的完成顺序实际上是不确定的.
  • 对于分配到SM上的Block,在执行完成前,会一直占有所需的资源.主要是shared memory和register file, 所以在SM内切换线程几乎是没有开销的.这些分配到SM上的block称为reside block.
  • 当Block的资源被分配后,Block内的线程将依照ThreadID划分为32个线程一组的warp,由于这些warp都是已经占好资源的了,所以常被称为reside warp
  • 所有的reside warp都会被分配给某一个warp scheduler,由scheduler负责issue指令.
    • scheduler一般支持称为dual issue的特性.部分指令组合是可以被dual issue的,例如,在没有依赖时,可以在一个时钟周期同时issue一个计算指令和一个IO指令.
    • 一般而言,计算单元是scheduler平分的, 其他单元则是scheduler共享的.例如,有64个CUDA Core和16个LD/ST Unit的SM中, 每个scheduler仅会使用32个Core,但是16个LD/ST Unit则是共享的.
  • 在Volta之前的显卡,warp内的32个线程共享同一个PC寄存器,有隐式的同步行为.但是自Volta之后,每个线程都由自己独立的PC, 隐式同步行为被消除了.

4.1.1. 不同等级的并行.

  • grid level : 俗称为”concurrent launch”
    • 如果两个launch没有时间先后关系(不在同一个connection中),那么只要资源充足,这两个launch grid中的block都会被分配到SM上,成为reside block,并开始执行.
    • 同一个connection中的不同stream仍可能存在串行launch的问题.
  • warp level: ( 由于SM内的调度是以warp为单位的,所以也就没有block level了)
    • 当一个warp因为某种原因被阻塞时(例如数据依赖) ,那么 scheduler就会自动选择其他warp来发送指令.
  • instruction level:
    • 如果warp内连续的指令之间没有数据依赖,且当前有足够的资源,那么这些指令都会被连续的issue出去.
    • 一般通过unroll来提高指令级并行的水平
  • 在实践中,指令级并行是最应该优先被满足的. 这相当于我们手动的接管了硬件调度工作. 例如, 在下面的例子中,每个执行都由数据依赖, 在scheduler issue一个指令之后, 就必须选择另外一个warp来issue指令, 以保证各个执行单元流水线的充盈. 这就有两个隐含的问题: 第一,需要保证有足够的warp; 第二, 调度warp仍然存在很小的开销. 使用unroll技术来提高指令级并行就能完美的避开这两个问题.
void foo(float * dev_a){
    float a = dev_a[i]; //gld 400 clk
    a *= a; // float mul // 40 clk
    dev_a[i];//gst // 400 clk
}
  • “Occupancy”: 用于表征一个kernel 的warp level 可并行能力的指标,它的值为(kernel在一个SM内至多的reside warp数 / SM内可存储reside warp的上限)
    • 例如,一个kernel在launch时产生了8个Block,每个Block内有4个warp. 假如每个block都需要48KB的SMEM,且SM仅有96KB的SMEM, 则一个SM内至多也就能放2个这样的block, 也就对应至多8个warp. 假设这个SM至多支持32个reside warp,那么这个kernl launch也就只能达到 8/32 = 25% 的Occupancy.
    • 就目前的硬件设计而言,ocucupancy一般是不会太大的(每个线程使用的资源有限),因此,主要是通过提升 instruction level 的并行程度来充分利用硬件.
  • 所谓的”最大化并行水平”,最终目的是保证SM内的各个流水线始终保持充盈的状态. 实现”最大化并行”是三种不同层次并行水平的组合,这三者往往是相互影响的,必须在调试/改进的循环中不断寻找最优的方案. 一个典型的流程如下.
    • 写出尽可能好的kernel,并通过unrool来提高 kernel的指令级并行水平.
    • 调整kernel代码和Launch Configuration, 最好能使得Occupancy达到50%以上.
    • 产生足够多的Block来充分利用已有的SM, 有两个策略来实现这种目的: 第一, 产生尽可能多的concurrent launch (launch也是有开销的,只有每个laucnh内都有足够的任务时才能使用这种策略); 第二, 改变Launch Configuration, 使每个Launch产生更多的Block

由此观之,写kernel时,最好尽可能使得代码能自动的适应不同的Configruation

  • 一种测试occunpancy的方法是:
    • launch时额外动态分配shared mem,使得occupancy降低.
    • 如果降低occupancy后性能没有下降,一般也就意味着代码的ILP水平很好, occupancy已经足够了.此时,提升occupancy不会带来明显的收益.
  • 对于IO指令, 在没有数据依赖时,总是乱序执行的,例如X=10;Y=20这样的指令,有可能是后面一条先执行. 这在并发时可能导致内存访问不一致的现象.
    • threadfence_block()用于消除乱序issue带来的影响,作用仅限于单个block.
    • 保证在调用xxxfence()之前,所有的写入代码都是切实写入的,不可能被乱序到xxfence()之后.
    • 保证在调用xxxfence()之前,所有的读取都是按照代码顺序执行的.
    • threadfence(),作用域为单个device;threadfence()_system 作用域为全部的device

5. CUDA TUNING

  • TUNING前应当选定TUNING的指标,一般而言,可以根据极限性能来分为两类,IO bound或 计算bound.
    • 极限计算性能: CORE_CLK * DEVICE_CORE_NUM * OP_PER_CLK
    • 极限IO性能: INNER_BANDWIDTH * MEM_CLK * MEMOP_PER_CLK
      • MEMOP_PER_CLK 对于GDDR5而言是2, 其他时候一般是1
  • 在实践中,首先应该避免带宽浪费, 在避免带宽浪费的基础上,通过指令级并行等手段来提升单位时间内的IO指令数,可以更有效的利用带宽.
    • 带宽浪费可以通过request gld 和 gld这两个值来判断,前者是逻辑上需要的gld数(没有考虑对齐),后者是实际触发的gld数(考虑对齐后实际触发的gld数), nvprof中, (request gld)/(gld)称为gld efficiency
  • 当一个kernel内的 “计算指令/IO指令” > “极限计算性能/极限IO性能时”,就可以认为是计算bond的,否则就是IO bound.
  • NVCC默认会优化所有与写入global无关的代码, 为此, 在进行测试时,必须要有一些语句进行写入操作
  • 默认的小数型字面值被解析为double类型, 这将导致计算使FP64单元,而非FP32单元, 有可能会导致显著的性能损失.
  • 128或者256一般是最佳的block size , 每个BLOCK不要少于64个 thread为宜

索引

  • 在脑海中用数学规则来想象一个矩阵 A[i,j,k] 分别对应了行,列,页.
  • 对于C语言,其维度则有所不同
    • 一维数组是(列号),A[i] <=>A[1,i,1]
    • 二维数组是(行号,列号),A[i][j] <=> A[i,j,1]
    • 三维数组是(页号,行号,列号) , A[k][i][j] <=> A[i,j,k]
  • CUDA内,用(x,y,z)右手坐标系描述,(列号,行号,页号)顺序使用
    • 一维:仅(x),即(列号)
    • 二维:仅(x,y),即(列号,行号)
    • 三维:仅(x,y,z)即(列号,行号,页号)

顺序索引

  • 对于MATLAB,行号先增加
  • 对于C,列号先增加
  • CUDA和C相同