try ai
科普
编辑
分享
反馈
  • 合并内存访问

合并内存访问

SciencePedia玻尔百科
核心要点
  • 当 GPU warp 中的所有 32 个线程访问连续的内存位置时,就会发生合并内存访问,从而实现单一、高效的内存事务。
  • 未能通过跨步或随机模式实现合并访问,会强制执行多次缓慢的事务,从而显著降低有效内存带宽和性能。
  • 数据布局至关重要;使用数组结构体 (SoA) 格式代替结构体数组 (AoS) 是确保合并访问的主要方法。
  • 合并原则影响着各个领域的算法设计,从三维 FFT 中的数据转置到分子动力学模拟中的粒子排序。
  • 合并不同于缓存,因为它涉及捆绑来自 warp 内单个指令的请求,而不是存储数据以供将来使用。

引言

在高性能计算领域,图形处理器 (GPU) 就像一个巨大的图书馆,由成千上万名完美同步工作的图书管理员(线程)组成。他们生产力的最终限制不在于他们阅读的速度,而在于他们从书架上取书的效率。如果他们能一次性取回大块连续的书籍,性能就会飙升;如果他们每个人都必须跑到不同且随机的书架,整个系统就会陷入停顿。这种为并行处理器大军提供数据的挑战是现代计算中最重大的障碍之一,通常被称为“内存墙”。

本文探讨了解决此问题的优雅方案:​​合并内存访问​​。这是释放 GPU 真正潜力的最重要原则,它将潜在的数据交通拥堵转变为信息高速公路。通过理解和应用这一概念,开发人员可以实现显著的性能提升,这意味着模拟运行时间可能从几小时缩短到几分钟。

我们将首先深入探讨合并访问的​​原理与机制​​,探索 GPU 硬件如何设计来捆绑内存请求,以及未能这样做所带来的严重性能损失。随后,​​应用与跨学科联系​​一章将展示这种底层硬件约束如何塑造了从深度学习中使用的数据结构到模拟宇宙的算法等不同领域的高层软件设计。

原理与机制

想象一下,你是一名图书管理员,任务是为一组 32 个人取书,他们都在同一时刻请求一本书。如果他们聪明地请求同一书架上从 1 到 32 号的书,你就可以一次性高效地把它们全部取来。但如果他们请求的是散布在整个图书馆的 32 本随机书籍呢?你将被迫进行 32 次独立且耗时的取书之旅。你的效率差异是巨大的。这个简单的类比抓住了高性能计算中最关键的概念之一的核心:​​合并内存访问​​。

GPU 的“军团”与内存墙

现代图形处理器 (GPU) 不仅仅是一个快速的单核处理器;它是一支数字化的军队。它包含数千个简单的处理核心,这些核心以高度同步的方式执行指令。这些核心被组织成 32 个线程的组,称为 ​​warp​​。你可以把 warp 想象成一个花样游泳队;所有 32 名成员在同一时间执行完全相同的指令,这种模型被称为​​单指令多线程 (SIMT)​​。

这支线程大军需要数据来工作,而且需要快速获取数据。与任何处理器一样,GPU 也有一个内存层次结构。处理器芯片上直接集成了少量速度极快的内存(寄存器和共享内存),但绝大多数数据驻留在更大但速度明显慢得多的片外全局内存中,这是一种 DRAM。大多数复杂计算问题(从模拟流体动力学到训练神经网络)的性能瓶颈,不在于 GPU 进行算术运算的速度,而在于它在缓慢的全局内存和处理核心之间来回传输数据的速度。这就是臭名昭著的“内存墙”。对于​​计算强度​​较低的内核——即每获取一字节数据执行的计算很少——内存速度就是一切。

因此,核心挑战是:如何从一个缓慢、遥远的仓库为成千上万的线程提供数据,而又不让他们都站着等待?答案在于让每次去仓库的行程都尽可能高效。

合并内存访问的艺术

GPU 的内存系统设计中藏着一个绝妙的技巧。它知道从全局内存中获取单个字节是极其低效的;建立请求的开销远大于传输那一点点数据所花费的时间。因此,它被设计成以大块、连续的方式获取数据。一个典型的事务可能一次性抓取一个对齐的 128 字节块。

​​内存合并​​是当硬件能够将来自一个 warp 的 32 个独立内存请求捆绑成少数几个这样的大型、高效事务时发生的奇迹。实现这一点的“黄金法则”非常简单:一个 warp 中的线程应该访问内存中的连续元素。

假设一个包含 32 个线程的 warp 正在运行,线程 ttt(其中 ttt 的范围是 000 到 313131)需要从一个名为 data 的数组中读取一个 4 字节的浮点数。如果代码编写成线程 ttt 访问 data[base_index + t],那么这 32 个线程请求的就是 32 个连续的 4 字节值。这总共是 32×4=12832 \times 4 = 12832×4=128 字节的完美连续数据。硬件内存控制器看到这种情况,会心一笑,用一个单一、最优的 128 字节内存事务服务了所有 32 个请求。图书管理员只跑了一趟。

非合并访问之“过”

当我们打破黄金法则时会发生什么?后果是严重的,并且有几种常见的形式。

​​跨步访问:​​ 想象一下,线程被编程为访问 data[base_index + s*t],其中步长 sss 是某个大于 1 的整数。现在,线程 0 想要元素 0,线程 1 想要元素 sss,线程 2 想要元素 2s2s2s,依此类推。内存请求不再相邻,而是分散开来。硬件无法再用单个事务来满足它们。如果步长足够大——比如说,17 个四字节元素,如一个假设场景 中所述——这 32 个线程的请求可能会落入 32 个不同的内存段,迫使硬件发出 32 个独立、低效的事务。图书管理员跑了 32 趟。​​有效带宽​​,即衡量每秒获得多少有用数据的指标,急剧下降。在最好的情况下(完美合并),有效带宽等于硬件的峰值带宽;在最坏的情况下,它可能只有峰值带宽的一小部分。

​​未对齐访问:​​ 即使步长为 1 (s=1s=1s=1),我们也会遇到麻烦。硬件的内存事务是对齐到特定边界的(例如,128 字节边界)。如果一个 warp 请求的数据块恰好跨越了这些边界之一,本可以是一个事务的请求就会变成两个。与大步长相比,这只是一个小过失,但这些小小的低效率会累积起来。

​​随机访问(Gather):​​ 最终的性能杀手是当 warp 中的每个线程访问一个完全随机且不可预测的内存位置时。这被称为“gather”操作。在这种情况下,硬件没有任何模式可以利用。它别无选择,只能为每个线程发出一个单独的事务,导致最差的内存性能。这对 GPU 来说尤其浪费,因为其大事务尺寸是一把双刃剑:对于移动连续数据非常有效,但当获取一个 4 字节的值需要传输整个 128 字节段时,就变得非常低效。

数据布局决定命运:为成功而构建

这一切可能听起来很抽象,但它对我们如何编写代码有着深远而具体的影响。最重要的教训是,​​在内存中组织数据的方式决定了访问是否能够合并。​​

考虑一个常见的任务:在模拟中处理大量粒子,每个粒子都有一个位置 (x,y,z)(x, y, z)(x,y,z)。有两种自然的方式来在内存中存储这些数据。

第一种,也许对于面向对象的程序员来说最直观,是​​结构体数组 (AoS)​​ 布局。你定义一个 Particle 结构体,然后创建一个由它们组成的大数组。在内存中,它看起来像这样: [x0,y0,z0,x1,y1,z1,x2,y2,z2,… ][x_0, y_0, z_0, \quad x_1, y_1, z_1, \quad x_2, y_2, z_2, \quad \dots][x0​,y0​,z0​,x1​,y1​,z1​,x2​,y2​,z2​,…] 现在,想象一个 warp 的线程,其中线程 ttt 被分配来处理粒子 ttt。如果第一步是处理所有的 x 坐标,线程 ttt 试图读取 xtx_txt​。看一下内存布局,这些 x 坐标不是连续的!它们被 y 和 z 坐标隔开,导致了一个大的步长。这是一个典型的非合并访问模式。

第二种方法是​​数组结构体 (SoA)​​ 布局。在这里,你为每个分量创建单独的数组:一个用于所有 x 坐标,一个用于所有 y 坐标,等等。在内存中,它看起来像这样: Array X: [x0,x1,x2,… ]\text{Array X: } [x_0, x_1, x_2, \dots]Array X: [x0​,x1​,x2​,…] Array Y: [y0,y1,y2,… ]\text{Array Y: } [y_0, y_1, y_2, \dots]Array Y: [y0​,y1​,y2​,…] Array Z: [z0,z1,z2,… ]\text{Array Z: } [z_0, z_1, z_2, \dots]Array Z: [z0​,z1​,z2​,…] 现在,当线程 ttt 需要读取 xtx_txt​ 时,warp 的 32 个线程正在访问一个完美连续的内存块。硬件可以发出一个单一、完全合并的事务。性能上的差异绝非细微。对于一个典型的工作负载,仅仅将数据布局从 AoS 更改为 SoA 就可以带来 16 倍或更多的速度提升! 这不是微观优化;这是一个根本性的架构对齐,可能是一个实时模拟和通宵运行的模拟之间的区别。即使对于像分子动力学中遍历邻居列表这样高度复杂、不规则的问题,程序员也会不遗余力地重新排序和构建他们的数据,以实现这些宝贵的合并访问。

区别与细微之处

与任何强大的原则一样,理解其边界以及它与其他概念的关系非常重要。

​​合并不是缓存:​​ 缓存是一个小而快的内存,用于存储最近使用的数据,希望它们很快会再次被需要。缓存有助于利用不同指令甚至不同 warp 之间的​​时间局部性和空间局部性​​。另一方面,合并是关于内存系统如何处理由单个 warp 发出的单个指令的请求。缓存无法修复一个根本上非合并的访问。如果请求的数据恰好在缓存中,它可能会减轻打击,但它并不能改变 warp 发起了一个低效、分散请求的事实。

​​原子操作另当别论:​​ 一些操作,比如用于并行计数的原子加法,必须是不可分割的。它们有不同的规则。它们通常不像简单的加载和存储那样被合并。它们的性能主要由​​争用​​主导——即有多少线程试图同时更新相同的内存位置或访问相同的内存控制器分区。一个内存访问模式如果完全连续(因此对于合并加载会非常棒),但如果所有 32 个请求都指向同一个内存分区,对于原子操作来说可能就是最坏的情况,迫使它们逐一处理。

​​宏观视角:​​ 最后,虽然实现完美的合并是内存密集型代码的主要目标,但这并不是性能的唯一因素。总吞吐量是内存效率、隐藏延迟的能力(称为​​占用率​​)和原始计算能力之间复杂相互作用的结果。 有时,一些略微降低合并性的选择可能会提高占用率,从而带来净性能增益。在 GPU 上进行性能调优通常是在一个多维度的权衡空间中寻找“最佳点”。

归根结底,合并内存访问原则是一个 прекрасный 例子,展示了硬件架构如何塑造软件设计。通过理解 GPU 的线程大军希望如何从内存中读取数据——以宽阔、有序、连续的队列方式——我们可以构建我们的数据和算法,使其与机器和谐共处,将潜在的内存交通拥堵转变为数据的高速公路。

应用与跨学科联系

想象一下,你身处一个巨大的图书馆,带领着一个由三十二名图书管理员组成的团队。如果你要求他们每个人从一个不同且随机选择的书架取一本书,结果将是一片混乱——他们花在走路和避免碰撞上的时间会比取书的时间还多。现在,想象你要求他们取回并排放在同一个书架上的三十二本书。他们可以排成一列,以一个平滑、协调的动作收集所有书籍。效率之高令人惊叹。

这个简单的类比抓住了​​合并内存访问​​的精髓。当现代并行处理器(如图形处理器 GPU)上的一组计算线程需要从内存中获取数据时,如果它们都能从一个单一、连续的内存地址“书架”上读取,它们的性能就会飙升。这不仅仅是一个巧妙的优化;这是一个基础性原则,其回响贯穿于计算科学与工程的整个领域。让我们踏上一段旅程,看看这条经验法则如何塑造从我们设计深度学习网络的方式到我们模拟宇宙结构的一切。

计算的画布:构建数据结构

影响内存访问最直接的方法是控制数据最初的布局方式。可以把它想象成在图书管理员上班前就组织好图书馆的藏书。

最简单的画布:矩阵

我们的第一站是不起眼的矩阵,它是科学计算的基石。为了被计算机处理,一个二维矩阵必须被展平成一条长长的一维内存带。我们有两种主要的方式来做到这一点:行主序,即我们先排列第一行,然后是第二行,依此类推;或者*列主序*,即我们一个接一个地排列列。哪种更好?合并原则给出了一个清晰明确的答案:这完全取决于你打算如何读取它。

想象你的线程团队被组织成这样:连续的线程处理连续的行,同时保持在同一列。如果你的矩阵是以行主序存储的,它们需要访问的内存位置会相距很远——被一整行的长度隔开。访问是分散的,就像派每个图书管理员去不同的书架。然而,如果线程被组织成在同一行内处理连续的列,它们的内存请求就会变得完全顺序。它们正在从内存带上并排读取,实现了完美的合并访问。对于列主序存储,情况正好相反。这个教训的简单性中蕴含着深刻的道理:要实现合并访问,你必须将并行工作的维度与数据存储的连续维度对齐。硬件对秩序的偏好决定了我们的算法策略。

现代画布:深度学习张量

这一原则直接延伸到驱动人工智能革命的更高维数组,即张量。例如,一批图像可能由一个四维张量表示,其维度包括批量大小 (NNN)、通道 (CCC)、高度 (HHH) 和宽度 (WWW)。两种流行的存储格式应运而生:NCHW 和 NHWC。字母的顺序告诉你维度在内存中的顺序,从变化最慢的到最快的。

在 NCHW 格式中,宽度维度 W 是最快的,这意味着单个颜色通道的单条水平线上的所有像素在内存中是连续的。这对于像卷积这样的操作非常有利,因为卷积通常涉及在图像宽度上滑动的空间滤波器。但如果一个操作需要访问单个像素的所有通道(例如,红、绿、蓝值)怎么办?在 NCHW 中,这些通道值在内存中相隔很远,步长为 H×WH \times WH×W 个元素。这种访问是极其不合并的。

现在考虑 NHWC 格式。在这里,通道维度 C 是最快的。单个像素的所有通道值都紧密地打包在内存中。这对于我们刚才描述的操作是完美的——一个线程团队可以在一次合并读取中获取一个像素的所有通道。然而,它对于空间滑动窗口就不那么理想了。因此,NCHW 和 NHWC 之间的选择不是一个随意的细节;它是一个基于神经网络架构中哪些类型的操作最关键而做出的深思熟虑的设计决策,并且它是追求合并内存访问的直接结果。

驯服不规则性:当数据不“听话”时

当我们的数据不是一个整洁的矩形网格时会发生什么?自然界常常是杂乱无章的,我们的计算模型必须反映这一点。在这里,合并向我们提出了更具创造性的挑战。

稀疏性的挑战

科学和工程中许多最重要的矩阵都是稀疏的——它们大部分被零填充。想想代表社交网络连接的矩阵,或者描述量子力学系统中相互作用的哈密顿量。存储所有的零将是空间和时间的荒谬浪费。所以我们使用只存储非零值的特殊格式。

这就带来了一个两难的境地。像压缩稀疏行 (CSR) 这样的简单格式逐行存储所有非零值。它很紧凑,但常常导致分散、不合并的内存访问。更糟糕的是,每行的非零元素数量可能差异很大。这会导致warp 发散,即分配给短行的线程很快完成并闲置,等待它们组中分配给一个非常长行的那个线程。另一种格式,ELLPACK (ELL),则强制要求规整性。它用零填充每一行,直到所有行的长度都与最长的一行相同。这对于合并和发散非常有利——每个线程做同样多的工作,内存访问结构完美。但代价是填充!如果只有一行特别长,我们就会浪费大量的内存和带宽来读取无用的零。

这种权衡催生了一些优美而优雅的折衷方案,如 HYBRID (HYB) 和 Sliced ELL (SELL) 格式。它们巧妙地划分矩阵,对大部分“行为良好”的行使用高效的 ELL 格式,而对少数特别长的行使用更灵活(但更慢)的格式。这是一种分而治之的策略,通过隔离不规则性,使得大部分计算能够以完全合并、有序的方式进行。

模拟分子的舞蹈

在模拟粒子系统时,例如蛋白质中的原子或星系中的恒星,也出现了同样的不规则性问题。粒子不在网格上;它们散布在整个空间中。一种寻找相互作用对的常用方法是链式网格算法,它将空间划分为一个单元格网格,并将每个粒子分配到一个单元格中。

为了计算力,分配给某个单元格的一组线程需要访问该单元格内粒子的位置,以及相邻单元格中粒子的位置。如果粒子在内存中以某种任意顺序存储,那么负责某个单元格的线程将在内存中到处跳转以寻找其分配的数据——这是一个典型的非合并“gather”操作。

解决方案既巧妙又简单:在计算力之前,我们根据粒子所在的单元格​​对粒子进行排序​​。单元格 1 的所有粒子首先放置,然后是单元格 2 的所有粒子,依此类推。现在,当单元格 c 的线程需要它们的粒子数据时,它们都从一个单一、连续的内存块中读取。访问是完美合并的。通过对我们的数据施加一个与我们的计算结构相匹配的顺序,我们驯服了不规则性,并释放了巨大的性能增益。

编排流程:运动中的算法

有时,数据布局是固定的,或者算法本身的性质似乎违背了合并。在这里,合并原则激励我们重新设计算法的流程。

波的交响曲:三维 FFT

快速傅里叶变换 (FFT) 是科学的基石,应用于从信号处理到计算燃烧学中求解微分方程的各种领域。对一个数据立方体的三维 FFT 通常是沿着三个轴向的一系列一维 FFT 来计算的:首先沿 X 轴,然后是 Y 轴,最后是 Z 轴。

如果数据存储时 X 轴作为变化最快的维度(在内存中是连续的),那么第一遍的一维 FFT 将会是完美合并的。但是当我们转到 Y 轴时,我们正在以一个大的步长访问数据,跳过整行的内存。Z 轴的情况更糟。

解决方案是一场计算的芭蕾。在 X 轴遍之后,我们执行一个​​原地数据转置​​。我们在内存中重新排列整个三维数据立方体,使得 Y 维度成为新的连续维度。然后我们执行 Y 轴遍,现在它是完美合并的。接着,我们再次转置,使 Z 维度为最后一遍变得连续。这是一个令人惊叹的例子,动态地重塑数据以满足硬件对顺序的需求,将一个跨步访问的噩梦变成一个合并访问的梦想。

建墙亦为拆墙:区域分解

这种转置数据的思想在另一个情境中以微缩形式出现:大规模并行模拟。当计算流体动力学或声学中的问题对于单个 GPU 来说太大时,它会被分解成更小的子域,每个子域由不同的处理器处理。为了正确计算,每个子域都需要来自其邻居的“光环”层或“幽灵”层数据。这些数据必须被打包成一个连续的缓冲区并通过网络发送。

为与内存连续维度对齐的面(比如 ±x\pm x±x 面)打包光环数据是容易且合并的。但 ±y\pm y±y 和 ±z\pm z±z 面呢?从这些面读取数据涉及跨内存的步进访问。解决方案再次是局部转置。包含光环数据的一个小数据块使用高效、合并的读取沿 xxx 维度加载到 GPU 极其快速的片上共享内存中。然后,从这个快速的本地内存中,它以转置的顺序写出到发送缓冲区,创建一个连续的消息。这种微小的、动态的重组确保了从全局内存的读取和通信缓冲区的准备都尽可能高效。

俯瞰全局:抽象层面上的合并

最后,让我们退后一步,看看这个底层原则如何影响高层算法的选择。

能力的层级:BLAS 与计算强度

在科学计算中,我们常说操作的层级:BLAS-1(向量-向量)、BLAS-2(矩阵-向量)和 BLAS-3(矩阵-矩阵)。为什么 BLAS-3 操作在现代硬件上效率如此之高?答案是计算强度——浮点运算次数与从内存移动的数据字节数之比。BLAS-3 操作,如两个矩阵相乘,可以为它们加载的每一块数据执行大量的计算。

只有当数据访问可以以块状方式结构化,从而允许完美的内存合并和在快速本地缓存中的数据重用时,才能实现这种高计算强度。当我们在不同的高层算法(如 LU 分解)之间进行选择时,我们实际上是在不同的内存访问模式之间进行选择。一个“左视”或基于内积的算法主要由效率较低的 BLAS-1 和 BLAS-2 操作主导。然而,一个“右视”或基于外积的算法可以被结构化,使其绝大部分工作被转化为一个大型的 BLAS-3 矩阵-矩阵乘法。这就是为什么它在 GPU 上性能优越得多的原因。对这种形式的偏好是底层对合并内存访问需求的一个直接、高层次的结果。

一个统一的原则

我们的旅程结束了。我们从一个为一组计算线程制定的简单顺序规则开始。我们看到这条规则决定了矩阵和深度学习张量中数据的静态布局。我们看到它通过排序和分区,激发了驯服稀疏和不规则数据野性的巧妙方案。我们看到它在三维 FFT 和光环交换等算法中编排了一场优美的数据转置之舞。最后,我们看到它在算法设计的最高层次上显现出来,偏爱那些相对于内存需求而言计算丰富的方​​法。

合并内存访问的原则教给我们一个至关重要的教训:高性能计算不仅仅是关于原始的数字运算速度。它是关于数据优雅而高效的编排。它揭示了硬件架构和软件设计之间深刻的统一性,展示了一个源于硅和导线物理学的单一约束,如何向上层层涟漪,塑造我们用以理解世界的工具。