分享

GPU计算的工作原理

 yi321yi 2024-04-28 发布于江苏

要点

  • FLOPS不是唯一重要的因素,内存带宽在处理高计算强度任务时同样重要。
  • 延迟是影响性能的关键因素,需要利用大量线程来解决。
  • GPU是一个吞吐量机器,需要超量订阅以提高效率。
  • GPU架构基于大量线程和超量订阅来隐藏延迟。
  • GPU按层次结构运行线程,大工作网格分成块,块中线程协同操作。
  • 线程可减少延迟影响,数据局部性可优化带宽利用。
  • 数据最初存储的位置决定线程、内存和计算能力的效率极限。
  • 数据的位置对利用FLOPS的能力至关重要。

图片

Stephen Jones

Distinguished Software Architect

NVIDIA
<How GPU Computing Works>
2021年4月
我是CUDA架构师Stephen Jones,我的职责是深入探索如何编写高效的GPU程序,这包括选择适合的编程语言和适配硬件。我投入大量时间研究计算的核心原理。我常常在白板上为实习生描绘计算过程的蓝图。你或许会对我是如何理解GPU运作机制以及硬件对编程限制的图解感兴趣。物理定律和硬件特性决定了我们如何为这些机器编写程序。
这次演讲题目是“GPU计算的工作原理”,但实际上,“为什么GPU计算有效”更贴切,因为理解其背后的原因能帮你更有效地运用它。但后来我又想了想,意识到标题应该是“我的数据在哪里?”因为这才是决定计算效率的关键。接下来,我将为你揭示GPU计算的奥秘,但核心在于如何合理管理你的数据。
我想从一个颇具争议的观点开始:大多数人其实并不真正关心FLOPS,这是衡量机器数学性能的指标。虽然大家常问一个设备的FLOPS是多少,但实际上这并不是一个核心问题。可能只有少数专家或特定算法会特别关注FLOPS。简而言之,FLOPS并不是大众关心的焦点。
图片
为什么我会这样认为呢?让我们看看现代CPU的情况:内存能以大约200 GB/s的速度向CPU提供数据,但CPU2000 GFLOPs FP64的速度进行运算。这是现代处理器的典型性能。问题在于,CPU想要每秒处理2万亿个双精度数值,但内存每秒只能提供250亿个。这种不平衡被称为设备的“计算强度”(compute intensity),即设备需要付出多少努力来弥补内存提供数据的速度不足。在这个例子中,CPU需要进行约80次运算才能平衡这种差距。这意味着,对每个从内存中读取的数据,CPU需要进行80次运算;否则,处理器就会闲置,这时购买更便宜的CPU或许更为合适。然而,这种高计算强度要求对于大多数算法来说都是难以达到的。实际上,只有矩阵乘法这类特殊算法能满足这一要求。
图片
接下来,我通过一个表格对比了几个不同进程的性能。你会发现,这些进程在计算强度上几乎相同,这对于编写高效程序来说并不是个好消息。但值得注意的是,NVIDIA芯片虽然拥有更高的FLOPS,但同时也配备了更高带宽的内存以保持平衡。这并不是巧合。我们总是努力降低计算强度,因为很少有算法能在每次加载数据时完成100次或更多次运算。然而,一个“不为人知”的事实是,每一代GPU在增加FLOPS方面的速度往往超过了增加内存带宽的速度。这导致计算强度不断上升,给算法编程带来了更大的挑战。我们需要不断努力优化算法,以确保这些强大的芯片能够保持高效运行。这些芯片就像饥饿的怪兽,需要不断的数据来满足其运算需求。因此,我接下来要分享的许多内容都将围绕这一挑战,以及它是如何影响和塑造我们为这些机器编写程序的方式的。
我之所以认为FLOPS不是关键,是因为我们已经拥有足够的计算能力,而且这种趋势还在持续加剧。如果CPU无法保持忙碌状态,那么它就会陷入所谓的“内存带宽限制”模式。事实上,我估计至少有四分之三甚至更多的程序在实际运行中都会受到内存带宽的限制,因为很少有算法能在每次数据加载时完成足够多的运算来充分利用硬件性能。
图片
实际上,这还不是问题的全部。我们真正应该关注的是延迟。当然,带宽和FLOPS也很重要。我们来深入谈谈延迟这个概念。为何延迟如此关键呢?让我们从最基础的运算操作来看:ax + y。在双精度下,这被称为DAXPY;在单精度下,则是SAXPY。你会看到许多关于这些操作的基准测试,但基本上可以忽略它们。这是一个基础且至关重要的指令,以至于处理器都为其设计了专门的指令——FMA(融合乘加),它能在单个指令周期内完成整个运算。也就是说,可以一次性完成所有的运算步骤。请注意,这里计算的是加载次数,而不是存储次数,因为存储操作并不会造成延迟,无需等待它们。需要等待的是加载操作,它们与我们要执行的FLOPS相互抵消,以覆盖加载数据所花费的时间。
延迟,让我们通过一个时间线来直观理解。首先,要加载变量x。接着,加载y并不依赖于x的值,因为运算是alpha乘以x再加上y。所以,会同时发起对y的加载请求。然后,会经历一段相当长的等待时间,直到x的数据返回。这段时间往往是空闲的,非常不高效。随后,情况变得复杂起来。两件事几乎同时发生:开始进行alpha乘以x的乘法运算,因为x的数据已经到位,可以开始处理;同时,也在等待y的数据加载完成。我们称这种处理方式为流水线作业。这意味着,虽然有额外的内存操作正在进行,但它们被其它有用的计算工作所掩盖,不会造成明显的延迟。流水线处理是现代计算机编程的核心基础。虽然你可能在编写程序时并没有太多考虑它,但编译器实际上花费了大量精力来进行流水线优化,确保数据加载尽可能早地发起,以便被其它计算操作所覆盖。编译器会重新排列你的代码,以实现这种效果。这种流水线处理是大多数程序性能优化的关键,因为内存访问的延迟往往比计算延迟要大得多。
图片
那么,为什么会这样呢?原因在于物理学。光速确实非常快,但现代计算机的时钟频率也同样惊人。因此,在一个时钟周期内,光只能传播很短的距离。更具体地说,电在硅材料中的传播速度只有光速的五分之一左右。这听起来很复杂,但你可以简单理解为:在一个时钟周期内,电信号只能传播很短的距离。考虑到芯片的尺寸,电信号从芯片的一侧传输到另一侧可能需要一个或多个时钟周期,这还不包括进行任何计算操作的时间。
图片
当你看到某些操作的延迟达到五、六或七个时钟周期时,这其实是相当惊人的。这意味着电信号的传输速度与计算速度之间存在着激烈的竞争。因此,物理定律成为了限制性能的关键因素。当需要从内存中获取数据时,数据的往返传输可能就需要十到二十个时钟周期
图片
但问题的关键并不在于数据到达内存的物理距离,而是数据在传输过程中需要经过的众多晶体管。电路的工作原理是将信号从一个晶体管组传递到另一个晶体管组,完成设备内部的所有逻辑操作。因此,晶体管会按照时钟的节奏开启和关闭,电子也只会按照时钟的节拍前进。光速虽然是一个因素,但并不是决定性的。实际上,晶体管管道的深度对性能的影响更为显著。
图片
这意味着花费了大量时间等待数据的到来。那么,这究竟意味着什么呢?让我们通过一些计算来看看这对成本造成了怎样的影响。我之前提到CPU经常处于空闲状态,因为内存延迟导致它无法保持忙碌。尽管CPU拥有强大的计算能力(即FLOPS),但我希望内存能够与之匹配,确保数据能够及时到达。以Xeon 8280为例,我选择它仅仅是因为它的延迟数据是公开的。这款CPU拥有131GB的内存和89纳秒的延迟。具体选择哪款芯片并不重要,重要的是理解这种延迟对性能的影响。如果有89纳秒的延迟,而内存带宽为131GB/s,那么在一个内存延迟周期内,只能移动约11659字节的数据。这似乎还不错,但当我们考虑到DAXPY操作只加载了两个8字节的值(即x和y),总共只有16字节时,效率就显得非常低下,仅为0.14%。这显然不是一个好的结果。即使有高带宽的内存来应对计算强度,实际上几乎没有利用到它的优势。为高性能的CPU和内存付出了巨大的成本,但结果却并不理想。
图片
通过为一系列进程制定延迟图表,你可以看到几乎所有进程的表现都很糟糕。实际上,Xeon 8280的0.14%效率已经是其中表现最好的了。这是因为程序受到了延迟绑定的影响,这是一种常见的内存限制形式,其发生的频率远高于我们的想象。这也解释了为什么我对FLOPS并不太关心,因为即使内存带宽无法充分利用,计算单元更是无法忙碌起来。值得关注的是,GPU在这方面的表现甚至比其它类型的处理器更差。这就是“GPU编程原理”中需要深入探讨的部分。当然,我会在后续的讲解中详细阐述这一点,但现在让我们先思考如何解决这个问题。
图片
如果我将11659字节的数据除以16字节(即DAXPY操作加载x和y所需的总字节数),发现需要同时执行729个DAXPY迭代,才能让花在内存上的钱物有所值。因此,面对这种低内存效率,需要同时处理729个操作。
图片
首先,我们可以通过并发操作来解决这个问题。并发,顾名思义,就是同时进行许多事情。但请注意,这些操作不必是严格同时发生的,它们只需要能够独立进行。编译器有一种优化手段叫做循环展开,它能够识别出可以独立执行的部分,并将它们连续地发出,从而提高执行效率。记住,我们之前谈到过连续加载x和y的操作,通过多次展开循环,我们可以实现这一连续加载。然而,这种优化方式受限于硬件能够同时跟踪的操作数量。在硬件的流水线中,它只能同时处理有限数量的事务,超出这个数量就不得不等待之前的事务完成。硬件会跟踪每个请求的状态,但请注意,我这里所说的计算仍然只涉及到一个线程。所以,即使我通过循环展开处理了729个事务,这在现实中几乎是不可能的,而且即使处理器能够处理729个待处理的加载操作,最终仍然需要执行729次计算。因此,循环展开确实有益,它可以让流水线更加饱满,但显然它也受到机器架构中其它多种因素的制约。
图片
并行性比并发性在概念上更加强大和直接,它意味着多个操作是真正同时发生的。处于并行状态的事物是真正意义上的同时进行的。所以,尽管循环展开让我能够连续执行许多操作,但并行性实际上是让每个线程同时发出一个操作,直到达到硬件所允许的最大线程数量,也就是硬件能够同时处理的线程数量,因此,实际上,我可以结合使用循环展开和多线程操作,这样就可以使用更少的线程来完成同样的任务。但为了简化讨论,我们暂时只关注在硬件限制下可以运行的最大线程数量。
图片
现在我就可以在分析表格中添加几行数据了。我可以看看在理想情况下,我需要多少线程来弥补内存系统带来的延迟。结果是我需要的线程数量非常多。但这就是GPU与CPU之间一个非常值得关注的差异点,GPU的延迟和带宽要求比CPU高得多,这意味着它需要大约40倍的线程来弥补这种延迟。但实际上,GPU拥有的线程数量比其它类型的处理器多100倍。因此,在实际应用中,GPU的表现反而更好。它拥有的线程数量比实际需要的多出五倍半,而其它类型的CPU,它们的线程数量可能只够覆盖1.2英寸范围内的操作,这就是GPU设计中最为关键的一点。如果你从这次讲解中只能记住一件事,那就是:GPU拥有大量的线程,远超过它实际需要的数量,这是因为它被设计为“超额订阅”。它旨在确保有大量线程在同时工作,这样即使某些线程在等待内存操作完成,仍然有其它线程可以继续执行。GPU通常被称为“吞吐量机器”。GPU的设计者将所有的资源都投入到了增加线程数量而不是减少延迟上。相比之下,CPU则更侧重于减少延迟,因此它通常被称为“延迟机器”。CPU期望单个线程能够完成大部分工作。在CPU中切换线程(从一个线程切换到另一个线程)是一个资源消耗高的操作,它涉及到上下文切换,因此只需要足够多的线程来覆盖内存延迟即可。所以,CPU的设计者将所有资源都投入到了减少延迟而不是增加线程数量上。这两种方法是截然相反的,但它们都是用来解决相同的延迟问题,这实际上也是GPU和CPU在运行方式和工作原理上的根本差异所在。
图片
我之前一直在谈论的是事物运行的一般原理和面临的挑战,这些挑战涉及到物理学和电子学的知识,你可以从之前的幻灯片中看到,GPU解决这些问题的方式与CPU截然不同。但无论如何,内存都是最为关键的因素;所有的编程工作都是围绕内存展开的。这涉及到内存带宽、内存延迟以及数据在内存中的位置等问题。但GPU采用了一种完全不同的解决策略,这也是我这次讲解的核心内容。这就是为什么我要解释GPU编程的工作原理。现在让我们来看看这些关于内存的数字,其中最为核心的概念是缓存。请注意,我在这里将寄存器文件也视为一种缓存。这实际上是GPU设计中一个非常重要的细节。GPU为每个线程分配了大量的寄存器来存储实时数据,从而实现了非常低的延迟。这是因为与CPU相比,GPU中每个线程都需要处理更多的数据,因此它需要能够快速访问这些数据。所以,GPU需要一种靠近其计算核心的快速内存,并且这种内存需要足够大,以便能够存储进行有用计算所需的所有数据。不仅如此,当你发出一个加载操作(比如将某个指针的值加载到变量x中)时,硬件需要一个地方来暂存这个加载结果。所以,当我说从内存中加载数据时,我实际上是指将这个加载结果放入寄存器中,这样就可以对它进行计算了。而GPU所拥有的寄存器数量直接决定了它能够同时处理的内存操作数量。这意味着在理想情况下,
图片
你可能在疑惑,为什么我在谈论线程和带宽时会提及缓存。原因就在于此。线程之所以能在不同延迟下有效工作,正是得益于缓存的存在。那么,让我们深入探讨一下带宽和延迟的概念。想象GPU的主内存,那就是高带宽的HBM内存。如果我把GPU主内存的带宽看作一个单位,无论它有多快,都只能算作一。而L2缓存带宽则是它的五倍,L1缓存,也就是我即将提到的共享内存,更是快了13倍。因此,随着带宽的增加,它更容易满足计算强度的需求,这无疑是一件好事。如果可能的话,我希望能充分利用缓存来满足计算强度。同时,如果看一下物理上最接近内存的L1缓存,假设它的延迟为一,那么L2缓存的延迟就是它的五倍,而主内存的延迟则是L1缓存的15倍。现在,与离片带宽和延迟相比,你很快就会明白为什么我们如此渴望在GPU上本地运行所有数据。通过PCIe总线传输数据是目前最大的瓶颈。
图片
我们可以利用这些带宽和延迟的数据,来看看计算强度是怎样的。我们来看一下每个内存层在操作时所需的计算强度。对于HBM,我们之前看过的计算强度是100。而L2缓存的计算强度则要好得多,只需要39次加载操作,L1缓存更是只需要8次,这是一个非常可实现的数字。这就是为什么L1缓存、共享内存和GPU如此有用的原因,因为我实际上可以让数据足够接近计算核心,从而有意义地进行8次操作并充分利用FLOP。所以,如果可以的话,我真的很希望所有数据都能从缓存中读取。同时,我真的非常不希望从PCIe中读取数据,因为PCIe的带宽很有限,延迟又很大,这意味着需要做大量的操作。
这里我提到了NVLink,虽然它没有在之前的展示中显示出来,但它是GPU-GPU之间的链接。NVLink在性能上比PCIe更接近主内存。这也是为什么NVLink作为芯片之间和GPU之间的互连方式,比PCIe总线要好得多的原因。
图片
如果我们看一下隐藏这种延迟所需的线程数,这里有一个值得关注的现象。你可能会认为,由于延迟降低了,所需的线程数也会减少。但请记住,带宽也在增加。对于主内存,几乎需要与L2缓存和L1缓存一样多的线程,这并不是巧合。考虑一下,我们总是希望整个内存系统都能保持忙碌状态。因为计算强度很高,需要不断给计算核心提供数据。所以,如果内存系统中的某个部分比其它部分需要更多的线程,那么那个部分就会成为瓶颈。必须增加更多的线程来满足那个部分的需求,但这又会导致其它部分的线程过多。硬件设计者会有意识地平衡这些因素,使得整个设备在编程时都能保持均匀负载。
图片
我提到了HSM内部的SM,它基本上是一个处理核心。在A100 GPU上,有108个这样的核心。一个SM内部包含了很多组件,但简单来说,它以32个线程组为单位运行所有任务,这被称为一个warp。warp基本上就是机器的向量宽度,一个warp包含32个线程,并且同时运行四个这样的warp。所以,在任何给定的时钟周期,都有四个warp在执行任务。实际上,有64个warp在等待执行。这意味着,在任意时刻,都有四个warp在工作,而其余60个warp在等待。GPU构建了所有这些SM以及每个SM中的线程。这是整个设计策略的一部分。记住,GPU设计者通过增加线程数量来对抗延迟,而不是通过减少延迟来降低延迟。
图片
所以,在任何给定的SM中,可以有更多的线程处于活跃状态,但实际上只有128个线程在任何时候运行。这就是我之前提到的,GPU是被过度订阅的。这意味着,当一些线程在等待读取数据时,其它线程已经完成了读取并准备执行。这就是GPU工作原理的关键所在。它可以在一个时钟周期内轻松地在不同的warp之间切换,因此几乎没有上下文切换的开销。它可以连续运行线程。这意味着,为了弥补延迟,我们需要保持的活跃线程数要远远超过系统在任何时候能够运行的线程数。这与CPU的工作方式截然不同,对于CPU来说,我们永远不希望线程过多。
图片
现在,让我们来谈谈吞吐量和延迟。我一直在提到这两个词,让我解释一下。我住在旧金山,在圣克拉拉工作。我的通勤时间很长,圣马特奥北部经常堵车。
图片
我有两种上班方式可以选择。我可以开车,需要45分钟,或者我可以坐火车,需要73分钟。我的车是为减少延迟而设计的,但火车是一个吞吐量机器。我解释一下它的工作方式。开车的问题在于它尽量快速地完成一次旅程,但并没有真正帮助到其它人。它速度快,但效率不高,只能载少数人,并且只能从一个地方到另一个地方。另一方面,火车可以载很多人,它在很多地方停靠,所以沿途的所有人都能得到帮助。沿途可以设置很多列火车来运输乘客。
关于延迟系统,有一点很重要,那就是如果它们被过度订阅,性能就会大打折扣。想象一下,如果路上的车太多,交通就会陷入瘫痪,没人能顺利到达目的地。同样,如果火车已经满员,你就只能等待下一班。而且,与汽车不同,火车晚点通常不会长达三个小时,因为总有下一班火车可以搭乘。
图片
所以,GPU其实是一个吞吐量机器,它的设计初衷是能够处理比它一次运行的工作多得多的任务。这就像火车系统,如果火车没有满载,那就没有充分利用其运输能力。对于GPU来说也是如此,吞吐量系统通常希望有深度的等待队列。火车公司其实希望你在站台上等待,因为如果火车到站时站台上没有人,车厢没有满载,那他们就是在浪费资源。GPU也是如此,它需要保持忙碌状态,才能充分发挥其性能。
CPU则更偏向于一个延迟机器。切换线程需要消耗资源,所以我们希望每个线程都能尽快完成其任务。但如果任务太多,系统就会陷入停滞。因此,我们的目标是尽快完成每个任务,然后为下一个任务腾出空间。这就像我们希望车辆在路上畅通无阻,而不是停滞不前,因为道路上的车辆数量是有限的。简而言之,我们利用这些线程来解决延迟问题,这是一个非常有效的策略。
图片
现在我们已经解决了延迟问题,接下来面临的是带宽挑战。由于整个系统都是基于吞吐量的设计,我们通常会过度订阅资源。这意味着我们总是有任务在执行,内存也在不断地被访问。
在这个过程中,我们必须考虑步性。很重要的一点是,CPU和GPU是独立的处理器,这意味着它们可以同时处理不同的任务,而且应该这样做。如果CPU停下来等待GPU,或者GPU停下来等待CPU,那么整个系统的效率就会下降。这就像每个站点都要等待下一班火车才能继续前行,这样显然不如只有一个高效的处理器。
图片
异步性的重要性在于它让所有的处理器都在工作,没有人停下来等待。CPU可以向GPU发送工作指令,然后继续执行其它任务,而GPU则独立地处理这些任务。我们只需要等待最终的结果。
图片
为了更形象地解释这个概念,我们可以想象一下道路交通。如果你想一次性移动很多东西,那么你需要更多的车道,就像右边的道路一样。这样的交通是异步的,每个车辆都可以独立地前进,不会被前面的车辆阻塞,因为车道足够多。相反,如果交通是同步的,那么只有一条车道,所有的车辆都必须等待最慢的那辆车,效率就会大打折扣。因此,异步性对于我们追求的高吞吐量至关重要。
图片
然而,在现实世界中,很少有工作是每个元素完全独立于其它元素的。DAXPY就是一个很好的例子。这些被称为逐元素(element-wise)算法,只有最简单的算法才能以这种方式工作。大多数算法至少需要一个或多个周围的元素,比如卷积操作,它会考虑图像中的每个像素及其邻居。还有一些算法,如傅里叶变换,需要每个元素与其它每个元素进行交互。这些被称为全对全算法,它们的行为方式与逐元素算法截然不同。
图片
现在,让我展示GPU上并行处理的工作原理,以及我们如何获得所需的吞吐量。假设训练了一个AI来识别互联网上的猫。现在,我们有一张猫的图片。我会在这张图片上覆盖一个网格,这个网格将图片分割成许多工作块。然后,我会独立地处理每个工作块。这些工作块是彼此独立的,它们在图片的不同部分工作,而且工作块的数量非常多。因此,GPU会被这些工作块过度订阅。但请记住,过度订阅是我们追求高效执行和最大内存使用的一种策略。
在每个工作块中,都有许多线程共同工作。这些线程可以共享数据并完成共同的任务。所有的线程都同时并行运行,这样我们就能够实现高效的并行处理。现在,已经构建了层次结构。在最高层,有总工作量,它通过网格被分解成工作块,这些工作块为GPU提供了所需的过度订阅。然后,在每个工作块中,有一些本地线程,它们一起协同工作。通过这种方式,能够充分利用GPU的并行处理能力,实现高效的吞吐量。
图片
我训练了一个AI来处理图像。这些线程协同工作,它们在各自的分片(tile)上工作,组成一个个块。请记住,每个块都以自己的速度独立运行,最终,整个图像会被处理完成,使网络环境更加安全。
图片
在GPU上,工作是以网格的形式运行的,这些网格进一步被分解成线程块。每个块都拥有并行运行的线程,确保它们能够同时处理任务并共享数据。然而,所有的块都是独立调度的,这种模式被称为过度订阅。这带来了两种最佳的世界的结合。它既能保持机器的忙碌状态,提供所需的吞吐量,又允许线程之间进行必要的交互。这就是GPU编程的精髓:将问题分解成多个块,在这些块中,协作的线程共同处理任务,但每个块都保持着相对的独立性。
图片
到这里,我们已经解决了延迟问题。延迟被过度订阅所掩盖。我说过,延迟实际上是你应该关注的事情。所有这些——大量的线程、过度订阅、网格和块的编程模型,以及在块中运行的线程——它们都是为了对抗延迟而存在的。我们已经做到了,取得了成功,但现在我们受到了带宽的限制,这又将我们带回了计算强度和FLOP的问题上。我们需要提高算法的工作量,但现阶段这并不容易实现。
我们有众多线程。根据那张表,线程数量比实际需要的多出了5.6倍。那么,问题就来了,如果增加线程来处理问题,会发生什么呢?这实际上取决于算法的复杂性。也就是说,如果增加问题的大小,也就是增加处理对象的数量,由于拥有充足的线程,那么需要增加多少次操作呢?
以逐元素处理为例,每次增加一个线程,就会加载一个新的数据元素,但只做一次额外的操作。所以,添加一个线程,加载一个数据片段,然后进行一次额外的计算。实际上,这并没有带来太大的改变。增加线程并不会使所需的FLOP数量显著增加。算法的算术强度是平稳的。
即使是像2D卷积或3D卷积这样的算法,当增加处理对象的尺寸时,数据按n的平方缩放,但计算量也按n的平方缩放。因此,这些算法的复杂性也保持不变。算术强度也是按比例增加的。再次强调,对于卷积操作,增加数据量并不会对机器所需的这种计算强度产生太大的影响。
全连接层开始变得更有意思。在那里,每当加倍线程数量时,需要增加四倍的计算量,因为所有的线程都在进行交互。突然之间,处于一个更有利的位置。突然之间,算法的算术强度随着线程数量的增加而线性增加。我们有大量线程;可以充分利用它们。这增加了所需的FLOP数量,使我们能够开始挑战这个计算强度数字。
图片
这最终将我们带到了矩阵乘法这一话题。正如我所说,这是我们真正关心的算法之一,而实际上可以找到这种计算强度。你可能已经知道矩阵乘法是什么,但我不会详细解释它的原理。我会向你展示机器是如何理解它的。在最简单的情况下,将绿色的每一行与蓝色的每一列相乘,然后得到白色的每一个点。这就是矩阵乘法的基本原理。
这就是关键所在。记得我之前介绍过DAXP吗?它拥有融合乘法加法功能,即FMA。我说过这是一个非常重要的函数,很多处理器都专门为它设计了自己的指令,这就是为什么它如此重要。FMA是许多数学算法中的基础操作。
矩阵乘法算法庞大且复杂,但它是由一系列这样的操作堆叠而成的。对于每一个输出值,都会重复发生相同的计算过程。请注意,绿色的行在运算过程中保持不变,这是多次重复利用已加载数据的一个典型例子。以这个矩阵为例,对于每一个加载的绿色点,只用这一行进行了25次计算。这显示了极高的计算强度。如果矩阵是10乘10的规模,那么每次加载数据后,将进行100次运算。
图片
随着矩阵规模的扩大,显著提高了保持高FLOPS的能力。矩阵乘法的算术强度会随着矩阵大小的立方而增长,这正是矩阵乘法算法的独特性质。同时,随着矩阵变大,数据加载次数按照矩阵大小的平方增加。换句话说,当矩阵的维度为( n )时,需要加载( n^2 )个数据点。因此,算法复杂性和算术强度都与( n )的阶数成正比。
图片
这是我根据矩阵大小绘制的所需计算强度增长曲线图。当矩阵大小从1增长到64时,它呈现出一条直线趋势,因为( n )阶增长意味着它随着( n )的增大而单调递增。因此,当把矩阵做得更大时,需要相应提升计算强度来支持它。
可以绘制一条线来表示GPU上单精度浮点数的计算强度。举例来说,Ampere A100 GPU的GPU计算性能可以达到约19.5 teraFLOPS,这相当于一个50的计算强度。交叉点表示控制点,它告诉我们一旦矩阵大小达到50,我们就能获取到保持FLOPS高利用率所需的所有数据。
这就是我能有效执行的最大矩阵规模。超过这个规模的矩阵,内存将比计算核心更空闲。理想情况下,我们希望机器的各个部分都能平衡运行。我们希望所有资源都能保持在100%的利用率。这实际上就是吞吐量机器的设计目标。因此,“甜蜜点”就是那条线上的交叉点。如果绘制双精度的性能曲线,你会看到这里的值更高,因为在A100上,我们有双精度张量核心,它们能提供更高的每线程FLOPS。关于张量核心,我稍后会详细介绍。
在这张图上,你可以看到已经在单精度性能上达到了极限,但双精度性能还未达到极限。我们可以稍微放大这张图的某一部分。我在底部放置了一个绿色的箭头,以便你能更清楚地看到( n = 64 )的跨度。
图片
我们放大后的图表只是原图的左下角部分。对于更大的矩阵,你可以看到在大约100的计算强度处矩阵性能曲线相交,这意味着一个100乘100的矩阵将使双精度性能达到极限。当然,随着矩阵规模的增大,内存将变得越来越空闲,因为计算所需的时间将越来越多。因此,我实际上是在寻找这个平衡点。
图片
我们可以深入聊聊张量核心(Tensor Cores)。张量核心,作为SM中的专用硬件单元,其功能强大得如同算术单元中的乘法器和加法器。但它们能够一次性完成整个矩阵操作,即一次性执行矩阵乘法运算。这意味着在单个步骤中,它们能够完成大量的浮点运算(FLOPS)。传统的FMA每次执行仅涉及两个FLOPS,而张量核心每次执行的FLOPS数量远超这个数值。TensorFlow中32位张量核心的计算强度高达400,正是因为它具备了如此强大的浮点运算能力。然而,这也意味着我需要大量的内存来存储和处理这些数据。因此,要使张量核心达到饱和状态,所需的矩阵大小至少是400。但实际上,处理的矩阵规模要远大于这个数值。
这就是目前面临的困境。我们渴望更多的FLOPS,因为那意味着更快的计算速度,但更多的FLOPS同时也需要更大的问题规模来支撑。否则,内存系统将成为瓶颈,而更大的问题规模并不总是可行的。因此,仅仅增加FLOPS的数量并不能解决问题,还需要考虑如何在有限的空间内高效利用这些资源。我认为400乘400的矩阵已经是一个非常大的矩阵了,但我仍然希望能够在更小的矩阵上获得高效的FLOPS。这听起来有些贪心,但我既想要速度,又想要效率。这正是缓存能够发挥作用的地方。
图片
我们再次回到带宽和延迟的问题上。之前已经看过那张表,现在我们可以重点看看张量核心的计算强度需要多少带宽来支撑。在之前的幻灯片中,我在400的位置画了一条线,代表了张量核心的计算强度。这意味着它需要从主存储器(如HBM存储器)中读取和写入大量的数据。但如果能够从L2缓存中读取数据,那么计算强度将降低到只有156;而如果能够始终从共享内存中读取数据,那么计算强度更是降低到只有32。显然,需要充分利用缓存机制,以便在较小的矩阵规模下也能使张量核心高效运行。
图片
实际上,我可以绘制一个图表来展示数据位置对计算效率的影响。当数据存储在主内存中时,能高效处理的最小矩阵规模是400乘400;但当数据存储在L2缓存中时,这个规模可以降低到大约150;而当数据存储在共享内存中时,甚至可以处理32乘32这样的小矩阵。通过优化数据位置,能够显著提高处理小矩阵的效率,这也是我今天演讲想要传达的核心信息。
图片
那么,我们今天到底学到了什么呢?
我们了解到FLOPS的数量并不是唯一重要的因素,带宽同样至关重要,尤其是在处理高计算强度任务时。
同时,我们也意识到带宽并不是唯一影响性能的因素,延迟同样是一个需要关注的关键点。
为了解决延迟问题,我们需要利用大量的线程。
GPU架构正是基于拥有大量线程和超量订阅来隐藏延迟的思想构建的。
我们还了解到,GPU有一个可衡量的“通勤时间”,它是一个吞吐量机器,需要通过超量订阅来提高效率,而不是一个固定工作量的延迟机器。
尽管有了这么多线程,但有时它们仍然需要协同工作,因为并不是所有的操作都是逐元素进行的。
因此,GPU按照层次结构运行线程,大的工作网格被分成块,这些块在吞吐量模式下运行,而块中的线程则可以协同完成某个操作。
在解决了延迟问题后,我们转而关注如何平衡计算强度与带宽,特别是在处理像矩阵乘法这样的重型算法时。
对于小型计算强度任务,实现高效率的关键在于巧妙利用缓存层次结构。
我们可以通过线程来减少延迟的影响,通过数据局部性来优化带宽的利用,从而充分利用包括张量核心在内的所有FLOPS。
这不禁让我想起了我最初的标题:“我的数据在哪里?”因为在系统中,无论是线程、内存还是计算能力,其效率极限都取决于数据最初存储的位置。
对于低计算强度的任务,我们可以付出更少的代价,因为我们可以使用较少的线程来隐藏延迟,并且我们有更多的带宽来支持FLOPS的执行。
但无论如何,一切都离不开数据的位置。即使是我利用这些FLOPS的能力,也完全取决于数据存放在哪里
这就是我今天想要传达给大家的信息。
来源:Andy730

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多