当前位置: 首页 > 知识库问答 >
问题:

你能使用C内联程序集来对齐指令吗?(没有编译器优化)

终睿
2023-03-14

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

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

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

为了实现我的目标?我不知道在指令对齐方面是否有可能做到这一点。我见过一些技巧,如实现数据对齐的mm\U malloc,但没有针对指令的技巧。有人能告诉我这件事吗?

共有1个答案

山阳辉
2023-03-14

TL:DR:这可能不是很有用(因为带有uop缓存的现代x86通常不关心代码对齐),而是在do{}while()循环前面“工作”,该循环可以使用相同的布局直接编译到asm,在实际循环顶部之前没有任何循环设置(序言)指令。(向后分支的目标)。

一般来说,https://gcc.gnu.org/wiki/DontUseInlineAsm,尤其是从不在函数内部使用GNU C Basicash("foo");,但在调试模式下(-O0默认,又名优化禁用)每个语句(包括ash();)按源代码顺序编译到单独的ash块。因此,您的案例实际上不需要扩展的ash(". p2审视4":::"内存")来排序ash语句wt.内存操作。(同样在最近的GCC中,使用非空模板字符串隐式为Basic ash提供内存冲击)。最坏的情况是,启用优化后,填充可能会变得无用并损害性能,但不会损害正确性,这与ami()的大多数用途不同。

这完全不起作用;C for循环在asm循环之前编译为一些asm指令。尤其是在语句a中的首次迭代初始化之前使用(a;b;c)循环时!当然,您可以在源代码中提取它,但是GCC编译的策略以及循环的策略是使用jmp进入循环,直到底部的条件。

但是,仅jmp一条指令就只有一条小指令(2字节),因此在此之前进行对齐将使循环的顶部接近可能的指令获取块的开始,如果这是一个瓶颈,它仍然可以获得大部分好处。(或者在一组新的uop缓存线Sandybridge系列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;
    }
}

在Goldbolt编译器资源管理器上进行如下编译。请注意,我使用寄存器的方式意味着尽管进行了调试构建,但我得到了并不可怕的asm,并且不必将p组合到p中

# 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个uops长(假设cmp/JCC的宏融合),所以如果一切顺利,可以在冰湖或Zen上以每次迭代1个周期运行。(每个周期加载/存储1个dword的内存带宽不多,因此即使不适合L3 cahce,也可以在大型阵列上使用。)或者以Haswell为例,每次迭代可能会有1.25个周期,或者由于循环缓冲区效应,可能会更糟一些。

如果在Godbolt上使用“二进制”输出模式,可以看到lea rbx、[rax 409600]是一条7字节指令,而jmp是。L2是2个字节,循环顶部的地址是0x401149,即在该大小的CPU上的16字节fetch块中有9个字节。我按32对齐,因此只浪费了与此块关联的第一个uop缓存线中的2个uop,因此就32字节块而言,我们仍然相对较好。

(锁销“二进制”模式编译并链接到可执行文件中,并在其上运行objdump-d。这也让我们看到了<代码>。p2align指令扩展为某个宽度的NOP指令,如果必须跳过超过11个字节,则扩展为多个,这是x86-64 GAS的默认最大NOP宽度。请记住,每次控件传递此asm语句时,都必须获取这些NOP指令并通过管道,因此函数内部的巨大对齐对这一点以及I-cache占用空间都是一件坏事。)

一个相当明显的转换会在. p2ale之前获得LEA。(如果您好奇,请参阅所有这些源代码版本的Goldbolt链接中的ami)。

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

while(p

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

使用for(foo=bar, ami(". p2ale 4); p可能会实现同样的事情

    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,循环内没有分支目标标签。(如果您想尝试-Falign-tag=32让GCC为您填充,而无需将NOP放入循环内,这很有趣。请参阅下面:-Falign-loops在-O0中不起作用。)

因为我正在硬编码一个非零大小,所以在第一次迭代之前不会运行任何检查。如果该长度是一个运行时变量,例如一个函数arg,您可以在(n==0)返回时执行<代码>操作 。或者更一般地说,如果GCC不能证明它总是至少运行一次迭代,那么可以像GCC在为编译或启用优化的循环时那样,将循环放在if中。

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

GCC-O2启用-falign循环=16:11:8或类似的内容(如果跳过的字节数少于11个,则按16对齐,否则按8对齐)。这就是GCC使用两个序列的原因。p2align指令,第一个指令上有一个填充限制(请参阅气体手册)。

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

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

然而,即使在0级,GCC也尊重标签。但不幸的是,这适用于所有标签,包括内部循环内的循环入口点。螺栓。

# 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

将NOP放在最内部的循环中比在现代x86 CPU上未对齐其起始位置更糟糕。

您在do{}while()循环中没有这个问题,但在这种情况下,使用asm()将对齐指令放在那里似乎也是可行的。

(我使用了如何从GCC/clangassembly输出中删除“噪音”的方法?对于编译选项,可以在不过滤掉指令的情况下最小化混乱,其中包括.p2align。如果我只是想看看内联asm的去向,我可以使用asm(“nop#hi mom”)使其在过滤掉指令后可见。)

如果您可以使用内联asm,但必须使用反优化调试模式进行编译,那么在内联asm中使用输入/输出约束重写整个内部循环可能会大大加快速度。(但不要这样做;很难做到正确,在现实生活中,一个普通人只会将优化作为第一步。)

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

它可以在较旧的x86 CPU上提供更多帮助,或者可能对于其他一些ISA;只有x86很难解码,以至于uop缓存是一件事(您实际上没有指定x86,但目前大多数人都在他们的台式机/笔记本电脑中使用x86 CPU,所以我假设。)

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

但最糟糕的错误对齐情况(除非有其他奇怪的效果)只需花费额外的一个前端提取周期,就可以在循环体中获得更多的指令。(例如,如果循环的顶部有一个以0xf结尾的地址,因此它是16字节块的最后一个字节,则包含该字节的对齐16字节块将只在末尾包含该有用字节。)这可能是一条单字节指令,如cdq,但管道通常是4条指令宽或更多。

(或在早期Intel P6系列时代为3-wide,在此之前,在提取、预解码(长度查找)和解码之间有缓冲区。如果循环的其余部分有效解码且平均指令长度较短,则缓冲可以隐藏气泡。但是解码仍然是一个重要的瓶颈,直到Nehalem的循环缓冲区可以将解码结果(UOP)循环用于一个小循环(几十个UOP)。Sandybridge家族添加了一个uop缓存来缓存包含多个频繁调用的函数的大型循环。David Kanter对SnB的深入研究有很好的方框图,请参见https://www.agner.org/optimize/尤其是Agner的Microach pdf。

即使如此,只有当前端(指令提取/解码)带宽出现问题时,它才有帮助,而不是一些后端瓶颈(实际执行这些指令)。无序执行(Out-of-order exec)通常在让CPU以最慢的瓶颈速度运行方面做得很好,而不是等到缓存未加载后再获取和解码后续指令。(请参阅此,此,尤其是现代微处理器的90分钟指南!)

在某些情况下,它可以在Skylake CPU上提供帮助,其中微代码更新禁用了循环缓冲区(LSD),因此跨32字节边界拆分的微小循环体最多可以每2个周期运行1次迭代(从2个单独的缓存行获取uops)。或者在Skylake上,如果您无法通过-Wa,-mclents-venin-32B-边界让汇编器绕过它,那么以这种方式调整代码对齐可以帮助避免JCC勘误表(这可以使您的部分代码从旧解码而不是uop缓存运行)。(我如何减轻Intel jcc勘误表对gcc的影响?)。这些问题特定于Skylake派生的微架构,并在ICE Lake中得到修复。

当然,反优化的调试模式代码是如此臃肿,以至于即使是紧密循环也不太可能少于8 uops,因此32字节边界问题可能不会造成太大伤害。但是,如果您设法通过在本地vars上使用寄存器来避免存储/重新加载延迟瓶颈(是的,这只在调试构建中做一些事情,否则它毫无意义1),如果内部循环最终因循环内部或底部的条件分支而跳过JCC勘误表,那么通过管道获取所有这些低效指令的前端瓶颈很可能会影响Skylake CPU。

无论如何,正如Eric评论的那样,您的作业可能更多地是关于数据访问模式,以及可能的布局和对齐。大概涉及在大量内存上的小循环,因为L2或L3缓存未命中是唯一慢到足以成为瓶颈的事情,而不是在禁用优化的情况下构建。也许在某些情况下是L1d,如果您设法让编译器为调试模式做出不可怕的提升,或者如果负载使用延迟(不仅仅是吞吐量)是关键路径的一部分。

有关最终分配(禁用编译器优化)的信息,请参阅C循环优化帮助:为调试模式优化源代码或为正常用例优化基准测试是多么愚蠢。但也提到了一些在这种情况下更快的事情(与正常构建不同),比如在单个语句或表达式中执行更多操作,因为编译器不会在语句之间的寄存器中保存内容。

(另请参见为什么clang使用-O0生成效率低下的asm(对于这个简单的浮点和)?详细信息)

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

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

在内部循环中,变量是最重要的,禁用优化后,编译器可能不会很聪明地决定哪个变量在寄存器耗尽时实际获得寄存器。(x86-64有15个整数reg,而不是堆栈指针,调试构建将其中一个用于帧指针。)

特别是对于循环内部发生变化的变量,以避免存储/重新加载延迟瓶颈,例如,for(register int i=1000000;--i;)可能每个时钟运行1次迭代,而在像Skylake这样的现代x86-64 CPU上,没有寄存器的情况下运行5次或6次迭代。

如果使用整数变量作为数组索引,将其设为intptr_tuintptr_t#include

(除非您是为AArch64编译,因为AArch64的寻址模式采用64位寄存器和32位寄存器,进行符号或零扩展,并忽略窄整数寄存器中的高垃圾。正是因为这是编译器无法始终优化掉的。虽然通常情况下,由于有符号整数溢出未定义,编译器可以扩展整数loop变量或转换为指针增量。)

同样松散相关的是:为Intel Sandybridge系列CPU中的管道取消优化程序有一节是关于通过缓存效果故意使事情变慢的,所以做相反的事情。可能不太适用,IDK你的问题是什么。

 类似资料:
  • 我正在尝试为高度优化的x86-64位操作代码编写一个小型库,并且正在摆弄内联ASM。 在gcc和icc中编译和运行都很好,但是当我检查程序集时,我发现了差异 我在想为什么这么复杂?我正在编写高性能代码,其中指令的数量是关键的。我特别想知道为什么gcc在将变量传递给第二个内联ASM之前会对它进行复制? 尽管gcc决定将变量保存在堆栈中,而不是寄存器中,但我不明白的是,为什么要在将传递给第二个ASM之

  • 我经常遇到这种情况。乍一看,我认为,“这是糟糕的编码;我正在执行一个方法两次,必然会得到相同的结果。”但想到这里,我不得不怀疑编译器是否像我一样聪明,并能得出相同的结论。 编译器的行为是否取决于 方法的内容?假设它看起来像这样(有点类似于我现在的真实代码): 除非对这些对象来自的任何存储进行处理不当的异步更改,否则如果连续运行两次,肯定会返回相同的内容。但是,如果它看起来像这样(为了论证而无意义的

  • 问题内容: 假设我在C代码中有类似的内容。我知道您可以使用a 代替,以使编译器不对其进行编译,但是出于好奇,我问编译器是否也可以解决此问题。 我认为这对于Java编译器来说更为重要,因为它不支持。 问题答案: 在Java中,if内的代码甚至都不是已编译代码的一部分。它必须编译,但不会写入已编译的字节码。它实际上取决于编译器,但我不知道没有对它进行优化的编译器。规则在JLS中定义: 优化的编译器可能

  • 程序转移指令 1>无条件转移指令 (长转移) JMP 无条件转移指令 CALL 过程调用 RET/RETF过程返回. 2>条件转移指令 (短转移,-128到+127的距离内) ( 当且仅当(SF XOR OF)=1时,OP1循环控制指令(短转移) LOOP CX不为零时循环. LOOPE/LOOPZ CX不为零且标志Z=1时循环. LOOPNE/LOOPNZ CX不为零且标志Z=0时循环. JCX

  • 问题内容: 我一直在尝试在我的EC2实例上安装Gearman,但是当我尝试./configure gearmand时,我得到了: 现在,奇怪的是,GCC肯定已安装。 退货 但是,当我尝试运行命令“ gcc”时,找不到… 我试图通过yum擦除/安装/重新安装gcc和gcc-c ++,但这似乎无济于事。 有什么建议吗?提前致谢。 问题答案: 您可以通过链接到以下命令来解决此问题: 升级时,您可以保留多

  • 变量res的值应等于3。但是当我打开优化时,编译器错误地重新排列了指令,并且res包含一些垃圾。一些可能的重新排序示例: 这是编译器中的错误吗?还是不允许像这样访问结构数据成员? 编辑: 我刚刚意识到之前的代码实际上有效,抱歉。但这不起作用: 当编译时不知道变量i时,编译器会错误地重新排序指令。