作者:tomoyazhang,腾讯 PCG 后台开发工程师
,腾讯 PCG 后台开发工程师
随着软件从 1.0 进化到 2.0,也即从图灵机演进到类深度学习算法。计算用的硬件也在加速从 CPU 到 GPU 等迁移。本文试图整理从英伟达 2010 年开始,到 2020 年这十年间的架构演进历史。
我们先对 GPU 有一个直观的认识,如下图:
众所周知,由于存储器的发展慢于处理器,在 CPU 上发展出了多级高速缓存的结构,如上面左图所示。而在 GPU 中,也存在类似的多级高速缓存结构。只是相比 CPU,GPU 将更多的晶体管用于数值计算,而不是缓存和流控(Flow Control)。这源于两者不同的设计目标,CPU 的设计目标是并行执行几十个线程,而 GPU 的目标是要并行执行几千个线程。
可以在上面右图看到,GPU 的 Core 数量要远远多余 CPU,但是有得必有失,可以看到 GPU 的 Cache 和 Control 要远远少于 CPU,这使得 GPU 的单 Core 的自由度要远远低于 CPU,会受到诸多限制,而这个限制最终会由程序员承担。这些限制也使得 GPU 编程与 CPU 多线程编程有着根本区别。
这其中最根本的一个区别可以在上右图中看出,每一行有多个 Core,却只有一个 Control,这代表着多个 Core 同一时刻只能执行同样的指令,这种模式也称为 SIMT (Single Instruction Multiple Threads). 这与现代 CPU 的 SIMD 倒是有些相似,但却有根本差别,本文在后面会继续深入细究。
从 GPU 的架构出发,我们会发现,因为 Cache 和 Control 的缺失,只有 计算密集 与 数据并行的程序适合使用 GPU。
而深度学习恰好满足以上两点,本人认为,即使存在比深度学习计算量更低且表达能力更强的模型,但如果不满足以上两点,都势必打不过 GPU 加持下的深度学习。
Fermi 是 Nvidia 在 2010 年发布的架构,引入了很多今天也仍然不过时的概念,而比 Fermi 更早之前的架构,也已经找不到太多资料了,所以本文从 Fermi 开始,先来一张总览。
GPU 通过 Host Interface 读取 CPU 指令,GigaThread Engine 将特定的数据从 Host Memory 中拷贝到内部的 Framebuffer 中。随后 GigaThread Engine 创建并分发多个 Thread Blocks 到多个 SM 上。多个 SM 彼此独立,并独立调度各自的多个 Thread Wraps 到 SM 内的 CUDA Cores 和其他执行单元上执行。
上面这句话有几个概念解释一下:
由于本文不是讲怎么写 CUDA,所以如果对 SM/Block 的解释仍然不明白,可以参考这一小节:
https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#scalable-programming-model
上图存在 16 个 SMs,每个 SM 带 32 个 Cuda Cores,一共 512 个 Cuda Cores. 这些数量不是固定的,和具体的架构和型号相关。
接下来我们深入看 SM,来一张 SM 总览:
从上图可知,SM 内有 32 个 CUDA Cores,每个 CUDA Core 含有一个 Integer arithmetic logic unit (ALU)和一个 Floating point unit(FPU). 并且提供了对于单精度和双精度浮点数的 FMA 指令。
SM 内还有 16 个 LD/ST 单元,也就是 Load/Store 单元,支持 16 个线程一起从 Cache/DRAM 存取数据。
4 个 SFU,是指 Special Function Unit,用于计算 sin/cos 这类特殊指令。每个 SFU 每个时钟周期只能一个线程执行一条指令。而一个 Warp(32 线程)就需要执行 8 个时钟周期。SFU 的流水线是从 Dispatch Unit 解耦的,所以当 SFU 被占用时,Dispatch Unit 会去使用其他的执行单元。
之前一直提到 Warp,但之前只说明了是 32 个线程,我们在这里终于开始详细说明,首先来看 Dual Warp Scheduler 的概览。
在之前的 SM 概览图以及上图里,可以注意到 SM 内有两个 Warp Scheduler 和两个 Dispatch Unit. 这意味着,同一时刻,会并发运行两个 warp,每个 warp 会被分发到一个 Cuda Core Group(16 个 CUDA Core), 或者 16 个 load/store 单元,或者 4 个 SFU 上去真正执行,且每次分发只执行 一条 指令,而 Warp Scheduler 维护了多个(比如几十个)的 Warp 状态。
这里引入了一个核心的约束,任意时刻,一个 Warp 里的 Thread 都在执行同样的指令,对于程序员来说,观测不到一个 warp 里不同 thread 的不同执行情况。
但是众所周知,不同线程可能会进入不同的分支,这时如何执行一样的指令?
可以看上图,当发生分支时,只会执行进入该分支的线程,如果进入该分支的线程少,则会发生资源浪费。
在 SM 概览图里,我们可以看到 SM 内 64KB 的 On-Chip Memory,其中 48KB 作为 shared memory, 16KB 作为 L1 Cache. 对于 L1 Cache 以及非 On-Chip 的 L2 Cache,其作用与 CPU 多级缓存结构中的 L1/L2 Cache 非常接近,而 Shared Memory,则是相比 CPU 的一个大区别。无论是 CPU 还是 GPU 中的 L1/L2 Cache,一般意义上都是无法被程序员调度的,而 Shared Memory 设计出来就是让渡给程序员进行调度的片上高速缓存。
2012 年 NVIDIA 发布了 Kepler 架构,我们直接看使用 Kepler 架构的 GTX680 概览图:
可以看到,首先 SM 改名成了 SMX,但是所代表的概念没有大变化,我们先看看 SMX 的内部:
还是 Fermi 中熟悉的名词,就是数量变多了很多。
本人认为这个 Kepler 架构中最值得一提的是 GPUDirect 技术,可以绕过 CPU/System Memory,完成与本机其他 GPU 或者其他机器 GPU 的直接数据交换。毕竟在 2021 年的当今,Bypass CPU/OS 已经是最重要加速手段之一。
2014 年 NVIDIA 发布了 Maxwell 架构,我们直接看架构图:
可以看到,这次的 SM 改叫 SMM 了,Core 更多了,也更强大了,这里就不过多介绍了。
2016 年 NVIDIA 发布了 Pascal 架构,这是第一个考虑 Deep Learning 的架构,也是一个值得大书笔墨的架构,首先看如下图 P100。
可以看到,还是一如既往地增加了很多 Cores, 我们细看 SM 内部:
单个 SM 只有 64 个 FP32 Cuda Cores,相比 Maxwell 的 128 和 Kepler 的 192,这个数量要少很多,并且 64 个 Cuda Cores 分为了两个区块。需要注意的是,Register File 的大小并未减少,这意味着每个线程可以使用的寄存器更多了,而且单个 SM 也可以并发更多的 thread/warp/block. 由于 Shared Memory 并未减少,同样意味着每个线程可以使用的 Shared Memory 及其带宽都会变大。
增加了 32 个 FP64 Cuda Cores, 也就是上图的 DP Unit. 此外 FP32 Cuda Core 同时具备处理 FP16 的能力,且吞吐率是 FP32 的两倍,这却是为了 Deep Learning 准备的了。
这个版本引入了一个很重要的东西:NVLink。
随着单 GPU 的计算能力越来越难以应对深度学习对算力的需求,人们自然而然开始用多个 GPU 去解决问题。从单机多 GPU 到多机多 GPU,这当中对 GPU 互连的带宽的需求也越来越多。多机之间,采用 InfiniBand 和 100Gb Ethe.NET 去通信,在单机内,特别是从单机单 GPU 到达单机 8GPU 以后,PCIe 的带宽往往就成为了瓶颈。为了解决这个问题,NVIDIA 提供了 NVLink 用以单机内多 GPU 内的点到点通信,带宽达到了 160GB/s, 大约 5 倍于 PCIe 3 x 16. 下图是一个典型的单机 8 P100 拓扑。
一些特殊的 CPU 也可以通过 NVLink 与 GPU 连接,比如 IBM 的 POWER8。
2017 年 NVIDIA 发布了 Volta 架构,这个架构可以说是完全以 Deep Learning 为核心了,相比 Pascal 也是一个大版本。 首先还是一如既往地增加了 SM/Core, 我们就直接看单个 SM 内部吧。
和 Pascal 的改变类似,到了 Volta,直接拆了 4 个区块,每个区块多配了一个 L0 指令缓存,而 Shared Memory/Register File 这都没有变少,也就和 Pascal 的改变一样,单个线程可使用的资源更多了。单个区块还多个两个名为 Tensor Core 的单元,这就是这个版本的核心了。可以吐槽一下,这个版本又把 L1 和 Shared Memory 合并了。
我们首先看 CUDA Core, 可以看到,原本的 CUDA Core 被拆成了 FP32 Cuda Core 和 INT32 Cuda Core,这意味着可以同时执行 FP32 和 INT32 的操作。
众所周知,DeepLearning 的计算瓶颈在矩阵乘法,在 BLAS 中称为 GEMM,TensorCore 就是只做 GEMM 计算的单元,可以看到,从这里开始,NVIDIA 从 SIMT 走到了 SIMT+DSA 的混合。
每个 TensorCore 只做如下操作:
D=A*B+C
即:
其中 A, B, C, D 都是 4x4 的矩阵,且 A 和 B 是 FP16 矩阵,C 和 D 可以是 FP16 或者 FP32. 通常,更大的矩阵计算会被拆解为这样的 4x4 矩阵乘法。
这样的矩阵乘法是作为 Thread Warp 级别的操作在 CUDA 9 开始暴露给程序员,除此以外,使用 cublas 和 cudnn 当然同样也会在合适的情况下启用 TensorCore.
在这个版本中,另一个重要更新是 NVLink, 简单来说就是更多更快。每个连接提供双向各自 25GB/s 的带宽,并且一个 GPU 可以接 6 个 NVLink,而不是 Pascal 时代的 4 个。一个典型的拓扑如下图:
从 Volta 开始,线程调度发生了变化,在 Pascal 以及之前的 GPU 上,每个 Warp 里的 32 个线程共享一个 Program Counter (简称 PC) ,并且使用一个 Active Mask 表示任意时刻哪些线程是可运行的,一个经典的运行如下:
直到第一个分支完整结束,才会执行另一个分支。这意味着同一个 warp 内不同分支失去了并发性,不同分支的线程互相无法发送信号或者交换数据,但同时,不同 warp 之间的线程又保留了并发性,这当中的线程并发存在着不一致,事实上如果程序员不注意这点,很可能导致死锁。
在 Volta 中解决了这个问题,同 warp 内的线程有独立的 PC 和栈,如下:
由于运行时仍然要符合 SIMT,所以存在一个调度优化器负责将可运行的线程分组,使用 SIMT 模式执行。经典运行如下:
上图可以注意到,Z 的执行并没有被合并,这是因为 Z 可能会产生一些被其他分支需要的数据,所以调度优化器只有在确定安全的情况下才会合并 Z,所以上图 Z 未合并只是一种情况,一般来说,调度优化器足够聪明可以发现安全的合并。程序员也可以通过一个 API 来强制合并,如下:
从 Volta 开始,提高了对多进程并发使用 GPU 的支持。在 Pascal 及之前,多个进程对单一 GPU 的使用是经典的时间片方式。从 Volta 开始,多个用不满 GPU 的进程可以在 GPU 上并行,如下图:
2018 年 NVIDIA 发布了 Turing 架构,个人认为是 Volta 的延伸版本,当然首先各种参数加强,不过我们这里就不提参数加强了。
比较重要是的增加了一个 RT Core,全名是 Ray Tracing Core, 顾名思义,这个是给游戏或者仿真用的,因为本人没有从事过这类工作,就不介绍了。
此外 Turing 里的 Tensor Core 增加了对 INT8/INT4/Binary 的支持,为了加速 deep learning 的 inference, 这个时候深度学习模型的量化部署也渐渐成熟。
2020 年 NVIDIA 发布了 Ampere 架构,这就是一个大版本了,里面又细分了 GA100, GA102, GA104, 我们这里就只关注 GA100。
我们先看 GA100 的 SM:
这里面最核心的升级就是 Tensor Core 了。
除了在 Volta 中的 FP16 以及在 Turing 中的 INT8/INT4/Binary,这个版本新加入了 TF32, BF16, FP64 的支持。着重说说 TF32 和 BF16, 如下图:
FP16 的问题在于表示范围不够大,在梯度计算时容易出现 underflow, 而且前后向计算也相对容易出现 overflow, 相对来说,在深度学习计算里,范围比精度要重要得多,于是有了 BF16,牺牲了精度,保持和 FP32 差不多的范围,在此前比较知名支持 BF16 的就是 TPU. 而 TF32 的设计,在于即汲取了 BF16 的好处,又保持了一定程度对主流 FP32 的兼容,FP32 只要截断就是 TF32 了。先截断成 TF32 计算,再转成 FP32, 对历史的工作几乎无影响,如下图:
另一个变化则是细粒度的结构化稀疏,深度学习模型压缩这个领域除了量化,稀疏也是一个大方向,只是稀疏化模型难以利用硬件加速,这个版本的 GPU 则为稀疏提供了一些支持,当前的主要目的则是应用于 Inference 场景。
首先说 NVIDIA 定义的稀疏矩阵,这里称为 2:4 的结构化稀疏,2:4 的意思是每 4 个元素当中有 2 个值非 0,如下图:
首先使用正常的稠密 weight 训练,训练到收敛后裁剪到 2:4 的结构化稀疏 Tensor,然后走 fine tune 继续训练非 0 的 weight, 之后得到的 2:4 结构化稀疏 weight
理想情况下具有和稠密 weight 一样的精确度,然后使用此稀疏化后的 weight 进行 Inference. 而这个版本的 TensorCore 支持一个 2:4 的结构化稀疏矩阵与另一个稠密矩阵直接相乘。
最后一个比较重要的特性就是 MIG(Multi-Instance GPU)了,虽然业界的计算规模确实越来越大,但也存在不少的任务因为其特性导致无法用满 GPU 导致资源浪费,所以存在需求在一个 GPU 上跑多个任务,在这之前有些云计算厂商会提供虚拟化方案。而在安培中,会为此需求提供支持,称为 MIG.
可能会有人有疑问,在 Volta 中引入的多进程支持不是解决了问题吗?举个例子,在 Volta 中,虽然多个进程可以并行,但是由于所有进程都可以访问所有的内存资源,可能存在一个进程把所有的 DRAM 带宽占满影响到其他进程的运行,而这些被影响的进程很可能有 Throughput/Latency 要求。所以我们需要更严格的隔离。
而在安培 MIG 中,每个 A100 可以被分为 7 个 GPU 实例被不同的任务使用。每个实例的 SMs 有独立的内存资源,可以保证每个任务有符合预期的稳定的 Throughput/Latency. 用户可以将这些虚拟的 GPU 实例当成真实的 GPU 使用。
事实上关于各个架构的细节还有很多,限于篇幅这里只能简单概述。有机会后面再分享一些更具体的关于 CUDA 编程的东西。也欢迎大家与我多多交流(线上线下都欢迎),共同进步。
原文来源:
https://zhuanlan.zhihu.com/p/413145211
Reference