GPU 其实是相当低效的人工智能加速芯片
编译:王庆法
【译者注:大模型为代表的人工智能对GPU的极端依赖和暴买,导致了大规模的GPU荒,甚至有人为此现象谱写了旋律上头的歌曲。
但从硬件运作的原理角度看,GPU的简单并行架构在内存的使用方式上对很多神经网络算法特别是大模型transformer计算图并不适配,阻碍了AI编译器针对整个计算图的最优化,因而导致算力大打折扣。
计算加速的要义是让数据尽最大可能在合适的时间贴近计算,因此内存的架构方式与算法计算图的优化匹配是硬件加速芯片设计的核心,也是AI编译器难度最大的部分。
英伟达的CUDA作为Pytorch/TF/JAX等AI框架与GPU芯片中间承上启下的基础设施,完善的算子支持,编程灵活性,业已形成完整的生态,这是短期还离不开GPU的原因。
其他芯片厂商AMD、华为、寒武纪、沐曦等正在努力突破。特斯拉DOJO就是个最新的好例子。本文最后有些Quadric的软广,但瑕不掩瑜,不影响本文作为该领域非常好的一篇科普。】
1886 年 7 月,卡尔·本茨 (Carl Benz) 首次公开展示他的“燃气发动机汽车”——有史以来发明的第一辆汽车。在第一辆汽车公开展示后短短九个月,第一场汽车比赛于 1887 年 4 月 28 日举行,比赛全程 2 公里(1.2 英里),在巴黎从讷伊桥到布洛涅森林。【译者注:这两个事件时间存在不同说法】
随着汽车作为一种便捷的交通方式变得更加可靠和普遍,人们探索该技术潜力的愿望也越来越强烈。赛车在 1900 年代初期正式成为一项有组织的运动,如今它已成为每年价值数十亿美元的产业,并生产出最高时速接近 500 公里(超过 300 英里/小时)的车辆。
与第一场汽车比赛类似,人工智能(AI)应用正处于起步阶段,但许多人猜测它们的影响可能比汽车更大,并且许多人正在竞相测试其当前的极限。就像今天的赛车看起来与原来的三轮汽车完全不同一样,人工智能平台正在发生变化,以满足此类新程序的性能需求。
如果您一直在关注加入这场竞赛的任何一家公司,您可能听说过他们在吹嘘自己的人工智能加速能力时,使用了“算子融合”这个术语。PyTorch 2.0中最值得夸耀的功能之一 。今年早些时候发布的“TorchInductor”编译器后端可以自动执行算子融合,这为某些用户带来了30-200% 的运行时性能改进。
从上下文中,您可能可以推断出“算子融合”是一种神奇地使深度神经网络( DNN ) 模型更容易、更快执行的技术……您是对的。但算子融合到底是什么?如何使用算子融合来加速我的 AI 推理应用?
人工智能应用程序,尤其是那些包含深度神经网络(DNN) 推理的应用程序,由许多基本操作组成,例如乘法累加 (MAC)、池化层、激活函数等。
从概念上讲,算子融合(有时也被称为核或层融合)是一种优化技术,通过重新考虑两个或更多的接续的算子的范围,就好像它们是一个单一的算子,从而降低其总体成本。
您在日常生活中常常靠直觉进行这种类型的“融合”,无需太多思考。例如,考虑一下您在某一天有两项任务:去办公室工作和去杂货店购买本周的杂货。
图 1. 通过融合两项任务(“上班”和“去杂货店”),将它们重新视为一项任务,从而减少一次驾驶次数。
如果您的办公室和当地的杂货店位于城镇的同一区域,您可能会本能地在下班回家的路上顺便去一下杂货店,从而将您的旅程减少 1 个路段,这可能会减少驾驶和出行所花费的总时间。总行驶距离更短。
算子融合以类似的方式直观地优化人工智能程序,但性能优势是通过更少的内存读/写来衡量的,这会导致专用于程序开销的时钟周期更少,从而生成更高效的程序二进制代码。
【译者注:人工智能的算法通常可以转化为计算图 Computation Graph, 可以简单的将计算图中的节点看成本文所说的算子,仔细观察 Transformer 架构或者 SQL Access Plan(DAG有向无环图)就容易理解了。】
让我们看一个实际的例子。以下是 NVIDIA TensorRT™ 优化器执行算子融合之前和之后的网络可视化图:
图 2. 由 NVIDIA 的 TensorRT™ 优化器执行算子融合之前和之后的 GoogLeNet Inception 模块网络。NVIDIA 的博文中提供了原始图像。
在图 2 的示例中,算子融合能够通过融合以下算子的组合将层数(未命名为“input”或“next input”的块)从 20 减少到 5:
ReLU 激活(“relu“) 偏差添加(“偏差”) 卷积层(“1×1 卷积”、“3×3 卷积”和“5×5 卷积”)
性能优势因目标硬件平台而异,但算子融合可以为几乎每个运行时目标平台提供优势。由于其普遍性,算子融合是几乎所有 DNN 编译器和执行框架中的关键优化技术。
如果减少内核数量可以减少程序开销并提高效率,并且这些好处普遍适用,那么这可能会促使我们提出以下问题:
我们为什么不将所有算子融合在一起呢?
是什么阻止算子被融合?
是否存在融合算子没有意义的情况?
为了更好地了解算子融合的优点和局限性,让我们更深入地研究它正在解决的问题。
一个实际示例:融合卷积、偏差添加和激活函数
融合卷积层、偏差添加和激活函数层(如图 2 中 NVIDIA 的 TensorRT 工具所做的那样)是算子融合的极其常见的选择。卷积和激活函数可以分解为一系列矩阵乘法运算,然后是逐元素运算,如下所示:
图 3.图算子的 3×3 卷积、偏差添加和 ReLU 激活函数序列中使用的张量。
如果这些操作分三个连续步骤执行,则图计算将类似于(图 4 左侧):
输入( x ) 与核权重 ( w ) 从全局加载到本地内存
计算输入( x ) 和权重 ( w ) 的张量积,输出中间张量 ( m ) 存储在本地内存中
中间张量输出(m)从本地内存写入全局内存
偏差值 ( b ) 和中间张量输出 ( m ) 从全局内存加载到本地内存
偏差值 ( b ) 与中间张量 ( m ) 相加以产生卷积输出 ( z )
卷积输出 ( z ) 写入全局内存
卷积输出 ( z ) 被加载到本地内存中
ReLU 激活函数针对卷积输出 ( z )进行计算,激活输出 ( y ) 存储在本地内存中
激活输出 ( y ) 被写入全局内存以供图中的下个算子使用
图 4. 与单个融合算子相比,图算子的 3×3 卷积、偏差添加和 ReLU 激活函数序列中使用的张量的本地内存加载和存储。
如果融合这两个操作,图计算就会简化为如下所示(图 4 右侧):
输入( x )、内核权重 ( w ) 和偏差值 ( b ) 从内存加载
计算输入( x ) 和权重 ( w ) 的张量积,输出中间张量 ( m ) 存储在本地内存中
偏差值 ( b ) 与中间张量 ( m ) 相加以产生卷积输出 ( z )
ReLU 激活函数针对卷积输出 ( z )进行计算,激活输出 ( y ) 存储在本地内存中
激活输出 ( y ) 被写入全局内存以供图中的下个算子使用
通过将这三个操作表示为单个操作,我们能够删除四个步骤:将中间张量 ( m ) 和卷积输出张量 ( z ) 写入和读回本地内存的 需要。实际上,当平台 可以将中间张量保存在 加速器平台的本地内存中时,通常可以实现算子融合。
中间张量是否可以保留在本地内存中取决于三件事:
内存可用性要求:是否有足够的本地内存用于中间张量和下一个算子的输入和输出?
计算通用性要求:连接到本地内存的计算引擎是否足够通用以计算这两个操作?
内存组织要求:上一个操作产生的中间张量是否在本地内存中正确组织,或者是否需要在下一个操作开始之前重新变换?
正如我们之前提到的,某些加速器平台比其他加速器平台更适合利用算子融合的性能优势。在下一节中,我们将探讨为什么某些架构更能满足这些要求。
加速器架构困境:优化计算还是内存?
DNN 模型变得越来越深,具有数百甚至数千个算子层,以便在解决领域越来越广的问题时获得更高的精度。随着 DNN 变得越来越大、越来越深,运行推理的内存和计算要求也随之增加。
有许多硬件平台可以通过多种巧妙的方式优化这些计算和内存性能瓶颈。每个的优势对于正在部署的程序来说可能是非常主观的。
我们将研究三种不同类型的加速器内核,它们可以与系统芯片 (SoC) 上的主机 CPU 配合使用,并有针对性的看看每个架构的算子融合能力怎么样:
GPU(图形处理单元)
NPU(神经处理单元)
GPNPU(通用神经处理单元)
GPU:通过缓存解决问题
GPU(例如 Arm Mali-G720)可解决 AI 的内存瓶颈,并通过使用 L2 缓存抽象化管理内存的编程复杂性。它们由并行运行但无法直接相互通信的通用计算核心组成,从而解决了计算瓶颈。
图 5.Arm Mali-G720 GPU内存层次结构。
在 GPU 内存层次结构的上下文中,如果中间张量可以完全装进给定ShaderCore计算核心的本地寄存器内存 (LRM)中,而不需要传回 L2 缓存,则算子融合是可行的。
图 6. Arm Mali-G720 GPU 内存层次结构中图形算子的 3×3 卷积、偏差添加和 ReLU 激活函数序列的张量位置。
由于 GPU 核心无法直接相互共享数据,因此它们必须写回 L2 Cache,以便在处理元素之间交换中间张量数据或重新组织以满足后续算子的要求。由于无法在不写入 L2 缓存情况下在计算核心之间共享张量数据,因而阻碍了将多个卷积算子融合在一起,也就没办法避免在计算核心之间复制权重张量,从而增加了其计算效率和内存利用率的负担。由于内存层次结构是基于缓存的,因此硬件决定内存块何时被清理,如果可用内存接近饱和,这可能会使算子融合变得无法确保。
由于这些原因,GPU 满足 算子融合的计算通用性要求 ,但有时容易受到内存可用性 和 内存结构要求的影响。
NPU:以系统灵活性为代价优化计算
NPU(例如 Arm Ethos-N78)是用于加速 AI 的定制架构,因此它们加速 AI 的方式各不相同;然而,当今性能最高的通常使用 硬线脉动阵列 来加速 MAC 操作。
图 7. 用于 MAC 操作的脉动阵列图。
MAC 运算是最频繁的,因此通常也是 DNN 推理中最昂贵的运算。由于使用脉动阵列的 NPU 旨在加速这种计算,因此它们具有超高效和高性能,可满足运行 DNN 推理所需的 90% 以上的计算,并且可以成为某些 AI 应用程序的合理选择。
尽管 NPU 对于 MAC 运算具有极高的性能,但它们无法处理所有算子,并且大多数需要与更通用的处理元件相结合才能运行整个程序。一些 NPU 会将计算负载转移到主机 CPU。性能更高的系统在脉动阵列旁边有专用的硬件块,用于执行有限的算子融合,例如偏差添加和激活函数。
图 8. MAC 加速脉动阵列 NPU 的内存层次结构。
在 ASIC 内存层次结构的背景下,如果中间张量可以连续流过脉动阵列和片上处理元件,而不需要与主机的共享内存进行通信,则算子融合是可能的。
性能最高的 NPU(例如 Arm Ethos-N78)在设计时就考虑了算子融合,以实现一组有限的算子排列。由于它们是硬连线的,从而无法编程,因此它们无法处理内存重塑或重组,甚至无法融合一些与其耦合的硬件块无法处理的激活功能。要执行这些操作,程序必须写回某些共享内存:设备上或 SoC 上的共享内存。
图 9. Arm Ethos-N78 NPU 内存层次结构中图形算子的 3×3 卷积、偏差添加和 ReLU 激活函数序列的张量位置。
它们缺乏可编程性和对某些模型架构(例如日益流行的 Transformer)的敏感性,使得许多人工智能应用程序无法使用它们。
由于这些原因,定制 NPU 无法满足 算子融合的计算通用性 和 内存组织要求 。
GPNPU:最大化内存和计算利用率
GPNPU,如 Quadric Chimera QB16,也是定制架构,因此,它们加速 AI 的方式各不相同;然而,他们大都在网格中使用分布式计算元素来实现计算并行化和更高效的内存管理。
GPNPU 介于 NPU 和 GPU 之间,能够实现特定于 AI 应用的功能和通用可编程性。通过深思熟虑的软硬件协同设计,它们能够减少可用硬件资源的数量,同时也完全可编程。
Chimera 系列 GPNPU 是完全可编程的处理器内核,包含处理元件 (PE) 的网格,每个处理元件都有自己的本地寄存器存储器 (LRM),并且能够共同并行运行标量、向量和矩阵运算。由于它们是网状连接的,因此它们还能够直接与相邻的 PE 共享中间张量数据,而无需写入共享的 L2 内存。
图 10. Quadric GPNPU ASIP 的内存层次结构。
在 GPNPU 内存层次结构的上下文中,如果两个连续算子之间的中间张量没有离开分布式 LRM(每个 PE 内的内存块),则这两个操作被视为融合。
脉动阵列中的 PE 与 GPNPU 中的 PE 阵列之间的区别在于,PE 可以集体编程以使用相同的指令流并行操作。由于大多数 DNN 算子的数据流可以使用访问模式静态定义,因此可以编写简单的 API 来描述数据在 PE 数组中的分布和流动。这极大地简化了开发人员的体验,因为无需对每个内部 DMA 传输进行显式编程即可编写复杂的算法。
出于这些原因,GPNPU 在精心设计的 AI 计算硬件,与 GPU 等通用并行计算平台的可编程性和灵活性之间取得了平衡。GPNPU 满足 算子融合的一般计算和内存组织要求 ,并且只会偶尔受到内存可用性要求的限制。
让我们看一下由 GPNPU 及其编译器 Chimera 图形编译器 (CGC) 执行的算子融合的类似实际示例。
Quadric 的 Chimera 图编译器可以更好地进行算子融合
以下是由 Quadric 的 Chimera™ 图编译器 (CGC) 执行算子融合之前和之后的 MobileNetV2 网络的可视化图表,其风格与图 2 中的 NVIDIA 图表风格相同:
图 11. 由 Quadric 的 Chimera™ 图编译器 (CGC) 执行层算子融合之前和之后的 MobileNetV2 模块网络。
在上面的示例中,CGC 执行的算子融合能够将层数从 17 层减少到 1 层。当您考虑融合层的顺序时,这一壮举更加令人印象深刻。
CGC 生成的融合层包含四个具有不同通道数和滤波器大小的卷积算子。由于 GPNPU 使用 PE 的网格,因此相邻 PE 可以共享数据,而无需写回共享 L2 内存。这些数据移动比写入 L2 内存快一个数量级,并且允许数据“流”过 PE,类似于脉动阵列计算 MAC 操作的方式,但以可编程的方式。
图 12. Quadric Chimera GPNPU 内存层次结构中图算子的 3×3 卷积、偏差添加和 ReLU 激活函数序列的张量位置。
由于可以使用单个指令流对 PE 进行集中编程,因此可用于表示这些算子的 API 非常简单,这可以生成非常短但高性能的代码。下面是由 Quadric 的 CGC 生成的图 11 中 MobileNetV2 算子的自动生成的 C++ 代码片段。如果没有添加的注释, 所有 17 个算子的代码为 37 行 :
/* Input image is 224x224. Iterate 14 times in both the X and Y dimension over 2D patches of the image of size 16x16. */for (int32_t tb_y1 = 0; tb_y1 < 14; ++tb_y1) { for (int32_t tb_x1 = 0; tb_x1 < 14; ++tb_x1) { /* Read input tensors into LRM (OCM = On-Chip Memory) */ container::NDArray <qVar_t<< span>int8_t>, 27> ocm_tensor_0_rf;</qVar_t<<> ocm_tensor_0_flow_1.read(ocm_tensor_0_rf); /* Stride of 2x2 with a Kernel Size of 3x3 */ dataAggregationStrideOf2x2ConvWithDataMov3x3 <OcmTensor<std::< span>int8_t, 1, 3, 16, 16>>(ocm_tensor_0_rf);
</OcmTensor<std::<> container::NDArray <qVar_t<< span>int8_t>, 32> T_contrib_epu_qlinear_conv2d_rf;
</qVar_t<<> BroadcastFlow <TensorAccessor<MinRoiDeor<OcmTensor<std::< span>int8_t, 1, 1, 1, 1280>, AxisGroup<>, Granularity::Row, false, 1, 1, 1>>, OcmTensor <std::< span>int8_t, 1, 1, 1, 1280>, OcmTensor <std::< span>int8_t, 1, 1, 1, 1280>, 1> const_tensor_0_ocm_flow_0(const_tensor_0_ocm);
</std::<>
</std::<>
</TensorAccessor<MinRoiDeor<OcmTensor<std::<> /* First Conv layer produces 32 output channels */ for (int32_t ch1 = 0; ch1 < 32; ++ch1) { /* Conv 3x3 */ qVar_t<int32_t> _0 = nn::convTileBlockInt8 <std::< span>int32_t, 27, 1, 0, false>(ocm_tensor_0_rf);
</std::<> qVar_t<int32_t> _11 = qBroadcast<0, std::int32_t, BroadcastAction::POP>; /* Linear Dequantization, Clip, Linear Quantization */ T_contrib_epu_qlinear_conv2d_rf[(ch1)] = ((qVar_t<int8_t>)math::min(math::max(cgc::fxRoundPosInf<23>(__builtin_epu_fxsmul(math::min(math::max(__builtin_epu_fxsmul(((qVar_t<int8_t>)math::min(math::max(cgc::fxRoundPosInf<2>(__builtin_epu_fxsmul((_0 + _11), 4679030, 29)), -128), 127)),58507024, 2), ((qVar_t<int32_t>)0.000000e+00f)), ((qVar_t<int32_t>)3.221225e+09f)), 1231605867, 31)), -128), 127)); } container::NDArray <qVar_t<< span>int8_t>, 32> T_contrib_epu_qlinear_conv2d_rf1;
</qVar_t<<> BroadcastFlow <TensorAccessor<MinRoiDeor<OcmTensor<std::< span>int8_t, 1, 1, 1, 1024>, AxisGroup<>, Granularity::Row, false, 1, 1, 1>>, OcmTensor <std::< span>int8_t, 1, 1, 1, 1024>, OcmTensor <std::< span>int8_t, 1, 1, 1, 1024>, 1> const_tensor_1_ocm_flow_0(const_tensor_1_ocm);
</std::<>
</std::<>
</TensorAccessor<MinRoiDeor<OcmTensor<std::<> cgc::initNonParticipatingCores<112, 112, 32, 1, 1, 1, 1, 1, false>(T_contrib_epu_qlinear_conv2d_rf, ((qVar_t<int8_t>)0), tb_y1, tb_x1); for (int32_t ch2 = 0; ch2 < 32; ++ch2) { /* Conv 3x3 */ qVar_t<int32_t> _2 = nn::groupwiseConvTileBlockInt8 <std::< span>int32_t, 1, 3, 1, 0, false>(T_contrib_epu_qlinear_conv2d_rf, ch2);
</std::<> qVar_t<int32_t> _3 = qBroadcast<0, std::int32_t, BroadcastAction::POP>; /* Linear Dequantization, Clip, Linear Quantization */ T_contrib_epu_qlinear_conv2d_rf1[(ch2)] = ((qVar_t<int8_t>)math::min(math::max(cgc::fxRoundPosInf<22>(__builtin_epu_fxsmul(math::min(math::max(__builtin_epu_fxsmul(((qVar_t<int8_t>)math::min(math::max(cgc::fxRoundPosInf<2>(__builtin_epu_fxsmul((_2 + _3), 63275241, 29)), -128), 127)), 235664992, 4), ((qVar_t<int32_t>)0.000000e+00f)), ((qVar_t<int32_t>)8.053064e+08f)), 1420470960, 31)), -128), 127)); } container::NDArray <qVar_t<< span>int8_t>, 16> T_contrib_epu_qlinear_conv2d_rf2;
</qVar_t<<> BroadcastFlow <TensorAccessor<MinRoiDeor<OcmTensor<std::< span>int8_t, 1, 1, 1, 640>, AxisGroup<>, Granularity::Row, false, 1, 1, 1>>, OcmTensor <std::< span>int8_t, 1, 1, 1, 640>, OcmTensor <std::< span>int8_t, 1, 1, 1, 640>, 1> const_tensor_2_ocm_flow_0(const_tensor_2_ocm);
</std::<>
</std::<>
</TensorAccessor<MinRoiDeor<OcmTensor<std::<> for (int32_t ch3 = 0; ch3 < 16; ++ch3) { /* Conv 1x1 */ qVar_t<int32_t> _4 = nn::convTileBlockInt8 <std::< span>int32_t, 32, 1, 0, false>(T_contrib_epu_qlinear_conv2d_rf1);
</std::<> qVar_t<int32_t> _5 = qBroadcast<0, std::int32_t, BroadcastAction::POP>; T_contrib_epu_qlinear_conv2d_rf2[(ch3)] = ((qVar_t<int8_t>)math::min(math::max(cgc::fxRoundPosInf<2>(__builtin_epu_fxsmul((_4 + _5), 14656876, 29)), -128), 127)); } container::NDArray <qVar_t<< span>int8_t>, 96> T_contrib_epu_qlinear_conv2d_rf3;
</qVar_t<<> BroadcastFlow <TensorAccessor<MinRoiDeor<OcmTensor<std::< span>int8_t, 1, 1, 1, 2304>, AxisGroup<>, Granularity::Row, false, 1, 1, 1>>, OcmTensor <std::< span>int8_t, 1, 1, 1, 2304>, OcmTensor <std::< span>int8_t, 1, 1, 1, 2304>, 1> const_tensor_3_ocm_flow_0(const_tensor_3_ocm);
</std::<>
</std::<>
</TensorAccessor<MinRoiDeor<OcmTensor<std::<> for (int32_t ch4 = 0; ch4 < 96; ++ch4) { /* Conv 1x1 */ qVar_t<int32_t> _6 = nn::convTileBlockInt8 <std::< span>int32_t, 16, 1, 0, false>(T_contrib_epu_qlinear_conv2d_rf2);
</std::<> qVar_t<int32_t> _7 = qBroadcast<0, std::int32_t, BroadcastAction::POP>; /* Linear Dequantization, Clip, Linear Quantization */ T_contrib_epu_qlinear_conv2d_rf3[(ch4)] = ((qVar_t<int8_t>)math::min(math::max(cgc::fxRoundPosInf<23>(__builtin_epu_fxsmul(math::min(math::max(__builtin_epu_fxsmul(((qVar_t<int8_t>)math::min(math::max(cgc::fxRoundPosInf<2>(__builtin_epu_fxsmul((_6 + _7), 9891659, 29)), -128), 127)), 124856608, 3), ((qVar_t<int32_t>)0.000000e+00f)), ((qVar_t<int32_t>)1.610613e+09f)), 1420470960, 31)), -128), 127)); } /* Write output tensors back to L2 memory (OCM = On-Chip Memory) */ ocm_tensor_1_flow_0.write(T_contrib_epu_qlinear_conv2d_rf3); }}
在此代码片段中融合并且因此不在 LRM 和 L2 内存之间移动的中间张量的总大小为 6,623.2 KB 或 ~ 6.6 MB。就上下文而言,传递到该块第一层的输入数据为 150.5 KB ,最终移出到 L2 内存的中间张量数据为 1.2 MB。通过积极利用算子融合,CGC 能够将 MobileNetV2 内核这一部分的总内存移动开销减少 83%。
对于优化 TOPS/W 性能的高性能边缘应用程序,这些数据移动节省可直接转化为功耗节省。下面的图 13 是与将 32b 数据元素从 Chimera GPNPU 上的 ALU/MAC 引擎寄存器文件存储器移动到其他每个存储器级别相关的相对成本表:
通过执行算子融合并且不移动那些 6.6 MB 的中间张量数据,应用程序开发人员可以预期功耗降低约 185 倍。
如果您是一名硬件设计师,希望为开发人员启用高性能、低功耗的 AI 应用程序,并且对 Quadric 的 Chimera 图形编译器 (CGC) 可以提供的潜在性能优势感兴趣,请考虑注册 Quadric DevStudio 帐户了解更多。