C 编译器如何在内存方面处理 post-增量 (i++)?
How does C compiler handle post-increment (i++), memory wise?
我一直在向朋友解释 i++
与 ++i
的细节。我告诉他 没有优化 ,for
循环中的 i++
本质上意味着制作一个不用于任何用途的 i
副本。因为 i++
可以用这个伪代码来描述:
tmp = i;
i = i + 1;
return tmp;
好吧,我注意到我真的不知道一件事:我们 tmp
的内存分配在哪里?它会增加整个 procedure/function 所需的内存大小吗? (也就是说,它在堆栈上吗?)
我想是的,但是如何测试呢? 当且仅当它重要时 我们正在谈论 C99 标准和 GCC 编译器。但我更喜欢更广泛的答案,以便对此事有一些看法。
从语义上讲,它是一个自动变量,它在内存中的位置是留给编译器的。
实际上,这取决于您是在谈论内置运算符还是用户定义的运算符、return 类型和您的 ABI。
例如,对于整数类型的内置增量,在 X64 CPU 上,整数的 return 值被放入寄存器中。所以变量不占用任何 space 并且根本不占用
对于原始类型,前缀与后缀仅影响增量步骤的顺序。比较 printf("%u", i++);
和 printf("%u", ++i);
。前者的伪汇编可能类似于(假设 i
已经在寄存器中):
load "%u" to arg1register
move i to arg2register
call printf
increment i // Could occur before call to printf if arg2register separate from i
而对于后者,它只是重新排序增量步骤:
load "%u" to arg1register
increment i // Could occur at any point before this and after last use of i
move i to arg2register
call printf
在for
循环递增步骤中,即使关闭优化,i++
和++i
也是一样的,因为"result"未被使用;它不需要加载、移动等,所以它只是 increment i
.
如果它不是基本类型,那么它会调用适当的 ++
重载,并且由后缀 ++
重载来制作副本(通常存储在堆栈中,像任何其他变量一样)在原地递增并返回副本之前。即使在 for
循环增量步骤中,它也不能保证后缀操作等同于前缀,因此除非进行极端的编译器优化(这可能不是标准合法的),对于非原始类型 i++
将必须调用后缀运算符,不必要地创建和销毁临时文件。
这是低效的,这就是为什么在 C++ 中一直坚持使用前缀递增被认为是好的做法;如果您的代码从使用 int
更改为 mpz_class
,您不希望在每次增量时都创建和销毁 mpz_class
。由于前缀对基元是无害的,much 对用户定义的类型更好,所以只使用前缀,除非这样做会更丑陋(即使那样,只有当它是基元时)。
不需要额外的内存。使用 ++i 或 i++ i 的值存在于内存中,它被加载到处理器中,放入寄存器中, cpu 指令递增它(加法或递增指令取决于处理器),结果最终在一个寄存器中,然后结果被保存到我来自的内存中的相同位置。
++i 和 i++ 之间的区别实际上只是处理器指令的序列。 i++ 将确保在值递增之前使用初始值首先发生 i 的值用于其他任何事情。
如果该值未在其他地方使用,则生成的机器代码几乎没有区别。
您假设编译器总是为 ++i
和 i++
没有优化 产生不同的结果是错误的。下面看看 pre and post increment on godbolt, in gcc 6.2, no optimization:
C 代码
int pre() {
int i = 0;
++i;
}
int post() {
int i= 0;
i++;
}
程序集 (x86-64)
pre():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0
add DWORD PTR [rbp-4], 1
nop
pop rbp
ret
post():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0
add DWORD PTR [rbp-4], 1
nop
pop rbp
ret
请注意,i++
和 ++i
的编译代码逐字节相同。两者都只是将 1 添加到堆栈上为 i
保留的内存位置。没有创建或不需要临时文件。
您可能会抱怨我实际上并没有使用递增表达式的值,所以让我们 look at something 实际使用该值:
C 代码
int pre(int i) {
return ++i;
}
int post(int i) {
return i++;
}
程序集 (x86-64)
pre(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
add DWORD PTR [rbp-4], 1
mov eax, DWORD PTR [rbp-4]
pop rbp
ret
post(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
lea edx, [rax+1]
mov DWORD PTR [rbp-4], edx
pop rbp
ret
在这里,装配是不同的。预自增版本使用内存RMW(read-modify-write)指令自增变量,而post自增版本通过edx
单独自增变量。虽然查看未优化的代码总是徒劳的,但我很确定 post-increment 版本在这里 faster,因为依赖链更小,因为关键路径中没有 RMW 指令和后续存储转发停顿。
要注意的是,即使在这里也没有在内存中分配 "temporary space" - 只有程序集发生变化,并且寄存器(eax
这里)免费用作在 post 增量之前 i
的值。
当然,您不应该真正将 任何内容 读入未优化的代码中。它不会在实践中使用,您无法通过研究它真正了解任何结构的效率,因为优化后的代码在不同的习语中会有很大差异。
所以最后,让我们看看a realistic example我们使用优化的地方,both增量表达式和底层变量的值都被实际使用了(所以两者都不是值被优化掉了)。这里我们在每个函数中对 i 进行 int &
引用,以便修改传入的值。我们使用 -O2,尽管我尝试的所有其他优化级别都会产生相同的结果,除了 -Os
:
C 代码
int pre(int& i) {
return ++i;
}
int post(int& i) {
return i++;
}
程序集 (x86-64)
pre(int&):
mov eax, DWORD PTR [rdi]
add eax, 1
mov DWORD PTR [rdi], eax
ret
post(int&):
mov eax, DWORD PTR [rdi]
lea edx, [rax+1]
mov DWORD PTR [rdi], edx
ret
两种功能的成本几乎完全相同。它们具有相同数量的指令,并且在现代英特尔硬件上以相同的成本产生相同数量的 uops(4 个融合域)。这些函数占用的指令字节数完全相同1.
post-增量的不同之处在于它使用 lea
指令将其结果放入 edx
,因此 eax
保持不变,因为 return 值。预增量版本只是对所有内容使用 eax
。 edx
的使用在这里没有直接开销,因为它在 x86-64 中是一个临时寄存器,所以不需要保存它的值。在更复杂的代码中,使用另一个寄存器 可能会 增加寄存器压力,尽管这种可能性很小,因为生命周期非常短并且有更多重新排序的机会。
post-increment 版本实际上对 return 值有一个更小的依赖链——假设调用者使用 eax
中的 return 值,它将需要1 个额外的周期使其准备就绪(因为 add eax, 1
是依赖链的一部分)。这实际上是预增量定义中固有的:在某种程度上,预增量较慢,因为值的增量和后续使用必须连续发生,而在post-increment case 它们可以并行发生,因为值的使用不依赖于增量操作。当然,这种影响非常小——通常不会超过一个周期。使用预增量的经典建议可能仍然适用,因为对于对象,它可以产生 big 差异。对于原语,没那么多。
1有趣的是,预增量版本可以用 inc eax
而不是 add eax, 1
实现,这在现代硬件上可能和保存一个字节。这可能不是因为由于部分标志停顿而避免 inc
和 dec
的大部分过时建议。事实上,使用 -Os
(针对大小进行优化)gcc 确实 在此处使用 inc
。
我一直在向朋友解释 i++
与 ++i
的细节。我告诉他 没有优化 ,for
循环中的 i++
本质上意味着制作一个不用于任何用途的 i
副本。因为 i++
可以用这个伪代码来描述:
tmp = i;
i = i + 1;
return tmp;
好吧,我注意到我真的不知道一件事:我们 tmp
的内存分配在哪里?它会增加整个 procedure/function 所需的内存大小吗? (也就是说,它在堆栈上吗?)
我想是的,但是如何测试呢? 当且仅当它重要时 我们正在谈论 C99 标准和 GCC 编译器。但我更喜欢更广泛的答案,以便对此事有一些看法。
从语义上讲,它是一个自动变量,它在内存中的位置是留给编译器的。 实际上,这取决于您是在谈论内置运算符还是用户定义的运算符、return 类型和您的 ABI。 例如,对于整数类型的内置增量,在 X64 CPU 上,整数的 return 值被放入寄存器中。所以变量不占用任何 space 并且根本不占用
对于原始类型,前缀与后缀仅影响增量步骤的顺序。比较 printf("%u", i++);
和 printf("%u", ++i);
。前者的伪汇编可能类似于(假设 i
已经在寄存器中):
load "%u" to arg1register
move i to arg2register
call printf
increment i // Could occur before call to printf if arg2register separate from i
而对于后者,它只是重新排序增量步骤:
load "%u" to arg1register
increment i // Could occur at any point before this and after last use of i
move i to arg2register
call printf
在for
循环递增步骤中,即使关闭优化,i++
和++i
也是一样的,因为"result"未被使用;它不需要加载、移动等,所以它只是 increment i
.
如果它不是基本类型,那么它会调用适当的 ++
重载,并且由后缀 ++
重载来制作副本(通常存储在堆栈中,像任何其他变量一样)在原地递增并返回副本之前。即使在 for
循环增量步骤中,它也不能保证后缀操作等同于前缀,因此除非进行极端的编译器优化(这可能不是标准合法的),对于非原始类型 i++
将必须调用后缀运算符,不必要地创建和销毁临时文件。
这是低效的,这就是为什么在 C++ 中一直坚持使用前缀递增被认为是好的做法;如果您的代码从使用 int
更改为 mpz_class
,您不希望在每次增量时都创建和销毁 mpz_class
。由于前缀对基元是无害的,much 对用户定义的类型更好,所以只使用前缀,除非这样做会更丑陋(即使那样,只有当它是基元时)。
不需要额外的内存。使用 ++i 或 i++ i 的值存在于内存中,它被加载到处理器中,放入寄存器中, cpu 指令递增它(加法或递增指令取决于处理器),结果最终在一个寄存器中,然后结果被保存到我来自的内存中的相同位置。
++i 和 i++ 之间的区别实际上只是处理器指令的序列。 i++ 将确保在值递增之前使用初始值首先发生 i 的值用于其他任何事情。
如果该值未在其他地方使用,则生成的机器代码几乎没有区别。
您假设编译器总是为 ++i
和 i++
没有优化 产生不同的结果是错误的。下面看看 pre and post increment on godbolt, in gcc 6.2, no optimization:
C 代码
int pre() {
int i = 0;
++i;
}
int post() {
int i= 0;
i++;
}
程序集 (x86-64)
pre():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0
add DWORD PTR [rbp-4], 1
nop
pop rbp
ret
post():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0
add DWORD PTR [rbp-4], 1
nop
pop rbp
ret
请注意,i++
和 ++i
的编译代码逐字节相同。两者都只是将 1 添加到堆栈上为 i
保留的内存位置。没有创建或不需要临时文件。
您可能会抱怨我实际上并没有使用递增表达式的值,所以让我们 look at something 实际使用该值:
C 代码
int pre(int i) {
return ++i;
}
int post(int i) {
return i++;
}
程序集 (x86-64)
pre(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
add DWORD PTR [rbp-4], 1
mov eax, DWORD PTR [rbp-4]
pop rbp
ret
post(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
lea edx, [rax+1]
mov DWORD PTR [rbp-4], edx
pop rbp
ret
在这里,装配是不同的。预自增版本使用内存RMW(read-modify-write)指令自增变量,而post自增版本通过edx
单独自增变量。虽然查看未优化的代码总是徒劳的,但我很确定 post-increment 版本在这里 faster,因为依赖链更小,因为关键路径中没有 RMW 指令和后续存储转发停顿。
要注意的是,即使在这里也没有在内存中分配 "temporary space" - 只有程序集发生变化,并且寄存器(eax
这里)免费用作在 post 增量之前 i
的值。
当然,您不应该真正将 任何内容 读入未优化的代码中。它不会在实践中使用,您无法通过研究它真正了解任何结构的效率,因为优化后的代码在不同的习语中会有很大差异。
所以最后,让我们看看a realistic example我们使用优化的地方,both增量表达式和底层变量的值都被实际使用了(所以两者都不是值被优化掉了)。这里我们在每个函数中对 i 进行 int &
引用,以便修改传入的值。我们使用 -O2,尽管我尝试的所有其他优化级别都会产生相同的结果,除了 -Os
:
C 代码
int pre(int& i) {
return ++i;
}
int post(int& i) {
return i++;
}
程序集 (x86-64)
pre(int&):
mov eax, DWORD PTR [rdi]
add eax, 1
mov DWORD PTR [rdi], eax
ret
post(int&):
mov eax, DWORD PTR [rdi]
lea edx, [rax+1]
mov DWORD PTR [rdi], edx
ret
两种功能的成本几乎完全相同。它们具有相同数量的指令,并且在现代英特尔硬件上以相同的成本产生相同数量的 uops(4 个融合域)。这些函数占用的指令字节数完全相同1.
post-增量的不同之处在于它使用 lea
指令将其结果放入 edx
,因此 eax
保持不变,因为 return 值。预增量版本只是对所有内容使用 eax
。 edx
的使用在这里没有直接开销,因为它在 x86-64 中是一个临时寄存器,所以不需要保存它的值。在更复杂的代码中,使用另一个寄存器 可能会 增加寄存器压力,尽管这种可能性很小,因为生命周期非常短并且有更多重新排序的机会。
post-increment 版本实际上对 return 值有一个更小的依赖链——假设调用者使用 eax
中的 return 值,它将需要1 个额外的周期使其准备就绪(因为 add eax, 1
是依赖链的一部分)。这实际上是预增量定义中固有的:在某种程度上,预增量较慢,因为值的增量和后续使用必须连续发生,而在post-increment case 它们可以并行发生,因为值的使用不依赖于增量操作。当然,这种影响非常小——通常不会超过一个周期。使用预增量的经典建议可能仍然适用,因为对于对象,它可以产生 big 差异。对于原语,没那么多。
1有趣的是,预增量版本可以用 inc eax
而不是 add eax, 1
实现,这在现代硬件上可能和保存一个字节。这可能不是因为由于部分标志停顿而避免 inc
和 dec
的大部分过时建议。事实上,使用 -Os
(针对大小进行优化)gcc 确实 在此处使用 inc
。