【问题标题】:Is there a penalty in having a non-aligned Jcc which is nearly never taken in Intel/AMD 64?在 Intel/AMD 64 中几乎从未采用的未对齐 Jcc 是否会受到惩罚?
【发布时间】:2024-01-06 14:36:01
【问题描述】:

我有一个循环,用于将数字与进位相加。

我想知道.done: align 是否会给我带来任何好处?毕竟,每次调用该函数时,它只会在那里分支一次。我知道 C 编译器可能会对齐所有受循环影响的分支。但我认为它不应该造成任何损失(特别是因为我们现在每天都有相当大的指令缓存)。

//    // corresponding C function declaration
//    int add(uint64_t * a, uint64_t const * b, uint64_t const * c, uint64_t size);
//
// Compile with:   gcc -c add.s -o add.o
//
// WARNING: at this point I've not worked on the input registers & registers to save
//          do not attempt to use in your C program with this very code.

    .text
    .p2align    4,,15
    .globl      add
    .type       add, @function
add:
    test        %rcx, %rcx
    je          .done
    clc
    xor         %rbp, %rbp

    .p2align    4,,10
    .p2align    3
.loop:
    mov         (%rax, %rbp, 8), %rdx
    adc         (%rbx, %rbp, 8), %rdx
    mov         %rdx, (%rdi, %rbp, 8)
    inc         %rbp
    dec         %rcx
    jrcxz       .done
    jmp         .loop

    // -- is alignment here necessary? --
.done:
    setc        %al
    movzx       %al, %rax
    ret

英特尔或 AMD 是否有关于此特定案例的明确文档?


实际上我决定通过删除循环来进行简化,因为我只有 3 种尺寸(128、256 和 512),因此编写展开循环很容易。但是,我只需要添加,所以我真的不想为此使用 GMP。

这是应该在你的 C 程序中工作的最终代码。这个是专门针对 512 位的。只需将三个 add_with_carry 用于 256 位,一个用于 128 位版本。

//    // corresponding C function declaration
//    void add512(uint64_t * dst, uint64_t const * src);
//

    .macro add_with_carry offset
        mov         \offset(%rsi), %rax
        adc         %rax, \offset(%rdi)
    .endm

    .text
    .p2align    4,,15
    .globl      add512
    .type       add512, @function
add512:
    mov         (%rsi), %rax
    add         %rax, (%rdi)

    add_with_carry 8
    add_with_carry 16
    add_with_carry 24
    add_with_carry 32
    add_with_carry 40
    add_with_carry 48
    add_with_carry 56

    ret

请注意,我不需要clc,因为我第一次使用add(忽略进位)。我还把它添加到目的地(即 C 中的 dest[n] += src[n]),因为我的代码中不太可能需要副本。

偏移量允许我不增加指针,每次添加它们只使用一个额外的字节。

【问题讨论】:

  • 哦,大声笑,您的尺码偏小且固定?是的,完全展开非常好,并且避免了旧 CPU 的所有部分标志问题。 memory-destination add 是高效的,但是 memory-destination adc 在 Intel CPU 上浪费了微指令;通常比 load + adc (mem), %reg + mov-store 更糟糕:SKL 上的 4 个融合域 uops 用于 adc %reg, (mem),而 add %reg, (mem) 则为 2,因为 Intel CPU 在 uops 内的加载和存储之间没有一致的 TLB一条指令。 (有关英特尔架构师的详细信息,请参阅我在 What happens after a L2 TLB miss? 上的回答。)

标签: loops branch x86-64 memory-alignment micro-optimization


【解决方案1】:

神圣的时钟周期蝙蝠侠,当你在jmp 之后使用jrcxz 而不是在jnz 之后使用jnz 时,你问的是效率吗?

如果您通过使用lea 1(%rcx), %rcx 来避免FLAGS 完全写入,那么您只会考虑慢速loop 或有点慢的jrcxzdec 写入除 CF 之外的所有标志,CF 过去常常导致 Sandybridge 之前 CPU 上的 ADC 循环中的部分标志停止,但现在已经好了。 dec/jnz 循环非常适合现代 CPU 上的 ADC 循环。您可能希望避免 adc 和/或存储(可能带有循环展开)的索引寻址模式,以便 adc 可以微熔负载,因此存储地址 uop 可以在端口 7 上运行哈斯韦尔和后来。您可以将 mov 加载相对于您使用 LEA 递增的其他指针之一进行索引。


但无论如何,不​​,从未采用的分支目标的对齐是无关紧要的。除了通常的代码对齐/解码器效果之外,始终通过的分支的贯穿路径的对齐也是如此.

很少采用的分支目标对齐也不是什么大问题;代价可能是前端的一个额外周期,或者在一个时钟周期内准备好进行预解码的指令更少。 因此,在实际执行该路径的情况下,我们会在前端早期讨论 1 个时钟周期。 这就是为什么对齐循环顶部很重要的原因,尤其是在没有循环的 CPU 上缓冲区。 (和/或没有 uop 缓存和其他隐藏前端气泡的东西,除非在极少数情况下。)。

正确的分支预测通常会隐藏该 1 个循环,但通常留下一个循环会导致一个不正确预测的分支,除非迭代次数很少且每次都相同。第一个周期可能只在 16 字节的提取块末尾附近提取一条有用的指令(如果第一条指令跨越 16 字节边界,则甚至为零),后面的指令仅在下一个周期中加载。有关 Agner Fog 的微架构指南和 asm 优化指南,请参阅 https://agner.org/optimize/。 IDK 最近他如何更新了 asm 优化手册中的对齐指南;我主要看他对新微架构微架构指南的更新。

一般来说,流水线阶段之间的 uop 缓存和缓冲区使代码对齐很多比以前少了。将循环的顶部对齐 8 或 16 仍然是一个好主意,但否则通常不值得在将要执行的任何位置添加额外的 nop

您可以想象它可能会产生更大影响的情况,例如如果以前的代码从未执行过,与缓存行或页面边界对齐可以避免触及原本冷的缓存行或页面。您的代码不会发生这种情况;在跳转目标之前有少于 64 个字节的“热”指令。但这与通常的代码对齐目标不同。


更多代码审查:

RBP 是一个保留调用的寄存器。如果您想从 C 中调用它,请选择一个寄存器,如 RA/C/DX、RSI、RDI 或 R8..R11,您不用于任何用途。或者对于 Windows x64,调用破坏的“遗留”regs 更少(不需要 REX 前缀)。看起来你所有的循环指令都需要一个 64 位操作数大小的 REX 前缀。

clc 是不必要的:xor %ebp, %ebp 到零 RBP 已经清除了 CF。说到这,32 位操作数大小对于异或归零更有效。它节省了代码大小。

您还可以通过从数组的 end 开始索引来避免循环中的dec,负索引计数为零。例如rdi += len; rsi += len; 等等。 RCX = -len。所以inc %rcx / jnz 作为你的循环条件,作为你的索引增量。

但就像我上面所说的,你可能会更好地使用lea 来增加指针并相对于它来索引你的其他数组。 (p1 -= p2,然后使用 *(p1 + p2)*p2,并在 asm 中用一个 p2++ 递增。)所以你可能仍然需要一个单独的计数器。

您可以调用 GMP 库函数,而不是编写自己的扩展精度循环。他们为许多不同的 x86 微架构手动调整了 asm,包括循环展开等。

【讨论】:

  • 谢谢彼得。我有 ABI 的 this document。它是从 2013 年开始的,但我想从那以后它并没有太大变化。此外,我将其更改为专门针对我的可变大小(128、256、512)完全删除循环。它现在真的很干净,很快就会死掉。不过,关于p1 -= p2 的电话技巧!可能对大型循环很有用,尽管内存访问可能会导致它没那么有用。
  • @AlexisWilke:Where is the x86-64 System V ABI documented?。不过,自从最初的 AMD64 硬件于 2003 年发布以来,调用约定的基础(arg-passing regs,以及调用破坏与保留)并没有改变。 p1 -= p2 将一个数组相对于另一个进行索引的技巧只是在循环内保存了一个融合域 uop,同时仍然允许一个数组使用非索引寻址模式;它对中型案例最有用。如果您知道您的输入总是很大,那么您只需展开足以隐藏循环开销。