c++ - 分配顺序产生不同的装配
问题描述
这个实验是使用 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
是的,只要启用了优化,或者至少希望如此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,您可以将其编译为字面上的任何内容,包括ret
or 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 位虚拟地址!)
推荐阅读
- java - 如何解析这个语法?
- entity-framework - EF Core multiple datasets
- sqlite - 用于计算每个月特定日期的活动计数的 Sql 查询
- javascript - 字典中的日期对象在 Javascript/Node 中更改时区
- git - git rebase -i HEAD~所有提交计数
- php - 如何删除数据库中的 HTML 标签?
- swift - 如何在更改捆绑项目中相同 nib 文件中的下拉值时更改 nib 文件中的视图?
- python - Selenium Python 如何使用现有浏览器的 cookie 进行登录验证
- machine-learning - 展平层与输入不兼容
- react-native - SyntaxError: 不能在 Module._compile (internal/modules/cjs/loader.js:895:18) 的模块外使用 import 语句。反应原生