【问题标题】:Are scaled-index addressing modes a good idea?缩放索引寻址模式是个好主意吗?
【发布时间】:2018-06-29 11:50:51
【问题描述】:

考虑以下代码:

void foo(int* __restrict__ a)
{
    int i; int val = 0;
    for (i = 0; i < 100; i++) {
        val = 2 * i;
        a[i] = val;
    }
}

这个complies(具有最大优化但没有展开或矢量化)到...

GCC 7.2:

foo(int*):
        xor     eax, eax
.L2:
        mov     DWORD PTR [rdi], eax
        add     eax, 2
        add     rdi, 4
        cmp     eax, 200
        jne     .L2
        rep ret

叮当声 5.0:

foo(int*): # @foo(int*)
  xor eax, eax
.LBB0_1: # =>This Inner Loop Header: Depth=1
  mov dword ptr [rdi + 2*rax], eax
  add rax, 2
  cmp rax, 200
  jne .LBB0_1
  ret

GCC 与 clang 方法的优缺点是什么?即一个额外的变量单独递增,而不是通过更复杂的寻址模式相乘?

注意事项:

  • 这个问题也与this one 相关,代码大致相同,但使用float 而不是int

【问题讨论】:

  • @harold:好的,解决了。
  • 当然不是乘法,而是移位。以前由专用 AGU 免费执行。如果更新的 CPU 使性能变得更差,我会感到惊讶。
  • @BoPersson:所以比例因子只能是 2 的幂?另外,如果您说,出于这个原因,最好以这种方式进行循环,请在回答中这样说。
  • 比例因子只能是 2、4 或 8,并且是专门为 C 等语言中的数组索引而设计的。我的经验可以追溯到有一个单独的地址生成单元并且这些计算已经完成的时候“在一边”,免费。我不知道有关当前 CPU 的足够详细信息,但会相信 Peter Cordes 的回答。 :-)
  • 比例因子也可以是 1 或 0,尽管 0 的作用不同。 1 仍然是一个刻度。

标签: gcc assembly clang x86-64 micro-optimization


【解决方案1】:

索引寻址(在加载和存储中,lea 仍然不同)有一些权衡,例如

  • 在许多 µarch 上,使用索引寻址的指令比不使用的指令延迟稍长。但通常吞吐量是更重要的考虑因素。
  • 在 Netburst 上,具有 SIB byte 的存储会生成额外的 µop,因此也可能会消耗吞吐量。无论您是否将 SIB 字节用于索引寻址,SIB 字节都会导致额外的 µop,但索引寻址总是会花费额外的 µop。它不适用于负载。
  • 在 Haswell/Broadwell(仍在 Skylake/Kabylake)上,具有索引地址的存储不能使用 port 7 生成地址,而是使用更通用的地址生成端口之一,从而降低了可用于加载的吞吐量。

因此,对于加载,如果它在某处保存添加,则使用索引寻址通常是好的(或不坏的),除非它们是依赖加载链的一部分。对于商店来说,使用索引寻址更危险。在示例代码中,它不应该有很大的不同。保存add 并不真正相关,ALU 指令不会成为瓶颈。端口 2 或 3 中发生的地址生成也无关紧要,因为没有负载。

【讨论】:

  • SKL 负载可以使用 port7 吗?我没有在任何地方读过。这将需要 port7 处理 uop 的加载数据部分,而不仅仅是地址(并将该地址写入内存顺序缓冲区)。
  • @PeterCordes 也许只是地址生成? (虽然我不知道那将如何工作)Wikichip 将其列为 AGU。 Agner Fogs 执行单元表将其列为“加载和存储地址生成”,而不仅仅是“存储地址生成”
  • 嗯,如果我能解决这个问题,我会检查性能计数器。但是负载绝对仍然是单个 uop,因此拥有一个不能完成整个工作的端口是没有意义的。但是我可以看到有一个加载/存储端口,它只处理没有符号或零扩展的标量整数加载,并且只有 base + disp 寻址模式。因此,它通常不必处理广播负载、vmovddup 或矢量负载。
  • 您能定义“SIB 字节”、“端口 7”或链接到定义吗?
  • 我写了a test,它有两个负载(简单寻址)和一个存储(复杂)。以./uarch-bench.sh --timer=libpfc --test-name=misc/port7 --extra-events=UOPS_DISPATCHED.PORT_2,UOPS_DISPATCHED.PORT_3,UOPS_DISPATCHED.PORT_4,UOPS_DISPATCHED.PORT_7 运行它,每次迭代我得到恰好 1.5 个周期(即,3 个操作在 2 个端口 2/3 AGU 上成为瓶颈)和端口 2、3、4 和 7 分别为 1.5、1.5、1.0、0.0 微秒,如如果 port7 不能承受任何负载,您会期望。
【解决方案2】:

是的,利用 x86 寻址模式的强大功能来节省 uops,以防索引不分层为更多额外的 uops,而不是执行指针增量所需的成本

(在许多情况下,展开和使用指针增量是一个胜利,因为英特尔 Sandybridge 系列上的 unlamination,但如果您不展开或仅使用 mov 加载而不是将内存操作数折叠到 ALU 操作中对于微融合,索引寻址模式通常在某些 CPU 上达到收支平衡,而在另一些 CPU 上胜出。)

如果你想在这里做出最佳选择,阅读和理解Micro fusion and addressing modes 是必不可少的。(请注意,IACA 弄错了,并没有模拟 Haswell,后来保留了一些微指令fused,所以你甚至不能通过让它为你做静态分析来检查你的工作。)


索引寻址模式通常很便宜。在最坏的情况下,它们会为前端(on Intel SnB-family CPUs in some situations)花费一个额外的微指令,和/或阻止存储地址微指令使用端口 7(仅支持基址 + 位移寻址模式)。有关英特尔在 Haswell 中添加的端口 7 上的 store-AGU 的更多信息,请参阅 Agner Fog's microarch pdfDavid Kanter's Haswell 文章。
在 Haswell+ 上,如果您需要循环在每个时钟上维持超过 2 个内存操作,请避免使用索引存储。

除了机器码编码中额外字节的代码大小成本之外,它们充其量是免费的。 (拥有索引寄存器需要在编码中使用 SIB(Scale Index Base)字节)。

在英特尔 Sandybridge 系列 CPU 上,与简单的[base + 0-2047] 寻址模式相比,通常唯一的损失是 1 个额外周期的负载使用延迟。

如果要在多条指令中使用索引寻址模式,通常只值得使用额外的指令来避免索引寻址模式。 (例如加载/修改/存储)。


如果您已经在使用 2 寄存器寻址模式,则可以免费扩展索引(至少在现代 CPU 上)。对于lea,Agner Fog 的表格将 AMD Ryzen 列为具有 2c 延迟,lea 具有 缩放-index 寻址模式(或 3 分量)的每个时钟吞吐量只有 2 个,否则为 1c 延迟和@ 987654330@ 吞吐量。例如lea rax, [rcx + rdx]lea rax, [rcx + 2*rdx] 快,但不足以值得使用额外的指令。)出于某种原因,Ryzen 也不喜欢 64 位模式下的 32 位目标。但最坏情况下的 LEA 仍然一点也不差。无论如何,大多数情况下与加载的地址模式选择无关,因为大多数 CPU(除了有序 Atom)在 ALU 上运行 LEA,而不是用于实际加载/存储的 AGU。

主要问题是一个寄存器未缩放(因此它可以是机器代码编码中的“基本”寄存器:[base + idx*scale + disp])或两个寄存器。请注意,由于 Intel 的微融合限制,[disp32 + idx*scale](例如索引静态数组)是一种索引寻址模式。


这两个函数都不是完全最优的(即使不考虑展开或矢量化),但 clang 的看起来非常接近。

clang 唯一可以做得更好的是通过避免带有 add eax, 2cmp eax, 200 的 REX 前缀来节省 2 个字节的代码大小。它将所有操作数提升为 64 位,因为它将它们与指针一起使用,我想证明了 C 循环不需要它们来包装,所以在 asm 中它在任何地方都使用 64 位。这是没有意义的; 32 位操作始终至少与 64 位一样快,并且隐式零扩展是免费的。但这仅花费 2 字节的代码大小,并且除了间接的前端影响之外,不会花费任何性能。

您已经构建了循环,因此编译器需要在寄存器中保留特定值,并且不能将问题完全转换为指针增量 + 与结束指针进行比较(编译器通常在不这样做时会这样做)除了数组索引之外的任何东西都需要循环变量。

您也不能转换为将负索引计数到零(编译器从不这样做,但将循环开销减少到英特尔 CPU 上总共 1 个宏融合 add + 分支 uop(可以融合 add + jcc , 而 AMD 只能融合 test 或 cmp / jcc)。

Clang 做得很好,它注意到它可以使用2*var 作为数组索引(以字节为单位)。这是对 tune=generic 的一个很好的优化。索引存储将在英特尔 Sandybridge 和 Ivybridge 上取消分层,但在 Haswell 及更高版本上保持微融合。 (在其他 CPU,如 Nehalem、Silvermont、Ryzen、Jaguar 或其他 CPU 上,没有劣势。)

gcc 的循环在循环中有 1 个额外的 uop。理论上,它仍然可以在 Core2 / Nehalem 上以每时钟 1 个存储的速度运行,但它正好符合每时钟 4 微指令的限制。 (实际上,Core2 无法在 64 位模式下对 cmp/jcc 进行宏融合,所以它在前端出现了瓶颈)。

【讨论】:

  • 您所说的“未层压”与“保持微熔合”是什么意思?
  • 有关微融合和寻址模式的详细信息,请参阅我的答案中的第一个链接:stackoverflow.com/questions/26046634/…。 (以及 Agner Fog 的微架构指南,了解更多背景知识,以便在必要时了解该问答)。
  • 我认为您最好在这里将更多的注意力集中在分层的可能缺点上,也许为那些想要进一步挖掘的人提供一个指向您关于该主题的规范答案的链接。我从中得到的是“是的,使用索引寻址模式”——这也是我的策略:如果额外的 1 个周期延迟无关紧要,我认为它们或多或少是免费的(所以通常值得在循环中进行各种索引转换,以使用索引获取所有内容并减少索引/计数器/指针增量的数量。
  • 现在我了解了分层,相反的情况通常是正确的:最好的策略通常是一个小的展开和单独的索引/计数器寄存器用于不同的访问,允许它们使用简单的寻址模式。在融合域中,您为每个访问流的增量支付 1 uop,但通过避免分层节省了 N uop(N 是展开因子)。现在它不适用于未展开的循环或普通的mov,是的,它使它变得更加复杂,但是由于这听起来很笼统,所以至少有一个指向细节的指针会很好。
  • 原则上,仍然可以从正数向零计数。编译器重新排序分配以从高索引开始并下降是合法的,因为循环中没有可观察的行为。但是,我看到了不这样做的原因。
猜你喜欢
  • 2018-05-31
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-09-18
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多