首页 > 解决方案 > 分配顺序产生不同的装配

问题描述

这个实验是使用 GCC 6.3 完成的。有两个函数,唯一的区别在于我们在结构中分配 i32 和 i16 的顺序。我们假设这两个函数应该产生相同的程序集。然而,这种情况并非如此。“坏”功能产生更多指令。谁能解释为什么会这样?

#include <inttypes.h>

union pack {
    struct {
        int32_t i32;
        int16_t i16;
    };
    void *ptr;
};
static_assert(sizeof(pack)==8, "what?");

void *bad(const int32_t i32, const int16_t i16) { 
    pack p;
    p.i32 = i32;
    p.i16 = i16;
    return p.ptr;
}

void *good(const int32_t i32, const int16_t i16) { 
    pack p;
    p.i16 = i16;
    p.i32 = i32;
    return p.ptr;
}

...

bad(int, short):
        movzx   eax, si
        sal     rax, 32
        mov     rsi, rax
        mov     eax, edi
        or      rax, rsi
        ret
good(int, short):
        movzx   eax, si
        mov     edi, edi
        sal     rax, 32
        or      rax, rdi
        ret

编译器标志是 -O3 -fno-rtti -std=c++14

标签: c++gccx86compiler-optimization

解决方案


这是/曾经是 GCC10.2 及更早版本中错过的优化。它似乎已经在当前的 GCC 夜间版本中得到修复,因此无需在 GCC 的 bugzilla 上报告错过的优化错误。(https://gcc.gnu.org/bugzilla/)。看起来它最初是作为从 GCC4.8 到 GCC4.9 的回归出现的。(神箭

# GCC11-dev nightly build
# actually *better* than "good", avoiding a mov-elimination missed opt.
bad(int, short):
        movzx   esi, si          # mov-elimination never works for 16->32 movzx
        mov     eax, edi         # mov-elimination works between different regs
        sal     rsi, 32
        or      rax, rsi
        ret

是的,只要启用了优化,或者至少希望如此1 ,您通常会期望 C++ 实现相同逻辑的基本相同方式编译为相同的 asm 。通常,您可以希望没有无意义的错过优化,这些优化会无缘无故地浪费指令(而不是简单地选择不同的实现策略),但不幸的是,这也不总是正确的。

编写同一对象的不同部分然后读取整个对象对于编译器而言通常是棘手的,因此当您以不同的顺序编写完整对象的不同部分时,看到不同的 asm 并不令人震惊。

请注意,asm 并没有什么“聪明”之处bad,它只是在执行冗余mov指令。必须在固定寄存器中输入并在另一个特定硬寄存器中产生输出以满足调用约定是 GCC 的寄存器分配器并不擅长的:像这样的浪费mov错过优化在小函数中比在较大函数的一部分中更常见。

如果你真的很好奇,你可以深入研究 GCC 转换到这里的 GIMPLE 和 RTL 内部表示。(Godbolt 有一个 GCC 树转储窗格来帮助解决这个问题。)

脚注 1:或者至少希望如此,但在现实生活中确实会发生错过优化的错误。当你发现它们时报告它们,以防 GCC 或 LLVM 开发人员可以轻松地教优化器避免。编译器是具有多个通道的复杂机器;通常,优化器的一个部分的极端情况只是在其他优化过程更改为执行其他操作之前不会发生,从而暴露出该代码的作者在编写/调整时没有考虑的情况的糟糕最终结果它可以改善其他情况。


请注意,尽管评论中有抱怨,但这里没有未定义的行为:C 和 C++ 的 GNU 方言定义了 C89 和 C++ 中联合类型双关语的行为,而不仅仅是在 C99 和后来的 ISO C 中。实现可以自由定义任何 ISO C++ 未定义的行为。

从技术上讲,存在初始化的读取,因为void*对象的高 2 个字节尚未写入pack p. 但是修复它pack p = {.ptr=0};并没有帮助。(并且不会更改 asm;GCC 碰巧已经将填充归零,因为这很方便)。


另请注意,问题中的两个版本都比可能的效率低:

bad避免浪费的 GCC4.8 或 GCC11-trunk 的输出mov看起来是该策略选择的最佳选择。)

mov edi,edi 在 Intel 和 AMD 上都击败了 mov-elimination,因此该指令具有 1 个周期延迟而不是 0,并且需要一个后端 µop。选择一个不同的寄存器进行零扩展会更便宜。我们甚至可以在阅读 SI 后选择 RSI,但任何调用破坏寄存器都可以。

hand_written:
    movzx  eax, si    # 16->32 can't be eliminated, only 8->32 and 32->32 mov
    shl    rax, 32
    mov    ecx, edi   # zero-extend into a different reg with 0 latency
    or     rax, rcx
    ret

或者,如果在 Intel 上针对代码大小或吞吐量进行优化(低 µop 计数,而不是低延迟),shld则可以选择:Intel 上的延迟为 1 µop / 3c,但 Zen 上的延迟为 6 µops(不过也是 3c 延迟)。(https://uops.info/https://agner.org/optimize/

minimal_uops_worse_latency:  # also more uops on AMD.
    movzx  eax, si
    shl    rdi, 32              # int32 bits to the top of RDI
    shld   rax, rdi, 32         # shift the high 32 bits of RDI into RAX.
    ret

如果您的结构以其他方式排序,中间有填充,您可以执行一些涉及mov ax, si合并到 RAX 的操作。这在非英特尔以及 Haswell 及更高版本上可能是有效的,除了像 AH 这样的高 8 regs 之外不进行部分寄存器重命名。


鉴于读取未初始化的 UB,您可以将其编译为字面上的任何内容,包括retor ud2。或者稍微不那么激进,你可以编译它,只为结构的填充部分留下垃圾,最后 2 个字节。

high_garbage:
    shl    rsi, 32    # leaving high garbage = incoming high half of ESI
    mov    eax, edi   # zero-extend into RAX
    or     rax, rsi
    ret

请注意,对 x86-64 System V ABI(clang 实际上依赖)的非官方扩展是窄 args 符号或零扩展为 32 位。因此,指针的高 2 个字节不是零,而是符号位的副本。(这实际上可以保证它是 x86-64 上的规范 48 位虚拟地址!)


推荐阅读