繁体   English   中英

分配顺序产生不同的装配

[英]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

这是/曾经是 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

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

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

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

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

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


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

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


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

(来自 GCC4.8 或 GCC11-trunk 的bad输出避免了浪费的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,您可以将其编译为任何字面意思,包括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 System V ABI(clang 实际依赖的)的非官方扩展是窄参数被符号或零扩展到 32 位。 因此,指针的高 2 字节不是零,而是符号位的副本。 (这实际上可以保证它是 x86-64 上的规范 48 位虚拟地址!)

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM