赋值顺序产生不同的装配

Order of assignment produces different assembly

这个实验是使用 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

这 is/was GCC10.2 及更早版本中遗漏的优化。它似乎已经在当前的 GCC 夜间构建中得到修复,因此无需在 GCC 的 bugzilla 上报告 missed-optimization 错误。 (https://gcc.gnu.org/bugzilla/). It looks like it first appeared as a regression from GCC4.8 to GCC4.9. (Godbolt)

# 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

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

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

请注意,bad asm 没有任何“智能”之处,它只是在执行冗余的 mov 指令。必须在固定寄存器中获取输入并在另一个特定的硬寄存器中产生输出以满足调用约定是 GCC 的寄存器分配器并不令人惊奇的地方:浪费 mov 这样的错过优化在小函数比大函数的一部分。

如果您真的很好奇,可以深入研究 GCC 通过转换到达此处的 GIMPLE 和 RTL 内部表示。 (Godbolt 有一个 GCC tree-dump 窗格来帮助解决这个问题。)

脚注 1:或者至少希望如此,但是 missed-optimization 错误确实会在现实生活中发生。当您发现它们时报告它们,以防 GCC 或 LLVM 开发人员可以轻松地教优化器避免这种情况。编译器是具有多个通道的复杂机器;通常情况下,优化器的一个部分的极端情况不会发生,直到其他一些优化过程改变为做其他事情,对于代码作者在编写/调整时没有考虑的情况,暴露出糟糕的最终结果它可以改善其他情况。


请注意,尽管评论中有抱怨,但这里没有未定义的行为:C 和 C++ 的 GNU 方言 defines the behaviour of union type-punning in C89 and C++,不仅在 C99 中,而且后来像 ISO C 那样。实现可以自由定义 ISO C++ 未定义的任何行为。

从技术上讲 一个 read-uninitialized 因为 void* 对象的高 2 字节还没有写入 pack p .但是用 pack p = {.ptr=0}; 修复它并没有帮助。 (并且不更改 asm;GCC 碰巧已经将填充置零,因为这很方便)。


另请注意,问题中的两个版本都不太有效:

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

mov edi,edi 在 Intel 和 AMD 上,因此该指令有 1 个周期延迟而不是 0,并且花费 back-end µop。为 zero-extend 选择一个不同的寄存器会更便宜。我们甚至可以在读取 SI 后选择 RSI,但任何 call-clobbered 寄存器都可以。

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

或者如果针对 code-size 或 Intel 的吞吐量进行优化(低 µop 计数,而不是低延迟),shld 是一个选项:Intel 的 1 µop / 3c 延迟,但 Zen 的 6 µop (虽然还有 3c 延迟)。 (https://uops.info/ and 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 中。这在 non-Intel 以及 Haswell 和后来的 partial-register 上可能是有效的,除了像 AH 这样的 high-8 regs.


给定 read-uninitialized UB,你可以将它编译成任何字面意思,包括 retud2。或者稍微不那么激进,您可以将其编译为只为结构的填充部分(最后 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 系统 V ABI 的非官方扩展(clang 实际上依赖于它)是窄参数被符号化或 zero-extended 为 32 位。因此,指针的高 2 字节不是零,而是符号位的副本。 (这实际上可以保证它是 x86-64 上规范的 48 位虚拟地址!)