可以使用 C 内联汇编来对齐指令吗? (没有编译器优化)

Could you use C inline assembly to align instructions? (without Compiler optimizations)

我必须做一个大学项目,我们必须使用缓存优化来提高给定代码的性能,但 我们不能使用编译器优化 来实现它。

我阅读参考书目时的一个想法是将基本块的开头与行高速缓存大小对齐。但是你可以做类似的事情吗:

asm(".align 64;")
for(int i = 0; i<N; i++)
... (whole basic block)

为了实现我正在寻找的东西?我不知道是否可以在指令对齐方面做到这一点。我见过一些像 _mm_malloc 这样的技巧来实现数据对齐,但 none 用于说明。谁能告诉我一些关于这件事的信息?

TL:DR:这可能不是很有用(因为带有 uop 缓存的现代 x86 通常不关心代码对齐1),但是在 do{}while() 循环 之前“工作”,它可以 ,在循环的实际顶部之前没有任何循环设置(序言)指令。 (向后分支的目标)。

一般来说,https://gcc.gnu.org/wiki/DontUseInlineAsm,尤其是从不在函数内部使用 GNU C Basic asm("foo");,但在调试模式下(-O0 默认值,又名禁用优化)每个语句(包括asm();) 按源代码顺序编译成单独的 asm 块。因此,您的案例实际上并不需要 Extended asm(".p2align 4" ::: "memory") 来订购 asm 语句 wrt。内存操作。 (同样在最近的 GCC 中,对于具有 non-empty 模板字符串的 Basic asm,内存破坏是隐式的)。在最坏的情况下,启用优化后,填充可能会无用并损害性能,但不会损害正确性,这与 asm().

的大多数用途不同。

这实际上是如何编译的

这并不完全有效; C for 循环在 asm 循环 之前编译为一些 asm 指令 。尤其是在语句 a 中使用带有一些 before-first-iteration 初始化的 for(a;b;c) 循环时!您当然可以在源代码中将其拉出,但是 GCC -O0 策略是使用 jmp 进入循环底部的条件。

但是 jmp 本身只是一个小的(2 字节)指令,因此 在此之前对齐会将循环的顶部放在 near 可能的指令获取块的开始 ,如果它曾经是瓶颈,它仍然可以获得大部分好处。 (或者在一组新的 uop-cache 行 Sandybridge-family x86 的开始附近,其中 32 字节边界是相关的。或者甚至是 64 字节 I-cache 行,尽管这很少相关并且可能导致在为达到该边界而执行的大量 NOP 中。代码大小臃肿。)

void foo(register int *p)
{
    // always use .p2align n or .balign 1<<n so it's unambiguous across targets like MacOS vs. Linux, never .align
    asm("   .p2align 5 # from inline asm");
    for (register int *endp = p + 102400; p<endp ; p++) {
        *p += 123;
    }
}

Godbolt compiler explorer上编译如下。请注意,我使用 register 的方式意味着我得到了 not-terrible asm,尽管进行了调试构建,并且不必将 p++ 组合到 p++ <= endp*(p++) += 123; 中使 store/reload 开销不那么糟糕(因为首先 register 本地人没有任何开销)。我使用指针增量/比较来保持 asm 简单,并且调试模式更难去优化成更多浪费的 asm 指令。

# GCC11.3 -O0 (the default with no options, except for -masm=intel added by Godbolt)
foo:
        push    rbp
        mov     rbp, rsp
        push    rbx                        # GCC stupidly picks a call-preserved reg it has to save
        mov     rax, rdi
           .p2align 5 # from inline asm
        lea     rbx, [rax+409600]          # endp = p+102400
        jmp     .L2                        # jump to the p<endp condition before the first iteration
## The actual top of the loop.  9 bytes past the alignment boundary
.L3:                                       # do{
        mov     edx, DWORD PTR [rax]
        add     edx, 123
        mov     DWORD PTR [rax], edx         # A memory destination add dword [rax], 123  would be 2 uops for the front-end (fused-domain) on Intel, vs. 3 for 3 separate instructions.
        add     rax, 4                       # p++
.L2:
        cmp     rax, rbx
        jb      .L3                        # }while(p<endp)
        nop
        nop                                # These aren't for alignment, IDK what this is for.
        mov     rbx, QWORD PTR [rbp-8]     # restore RBX
        leave                              # and restore RBP / tear down stack frame
        ret

这个循环是 5 微指令长(假设 macro-fusion 或 cmp/JCC),如果一切顺利的话,运行 在 Ice Lake 或 Zen 上每次迭代 1 个循环也可以。 (每个周期加载/存储 1 个双字的内存带宽并不多,因此即使它不适合 L3 cahce,也应该保持在一个大数组上。)或者例如在 Haswell 上,每次迭代可能有 1.25 个周期,或 .

如果在Godbolt上使用“二进制”输出方式,可以看到lea rbx, [rax+409600]是7字节的指令,而jmp .L2是2字节的指令循环是 0x401149,即在 CPU 上以该大小获取的 16 字节 fetch-block 中的 9 个字节。我按 32 对齐,所以它只浪费了与该块关联的第一个 uop 缓存行中的 2 uops,所以我们在 32 字节块方面仍然相对较好。

(Godbolt“二进制”模式编译 links 为可执行文件,然后 运行s objdump -d。这也让我们看到了 .p2align 指令扩展为一定宽度的 NOP 指令,如果它必须跳过超过 11 个字节,则扩展为一个以上的 NOP 指令,这是 x86-64 的 GAS 的默认最大 NOP 宽度。请记住,这些 NOP 指令每次都必须获取并通过管道控制传递了这个 asm 语句,因此函数内部的大量对齐对于它以及 I-cache 足迹都是一件坏事。)

一个相当明显的转换在 .p2align. 之前获得了 LEA(如果您有所有这些源版本,请参阅 Godbolt link 中的 asm很好奇)。

    register int *endp = p + 102400;
    asm("   .p2align 5 # from inline asm");
    for ( ; p < endp ; p++) {
        *p += 123;
    }

while (p < endp){... ; p++}也可以。 asm 循环的顶部变为以下内容,循环条件只有 2 个字节 jmp。所以这是相当不错的,并且获得了大部分好处。

        lea     rbx, [rax+409600]
           .p2align 5 # from inline asm
        jmp     .L5                       # 2-byte instruction
.L6:

也许可以用 for(foo=bar, asm(".p2align 4) ; p<endp ; p++) 达到同样的目的。但是,如果您在 for 语句的第一部分声明变量,逗号运算符将无法让您潜入单独的语句。

要实际对齐 asm 循环,我们可以将其写为 do{}while

    register int *endp = p + 102400;
    asm("   .p2align 5 # from inline asm");
    do {
        *p += 123;
        p++;
    }while(p < endp);
        lea     rbx, [rax+409600]
           .p2align 5 # from inline asm
.L8:                                     # do{
        mov     edx, DWORD PTR [rax]
        add     edx, 123
        mov     DWORD PTR [rax], edx
        add     rax, 4
        cmp     rax, rbx
        jb      .L8                      # while(p<endp)

开始时没有 jmp,循环内没有 branch-target 标签。 (如果你想尝试 -falign-labels=32 让 GCC 为你填充而不让它把 NOPs 放在 循环中,这很有趣。见下文:-falign-loops 没有在 -O0 工作。)

因为我是 hard-coding non-zero 大小,所以在第一次迭代之前没有 p == endp 检查 运行s。如果该长度是一个 运行 时间变量,例如一个函数参数,你可以在循环之前做 if(n==0) return; 。或者更一般地说,将循环 放在 一个 if 中,就像 GCC 在编译启用优化的 forwhile 循环时所做的那样,如果它不能证明它总是 运行 至少有一次迭代。

  if(n!=0) {
      register int *endp = p + n;
      asm (".p2align 4");
      do {
          ...
      }while(p!=endp); 
  }

让 GCC 为您做这件事:-falign-loops=16-O0

上不起作用

GCC -O2 启用 -falign-loops=16:11:8 或类似的东西(如果跳过少于 11 个字节,则按 16 对齐,否则按 8 对齐)。这就是为什么 GCC 使用两个 .p2align 指令序列,第一个指令有填充限制 (see the GAS manual)。

        .p2align 4,,10            # what GCC does on its own
        .p2align 3

但是使用 -falign-loops=16-O0 没有任何作用。似乎 GCC -O0 不知道什么是循环。 :P

但是,GCC 确实 尊重 -falign-labels,即使在 -O0。但不幸的是,这适用于 all 标签,包括内部循环内的循环入口点。 Godbolt.

# gcc -O0 -falign-labels=16
## from compiling endp=...; asm(); while() {}
        lea     rbx, [rax+409600]              # endp = ...
           .p2align 5 # from inline asm
        jmp     .L5
        .p2align 4                         # from GCC itself, pads another 14 bytes to an odd multiple of 16 (if you didn't remove the manual .p2align 5)
.L6:
        mov     edx, DWORD PTR [rax]
        add     edx, 123
        mov     DWORD PTR [rax], edx
        add     rax, 4
        .p2align 4                         # from GCC itself: one 5-byte NOP in this particular case
.L5:
        cmp     rax, rbx
        jb      .L6

在 inner-most 循环中放置一个 NOP 比在现代 x86 CPUs 上不对齐它的开始更糟糕。

do{}while() 循环没有这个问题,但在那种情况下,使用 asm() 放置对齐指令似乎也有效。

(我将 用于编译选项,以在不过滤指令的情况下最大限度地减少混乱,其中包括 .p2align。如果我只是想查看内联汇编的去向,我可以使用asm("nop #hi mom") 过滤掉指令使其可见。)


如果您可以使用内联 asm 但必须使用 anti-optimized 调试模式进行编译,则在 input/output 约束下重写内联 asm 中的整个内部循环可能会大大加快速度。 (But don't really do that; it's hard to get right 而在现实生活中,普通人只会将优化作为第一步。)


脚注 1:代码对齐对现代 x86 帮助不大,可能对其他人有帮助

即使您确实对齐了向后分支的目标(而不仅仅是一些循环序言),这也不太可能有帮助;具有 uop 缓存(Sandybridge-family 和 Zen-family)和循环缓冲区(Nehalem 和后来的 Intel)的现代 x86 CPUs 不太关心循环对齐。

它可能对较旧的 x86 CPU 或其他一些 ISA 有更多帮助;只有 x86 很难解码,以至于 uop 缓存是一个东西(你实际上没有指定 x86,但目前大多数人在他们的 desktops/laptops 中使用 x86 CPUs 所以我'我假设。)

分支目标对齐有帮助的主要原因(尤其是循环顶部),是当 CPU 获取 16-byte-aligned 块时 包含目标地址,该块中的大部分机器代码将在之后它,因此循环体的一部分即将运行 另一个迭代。 (分支目标之前的字节在该获取周期中被浪费了)。

但是 mis-alignment 的最坏情况(除非有其他奇怪的影响)只会花费你 1 个额外的 front-end 获取周期来在循环体中获取更多指令。 (例如,如果循环的顶部有一个以 0xf 结尾的地址,那么它是 16 字节块的最后一个字节,包含该字节的对齐的 16 字节块将只包含那个有用的字节结束。)这可能是 one-byte 指令,如 cdq,但流水线通常是 4 条指令宽,或更多。

(或者在早期的 Intel P6 系列中是 3 宽,在获取、pre-decode(长度查找)和解码之间存在缓冲区之前。如果循环的其余部分有效地解码并且平均 instruction-length 很短。但解码仍然是一个重要的瓶颈,直到 Nehalem 的循环缓冲区可以为一个小循环(几十个 uops)回收解码结果(uops)。并且 Sandybridge-family 添加了一个 uop 缓存缓存包含多个经常调用的函数的大循环。David Kanter's deep-dive on SnB has nice block diagrams, and see also https://www.agner.org/optimize/ 尤其是 Agner 的微架构 pdf。

即便如此,它也只在 front-end(指令 fetch/decode)带宽成为问题时才有帮助,而不是 back-end 瓶颈(实际执行那些指令)。 Out-of-order exec 通常可以很好地让 CPU 运行 与最慢的瓶颈一样快,而不是等到 cache-miss 加载之后才能获取和解码后面的指令. (参见 this, this, and especially Modern Microprocessors A 90-Minute Guide!。)

在某些情况下,它可以在 Skylake CPU 上提供帮助,其中微码更新禁用了循环缓冲区 (LSD),因此跨越 32 字节边界的微小循环体可以 运行最多每 2 个周期进行 1 次迭代(从 2 个单独的缓存行中获取微指令)。或者再次在 Skylake 上,如果您不能将 -Wa,-mbranches-within-32B-boundaries 传递给让汇编程序解决它。 (How can I mitigate the impact of the Intel jcc erratum on gcc?)。这些问题特定于 Skylake-derived 微架构,并已在 Ice Lake 中修复。

当然,anti-optimized debug-mode 代码非常臃肿,即使是一个紧密的循环也不太可能少于 8 微指令,所以 32-byte-boundary 问题可能不会'不太疼。但是,如果您设法通过在本地变量上使用 register 来避免 store/reload 延迟瓶颈(是的,这会在 仅在调试构建 中起作用,否则它毫无意义1),如果内部循环最终跳闸,通过管道获取所有这些低效指令的 front-end 瓶颈很可能会影响 Skylake CPU由于循环内部或底部的条件分支结束,因此在 JCC 勘误表上。

无论如何,正如 Eric 评论的那样,您的作业可能更多地是关于数据访问模式,可能还有布局和对齐方式。大概涉及对一些大量内存的小循环,因为 L2 或 L3 缓存未命中是唯一会慢到比禁用优化构建更成为瓶颈的东西。在某些情况下可能是 L1d,如果你设法让编译器为调试模式制作 non-terrible asm,或者如果 load-use latency(不仅仅是吞吐量)是一部分关键路径。

脚注 2:-O0 很笨,但 register int i 可以提供帮助

回复:为调试模式优化源代码或以这种方式为正常 use-cases 进行基准测试是多么愚蠢。但也提到了一些在这种情况下更快的事情(与正常构建不同),比如在单个语句或表达式中做更多事情,因为编译器不会跨语句将事情保存在寄存器中。

(详见 Why does clang produce inefficient asm with -O0 (for this simple floating point sum)?

除了register个变量;那个过时的关键字仍然可以为使用 GCC 的未优化构建做一些事情(但不是 clang)。它在最近的 C++ 版本中被正式弃用甚至删除,但 C 还没有。

您肯定希望使用 register int i 让调试版本将其保存在寄存器中,并像 hand-written asm 一样编写您的 C。例如,在适当的地方使用指针增量而不是 arr[i],特别是对于没有索引寻址模式的 ISA。

register 变量在你的内部循环中是最重要的,并且在禁用优化的情况下,编译器可能不是很聪明地决定哪个 register var 实际获得一个寄存器,如果它 运行出来了。 (x86-64 除了堆栈指针外还有 15 个整数寄存器,调试版本会将其中一个用在帧指针上。)

特别是对于在循环内改变的变量,以避免store/reload延迟瓶颈,例如for(register int i=1000000 ; --i ; ); 可能 运行 每个时钟 1 次迭代,而在像 Skylake 这样的现代 x86-64 CPU 上没有 register 时需要 5 或 6 次。

如果使用整型变量作为数组索引,如果可能,将其设为intptr_tuintptr_t (#include <stdint.h>),这样编译器就不必重做sign-extension 从 32 位 int 到 64 位指针宽度,用于寻址模式。

(除非你正在为 AArch64 编译,它具有采用 64 位寄存器和 32 位寄存器的寻址模式,进行符号或零扩展并忽略窄整数 reg 中的高垃圾。正是因为这个是编译器不能总是优化掉的东西。虽然他们通常可以感谢 signed-integer 溢出是未定义的行为 allowing the compiler to widen an integer loop variable 或转换为指针增量。)

也松散相关: 有一个部分是关于通过缓存效果故意使事情变慢,所以反其道而行之。可能不是很适用,不知道你的问题是什么。