为什么我们不能从栈帧直接移动 1 个字节到寄存器?
Why can't we move directly 1 byte from stack's frame to register?
我正在阅读计算机系统:程序员的视角,3/E (CS:APP3e) Randal E. Bryant 和 David R. O'Hallaron,作者说 "Observe that the movl instruction of line 6 reads 4 bytes from memory; the following addb instruction only makes use of the low-order byte"
第6行,他们为什么要用movl?他们为什么不移动 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 只是选择不, 与正常的 movzbl
字节加载相比,节省 1 个字节的代码大小并避免任何部分 -注册来自 movb
加载+合并的惩罚。但是由于不明原因,这不会在加载函数 arg 时导致存储转发停顿。
(此代码 完全 我们从 GCC4.8 和更高版本的 gcc -O1
中获得的那些 C 语句和那些宽度的整数类型。看到它和 clang on the Godbolt compiler explorer 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) is your go-to instruction for loading bytes / words, not movb
or movw
. It's always safe, and on modern CPUs MOVZX loads are literally as fast as dword mov
loads, no extra latency or extra uops; handled right in the load execution unit. (Intel since Core 2 or earlier, AMD since at least Ryzen. https://agner.org/optimize/)。
唯一的成本是 1 个额外字节的代码大小(更大的操作码)。 movsbl
或 movsbq
(又名 MOVSX)符号扩展在较新的 CPU 上同样有效,但在某些 AMD(如某些 Bulldozer 系列)上,它们的延迟比 MOVZX 加载高 1 个周期。因此,如果您关心的只是在加载字节时避免部分寄存器恶作剧,那么更喜欢 MOVZX。
通常只使用movb
或movw
(带有寄存器目标)如果你特别想要合并到低字节或现有 64 位寄存器的字。 ,我只是在谈论 mov mem-to-reg 或 reg-to-reg。这条规则也有例外;有时,如果您小心并了解您关心的代码 运行 的微体系结构,有时您可以安全地使用字节操作数大小而不会出现问题。请注意,通过写入字节 reg 然后读取更大的 reg 来故意合并可能会导致某些 CPU 上的部分寄存器合并停顿。
写入 %dl
会错误地依赖于在某些 CPU 上编写 EDX 的指令(在您的调用程序中),包括当前的 Intel 和所有 AMD。 ()。 Clang 和 ICC 不关心,不管怎样,按照你期望的方式实现功能。
movl
写入完整的 64 位寄存器 (by implicit zero-extension when writing a 32-bit register) 避免了该问题。
但是如果调用者只使用字节存储,从 8(%rsp)
读取一个双字可能会导致存储转发停顿。 如果调用者用push
,你很好。但是,如果调用者仅在 call
之前使用 movb 3, (%rsp)
进入已保留的堆栈 space,那么现在您的函数正在从最后一个存储为字节的位置读取一个双字。除非有某种其他的停顿(例如在调用你的函数后的代码提取中),当加载 uop 执行时,字节可能在存储缓冲区中,但加载需要从缓存中加上 3 个字节。或者来自一些仍在存储缓冲区中的早期存储,因此在将存储缓冲区中的字节与缓存中的其他字节合并之前,它还必须扫描存储缓冲区以查找所有可能的匹配项。只有当您加载的所有数据都来自一个商店时,存储转发的快速路径才有效。 (Can modern x86 implementations store-forward from more than one prior store?)
但等等,x86-64 System V 调用约定的未成文 "extension" 意味着没有存储转发停顿的风险
,即使所写的 System V ABI(还?)不需要它。 Clang 生成的代码也依赖于它。这显然包括在内存中传递的参数,正如我们在 Godbolt 上查看调用者所看到的那样。 (我使用 __attribute__((noinline))
所以我可以在启用优化的情况下进行编译,但仍然没有调用内联和优化。否则我可以只注释掉主体并查看只能看到原型的调用者。
这是 不是 C 的 "default argument promotions" 的一部分,用于调用非原型函数。窄参数的 C 类型仍然是 short
或 char
。这只是一个调用约定功能,它允许被调用方对 C 对象的对象表示形式 外部 中的寄存器(或内存)中的位进行假设。但是,如果要求高 32 位为零,则它会更有用,因为您仍然不能将它们用作 64 位寻址模式的数组索引。但是你可以在没有 MOVSX 的情况下先做 int_arg += char_arg
。因此,当您使用 narrow args 时,它可以使代码更高效,并且它们通过 C 规则隐式提升为 int
二元运算符,如 +
.
通过使用 gcc -O3 -maccumulate-outgoing-args
(或 -O0
或 -O1
)编译调用程序,我让 GCC 使用 sub
保留堆栈 space 然后使用movl , (%rsp)
在 call proc
调用您的函数之前。 gcc 使用 movb
会更有效(更小的代码大小),但它选择使用带有 32 位立即数的 movl
。我认为这是因为它在调用约定中实现了那个不成文的规则,而不是其他原因。
更常见的是(没有-maccumulate-outgoing-args
)调用者会在加载之前使用push
或push %rdi
来做一个qword存储,这也可以有效地存储转发到一个dword(或字节)负载。因此,无论哪种方式,arg 将至少写入一个 dword 存储,使 dword 重新加载对性能安全。
双字 mov
加载的代码大小比 movzbl
加载小 1 个字节,并且避免了 MOVSX 或 MOVZX 可能产生的额外成本(在旧的 AMD CPU 和极旧的 Intel CPU 上(P5))。所以我认为这是最优的。
GCC4.7 和更早版本 do 对 char a4
arg 使用 movzbl
(MOVZX) 负载,就像我推荐的一般安全选项一样,但 GCC4.8 及更高版本使用 movl
.
我正在阅读计算机系统:程序员的视角,3/E (CS:APP3e) Randal E. Bryant 和 David R. O'Hallaron,作者说 "Observe that the movl instruction of line 6 reads 4 bytes from memory; the following addb instruction only makes use of the low-order byte"
第6行,他们为什么要用movl?他们为什么不移动 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 只是选择不, 与正常的 movzbl
字节加载相比,节省 1 个字节的代码大小并避免任何部分 -注册来自 movb
加载+合并的惩罚。但是由于不明原因,这不会在加载函数 arg 时导致存储转发停顿。
(此代码 完全 我们从 GCC4.8 和更高版本的 gcc -O1
中获得的那些 C 语句和那些宽度的整数类型。看到它和 clang on the Godbolt compiler explorer 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) is your go-to instruction for loading bytes / words, not movb
or movw
. It's always safe, and on modern CPUs MOVZX loads are literally as fast as dword mov
loads, no extra latency or extra uops; handled right in the load execution unit. (Intel since Core 2 or earlier, AMD since at least Ryzen. https://agner.org/optimize/)。
唯一的成本是 1 个额外字节的代码大小(更大的操作码)。 movsbl
或 movsbq
(又名 MOVSX)符号扩展在较新的 CPU 上同样有效,但在某些 AMD(如某些 Bulldozer 系列)上,它们的延迟比 MOVZX 加载高 1 个周期。因此,如果您关心的只是在加载字节时避免部分寄存器恶作剧,那么更喜欢 MOVZX。
通常只使用movb
或movw
(带有寄存器目标)如果你特别想要合并到低字节或现有 64 位寄存器的字。
写入 %dl
会错误地依赖于在某些 CPU 上编写 EDX 的指令(在您的调用程序中),包括当前的 Intel 和所有 AMD。 (
movl
写入完整的 64 位寄存器 (by implicit zero-extension when writing a 32-bit register) 避免了该问题。
但是如果调用者只使用字节存储,从 8(%rsp)
读取一个双字可能会导致存储转发停顿。 如果调用者用push
,你很好。但是,如果调用者仅在 call
之前使用 movb 3, (%rsp)
进入已保留的堆栈 space,那么现在您的函数正在从最后一个存储为字节的位置读取一个双字。除非有某种其他的停顿(例如在调用你的函数后的代码提取中),当加载 uop 执行时,字节可能在存储缓冲区中,但加载需要从缓存中加上 3 个字节。或者来自一些仍在存储缓冲区中的早期存储,因此在将存储缓冲区中的字节与缓存中的其他字节合并之前,它还必须扫描存储缓冲区以查找所有可能的匹配项。只有当您加载的所有数据都来自一个商店时,存储转发的快速路径才有效。 (Can modern x86 implementations store-forward from more than one prior store?)
但等等,x86-64 System V 调用约定的未成文 "extension" 意味着没有存储转发停顿的风险
__attribute__((noinline))
所以我可以在启用优化的情况下进行编译,但仍然没有调用内联和优化。否则我可以只注释掉主体并查看只能看到原型的调用者。
这是 不是 C 的 "default argument promotions" 的一部分,用于调用非原型函数。窄参数的 C 类型仍然是 short
或 char
。这只是一个调用约定功能,它允许被调用方对 C 对象的对象表示形式 外部 中的寄存器(或内存)中的位进行假设。但是,如果要求高 32 位为零,则它会更有用,因为您仍然不能将它们用作 64 位寻址模式的数组索引。但是你可以在没有 MOVSX 的情况下先做 int_arg += char_arg
。因此,当您使用 narrow args 时,它可以使代码更高效,并且它们通过 C 规则隐式提升为 int
二元运算符,如 +
.
通过使用 gcc -O3 -maccumulate-outgoing-args
(或 -O0
或 -O1
)编译调用程序,我让 GCC 使用 sub
保留堆栈 space 然后使用movl , (%rsp)
在 call proc
调用您的函数之前。 gcc 使用 movb
会更有效(更小的代码大小),但它选择使用带有 32 位立即数的 movl
。我认为这是因为它在调用约定中实现了那个不成文的规则,而不是其他原因。
更常见的是(没有-maccumulate-outgoing-args
)调用者会在加载之前使用push
或push %rdi
来做一个qword存储,这也可以有效地存储转发到一个dword(或字节)负载。因此,无论哪种方式,arg 将至少写入一个 dword 存储,使 dword 重新加载对性能安全。
双字 mov
加载的代码大小比 movzbl
加载小 1 个字节,并且避免了 MOVSX 或 MOVZX 可能产生的额外成本(在旧的 AMD CPU 和极旧的 Intel CPU 上(P5))。所以我认为这是最优的。
GCC4.7 和更早版本 do 对 char a4
arg 使用 movzbl
(MOVZX) 负载,就像我推荐的一般安全选项一样,但 GCC4.8 及更高版本使用 movl
.