每一个程序员都应该了解的内存知识-Part8

未来技术

注:本文由 Kimi AI 翻译

8 即将到来的技术

在前面关于多处理器处理的部分,我们已经看到,如果 CPU 或核心的数量增加,可能会出现显著的性能问题。但这正是未来所期望的。处理器将拥有越来越多的核心,程序必须越来越并行化,以利用 CPU 增加的潜力,因为单核性能的提高速度将不会像过去那样快。

8.1 原子操作的问题

传统上,同步对共享数据结构的访问有两种方式:

  • 通过互斥,通常使用系统运行时的功能来实现;
  • 通过使用无锁数据结构。

无锁数据结构的问题在于,处理器必须提供可以原子执行整个操作的原语。这种支持是有限的。在大多数架构上,支持仅限于原子地读取和写入一个字。实现这一点有两种基本方式(见第 6.4.2 节):

  • 使用原子比较和交换(CAS)操作;
  • 使用加载锁定/存储条件(LL/SC)对。

可以很容易地看出如何使用 LL/SC 指令实现 CAS 操作。这使得 CAS 操作成为大多数原子操作和无锁数据结构的构建块。

一些处理器,特别是 x86 和 x86-64 架构,提供了更精细的原子操作集。它们中的许多是针对特定目的的 CAS 操作的优化。例如,原子地将一个值添加到内存位置可以使用 CAS 和 LL/SC 操作实现,但 x86/x86-64 处理器对原子增量的原生支持更快。程序员需要知道这些操作,以及编程时可用的内建函数,但这并不是什么新鲜事。

这两个架构的非凡扩展是它们具有双字 CAS(DCAS)操作。这对于某些应用程序很重要,但并非全部(见 [dcas])。作为 DCAS 如何使用的示例,让我们尝试编写一个基于锁自由数组的栈/LIFO 数据结构。使用 gcc 的内建函数的第一次尝试可以在图 8.1 中看到。


图 8.1:非线程安全的 LIFO

这段代码显然不是线程安全的。不同线程中的并发访问将修改全局变量 top,而不考虑其他线程的修改。可能会丢失元素,或者删除的元素可能会神奇地重新出现。可以使用互斥,但这里我们只尝试使用原子操作。

修复问题的第一次尝试是在安装或删除列表元素时使用 CAS 操作。生成的代码看起来像图 8.2。


图 8.2:使用 CAS 的 LIFO

乍一看,这看起来像是一个可行的解决方案。top 只有在与 LIFO 顶部的元素匹配时才永远不会被修改。但我们必须考虑所有级别的并发。可能会在最糟糕的时刻调度另一个线程来处理数据结构。这里一个这样的情况是所谓的 ABA 问题。考虑一下,如果第二个线程在 pop 中的 CAS 操作之前被调度,并执行以下操作:

  1. l = pop()
  2. push(newelem)
  3. push(l)

这一系列操作的最终效果是,LIFO 的前一个顶部元素回到了顶部,但第二个元素是不同的。回到第一个线程,因为顶部元素没有改变,CAS 操作将成功。但 res->c 的值不是正确的值。它是指向原始 LIFO 的第二个元素的指针,而不是 newelem。结果是这个新元素丢失了。

在文献 [lockfree] 中,你可以找到使用一些处理器上发现的特性来解决这个问题的建议。具体来说,这是关于 x86 和 x86-64 处理器执行 DCAS 操作的能力。这在图 8.3 中代码的第三个版本中使用。


图 8.3:使用双字 CAS 的 LIFO

与其他两个示例不同,这是(目前)伪代码,因为 gcc 不理解 CAS 内建函数中结构的使用。无论如何,示例应该足够理解方法。在 LIFO 顶部的指针上添加了一个代数计数器。由于它在每次操作中都被更改,pushpop,上述描述的 ABA 问题不再是问题。当第一个线程通过实际交换 top 指针来恢复其工作时,代数计数器已经增加了三次。CAS 操作将失败,在循环的下一轮中,确定 LIFO 的正确第一个和第二个元素,并且 LIFO 不会被破坏。瞧。

这真的是解决方案吗?[lockfree] 的作者们肯定让它听起来像是,而且,值得赞扬的是,应该提到,可以构建数据结构以允许使用上述代码。但是,一般来说,这种方法和前一种方法一样注定要失败。我们仍然有并发问题,只是在不同的地方。假设一个线程执行 pop 并在测试 old.top == NULL 后被中断。现在,第二个线程使用 pop 并获得 LIFO 的前一个第一个元素的所有权。它可以对它做任何事情,包括更改所有值,或者在动态分配的元素的情况下,释放内存。

现在,第一个线程恢复。old 变量仍然填充了 LIFO 的前一个顶部。更具体地说,top 成员指向第二个线程弹出的元素。在 new.top = old.top->c 中,第一个线程解引用了元素中的指针。但是,这个指针引用的元素可能已经被释放。该地址空间的部分可能无法访问,进程可能会崩溃。这不能被允许用于通用数据类型实现。任何对这个问题的修复都是极其昂贵的:内存永远不能被释放,或者至少在释放内存之前必须验证没有线程正在引用内存。鉴于无锁数据结构应该更快、更并发,这些额外的要求完全破坏了任何优势。在支持它的语言中,通过垃圾回收处理内存可以解决问题,但这也有其代价。

对于更复杂的数据结构,情况通常更糟。上述论文还描述了一个 FIFO 实现(在后续论文中有改进)。但这段代码有所有相同的问题。因为现有的硬件(x86、x86-64)上的 CAS 操作仅限于修改内存中连续的两个单词,它们在其他常见情况下根本没有帮助。例如,原子地在双向链表中的任何位置添加或删除元素是不可能的。{ _作为边注,IA-64 的开发人员没有包括这个功能。他们允许比较两个单词,但只替换一个。}

问题是通常涉及一个以上的内存地址,只有当这些地址的值同时没有被并发更改时,整个操作才能成功。这是数据库处理中众所周知的概念,这正是解决这一困境最有希望的提议之一的来源。

8.2 事务内存

在他们开创性的 1993 年论文 [transactmem] 中,Herlihy 和 Moss 提出在硬件中实现内存操作的事务,因为软件本身无法有效处理这个问题。当时,Digital Equipment Corporation 已经在他们的高端硬件上与可扩展性问题作斗争,这些硬件配备了几十个处理器。该原理与数据库事务相同:事务的结果要么一次性全部可见,要么事务被中止,所有值保持不变。

这是内存发挥作用的地方,也是为什么上一节费心开发使用原子操作的算法的原因。事务内存旨在在许多情况下替代并扩展原子操作,特别是对于无锁数据结构。将事务系统集成到处理器中听起来是一件非常复杂的事情,但实际上,大多数处理器已经在某种程度上拥有了一些类似的东西。

一些处理器实现的 LL/SC 操作形成了一个事务。SC 指令根据内存位置是否被触碰来中止或提交事务。事务内存是这个概念的扩展。现在,不仅仅是一个简单的指令对,多个指令参与到事务中。为了理解这是如何工作的,首先看看如何实现 LL/SC 指令是值得的。{ _这并不意味着它实际上是这样实现的。}

8.2.1 加载锁定/存储条件实现

如果发出 LL 指令,内存位置的值将被加载到寄存器中。作为该操作的一部分,该值被加载到 L1d 中。SC 指令后来只有在该值没有被篡改时才能成功。处理器如何检测到这一点?回顾一下图 3.18 中 MESI 协议的描述,应该可以明显看出答案。如果另一个处理器更改了内存位置的值,第一个处理器 L1d 中的值副本必须被撤销。当 SC 指令在第一个处理器上执行时,它将发现它必须再次将该值加载到 L1d 中。这是处理器必须已经检测到的。

还有一些细节需要解决,关于上下文切换(在同一处理器上可能的修改)和意外地在另一个处理器上写入后重新加载缓存行。这不是策略(上下文切换时的缓存冲洗)和额外的标志,或 LL/SC 指令的单独缓存行所不能解决的。总的来说,LL/SC 实现几乎免费地随着像 MESI 这样的缓存一致性协议的实现而实现。

8.2.2 事务内存操作

为了使事务内存普遍有用,事务不能在第一个存储指令后就完成。相反,实现应该允许一定数量的加载和存储操作;这意味着我们需要单独的提交和中止指令。一会儿我们将看到,我们还需要一个更多的指令,允许检查当前事务的状态,以及它是否已经被中止或不是。

我们需要实现三种不同的内存操作:

  • 读取内存
  • 读取稍后将被写入的内存
  • 写入内存

在查看 MESI 协议时,应该清楚这种特殊的第二种类型的读取操作如何有用。正常的读取可以由处于 ‘E’ 和 ‘S’ 状态的缓存行满足。第二种类型的读取操作需要处于 ‘E’ 状态的缓存行。为什么需要第二种类型的内存读取,可以从以下讨论中窥见一斑,但对于更完整的描述,感兴趣的读者应参考有关事务内存的文献,从 [transactmem] 开始。

此外,我们需要事务处理,主要由我们已经熟悉的提交和中止操作组成。然而,还有另一个操作,理论上是可选的,但对于使用事务内存编写健壮程序是必需的。这条指令允许线程测试事务是否仍然在正确的轨道上,并且是否可以稍后提交,或者事务是否已经失败,无论如何都将被中止。

在我们讨论这些操作实际上是如何与 CPU 缓存和总线操作交互之前,让我们先看看一些使用事务内存的实际代码。这将有助于理解本节的其余部分。

8.2.3 使用事务内存的示例代码

对于示例,我们再次回顾我们的运行示例,并显示一个使用事务内存实现的 LIFO。


图 8.4:使用事务内存的 LIFO

这段代码看起来与非线程安全的代码非常相似,这是一个额外的好处,因为它使得使用事务内存编写代码更容易。代码的新部分是 LTXSTCOMMITVALIDATE 操作。这四个操作是请求访问事务内存的方式。实际上还有一个更多的操作 LT,这里没有使用。LT 请求非排他性读取访问,LTX 请求排他性读取访问,ST 是将数据存储到事务内存中。VALIDATE 操作是检查事务是否仍在提交轨道上的操作。如果此事务仍然可以,它返回 true。如果事务已经被标记为中止,它将被实际中止,并且下一个事务内存指令将开始一个新的事务。因此,代码在事务仍在进行的情况下使用一个新的 if 块。

COMMIT 操作完成事务;如果事务成功完成,该操作返回 true。这意味着程序的这部分已经完成,线程可以继续前进。如果操作返回 false 值,这通常意味着整个代码序列必须重复。这里外层的 while 循环正在执行这个操作。然而,这并不是绝对必要的,在某些情况下放弃工作是正确的做法。

关于 LTLTXST 操作的有趣之处在于,它们可能会失败,但不会以任何直接的方式发出失败信号。程序可以通过 VALIDATECOMMIT 操作请求此信息。对于加载操作,这可能意味着实际上加载到寄存器中的值可能是无效的;这就是为什么在上面的示例中,在解引用指针之前使用 VALIDATE 是一个明智的选择。在下一节中,我们将看到为什么这是实现的一个明智选择。一旦事务内存实际上广泛可用,处理器可能会实现不同的东西。[transactmem] 的结果表明了我们在这里描述的内容。

push 函数可以总结如下:通过读取指向列表头部的指针来开始事务。读取请求排他性所有权,因为稍后在函数中这个变量将被写入。如果另一个线程已经启动了一个事务,加载将失败,并将尚未出生的事务标记为中止;在这种情况下,实际加载的值可能是垃圾。这个值,不管其状态如何,都存储在新列表成员的 next 字段中。这是可以的,因为此成员尚未使用,并且由一个线程访问。然后,将指向列表头部的指针分配给指向新元素的指针。如果事务仍然可以,这个写入可以成功。这是正常情况,只有在一个线程使用一些除提供的 pushpop 函数之外的代码来访问此指针时才会失败。

如果事务在执行 ST 时已经被中止,就什么都不做。最后,线程尝试提交事务。如果成功,工作就完成了;其他线程现在可以开始它们的事务。如果事务失败,必须从一开始就重复。然而,在这样做之前,最好插入一个延迟。如果这样做,线程可能会在一个忙循环中运行(浪费能源,使 CPU 过热)。

pop 函数稍微复杂一些。它也以读取包含列表头部的变量开始,请求排他性所有权。然后,代码立即检查 LTX 操作是否成功。如果没有,这一轮除了延迟下一轮之外什么也不做。如果 top 指针成功读取,这意味着其状态良好;我们现在可以解引用指针。记住,这正是使用原子操作的代码的问题;使用事务内存,这种情况可以毫无问题地处理。接下来的 ST 操作只在 LIFO 不为空时执行,就像原始的非线程安全代码一样。最后,事务被提交。如果成功,函数返回旧的指向头部的指针;否则,我们延迟并重试。这段代码的一个棘手的部分是要记住 VALIDATE 操作如果事务已经失败,则会中止事务。下一个事务内存操作将开始一个新的事务,因此我们必须跳过函数中剩余的代码。

延迟代码的工作方式将是我们看到事务内存在硬件中实现时的一件事。如果做得不好,系统性能可能会受到显著影响。

8.2.4 事务内存的总线协议

现在我们已经看到了事务内存背后的基本原理,我们可以深入到实现细节中。请注意,这不是基于实际硬件。它基于事务内存的原始设计和对缓存一致性协议的了解。一些细节被省略了,但它仍然应该能够让人洞察性能特性。

事务内存实际上并不是作为单独的内存实现的;这没有任何意义,因为任何位置的线程地址空间都想要事务。相反,它在第一级缓存中实现。实现理论上可以发生在正常的 L1d 中,但正如 [transactmem] 所指出的,这不是一个好主意。我们更有可能看到事务缓存在 L1d 旁边实现。所有访问将以与 L1d 相同的方式使用更高级别的缓存。事务缓存可能比 L1d 小得多。如果它是全关联的,其大小由事务可以包含的操作数量决定。实现可能会对架构和/或特定处理器版本有限制。可以很容易地想象一个具有 16 个元素甚至更少的事务缓存。在上面的示例中,我们只需要一个单一的内存位置;具有更大事务工作集的算法变得非常复杂。可能会看到支持同时活跃多个事务的处理器。缓存中的元素数量然后乘以,但它仍然足够小,可以全关联。

事务缓存和 L1d 是排他性的。这意味着缓存行最多在一个缓存中,而不是同时在两个缓存中。事务缓存中的每个插槽在任何时候都处于四种 MESI 协议状态之一。除此之外,插槽还有一个事务状态。状态如下(名称根据 [transactmem]):

EMPTY 缓存插槽不包含数据。MESI 状态总是 ‘I’。

NORMAL 缓存插槽包含已提交的数据。数据也可以存在于 L1d 中。MESI 状态可以是 ‘M’、’E’ 和 ‘S’。允许 ‘M’ 状态的事实意味着事务提交不会强制将数据写入主内存(除非内存区域被声明为未缓存或写通)。这可以显著提高性能。

XABORT 缓存插槽包含在中止时丢弃的数据。这显然是 XCOMMIT 的相反。所有在事务期间创建的数据都保留在事务缓存中,在提交之前不会写入主内存。这限制了最大事务大小,但这意味着,除了事务缓存之外,没有其他内存需要了解单个内存位置的 XCOMMIT/XABORT 二元性。

XCOMMIT 缓存插槽包含在提交时丢弃的数据。这是处理器可以实现的可能的优化。如果使用事务操作更改内存位置,不能只是丢弃旧内容:如果事务失败,则需要恢复旧内容。MESI 状态与 XABORT 相同。与 XABORT 的一个区别是,如果事务缓存已满,任何处于 ‘M’ 状态的 XCOMMIT 条目都可以写回内存,然后对于所有状态,都被丢弃。

LT 操作开始时,处理器在缓存中分配两个插槽。首先寻找操作地址的正常插槽,即缓存命中。如果找到这样的条目,找到第二个插槽,复制值,一个条目被标记为 XABORT,另一个被标记为 XCOMMIT。

如果地址尚未缓存在 EMPTY 缓存插槽中。如果没有找到 EMPTY 插槽,寻找 NORMAL 插槽。如果 MESI 状态是 ‘M’,则必须将旧内容刷新到内存中。如果没有找到 NORMAL 插槽,可能会牺牲 XCOMMIT 条目。这很可能是一个实现细节。事务的最大大小由事务缓存的大小决定,并且由于每个事务中的每个操作所需的插槽数量是固定的,因此在必须驱逐 XCOMMIT 条目之前,可以限制事务的数量。

如果事务缓存中找不到地址,总线上会发出 T_READ 请求。这就像正常的 READ 总线请求,但它表明这是为了事务缓存。就像正常的 READ 请求一样,所有其他处理器的缓存首先有机会响应。如果没有响应,值从主内存中读取。MESI 协议决定新缓存行的状态是 ‘E’ 还是 ‘S’。T_READ 和 READ 之间的差异在于,当缓存行当前由另一个处理器或核心上的活动事务使用时。在这种情况下,T_READ 操作简单地失败,不传输数据。生成 T_READ 总线请求的事务被标记为失败,并且在操作中使用的值(通常是简单的寄存器加载)是未定义的。回顾示例,我们可以看到,如果正确使用事务内存操作,这种行为不会引起问题。在事务中加载的值被使用之前,必须通过 VALIDATE 进行验证。这几乎不会增加额外的负担。正如我们在尝试使用原子操作创建 FIFO 实现时所看到的,我们添加的检查是使无锁代码工作所缺少的功能。

LTX 操作与 LT 几乎相同。唯一的区别是总线操作是 T_RFO 而不是 T_READ。T_RFO 像正常的 RFO 总线请求一样,请求缓存行的排他性所有权。结果的缓存行状态是 ‘E’。像 T_READ 总线请求一样,T_RFO 也可能失败,在这种情况下,使用的值也是未定义的。如果缓存行已经在本地事务缓存中处于 ‘M’ 或 ‘E’ 状态,则无需执行任何操作。如果本地事务缓存中的状态是 ‘S’,则必须发出总线请求以使所有其他副本失效。

ST 操作与 LTX 类似。值首先在本地事务缓存中独家提供。然后,ST 操作在缓存中的第二个插槽中制作该值的副本,并将条目标记为 XCOMMIT。最后,另一个插槽被标记为 XABORT,新值被写入其中。如果事务已经被中止,或者由于隐式的 LTX 失败而新建中止,什么都不会被写入。

VALIDATECOMMIT 操作都不会自动和隐式地创建总线操作。这是事务内存相对于原子操作的巨大优势。使用原子操作,通过将更改的值写回主内存来实现并发。如果你已经阅读了本文到目前为止,你应该知道这是多么昂贵。使用事务内存,不会强制访问主内存。如果缓存没有 EMPTY 插槽,当前内容必须被驱逐,对于处于 ‘M’ 状态的插槽,内容必须被写回主内存。这与常规缓存没有什么不同,并且回写可以在没有特殊的原子性保证的情况下执行。如果缓存大小足够,内容可以存活很长时间。如果对同一内存位置重复执行事务,性能提升可能是惊人的,因为在一种情况下,我们每一轮都有一到两次主内存访问,而对于事务内存,所有访问都命中事务缓存,这和 L1d 一样快。

所有 VALIDATECOMMIT 操作对已中止的事务所做的就是将标记为 XABORT 的缓存插槽标记为空,并将 XCOMMIT 插槽标记为 NORMAL。类似地,当 COMMIT 成功完成事务时,XCOMMIT 插槽被标记为空,XABORT 插槽被标记为 NORMAL。这些是对事务缓存的非常快速的操作。没有向其他处理器发出显式通知,这些处理器想要执行事务;那些处理器只需要继续尝试。有效地做到这一点是另一个问题。在上面的示例代码中,我们在适当的地方简单地有 ...delay...。我们可能会看到实际的处理器支持以有用的方式延迟。

总结一下,事务内存操作只在启动新事务时和向仍然成功的事务添加尚未在事务缓存中的新缓存行时才会引起总线操作。已中止的事务的操作不会引起总线操作。不会由于多个线程尝试使用相同的内存而导致缓存行乒乓。

8.2.5 其他考虑

在第 6.4.2 节中,我们已经讨论了如何在某些情况下使用 x86 和 x86-64 上可用的 lock 前缀来避免编写原子操作。然而,所提出的技巧在有多个线程使用时会失败,这些线程不争夺相同的内存。在这种情况下,原子操作被不必要地使用。有了事务内存,这个问题就消失了。昂贵的 RFO 总线请求只在内存在不同 CPU 上同时或连续使用时发出;这只在需要时出现。几乎不可能做得更好。

细心的读者可能会对延迟感到好奇。最坏的情况是什么?如果拥有活动事务的线程被调度出去,或者它接收到信号并且可能被终止,或者决定使用 siglongjmp 跳转到外层范围怎么办?答案是:事务将被中止。每当线程进行系统调用或接收到信号时(即,发生环级变化),都可以中止事务。当执行系统调用或处理信号时,中止事务也可能是操作系统职责的一部分。我们必须等待实现变得可用,以了解实际做了什么。

这里应该讨论的事务内存的最后一个方面是人们甚至今天可能想要考虑的事情。事务缓存和其他缓存一样,操作缓存行。由于事务缓存是排他性缓存,使用相同的缓存行进行事务和非事务操作将是一个问题。因此,重要的是:

  • 将非事务数据从缓存行移开
  • 为在单独事务中使用的数据保留单独的缓存行

第一点并不新鲜,今天原子操作的努力也会得到回报。第二点更成问题,因为今天的物体几乎不会对齐到缓存行,因为相关成本很高。如果使用原子操作修改的数据与数据在同一缓存行上,那么就需要一个更少的缓存行。这并不适用于互斥(其中互斥对象应该总是有自己的缓存行),但肯定可以找到原子操作与其他数据结合的案例。有了事务内存,将缓存行用于两个目的可能最有可能是致命的。对数据的每个正常访问 { _从相关缓存行。对任意其他缓存行的访问不会影响事务。} 将从事务缓存中移除缓存行,在此过程中中止事务。数据对象的缓存对齐在未来不仅是性能问题,也是正确性问题。

可能事务内存实现将使用更精确的会计,并且因此不会受到对作为事务一部分的缓存行上的数据进行正常访问的影响。这需要更多的努力,因为 MESI 协议信息不再足够。

8.3 增加延迟

关于内存技术未来发展的一件事几乎可以肯定:延迟将继续上升。我们已经在第 2.2.4 节中讨论过,即将到来的 DDR3 内存技术将比当前的 DDR2 技术具有更高的延迟。如果 FB-DRAM 部署,它也可能具有更高的延迟,特别是当 FB-DRAM 模块串联时。传递请求和结果并不免费。

延迟的第二个来源是 NUMA 的日益增加的使用。AMD 的 Opterons 如果它们有超过一个处理器,就是 NUMA 机器。有一些本地内存连接到具有自己的内存控制器的 CPU,但在 SMP 主板上,其余的内存必须通过 Hypertransport 总线访问。由于每个处理器的带宽限制和要求保持(例如)多个 10Gb/s 以太网端口忙碌,多插座主板不会消失,即使每个插座的核心数量增加。

延迟的第三个来源是协处理器。我们认为在 1990 年代初,商品处理器的数学协处理器不再必要后,我们已经摆脱了它们,但它们正在卷土重来。Intel 的 Geneseo 和 AMD 的 Torrenza 是平台的扩展,允许第三方硬件开发商将他们的产品集成到主板中。也就是说,协处理器不必坐在 PCIe 卡上,而是更接近 CPU 的位置。这为他们提供了更多的带宽。

IBM 通过 Cell CPU 采取了不同的路线(尽管仍然可以扩展 Intel 和 AMD 的功能)。Cell CPU 除了 PowerPC 核心外,还包括 8 个协同处理单元(SPUs),这些主要是用于浮点计算的专用处理器。

协处理器和 SPUs 的共同点是,它们很可能具有比真实处理器更慢的内存逻辑。这在一定程度上是由于必要的简化:所有的缓存处理、预取等都很复杂,特别是当也需要缓存一致性时。高性能程序将越来越多地依赖于协处理器,因为性能差异可能是戏剧性的。Cell CPU 的理论峰值性能为 210 GFLOPS,而高端 CPU 为 50-60 GFLOPS。今天使用的图形处理单元(GPU,显卡上的处理器)甚至达到了更高的数字(超过 500 GFLOPS),这些可能不需要太多努力就能集成到 Geneseo/Torrenza 系统中。

由于所有这些发展,程序员必须得出结论,预取将变得越来越重要。对于协处理器,它将绝对至关重要。对于 CPU,特别是随着越来越多的核心,有必要始终让 FSB 保持忙碌,而不是批量堆积请求。这需要通过有效使用预取指令,尽可能多地让 CPU 了解未来的流量。

8.4 向量操作

当今主流处理器中的多媒体扩展仅以有限的方式实现向量操作。向量指令的特点是一起执行大量操作。与标量操作相比,可以这样评价多媒体指令,但与像 Cray-1 或 IBM 3090 这样的机器的向量计算机或向量单元相去甚远。

为了补偿每条指令执行的操作数量有限(在大多数机器上是四个 float 或两个 double 操作),必须更频繁地执行周围的循环。第 9.1 节中的示例清楚地显示了这一点,每个缓存行需要 SM 次迭代。

有了更宽的向量寄存器和操作,可以减少循环迭代次数。这不仅仅是改进了指令解码等;我们更感兴趣的是内存效应。通过单条指令加载或存储更多数据,处理器对应用程序的内存使用有了更好的了解,不必尝试从单个指令的行为中拼凑信息。此外,提供不影响缓存的加载或存储指令变得更加有用。在 x86 CPU 中,使用 16 字节宽的 SSE 寄存器加载,使用非缓存加载是一个坏主意,因为后来对同一缓存行的访问必须再次从内存中加载数据(在缓存未命中的情况下)。另一方面,如果向量寄存器足够宽以容纳一个或多个缓存行,非缓存加载或存储就不会产生负面影响。对不适合缓存的数据集执行操作变得更加实用。

拥有大的向量寄存器并不一定意味着指令的延迟会增加;向量指令不必等到所有数据都被读取或存储。向量单元可以在已经读取的数据上开始,如果它能够识别代码流。这意味着,例如,如果要加载向量寄存器,然后乘以标量,CPU 可以在向量的第一部分加载后立即开始乘法操作。这只是一个向量单元的复杂性问题。

从理论上讲,向量寄存器可以变得非常宽,程序也可以潜在地这样设计。在实践中,由于处理器在多进程和多线程操作系统中使用,向量寄存器大小受到限制。因此,上下文切换时间(包括存储和加载寄存器值)是重要的。

有了更宽的向量寄存器,就存在操作的输入和输出数据不能在内存中顺序布局的问题。这可能是因为矩阵是稀疏的,矩阵是按列而不是按行访问的,还有很多其他因素。向量单元为此提供了访问非顺序模式内存的方法。单个向量加载或存储可以参数化,并指示从地址空间中的许多不同位置加载数据。使用今天的多媒体指令,这根本不可能。值必须逐个显式加载,然后费心地组合成一个向量寄存器。

旧日的向量单元有不同的模式允许最有用访问模式:

  • 使用 _步进_,程序可以指定两个相邻向量元素之间的间隙有多大。所有元素之间的间隙必须相同,但这将很容易地允许在一条指令中将矩阵的一列读入向量寄存器,而不是每行一条指令。
  • 使用间接,可以创建任意访问模式。加载或存储指令将接收一个指向数组的指针,该数组包含要加载的实际内存位置的地址或偏移量。

目前尚不清楚我们是否会在未来的主流处理器版本中看到真正的向量操作的复兴。也许这项工作将被归属于协处理器。无论如何,如果我们能够获得向量操作的访问权限,正确组织执行此类操作的代码就更加重要。代码应该是自包含的和可替换的,接口应该足够通用,以便有效地应用向量操作。例如,接口应该允许添加整个矩阵而不是操作行、列或甚至元素组。构建块越大,使用向量操作的机会就越好。

在 [vectorops] 中,作者为向量操作的复兴提出了热情的呼吁。他们指出了许多优点,并试图揭穿各种神话。然而,他们描绘了一个过于简单化的形象。如上所述,大型寄存器集意味着高上下文切换时间,这在通用操作系统中必须避免。看看 IA-64 处理器在涉及上下文切换密集型操作时的问题。向量操作的长时间执行也是一个问题,如果涉及中断。如果引发中断,处理器必须停止当前工作并开始处理中断。之后,它必须恢复执行被中断的代码。中断工作中间的指令通常是一个大问题;这并非不可能,但很复杂。对于长时间运行的指令,必须这样做,或者指令必须以可重启的方式实现,否则中断响应时间太长。后者是不可接受的。

向量单元在内存访问对齐方面也相对宽容,这影响了开发的算法。一些今天的处理器(特别是 RISC 处理器)要求严格的对齐,因此扩展到完整的向量操作并不平凡。拥有向量操作有很大的潜在好处,特别是当支持步进和间接时,因此我们可以期待在未来看到这个功能。

附录和参考文献

附录和参考文献页面包含了许多基准测试程序的源代码、有关 oprofile 的更多信息、对内存类型的一些讨论、对 libNUMA 的介绍以及参考文献。

参考资料


每一个程序员都应该了解的内存知识-Part8
https://ysc2.github.io/ysc2.github.io/2024/04/29/每一个程序员都应该了解的内存知识-Part8/
作者
Ysc
发布于
2024年4月29日
许可协议