class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="2"> class="hljs-ln-code"> class="hljs-ln-line">{ class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="3"> class="hljs-ln-code"> class="hljs-ln-line"> int n = 2000; class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="4"> class="hljs-ln-code"> class="hljs-ln-line"> for (int i = 0; i < n; ++i) class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="5"> class="hljs-ln-code"> class="hljs-ln-line"> { class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="6"> class="hljs-ln-code"> class="hljs-ln-line"> y[i] = alpha * x[i] + y[i]; class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="7"> class="hljs-ln-code"> class="hljs-ln-line"> } class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="8"> class="hljs-ln-code"> class="hljs-ln-line">} class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}" onclick="hljs.signin(event)">
示例代码中包含 2 FLOPS 操作,分别是乘法(Multiply)和加法(Add),对于每一次计算操作都需要在内存中读取两个数据,$x[i]$ 和 $y[i]$,最后执行一个线性操作,存储到 $y[i]$ 中,其中把加法和乘法融合在一起的操作也可以称作 FMA(Fused Multiply and Add)。
在 O(n) 的时间复杂度下,根据 n 的大小迭代计算 n 次,在 CPU 中串行地按指令顺序去执行 $AX+Y$ 程序。以 Intel Exon 8280 这款芯片为例,其内存带宽是 131 GB/s,内存的延时是 89 ns,这意味着 8280 芯片的峰值算力是在 89 ns 的时间内传输 11659 个比特(byte)数据。$AX+Y$ 将在 89 ns 的时间内传输 16 比特(C/C++中 double 数据类型所占的内存空间是 8 bytes)数据,此时内存的利用率只有 0.14%(16/11659),存储总线有 99.86% 的时间处于空闲状态。

内存总线 99.86%时间处于空闲状态
不同处理器计算 $AX+Y$ 时的内存利用率,不管是 AMD Rome 7742、Intel Xeon 8280 还是 NVIDIA A100,对于 $AX+Y$ 这段程序的内存利用率都非常低,基本 ≤0.14%。
class="table-box"> | AMD Rome 7742 | Intel Xeon 8280 | NVIDIA A100 |
---|
Memory B/W(GB/sec) | 204 | 131 | 1555 |
DRAM Latency(ns) | 122 | 89 | 404 |
Peak bytes per latency | 24,888 | 11,659 | 628,220 |
Memory Efficiency | 0.064% | 0.14% | 0.0025% |
由于上面的 $AX+Y$ 程序没有充分利用并发和线性度,因此通过并发进行循环展开的代码如下:
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="1"> class="hljs-ln-code"> class="hljs-ln-line">void fun_axy(int n, double alpha, double *x, double *y)
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="2"> class="hljs-ln-code"> class="hljs-ln-line">{
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="3"> class="hljs-ln-code"> class="hljs-ln-line"> for (int i = 0; i < n; i += 8)
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="4"> class="hljs-ln-code"> class="hljs-ln-line"> {
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="5"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 0] = alpha * x[i + 0] + y[i + 0];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="6"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 1] = alpha * x[i + 1] + y[i + 1];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="7"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 2] = alpha * x[i + 2] + y[i + 2];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="8"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 3] = alpha * x[i + 3] + y[i + 3];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="9"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 4] = alpha * x[i + 4] + y[i + 4];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="10"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 5] = alpha * x[i + 5] + y[i + 5];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="11"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 6] = alpha * x[i + 6] + y[i + 6];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="12"> class="hljs-ln-code"> class="hljs-ln-line"> y[i + 7] = alpha * x[i + 7] + y[i + 7];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="13"> class="hljs-ln-code"> class="hljs-ln-line"> }
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="14"> class="hljs-ln-code"> class="hljs-ln-line">}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}" onclick="hljs.signin(event)">
每次执行从 0 到 7 的数据,实现一次性迭代 8 次,每次传输 16 bytes 数据,因此同样在 Intel Exon 8280 芯片上,每 89 ns 的时间内将执行 729(11659/16)次请求,将程序这样改进就是通过并发使整个总线处于一个忙碌的状态,但是在真正的应用场景中:
- 编译器很少会对整个循环进行超过 100 次以上的展开;
- 一个线程每一次执行的指令数量是有限的,不可能执行非常多并发的数量;
- 一个线程其实很难直接去处理 700 多个计算的负荷。
由此可以看出,虽然并发的操作能够一次性执行更多的指令流水线操作,但是同样架构也会受到限制和约束。
将 $Z=AX+Y$ 通过并行进行展开,示例代码如下:
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="1"> class="hljs-ln-code"> class="hljs-ln-line">void fun_axy(int n, double alpha, double *x, double *y)
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="2"> class="hljs-ln-code"> class="hljs-ln-line">{
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="3"> class="hljs-ln-code"> class="hljs-ln-line"> Parallel for (int i = 0; i < n; i++)
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="4"> class="hljs-ln-code"> class="hljs-ln-line"> {
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="5"> class="hljs-ln-code"> class="hljs-ln-line"> y[i] = alpha * x[i] + y[i];
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="6"> class="hljs-ln-code"> class="hljs-ln-line"> }
- class="hljs-ln-numbers"> class="hljs-ln-line hljs-ln-n" data-line-number="7"> class="hljs-ln-code"> class="hljs-ln-line">}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}" onclick="hljs.signin(event)">
通过并行的方式进行循环展开,并行就是通过并行处理器或者多个线程去执行 $AX+Y$ 这个操作,同样使得总线处于忙碌的状态,每一次可以执行 729 个迭代。相比较并发的方式:
- 每个线程独立负责相关的运算,也就是每个线程去计算一次 $AX+Y$;
- 执行 729 次计算一共需要 729 个线程,也就是一共可以进行 729 次并行计算;
- 此时程序会受到线程数量和内存请求的约束。
GPU 线程原理
GPU 整体架构和单个 SM(Streaming Multiprocessor)的架构,SM 可以看作是一个基本的运算单元,GPU 在一个时钟周期内可以执行多个 Warp,在一个 SM 里面有 64 个 Warp,其中每四个 Warp 可以单独进行并发的执行,GPU 的设计者主要是增加线程和增加 Warp 来解决或者掩盖延迟的问题,而不是去减少延迟的时间。

GPU 整体架构与 SM 架构
为了有更多的线程处理计算任务,GPU SMs 线程会选择超配,每个 SM 一共有 2048 个线程,整个 A100 有 20 多万个线程可以提供给程序,在实际场景中程序用不完所有线程,因此有一些线程处于计算的过程中,有一些线程负责搬运数据,还有一些线程在同步地等待下一次被计算。很多时候会看到 GPU 的算力利用率并不是非常的高,但是完全不觉得它慢是因为线程是超配的,远远超出大部分应用程序的使用范围,线程可以在不同的 Warp 上面进行调度。
class="table-box"> | Pre SM | A100 |
---|
Total Threads | 2048 | 221,184 |
Total Warps | 64 | 6,912 |
Active Warps | 4 | 432 |
Waiting Warps | 60 | 6,480 |
Active Threads | 128 | 13,824 |
Waiting Threads | 1,920 | 207,360 |
小结
本节首先从架构层面分析了 CPU 和 GPU 的主要区别,因为 CPU 的设计目标是尽可能在低的延迟下执行任务,GPU 的设计目标是最大化吞吐量,因此决定了 CPU 适合处理顺序执行的任务,GPU 适合处理大规模并行计算的任务。
以 $AX+Y$ 为例讲解了并发和并行的区别以及和串行的区别,在串行计算时内存利用率很低,当把程序用并发的方式展开,并发操作的多流水线会受到 CPU 架构的限制,当程序用并行的方式循环展开时,程序的执行则只受到线程数量和内存请求的约束,因此 CPU 跟 GPU 的本质区别是并行的问题而不是并发的问题,GPU 通过大量的线程提供并行的能力。
为了提供并行的能力,GPU 通过多级缓存、多级流水、多级 Cache 提供并行的机制,同时可以尽可能减少内存的时延。为了将数据充分利用起来,引入了 GPU 线程的原理,GPU 里面提供了大量超配的线程去完成对不同层级数据的搬运和计算。
2.2 Computational Units (cores)
总的来看,我们可以这样说:CPU的Computational units是“大”而“少”的,然而GPU的Computational units是“小”而“多”的,这里的大小是指的计算能力,多少指的是设备中的数量。通过观察上图,显然可以看出,绿色的部分,CPU“大少”,GPU“小多”的特点。
CPU的cores 比GPU的cores要更加聪明(smarter),这也是所谓“大”的特点。
在过去的很长时间里,CPU的core计算能力增长是得益于主频时钟最大的频率增长。相反,GPU不仅没有主频时钟的提升,而且还经历过主频下降的情况,因为GPU需要适应嵌入式应用环境,在这个环境下对功耗的要求是比较高的,不能容忍超高主频的存在。例如英伟达的Jetson NANO,安装在室内导航机器人身上,就是一个很好的嵌入式环境应用示例,安装在机器人身上,就意味着使用电池供电,GPU的功耗不可以过高。(see,Indoor Mapping and Navigation Robot Build with ROS and Nvidia Jetson Nano):
CPU比GPU聪明,很大一个原因就是CPU拥有"out-of-order exectutions"(乱序执行)功能。出于优化的目的,CPU可以用不同于输入指令的顺序执行指令,当遇到分支的时候,它可以预测在不久的将来哪一个指令最有可能被执行到(multiple branch prediction 多重分支预测)。通过这种方式,它可以预先准备好操作数,并且提前执行他们(soeculative execution 预测执行),通过上述的几种方式节省了程序运行时间。
显然现代CPU拥有如此多的提升性能的机制,这是比GPU聪明的地方。
相比之下,GPU的core不能做任何类似out-of-order exectutions那样复杂的事情,总的来说,GPU的core只能做一些最简单的浮点运算,例如 multiply-add(MAD)或者 fused multiply-add(FMA)指令

通过上图可以看出MAD指令实际是计算A*B+C的值。
实际上,现代GPU结构,CORE不仅仅可以结算FMA这样简单的运算,还可以执行更加复杂的运算操作,例如tensor张量(tensor core)或者光线追踪(ray tracing core)相关的操作。

张量计算tensor core

光线追踪
张量核心(tensor cores)的目的在于服务张量操作在一些人工智能运算场合,光纤追踪(ray tracing) 旨在服务超现实主义(hyper-realistic)实时渲染的场合。
上文说到,GPU Core最开始只是支持一些简单的浮点运算FMA,后来经过发展又增加了一些复杂运算的机制tensor core以及ray trace,但是总体来说GPU的计算灵活性还是比不上CPU的核心。
值得一提的是,GPU的编程方式是SIMD(Single Instruction Multiple Data)意味着所有Core的计算操作完全是在相同的时间内进行的,但是输入的数据有所不同。显然,GPU的优势不在于核心的处理能力,而是在于他可以大规模并行处理数据。

赛艇运动中,所有人同时齐心划船
GPU中每个核心的作用有点像罗马帆船上的桨手:鼓手打着节拍(时钟),桨手跟着节拍一同滑动帆船。
SIMD编程模型允许加速运行非常多的应用,对图像进行缩放就是一个很好的例子。在这个例子中,每个core对应图像的一个像素点,这样就可以并行的处理每一个像素点的缩放操作,如果这个工作给到CPU来做,需要N的时间才可以做完,但是给到GPU只需要一个时钟周期就可以完成,当然,这样做的前提是有足够的core来覆盖所有的图像像素点。这个问题有个显著的特点,就是对一张图像进行缩放操作,各个像素点之间的信息是相互独立的,因此可以独立的放在不同的core中进行并行运算。我们认为不同的core操作的信息相互独立,是符合SIMD的模型的,使用SIMD来解决这样的问题非常方便。
但是,也不是所有的问题都是符合SIMD模型的,尤其在异步问题中,在这样的问题中,不同的core之间要相互交互信息,计算的结构不规则,负载不均衡,这样的问题交给GPU来处理就会比较复杂。
2.2. GPU多核底层结构
为了充分理解GPU的架构,让我们在返回来看下第一张图,一个显卡中绝大多数都是计算核心core组成的海洋。
在图像缩放的例子中,core与core之间不需要任何协作,因为他们的任务是完全独立的,然而,GPU解决的问题不一定这么简单,让我们来举个例子。
假设我们需要对一个数组里的数进行求和,这样的运算属于reductuin family类型,因为这样的运算试图将一个序列“reduce”简化为一个数。计算数组的元素总和的操作看起来是顺序的,我们只需要获取第一个元素,求和到第二个元素中,获取结果,再将结果求和到第三个元素,以此类推。

Sequential reduction
令人惊讶的是,一些看起来本质是顺序的运算,其实可以再并行算法中转化。假设一个长度为8的数组,在第一步中完全可以并行执行两个元素和两个元素的求和,从而同时获得四个元素,两两相加的结果,以此类推,通过并行的方式加速数组求和的运算速度。具体的操作如下图所示,

Parallel reduction
如上图计算方式,如果是长度为8的数组两两并行求和计算,那么只需要三次就可以计算出结果。如果是顺序计算需要8次。如果按照两两并行相加的算法,N个数字相加,那么仅需要log2(N)次就可以完成计算。
从GPU的角度来讲,只需要四个core就可以完成长度为8的数组求和算法,我们将四个core编号为0,1,2,3。
那么第一个时钟下,两两相加的结果通过0号core计算,放入了0号core可以访问到的内存中,另外两两对分别由1号2号3号core来计算,第二个个时钟继续按照之前的算法计算,只需要0号和1号两个core即可完成,以此类推,最终的结果将在第三个时钟由0号core计算完成,并储存在0号core可以访问到的内存中。这样实际三次就能完成长度为8的数组求和计算。

Parallel reduction with a GPU
如果GPU想要完成上述的推理计算过程,显然,多个core之间要可以共享一段内存空间以此来完成数据之间的交互,需要多个core可以在共享的内存空间中完成读/写的操作。我们希望每个Cores都有交互数据的能力,但是不幸的是,一个GPU里面可以包含数以千计的core,如果使得这些core都可以访问共享的内存段是非常困难和昂贵的。出于成本的考虑,折中的解决方案是将各类GPU的core分类为多个组,形成多个流处理器(Streaming Multiprocessors )或者简称为SMs。
The Turing SM
在SM的图灵结构中,绿色的部分CORE相关的,我们进一步区分了不同类型的CORE。主要分为INT32,FP32,TENSOR CORES。
FP32 Cores,执行单进度浮点运算,在TU102卡中,每个SM由64个FP32核,TU120由72个SMs因此,FP32 Core的数量是 72 * 64。
FP64 Cores. 实际上每个SM都包含了2个64位浮点计算核心FP64 Cores,用来计算双精度浮点运算,虽然上图没有画出,但是实际是存在的。
Integer Cores,这些core执行一些对整数的操作,例如地址计算,可以和浮点运算同时执行指令。在前几代GPU中,执行这些整型操作指令都会使得浮点运算的管道停止工作。TU102总共由4608个Integer Cores,每个SM有64个SM。
Tensor Cores,张量core是FP16单元的变种,认为是半精度单元,致力于张量积算加速常见的深度学习操作。图灵张量Core还可以执行INT8和INT4精度的操作,用于可以接受量化而且不需要FP16精度的应用场景,在TU102中,我们每个SM有8个张量Cores,一共有8 * 72个Tensor Cores。
在大致描述了GPU的执行部分之后,让我们回到上文提出的问题,各个核心之间如何完成彼此的协作?
在四个SM块的底部有一个96KB的L1 Cache,用浅蓝色标注的。这个cache段是允许各个Core都可以访问的段,在L1 Cache中每个SM都有一块专用的共享内存。作为芯片上的L1 cache他的大小是有限的,但它非常快,肯定比访问GMEM快得多。
实际上L1 CACHE拥有两个功能,一个是用于SM上Core之间相互共享内存,另一个则是普通的cache功能。当Core需要协同工作,并且彼此交换结果的时候,编译器编译后的指令会将部分结果储存在共享内存中,以便于不同的core获取到对应数据。当用做普通cache功能的时候,当core需要访问GMEM数据的时候,首先会在L1中查找,如果没找到,则回去L2 cache中寻找,如果L2 cache也没有,则会从GMEM中获取数据,L1访问最快 L2 以及GMEM递减。缓存中的数据将会持续存在,除非出现新的数据做替换。从这个角度来看,如果Core需要从GMEM中多次访问数据,那么编程者应该将这块数据放入功能内存中,以加快他们的获取速度。其实可以将共享内存理解为一段受控制的cache,事实上L1 cache和共享内存是同一块电路中实现的。编程者有权决定L1 的内存多少是用作cache多少是用作共享内存。
最后,也是比较重要的是,可以储存各个core的计算中间结果,用于各个核心之间共享的内存段不仅仅可以是共享内存L1,也可以是寄存器,寄存器是离core最近的内存段,但是也非常小。最底层的思想是每个线程都可以拥有一个寄存器来储存中间结果,每个寄存器只能由相同的一个线程来访问,或者由相同的warp或者组的线程访问。
小结
这篇文章,主要阐述了GPU的基本底层构成,主要是以TU120来举例,讲解了GPU计算核心 Cores,以及Memory以及控制单元,三大组成要素。Core是计算的基本单元,既可以用作简单的浮点运算,又可以做一些复杂的运算例如,tensor 或者ray tracing。文中谈到了多个core之间通讯的方式,在特定的应用场合多个core之间是不需要的通讯的,也就是各干各的(例如 图像缩放),但是也有一些例子,多个core之间要相互通讯配合(例如上文谈到的数组求和问题),每个core之间都可以实现交互数据是非常昂贵的,因此提出了SMs的概念,SMs是多个core的集合,一个SMs里面的cores可以通过L1 Cache进行交互信息,完成使用GPU处理数组求和问题的时候,多个核心共享数据的功能。
关于memory,文章谈到,存在全局的内存GMEM,但是访问较慢,Cores当需要访问GMEM的时候会首先访问L1,L2如果都miss了,那么才会花费大代价到GMEM中寻找数据。
读了这篇来自Vitality Learning的文章,我对GPU的底层硬件结构有了初步认识,收益颇丰!
2.3. memory
回到这个文章的第一张图中来,我们接下来会讨论GPU和CPU内存方面的差别。
CPU的memory系统一般是基于DRAM的,在桌面PC中,一般来说是8G,在服务器中能达到数百(256)Gbyte。
CPU内存系统中有个重要的概念就是cache,是用来减少CPU访问DRAM的时间。cache是一片小,但是访问速度更快,更加靠近处理器核心的内存段,用来储存DRAM中的数据副本。cache一般有一个分级,通常分为三个级别L1,L2,L3 cache,cache离核心越近就越小访问越快,例如 L1可以是64KB L2就是256KB L3是4MB。
CPU Cache的内容不再这里展开讲解,感兴趣的读者可以自行查阅资料。
从第一张图可以看到GPU中有一大片橙色的内存,名称为DRAM,这一块被称为全局内存或者GMEM。GMEM的内存大小要比CPU的DRAM小的多,在最便宜的显卡中一般只有几个G的大小,在最好的显卡中GMEM可以达到24G。GMEM的尺寸大小是科学计算使用中的主要限制。十年前,显卡的容量最多也就只有512M,但是,现在已经完全克服了这个问题。
关于cache,从第一张图中不难推断,左上角的小橙色块就是GPU的cache段。然而GPU的缓存机制和CPU是存在一定的差异的,稍后将会证明这一点。
GPU 缓存机制
在 GPU 工作过程中希望尽可能的去减少内存的时延、内存的搬运、还有内存的带宽等一系列内存相关的问题,其中缓存对于内存尤为重要。NVIDIA Ampere A100 内存结构中 HBM Memory 的大小是 80G,也就是 A100 的显存大小是 80G。
其中寄存器(Register)文件也可以视为缓存,寄存器靠近 SM(Streaming Multiprocessors)执行单元,从而可以快速地获取执行单元中的数据,同时也方便读取 L1 Cache 缓存中的数据。此外 L2 Cache 更靠近 HBM Memory,这样方便 GPU 把大量的数据直接搬运到 cache 中,因此为了同时实现上面两个目标, GPU 设计了多级缓存。80G 的显存是一个高带宽的内存,L2 Cache 大小为 40M,所有 SM 共享同一个 L2 Cache,L1 Cache 大小为 192kB,每个 SM 拥有自己独立的 Cache,同样每个 SM 拥有自己独立的 Register,每个寄存器大小为 256 kB,因为总共有 108 个 SM 流处理器,因此寄存器总共的大小是 27MB,L1 Cache 总共的大小是 20 MB。

NVIDIA Ampere A100 内存结构
GPU 和 CPU 内存带宽和时延进行比较,在 GPU 中如果把主内存(HBM Memory)作为内存带宽(B/W, bandwidth)的基本单位,L2 缓存的带宽是主内存的 3 倍,L1 缓存的带宽是主存的 13 倍。在真正计算的时候,希望缓存的数据能够尽快的去用完,然后读取下一批数据,此时时候就会遇到时延(Lentency)的问题。如果将 L1 缓存的延迟作为基本单位,L2 缓存的延迟是 L1 的 5 倍,HBM 的延迟将是 L1 的 15 倍,因此 GPU 需要有单独的显存。
假设使用 CPU 将 DRAM(Dynamic Random Access Memory)中的数据传入到 GPU 中进行计算,较高的时延(25 倍)会导致数据传输的速度远小于计算的速度,因此需要 GPU 有自己的高带宽内存 HBM(High Bandwidth Memory),GPU 和 CPU 之间的通信和数据传输主要通过 PCIe 来进行。

NVIDIA Ampere A100 存储延迟对比
DRAM 动态
随机存取存储器(Dynamic Random Access Memory)
一种计算机内存类型,用于临时存储计算机程序和数据,以供中央处理器(CPU)快速访问。与静态随机存取存储器(SRAM)相比,具有较高的存储密度和较低的成本,但速度较慢。它是计算机系统中最常用的内存类型之一,用于存储操作系统、应用程序和用户数据等内容。
DRAM 的每个存储单元由一个电容和一个晶体管组成,电容负责存储数据位(0 或 1),晶体管用于读取和刷新数据。由于电容会逐渐失去电荷,因此需要定期刷新(称为刷新操作)以保持数据的正确性,这也是称为“动态”的原因,用于临时存储数据和程序,提供快速访问速度和相对较低的成本。
class="table-box">存储类型 | 结构 | 工作原理 | 性能 | 应用 |
---|
DRAM(Dynamic Random Access Memory) | 一种基本的内存技术,通常以单层平面的方式组织,存储芯片分布在一个平面上 | 当读取数据时,电荷被传递到输出线路,然后被刷新。当写入数据时,电荷被存储在电容中。由于电容会逐渐失去电荷,因此需要周期性刷新来保持数据 | 具有较高的密度和相对较低的成本,但带宽和延迟相对较高 | 常用于个人电脑、笔记本电脑和普通服务器等一般计算设备中 |
GDDR(Graphics Double Data Rate) | 专门为图形处理器设计的内存技术,具有较高的带宽和性能 | 在数据传输速度和带宽方面优于传统的 DRAM,适用于图形渲染和视频处理等需要大量数据传输的应用 | GDDR 与标准 DDR SDRAM 类似,但在设计上进行了优化以提供更高的数据传输速度。它采用双倍数据速率传输,即在每个时钟周期传输两次数据,提高了数据传输效率 | 主要用于高性能图形处理器(GPU)和游戏主机等需要高带宽内存的设备中 |
HBM(High Bandwidth Memory) | 使用堆叠设计,将多个 DRAM 存储芯片堆叠在一起,形成三维结构 | 堆叠设计允许更短的数据传输路径和更高的带宽,同时减少了功耗和延迟。每个存储芯片通过硅间连接(Through Silicon Via,TSV)与其他存储芯片通信,实现高效的数据传输 | 具有非常高的带宽和较低的延迟,适用于高性能计算和人工智能等需要大量数据传输的领域 | 主要用于高端图形处理器(GPU)、高性能计算系统和服务器等需要高带宽内存的设备中 |
不同存储和传输的带宽和计算强度进行比较,假设 HBM 计算强度为 100,L2 缓存的计算强度只为 39,意味着每个数据只需要执行 39 个操作,L1 的缓存更少,计算强度只需要 8 个操作,这个时候对于硬件来说非常容易实现。这就是为什么 L1 缓存、L2 缓存和寄存器对 GPU 来说如此重要。可以把数据放在 L1 缓存里面然后对数据进行 8 个操作,使得计算达到饱和的状态,使 GPU 里面 SM 的算力利用率更高。但是 PCIe 的带宽很低,整体的时延很高,这将导致整体的算力强度很高,算力利用率很低。
class="table-box">DataLocation | Bandwidth(GB/sec) | ComputeIntensity | Latency(ns) | Threads Required |
---|
L1 Cache | 19,400 | 8 | 27 | 32,738 |
L2 Cache | 4,000 | 39 | 150 | 37,500 |
HBM | 1,555 | 100 | 404 | 39,264 |
NVLink | 300 | 520 | 700 | 13,125 |
PCIe | 25 | 6240 | 1470 | 2297 |
在带宽增加的同时线程的数量或者线程的请求数也需要相对应的增加,这个时候才能够处理并行的操作,每个线程执行一个对应的数据才能够把算力利用率提升上去,只有线程数足够多才能够让整个系统的内存处于忙碌的状态,让计算也处于忙碌的状态,因此看到 GPU 里面的线程数非常多。
3. GPU 逻辑管线介绍
因为GPU是为了图形处理而诞生的,所以想要整明白GPU的架构,首先也要对渲染管线有一定的了解,下面是DirectX的渲染管线流程图,递归这看懂了然后我们继续:

为了简单起见,我们做一些假设。我们假设已经填充了数据并存在于 GPU 的 DRAM 中,并且在整个流程中仅使用VS和PS。
GPU图形处理,可以大致分成 5 个步骤,如下图箭头的部分。
分别为 vertex shader、primitive processing、rasterisation、fragment shader、testing and blending。

第一步,vertex shader。是将三维空间中数个(x,y,z)顶点放进 GPU 中。
在这一步骤中,电脑会在内部模拟出一个三维空间,并将这些顶点放置在这一空间内部。接着,投影在同一平面上,也是我们将看到的画面。同时,存下各点距离投影面的垂直距离,以便做后续的处理。
这个过程就像是本地球观看星星一般。地球的天空,就像是一个投影面,所有的星星,不管远近皆投影在同一面上。本地球的我们,抬起头来观看星星,分不出星星的远近,只能分辨出亮度。
GPU 所投影出的结果,和这个情况类似。

从地球所看到的星空,星星就像是投影到一球面上,除非使用特别的仪器,不然分不出星星和地球的距离
第二步,primitive processing。是将相关的点链接在一起,以形成图形。在一开始输入数个顶点进入 GPU 时,程序会特别注记哪些点是需要组合在一起,以形成一线或面。就像是看星座的时候一样,将相关连的星星连起来,形成特定的图案。
第三步,rasterisation。因为电脑的屏幕是由一个又一个的像素组成,因此,需要将一条连续的直线,使用绘图的演算法,以方格绘出该直线。图形也是以此方式,先标出边线,再用方格填满整个平面。
第四步,fragment shader。将格点化后的图形着上颜色。所需着上的颜色也是于输入时便被注记。在游玩游戏时,这一步相当耗费 GPU 的计算资源,因为光影的效果、物体表面材质皆是在这一步进行,这些计算决定着游戏画面的精细程度。因此在游玩游戏时,调高游戏画面品质大幅增加这一步的计算负担,降低游戏品质。
将一个三角形,用方格呈现近似原始图案,并着上颜色。一块又一块的方格,就是显示器上的像素
最后一步,testing and blending。便是将第一步所获得的投影垂直距离取出,和第四步的结果一同做最后处理。在去除被会被其他较近距离的物体挡住的物体后,让剩下的图形放进 GPU 的输出内存。之后,结果便会被送到电脑屏幕显示。

- 程序在图形 API(DirectX 或 openGL)中进行绘图调用(drawcall)。在这里对数据进行合法性检查后,数据会被GPU可读编码后插入到Pushbuffer中。在这个过程中在 CPU 方面可能会出现很多瓶颈,这也是我们常说的DrawCall瓶颈的问题所在。
- 一段时间后或者显式刷新(explicit "flush" calls)后,驱动程序已在pushbuffer 中缓冲了足够的work并将其发送给 GPU 进行处理(在操作系统的某些参与下)。GPU 的Host Interface接收通过Front End处理。
- 数据完全到达GPU后,在图元分配器(Primitive Distributor)中开始工作分配,处理indexbuffer,将处理得到的三角形分成batch,发送给多个GPC。

- 在GPC中,每个SM中的Poly Morph Engine负责通过三角形索引(triangle indices)取出三角形的数据(vertex data),即下图中的Vertex Fetch模块。
- 在获取数据之后,在SM中以32个线程为一组的线程束(Warp)来调度,来开始处理顶点数据。
- SM的warp调度器会按照顺序分发指令给整个warp,单个warp中的线程会锁步(lock-step)执行各自的指令,如果线程碰到不激活执行的情况也会被遮掩(be masked out)。被遮掩的原因有很多,例如当前的指令是if(true)的分支,但是当前线程的数据的条件是false,或者循环的次数不一样(比如for循环次数n不是常量,或被break提前终止了但是别的还在走),因此在shader中的分支会显著增加时间消耗,在一个warp中的分支除非32个线程都走到if或者else里面,否则相当于所有的分支都走了一遍,线程不能独立执行指令而是以warp为单位,而这些warp之间才是独立的。
- warp中的指令可以被一次完成,也可能经过多次调度,例如通常SM中的LD/ST(加载存取)单元数量明显少于基础数学操作单元。
- 由于某些指令比其他指令需要更长的时间才能完成,特别是内存加载,warp调度器可能会简单地切换到另一个没有内存等待的warp,这是GPU如何克服内存读取延迟的关键,只是简单地切换活动线程组。为了使这种切换非常快,调度器管理的所有warp在寄存器文件中都有自己的寄存器。这里就会有个矛盾产生,shader需要越多的寄存器,就会给warp留下越少的空间,就会产生越少的warp,这时候在碰到内存延迟的时候就会只是等待,而没有可以运行的warp可以切换。

- 一旦warp完成了vertex-shader的所有指令,运算结果会被Viewport Transform模块处理,三角形会被裁剪然后准备栅格化,GPU会使用L1和L2缓存来进行vertex-shader和pixel-shader的数据通信。
- 接下来这些三角形将被分割,再分配给多个GPC,三角形的范围决定着它将被分配到哪个光栅引擎(raster engines),每个raster engines覆盖了多个屏幕上的tile,这等于把三角形的渲染分配到多个tile上面。也就是像素阶段就把按三角形划分变成了按显示的像素划分了。
- SM上的Attribute Setup保证了从vertex-shader来的数据经过插值后是pixel-shade是可读的。
- GPC上的光栅引擎(raster engines)在它接收到的三角形上工作,来负责这些这些三角形的像素信息的生成(同时会处理裁剪Clipping、背面剔除和Early-Z剔除)。
- 32个像素线程将被分成一组,或者说8个2x2的像素块,这是在像素着色器上面的最小工作单元,在这个像素线程内,如果没有被三角形覆盖就会被遮掩,SM中的warp调度器会管理像素着色器的任务。
- 接下来的阶段就和vertex-shader中的逻辑步骤完全一样,但是变成了在像素着色器线程中执行。 由于不耗费任何性能可以获取一个像素内的值,导致锁步执行非常便利,所有的线程可以保证所有的指令可以在同一点。

- 最后一步,现在像素着色器已经完成了颜色的计算还有深度值的计算,在这个点上,我们必须考虑三角形的原始api顺序,然后才将数据移交给ROP(render output unit,渲染输入单元),一个ROP内部有很多ROP单元,在ROP单元中处理深度测试,和framebuffer的混合,深度和颜色的设置必须是原子操作,否则两个不同的三角形在同一个像素点就会有冲突和错误。
流程结束。
这个时候我们再来回顾一下这张图:

这会儿就会比较清晰了。
参考
- ^life-triangle Life of a triangle - NVIDIA's logical pipeline | NVIDIA Developer
- ^X-Jun的DX11教程 https://www.cnblogs.com/X-Jun/p/9028764.html#_lab2_8_2
作者:Clarence
链接:https://zhuanlan.zhihu.com/p/598173226
原文地址 :Understanding the architecture of a GPU | by Vitality Learning | CodeX | Medium
Author :Vitality Learning
作者:XPU-进击的芯片
链接:https://www.zhihu.com/question/559866344/answer/3498127927
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
作者:渲大师
链接:https://zhuanlan.zhihu.com/p/530141476
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
参考资料
- 链接:https://zhuanlan.zhihu.com/p/393485253
- The evolution of a GPU: from gaming to computing
data-report-view="{"mod":"1585297308_001","spm":"1001.2101.3001.6548","dest":"https://blog.csdn.net/u012294613/article/details/140209282","extend1":"pc","ab":"new"}">>
评论记录:
回复评论: