提问人:Sasha 提问时间:9/21/2023 更新时间:9/21/2023 访问量:89
C 循环条件中的函数
function inside C loop condition
问:
我可以有这样的东西:
for (unsigned i = 0; i < get_length(object); ++i) {
...
}
我确定 get_length(object) 在循环生命周期内不会改变,或者我可以将其转换为:
const unsigned length = get_length(object);
for (unsigned i = 0; i < length; ++i) {
...
}
也许编译器经常无法确定是否真的不会改变,所以它会在每次循环中评估它,所以可能第二种形式更好,尽管它对眼睛来说更麻烦?get_length(object)
答:
第一种形式还是第二种形式最容易看是有争议的。
无论如何,对于第二种形式,我们确保编译器只会调用一次。我猜大多数有经验的程序员会更喜欢这种形式,如果函数确实打算在循环期间返回一个常量,因为第二种形式本身就传达了这个假设。get_length()
在第一种形式中,编译器可能在简单情况下能够推断出它将是常量,因此只调用一次。然而,似乎至少 GCC 还没有那么聪明。具有优化级别的 Godbolt 链接。get_length()
-O3
链接的汇编程序代码可能有点难以阅读,但在 中,请注意第 37 行中的指令跳转到第 21 行,这是与我的示例实现相对应的内联代码的开头。因此,在这种情况下,对应于 的代码将在循环的每次迭代中执行。f1
jne .L15
get_length()
get_length()
也许编译器可以更好地优化更简单的示例,或者编译器的未来版本或其他编译器可以优化此示例。无论如何,通过使用第二种形式,我们可以避免这种猜测,并且必须查看编译的汇编代码。
这个例子和分析应该说明第二种形式的好处。
评论
const
printf
__builtin_printf
__attribute__((pure))
const char *const object = "Hello";
object
get_length
pure
const object
const char *
get_length
extern const char object[];
)
return p->length
const
我通常将其呈现为:
for (unsigned i = 0, length = get_length(); i < length; ++i) {
...
}
...这稍微多了一点,可以自我记录,并保持外部示波器的清洁。但在大多数情况下,这确实是一个不必要的微优化,在许多情况下,编译器已经可以发现它可以将调用从循环中提升出来,特别是如果你已经正确地完成了指针限制,并且定义了它可以内联的某个地方。get_length
评论
get_length
可以是任何内容,从编译器知道的内联函数到编译器不知道的外部库调用。因此,微观优化可能不是其中之一。
get_length(const foo *p)
return p->length
第二种形式可能确实更好 - 我将尝试解释为什么,同时我们更接近这里可能发生的各种形式的优化。
假设我们有 where 不修改任何内容,并且结果将始终是相同的整数常量,那么:for (unsigned i = 0; i < get_length(object); ++i)
get_length
- 这里需要优化,将函数调用替换为硬编码数字。
- 或者,如果由于数字是在运行时计算而无法完成的,我们希望程序只调用该函数一次。
- 我们不希望程序毫无意义地一遍又一遍地调用该函数,因为这会非常慢。
从历史上看,编译器在这里非常糟糕,因此过去的传统良好做法总是将函数调用移到循环上方。较旧的 C 编程书籍肯定会告诉你很多。但它在今天仍然是很好的做法,因为我们可能并不总是能够依赖优化。
让我们从一个产生最需要的“硬编码数字”优化的示例开始:
#include <stdint.h>
#include <stdio.h>
typedef struct
{
size_t length;
} obj_t;
size_t get_length (const obj_t* obj)
{
return obj->length;
}
int main (void)
{
obj_t object = { 5 };
for (size_t i = 0; i<get_length(&object); ++i)
{
puts("hello world");
}
}
我们可以在这里查看 Godbolt 中具有最大优化 () 的生成汇编器:https://godbolt.org/z/9GnrEGvxc。相关部分是 .编译器完全忽略了结构、函数、函数调用等一切。它只是将值 5 存储在 CPU 寄存器中,然后调用与该数字一样多的次数。这是非常高效的代码。gcc -O3
mov ebx, 5
puts
现在,让我们通过在运行时将数字 5 传递给程序来稍微阻止优化器:
int main (int argc, char* argv[]) // I'll pass "5" in argv
{
// quick and dirty string-to-int conversion:
obj_t object = { argv[1][0]-'0' };
https://godbolt.org/z/4Mcjsha5d。现在,编译器必须从命令行参数中实际加载值。但它仍然很自大,因为它注意到它可以将读取的数字保存在寄存器中以备后用 - 仍然没有任何地方。当这种优化发生时,我们说函数是内联的。因为从本质上讲,函数所做的整个事情(在 Godbolt 反汇编中)入到调用者端(保存“rax”以备后用)。call get_length
mov rax, QWORD PTR [rdi]
mov rax...
正如我们目前所看到的,我们把函数调用放在哪里并不重要,因为编译器仍然表现得很聪明。我们必须编写一个效率低得多的程序来产生实际的函数调用。例如,我们可以将函数和结构变量移动到另一个 .c 文件中。
在这种情况下,我将作弊,只是通过 gcc 非标准函数属性禁用函数内联,同时仍然保留所有其他优化。https://godbolt.org/z/j3WPscxWq最终被击败,编译器被迫使用实际的函数调用生成错误代码。for 循环被翻译为:-O3
.L5:
mov edi, OFFSET FLAT:.LC0
add rbx, 1
call puts
.L4:
lea rdi, [rsp+8]
call get_length
cmp rbx, rax
jb .L5
现在,随着内联的消失,我们注意到它确实调用了循环的每一圈。每当编译器既不能在编译时确定函数的结果,也不能通过抓取函数的主体并将其插入调用方代码来执行内联时,就会发生这种情况。当函数位于另一个单独编译的 .c 文件中时,通常会发生这种情况。get_length
我们可能期望它只调用函数一次,但这种优化也没有发生。正如下面另一个答案的评论中所指出的,无论出于何种原因,gcc 编译器都未能对其进行优化,而 clang 编译器将其优化为仅调用一次函数。显然,我们不能在这里依赖编译器优化,因为不同的编译器和编译器端口的结果会有所不同。
在这一点上,移出循环将产生更好的结果。https://godbolt.org/z/f6dEGMfrn。该函数仅调用一次,调用结果存储在一个寄存器中,存储在另一个寄存器中,并且对循环的每一圈进行比较寄存器。get_length
mov rbp, rax
i
cmp rbp, rbx
现在我们已经证明,第二种形式确实更有效率,但前提是编译器不能内联函数或提前确定结果。
然而,我们应该注意,像 or etc 这样的通用标准库函数很可能会被编译器有效地内联。所以他们旧的 C 书告诉你专门离开循环条件已经过时了。如有疑问,请拆卸并查找说明 - 您甚至不需要了解汇编程序。strlen
memcpy
strlen
call strlen
评论
get_length
strlen
0
"H"
strlen
专门移出循环条件已经过时了——如果你在循环中调用任何可能修改你调用的缓冲区内容的函数,编译器一定不能优化,因为存储在任何地方可能会改变结果。因此,这仍然是一个不错的建议,除非您正在使用一个地址永远不会转义此函数的本地人(这不太可能),或者您的循环不调用任何函数并且不写入缓冲区。strlen
0
char buf[]
__attribute__((noinline))
__attribute__((noipa))
noinline
__attribute__((const))
gcc -O3 -fno-inline
甚至对微小的函数也禁用内联,而不需要属性。godbolt.org/z/5bYGeKEvT 显示 GCC 仍在循环内调用,而不是推断,但 clang 确实如此。如果我们更改为 ,则相同,以防 GCC 对 main 的隐式影响优化。所以,是的,GCC 在不内联的情况下进行优化确实更差。__attribute__((const))
main
foo
__attribute__((cold))
评论