gcc - 为什么我们不能直接从栈帧移动 1 个字节到寄存器?
问题描述
我正在阅读 Computer Systems: A Programmer's Perspective, 3/E (CS:APP3e) Randal E. Bryant 和 David R. O'Hallaron,作者说“观察第 6 行的 movl 指令从内存中读取 4 个字节;以下 addb 指令仅使用低位字节"
第 6 行,他们为什么使用 movl?他们为什么不 movb 8(%rsp), %dl?
void proc(a1, a1p, a2, a2p, a3, a3p, a4, a4p)
Arguments passed as follows:
a1 in %rdi (64 bits)
a1p in %rsi (64 bits)
a2 in %edx (32 bits)
a2p in %rcx (64 bits)
a3 in %r8w (16 bits)
a3p in %r9 (64 bits)
a4 at %rsp+8 ( 8 bits)
a4p at %rsp+16 (64 bits)
1 proc:
2 movq 16(%rsp), %rax Fetch a4p (64 bits)
3 addq %rdi, (%rsi) *a1p += a1 (64 bits)
4 addl %edx, (%rcx) *a2p += a2 (32 bits)
5 addw %r8w, (%r9) *a3p += a3 (16 bits)
6 movl 8(%rsp), %edx Fetch a4 (8 bits)
7 addb %dl, (%rax) *a4p += a4 (8 bits)
8 ret Return
解决方案
TL:DR:你可以,GCC 只是选择不,与普通字节加载相比,节省 1 个字节的代码大小,movzbl
并避免movb
加载+合并造成的任何部分寄存器惩罚。但是由于不明原因,这不会在加载函数 arg 时导致存储转发停止。
(这段代码正是我们从 GCC4.8 和更高版本中得到的,gcc -O1
带有那些 C 语句和这些宽度的整数类型。看到它并在 Godbolt 编译器资源管理器上发出叮当声, GCC之前-O3
安排了movl
一条指令。)
这样做没有正确的理由,只有可能的性能。您是正确的,字节加载也可以正常工作。(我省略了多余的操作数大小后缀,因为它们是由寄存器操作数隐含的)。
mov 8(%rsp), %dl # byte load, merging into RDX
add %dl, (%rax)
您可能从 C 编译器获得的是零扩展的字节加载。(例如 GCC4.7 和更早的版本会这样做)
movzbl 8(%rsp), %edx # byte load zero-extended into RDX
add %dl, (%rax)
movzbl
(又名MOVZX in Intel syntax)是您加载字节/单词的首选指令,而movb
不是movw
. 它总是安全的,并且在现代 CPU 上,MOVZX 加载几乎与 dwordmov
加载一样快,没有额外的延迟或额外的微指令;在加载执行单元中处理。(英特尔从 Core 2 或更早版本开始,AMD 至少从 Ryzen 开始 。https: //agner.org/optimize/ )。唯一的成本是 1 个额外字节的代码大小(更大的操作码)。 movsbl
或movsbq
(又名 MOVSX)符号扩展在较新的 CPU 上同样有效,但在某些 AMD(如某些 Bulldozer 系列)上,它们的延迟比 MOVZX 负载高 1 个周期。因此,如果您只关心在加载字节时避免部分寄存器恶作剧,那么更喜欢 MOVZX。
如果您特别想合并到现有 64 位寄存器的低字节或字中,通常只使用movb
或movw
(与寄存器目标一起使用) 。 字节/字存储在 x86 上非常好,我只是在谈论 mov mem-to-reg 或 reg-to-reg。这条规则有例外;有时,如果您小心并了解您关心代码在其上有效运行的微架构,有时您可以安全地使用字节操作数大小而不会出现问题。请注意,通过写入字节 reg 然后读取更大的 reg 来故意合并可能会导致某些 CPU 上的部分寄存器合并停止。
写入%dl
将对在某些 CPU(包括当前的 Intel 和所有 AMD)上编写 EDX 的指令(在您的调用者中)产生错误的依赖。(为什么 GCC 不使用部分寄存器?)。Clang 和 ICC 不在乎,无论如何都要做,按照您期望的方式实现功能。
movl
写入完整的 64 位寄存器(在写入 32 位寄存器时通过隐式零扩展)避免该问题。
8(%rsp)
但是,如果调用者只使用字节存储,则读取 dword可能会引入存储转发停顿。 如果调用者用 a 写了那个内存push
,你就没事了。但是如果调用者只movb $123, (%rsp)
在call
进入已经保留的堆栈空间,现在您的函数正在从最后一个存储是一个字节的位置读取一个 dword。除非有某种其他的停顿(例如,在调用你的函数后的代码获取中),当加载 uop 执行时,字节可能在存储缓冲区中,但加载需要加上缓存中的 3 个字节。或者来自仍然在存储缓冲区中的一些较早的存储,因此在将存储缓冲区中的字节与缓存中的其他字节合并之前,它还必须扫描存储缓冲区以查找所有潜在的匹配项。仅当您加载的所有数据都来自一个商店时,存储转发的快速路径才有效。(现代 x86 实现可以从多个先前的存储中存储转发吗?)
但是等等,x86-64 System V 调用约定的不成文“扩展”意味着没有存储转发停止的风险
clang/gcc 将窄 args 符号或零扩展为 32-bit,即使编写的 System V ABI (还没有?)需要它。Clang 生成的代码也依赖于它。这显然包括在内存中传递的参数,正如我们从 Godbolt 上的调用者中看到的那样。(我使用__attribute__((noinline))
了这样我可以在启用优化的情况下进行编译,但仍然没有调用内联并优化掉。否则我可能只是注释掉主体并查看只能看到原型的调用者。
这不是C 调用非原型函数的“默认参数提升”的一部分。窄 args 的 C 类型仍然是short
or char
。这只是一个调用约定功能,它允许被调用者对 C 对象的对象表示之外的寄存器(或内存)中的位进行假设。但是,如果要求高 32 位为零会更有用,因为您仍然不能将它们用作 64 位寻址模式的数组索引。但是您可以int_arg += char_arg
先不使用 MOVSX。int
因此,当您使用窄参数时,它可以使代码更高效,并且它们被 C 规则隐式提升为二进制运算符(如+
.
gcc -O3 -maccumulate-outgoing-args
通过用(或-O0
或)编译调用者-O1
,我让 GCC 保留堆栈空间,sub
然后在调用你的函数movl $4, (%rsp)
之前使用。call proc
使用 gcc 会更有效(更小的代码大小)movb
,但它选择使用movl
带有 32 位立即数的 a。我认为这是因为它在调用约定中实现了这条不成文的规则,而不是其他原因。
更常见(没有-maccumulate-outgoing-args
)调用者将在加载之前使用push $4
或push %rdi
执行 qword 存储,这也可以有效地存储转发到 dword(或字节)加载。因此,无论哪种方式,arg 都将至少使用 dword 存储来编写,从而使 dword 重新加载对性能安全。
dwordmov
加载的代码大小比movzbl
加载小 1 个字节,并且避免了 MOVSX 或 MOVZX 可能的额外成本(在旧的 AMD CPU 和极其旧的 Intel CPU (P5) 上)。所以我认为这是最优的。
GCC4.7 及更早版本确实使用movzbl
(MOVZX) 加载char a4
arg 就像我推荐的一般安全选项,但 GCC4.8 及更高版本使用movl
.
推荐阅读
- html - 具有类 ::before 的 div 的 CSS 选择器
- maven - 使用 mvn 依赖项将 tar.gz 解压缩到正确的路径:unpack
- node.js - 为 expo 应用程序运行 npm start 时出错我认为这是由于 sudo
- shell - 从 Bash curl web api 请求定义的 csv 输出
- java - 使用 ArrayList 中包含的图像设置 ImageIcon
- node.js - 如何在 deno 中删除运行时导入缓存?
- java - 如何验证数组的大小?
- android - 每小时在 SQLite db 中插入一行以获取特定 ID(不是唯一的)
- c++ - OpenFileMapping 有多贵?(内存映射文件)
- javascript - Redux Reducer 或组件的 ShouldComponentUpdate 中的深度比较?