如果寄存器如此之快,为什么我们没有更多的寄存器?

95

在32位计算机中,我们有8个“通用”寄存器。使用64位系统,寄存器数量翻倍了,但这似乎与64位本身没有关系。
现在,由于寄存器非常快(无内存访问),为什么不自然地增加更多的寄存器呢?难道CPU制造商不应该尽可能多地将寄存器集成到CPU中吗?为什么我们只有现在的数量,有什么逻辑限制呢?


CPU和GPU主要通过缓存和大规模多线程来隐藏延迟。因此,CPU具有(或需要)少量寄存器,而GPU具有数万个寄存器。请参阅我的GPU寄存器文件调查论文,其中讨论了所有这些权衡和因素。 - user984260
4个回答

126

为什么不能拥有大量寄存器呢?原因有很多:

  • 它们与大多数流水线阶段高度相关。首先,您需要追踪其寿命周期,并将结果转发回前面的阶段。复杂性很快变得难以处理,并且涉及的电线数量(从字面上)也以相同的速度增长。这在面积上很昂贵,最终意味着在一定程度上它会影响功率、价格和性能。
  • 它占用指令编码空间。16个寄存器占用4个位用于源和目的地,如果您有3操作数指令(例如ARM),则需要再使用4个位。这样就会占据太多的指令集编码空间,只是为了指定寄存器。这最终会影响解码、代码大小和复杂性。
  • 有更好的方法来实现同样的结果...

如今我们确实拥有很多寄存器 - 只是它们没有被显式编程。我们有“寄存器重命名”。虽然您只能访问少量(8-32个)寄存器,但它们实际上由一个更大的集合支持(例如64-256个)。 CPU然后跟踪每个寄存器的可见性,并将它们分配给重命名集。例如,在ARM中,您可以多次加载、修改和存储一个寄存器,并且每个操作都会独立执行,具体取决于缓存未命中等因素。

ldr r0, [r4]
add r0, r0, #1
str r0, [r4]
ldr r0, [r5]
add r0, r0, #1
str r0, [r5]

Cortex A9内核会进行寄存器重命名,因此对“r0”的第一次加载实际上是加载到一个重命名的虚拟寄存器 - 让我们称其为“v0”。加载、增量和存储都在“v0”上完成。同时,我们还对r0执行了一次加载/修改/存储操作,但由于这是完全独立的序列,所以它将被重命名为“v1”。假设由于缓存未命中而导致了从“r4”中指向的指针的加载延迟。没关系 - 我们不需要等待“r0”准备就绪。因为它已被重命名,我们可以使用“v1”(也映射到r0)运行下一个序列 - 也许这是一个缓存命中,我们刚刚获得了巨大的性能提升。

ldr v0, [v2]
add v0, v0, #1
str v0, [v2]
ldr v1, [v3]
add v1, v1, #1
str v1, [v3]
我认为现在的x86处理器有很多重命名寄存器(大约256个),这意味着每个指令都需要8位乘以2来表示源和目的地。这将大量增加内核所需的电线数量和大小。因此,大多数设计师已经选择了16-32个寄存器的甜点,对于乱序CPU设计,寄存器重命名是缓解问题的方法。
编辑:对于此问题,乱序执行和寄存器重命名的重要性。一旦你有了OOO,寄存器的数量就不那么重要了,因为它们只是“临时标签”,并被重命名为更大的虚拟寄存器集。您不希望数量太小,因为编写代码会变得困难。这对于x86-32来说是一个问题,因为有限的8个寄存器意味着很多临时变量最终要通过堆栈传递,并且内核需要额外的逻辑来转发读/写到内存。如果没有OOO,则通常是在讨论小内核,在这种情况下,大型寄存器集是性价比低劣的。
因此,对于大多数CPU类别,寄存器库大小有一个自然的最大值,最多达到32个结构化寄存器。 x86-32具有8个寄存器,这显然太小了。 ARM选择了16个寄存器,这是一个很好的折衷方案。如果有任何问题,32个寄存器也略微过多-您最终不需要最后10个左右。
这些都没有触及SSE和其他矢量浮点协处理器所获得的额外寄存器。这些作为额外集合是有意义的,因为它们独立于整数内核运行,并且不会呈指数级增长CPU的复杂性。

14
非常好的回答,我想再加入一个原因——拥有更多的寄存器会导致在上下文切换时需要更多时间将它们压入/弹出堆栈。这绝对不是主要问题,但是也需要考虑。 - Will A
8
不错的观点。然而,具有许多寄存器的体系结构有缓解这种成本的方法。ABI通常会保留大多数寄存器的被调用者保存,因此您只需要保存核心集。上下文切换通常足够昂贵,以至于与所有其他繁文缛节相比,额外的保存/恢复并不会花费太多成本。SPARC实际上通过将寄存器银行作为内存区域上的“窗口”来解决了这个问题,因此它可以在某种程度上进行扩展(这种做法有些牵强)。 - John Ripley
4
考虑到如此详尽的答案,我的想法被震撼了,我确实没有期望到这样。此外,感谢您对我们为什么不需要那么多命名寄存器的解释,这非常有趣!我非常喜欢阅读您的答案,因为我非常关注底层细节。 :) 我会再等一会儿才接受答案,因为你永远不知道,但我的+1是肯定的。 - Xeo
1
无论寄存器保存的责任在哪里,它所需的时间都是行政开销。好吧,上下文切换可能不是最常见的情况,但中断是。手写例程可以节省寄存器,但如果驱动程序是用C编写的,那么中断声明的函数将保存每个寄存器,调用isr,然后恢复所有保存的寄存器。IA-32具有与RISC体系结构的32+某些寄存器相比的15-20个寄存器的中断优势。 - Olof Forshell
1
回答非常好,但我不同意将“重命名”寄存器与“真实”的可寻址寄存器进行直接比较。在x86-32上,即使有256个内部寄存器,您也不能在任何单个执行点使用超过8个存储在寄存器中的临时值。基本上,寄存器重命名只是OOE的一个有趣的副产品,没有更多的东西。 - noop
正如noop所说:过少的架构寄存器会导致更多指令浪费在内存的溢出/重新加载上。有人发表了一篇论文,研究了指令计数的影响以及从8->16的开销减少效益远高于从16->32的效益。(请参见该论文,以及我在electronics.SE上对这个问题的重复评论)。 - Peter Cordes

13

我们确实拥有更多的寄存器

由于几乎每个指令都必须选择 1、2 或 3 个体系结构可见寄存器,增加它们的数量将使每个指令的代码大小增加几位,从而降低代码密度。它还增加了必须在线程状态中保存的上下文量以及部分保存在函数的激活记录 中。这些操作频繁发生。流水线互锁必须为每个寄存器检查记分牌,并且这具有二次时间和空间复杂度。也许最重要的原因是与已定义的指令集兼容。

但事实证明,感谢寄存器重命名,我们确实有很多可用的寄存器,而且我们甚至不需要保存它们。CPU 实际上具有许多寄存器集,并且在您的代码执行时自动在它们之间切换。它这样做纯粹是为了让您获得更多的寄存器。

示例:

load  r1, a  # x = a
store r1, x
load  r1, b  # y = b
store r1, y

在只有r0-r7的架构中,以下代码可能会被CPU自动重写为类似以下的形式:
load  r1, a
store r1, x
load  r10, b
store r10, y

在这种情况下,r10是一个隐藏的寄存器,它暂时替代了r1。CPU可以判断出第一次存储后r1的值不会再被使用。这允许第一个加载被延迟(即使在芯片上的缓存命中通常需要几个周期),而不需要延迟第二个加载或第二个存储。

函数序言只需要保存实际使用的调用保留寄存器。 (尽管不明智的编译器可能会保存/恢复过多的寄存器,而不是在函数中的其他位置接受溢出和重新加载,如果可选的寄存器太多。特别是在函数中有一个提前退出条件,在它到达可能使用那么多寄存器的代码之前,如果编译器没有缩小包装优化)而调用破坏的寄存器根本不需要被保存; 它们可以在调用之间和叶子函数内自由地用作临时空间。 - Peter Cordes
1
寄存器重命名允许有效地重复使用寄存器而不会出现错误依赖,但它并不完美。当您实际上想要在寄存器中同时“存活”超过16个不同的值时,它就无法帮助了,例如展开具有8或10个不同依赖链以让CPU找到指令级并行性。(这就是为什么AVX-512将FP /向量寄存器的数量增加到32个,因为FP代码更可能需要大量常量和/或使用多个累加器展开以隐藏FP延迟。) - Peter Cordes

3

他们一直在添加寄存器,但它们通常与特殊用途指令(例如 SIMD、SSE2 等)相关联,或者需要编译到特定的 CPU 架构中,这降低了可移植性。现有的指令通常使用特定的寄存器,并且如果其他寄存器可用,则无法利用它们。遗留指令集等。


1
为了增加一些有趣的信息,你会注意到拥有8个相同大小的寄存器可以使操作码与十六进制符号保持一致。例如指令push ax在x86上的操作码为0x50,最后一个寄存器di的操作码为0x57,而指令pop ax从0x58开始,一直到0x5F的pop di来完成第一个16进制数。每个大小有8个寄存器,十六进制的一致性得以维持。

2
在x86/64上,REX指令前缀使用更多的位扩展寄存器索引。 - Alexey Frunze

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接