为什么 const 无法让 C 代码跑得更快?

2023-09-06 0 646

为什么 const 无法让 C 代码跑得更快?

我曾说过“有个三个盛行的传闻,const 有利于C++强化 C 和 C++ 标识符”。我真的我须要嘿嘿,特别是曾我他们也误以为这是或许对的。

— Simon Arneaud(译者)

在三个月前的一则文章里,我曾说过“ 有个三个盛行的传闻,const 有利于C++强化 C 和 C++ 标识符 ”。我真的我须要嘿嘿,特别是曾我他们也误以为这是或许对的。我将会用许多方法论并内部结构许多范例来深入研究,接着在三个真实世界的标识符库 Sqlite 上做许多试验和基准试验。

三个单纯的试验

让他们从三个最单纯、最显著的范例已经开始,从前指出这是三个 const 让 C 标识符跑得更慢的范例。具体来说,假定他们有如下表所示三个表达式新闻稿:

void func(int *x); void constFunc(const int *x);

接着假定他们如下表所示三份标识符:

void byArg(int *x) { printf(“%d\n”, *x); func(x); printf(“%d\n”, *x); } void constByArg(const int *x) { printf(“%d\n”, *x); constFunc(x); printf(“%d\n”, *x); }

初始化 printf() 时,CPU 会透过操作符从 RAM 中获得 *x 的值。很或许,constByArg() 会稍稍快一点,即使C++晓得 *x 是自变量,因而不须要在初始化

$ gcc -S -Wall -O3 test.c $ view test.s

下列是表达式 byArg() 的完备编订标识符:

byArg: .LFB23: .cfi_startproc pushq %rbx .cfi_def_cfa_offset 16 .cfi_offset 3, -16 movl (%rdi), %edx movq %rdi, %rbx leaq .LC0(%rip), %rsi movl $1, %edi xorl %eax, %eax call __printf_chk@PLT movq %rbx, %rdi call func@PLT # constFoo 中唯一不同的指令 movl (%rbx), %edx leaq .LC0(%rip), %rsi xorl %eax, %eax movl $1, %edi popq %rbx .cfi_def_cfa_offset 8 jmp __printf_chk@PLT .cfi_endproc

表达式 byArg() 和表达式 constByArg() 生成的编订标识符中唯一的不同之处是 constByArg() 有一句编订标识符 call constFunc@PLT,这正是源标识符中的初始化。关键字 const 本身并没有造成任何字面上的不同。

好了,这是 GCC 的结果。或许他们须要三个更聪明的C++。Clang 会有更好的表现吗?

$ clang -S -Wall -O3 -emit-llvm test.c $ view test.ll

这是 IR 标识符(LCTT 译注:LLVM 的中间语言)。它比编订标识符更加紧凑,所以我可以把三个表达式都导出来,让你可以看清楚我所说的“除了初始化外,没有任何字面上的不同”是什么意思:

; Function Attrs: nounwind uwtable define dso_local void @byArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, …) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @func(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, …) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void } ; Function Attrs: nounwind uwtable define dso_local void @constByArg(i32*) local_unnamed_addr #0 { %2 = load i32, i32* %0, align 4, !tbaa !2 %3 = tail call i32 (i8*, …) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %2) tail call void @constFunc(i32* %0) #4 %4 = load i32, i32* %0, align 4, !tbaa !2 %5 = tail call i32 (i8*, …) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) ret void }

某些有作用的东西

接下来是一组 const 能够真正产生作用的标识符:

void localVar() { int x = 42; printf(“%d\n”, x); constFunc(&x); printf(“%d\n”, x); } void constLocalVar() { const int x = 42; // 对本地变量使用 const printf(“%d\n”, x); constFunc(&x); printf(“%d\n”, x); }

下面是 localVar() 的编订标识符,其中有两条指令在 constLocalVar() 中会被强化掉:

localVar: .LFB25: .cfi_startproc subq $24, %rsp .cfi_def_cfa_offset 32 movl $42, %edx movl $1, %edi movq %fs:40, %rax movq %rax, 8(%rsp) xorl %eax, %eax leaq .LC0(%rip), %rsi movl $42, 4(%rsp) call __printf_chk@PLT leaq 4(%rsp), %rdi call constFunc@PLT movl 4(%rsp), %edx # 在 constLocalVar() 中没有 xorl %eax, %eax movl $1, %edi leaq .LC0(%rip), %rsi # 在 constLocalVar() 中没有 call __printf_chk@PLT movq 8(%rsp), %rax xorq %fs:40, %rax jne .L9 addq $24, %rsp .cfi_remember_state .cfi_def_cfa_offset 8 ret .L9: .cfi_restore_state call __stack_chk_fail@PLT .cfi_endproc

在 LLVM 生成的 IR 标识符中更显著一点。在 constLocalVar() 中,第二次初始化 printf() 之前的 load 会被强化掉:

; Function Attrs: nounwind uwtable define dso_local void @localVar() local_unnamed_addr #0 { %1 = alloca i32, align 4 %2 = bitcast i32* %1 to i8* call void @llvm.lifetime.start.p0i8(i64 4, i8* nonnull %2) #4 store i32 42, i32* %1, align 4, !tbaa !2 %3 = tail call i32 (i8*, …) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 42) call void @constFunc(i32* nonnull %1) #4 %4 = load i32, i32* %1, align 4, !tbaa !2 %5 = call i32 (i8*, …) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %4) call void @llvm.lifetime.end.p0i8(i64 4, i8* nonnull %2) #4 ret void }

好吧,现在,constLocalVar() 成功的省略了对 *x 的重新读取,但是可能你已经注意到许多问题:localVar() 和 constLocalVar() 在表达式体中做了同样的 constFunc() 初始化。如果C++能够推断出 constFunc() 没有修改 constLocalVar() 中的 *x,那为何不能推断出完全一样的表达式初始化也没有修改 localVar() 中的 *x?

这个解释更贴近于为何 C 语言的 const 不能作为强化手段的核心原因。C 语言的 const 有三个有效的含义:它可以表示这个变量是某个可能是常数也可能不是常数的数据的三个只读别名,或者它可以表示该变量是真正的自变量。如果你移除了三个指向自变量的操作符的 const 属性并写入数据,那结果将是三个未定义行为。另一方面,如果是三个指向非自变量值的 const 操作符,将就没问题。

这份 constFunc() 的可能实现揭示了这意味着什么:

// x 是三个指向某个可能是常数也可能不是常数的数据的只读操作符 void constFunc(const int *x) { // local_var 是三个真正的常数 const int local_var = 42; // C 语言规定的未定义行为 doubleIt((int*)&local_var); // 谁晓得这是不是三个未定义行为呢? doubleIt((int*)x); } void doubleIt(int *x) { *x *= 2; }

localVar() 传递给 constFunc() 三个指向非 const 变量的 const 操作符。即使这个变量并非自变量,constFunc() 可以撒个谎并强行修改它而不触发未定义行为。所以,C++不能断定变量在调用 constFunc() 后仍是同样的值。在 constLocalVar() 中的变量是真正的自变量,因而,C++可以断定它不会改变 —— 即使在 constFunc() 去除变量的 const 属性并写入它将会是三个未定义行为。

第三个范例中的表达式 byArg() 和 constByArg() 是没有可能强化的,即使C++没有任何方法能晓得 *x 是否真的是 const 自变量。

补充(和题外话):相当多的读者已经正确地指出,使用 const int *x,该操作符本身不是限定的自变量,只是该数据被加个了别名,而 const int * const extra_const 是三个“双向”限定为自变量的指针。但是即使操作符本身的自变量与别名数据的自变量无关,所以结果是相同的。仅在 extra_const 指向使用 const 定义的对象时,*(int*const)extra_const = 0 才是未定义行为。(实际上,*(int*)extra_const = 0 也不会更糟。)即使它们之间的区别可以一句话说明白,三个是完全的 const 操作符,另外三个可能是也可能不是自变量本身的操作符,而是三个可能是也可能不是自变量的对象的只读别名,我将继续不严谨地引用“自变量操作符”。(题外话结束)

但是为何不一致呢?如果C++能够推断出 constLocalVar() 中初始化的 constFunc() 不会修改它的参数,那么肯定也能继续在其他 constFunc() 的初始化上实施相同的强化,是吗?并不。C++不能假定 constLocalVar() 根本没有运行。如果不是这样(例如,它只是标识符生成器或者宏的许多未使用的额外输出),constFunc() 就能偷偷地修改数据而不触发未定义行为。

你可能须要重复阅读几次上述说明和示例,但不要担心,它听起来很荒谬,它确实是正确的。不幸的是,对 const 变量进行写入是最糟糕的未定义行为:大多数情况下,C++难以晓得它是否将会是未定义行为。所以,大多数情况下,C++看见 const 时必须假定它未来可能会被移除掉,这意味着C++不能使用它进行强化。这在实践中是正确的,即使真实世界的 C 标识符会在“深思熟虑”后移除 const。

简而言之,很多事情都可以阻止C++使用 const 进行强化,包括使用操作符从另一内存空间接受数据,或者在堆空间上分配数据。更糟糕的是,在大部分C++能够使用 const 进行强化的情况,它都不是必须的。例如,任何像样的C++都能推断出下面标识符中的 x 是三个自变量,甚至都不须要 const:

int x = 42, y = 0; printf(“%d %d\n”, x, y); y += x; printf(“%d %d\n”, x, y);

总结,const 对强化而言几乎无用,即使:

除了特殊情况,C++须要忽略它,即使其他标识符可能合法地移除它在 #1 以外的大多数例外中,C++无论如何都能推断出该变量是自变量

C++

如果你在使用 C++ 那么有另外三个方法让 const 能够影响到标识符的生成:表达式重载。你可以用 const 和非 const 的参数重载同三个表达式,而非 const 版本的标识符可能可以被强化(由程序员强化而不是C++),减少某些拷贝或者其他事情。

void foo(int *p) { // 须要做更多的数据拷贝 } void foo(const int *p) { // 不须要保护性的拷贝副本 } int main() { const int x = 42; // const 影响被初始化的是哪一个版本的重载表达式 foo(&x); return 0; }

一方面,我不指出这会在实际的 C++ 标识符中大量使用。另一方面,为了导致差异,程序员须要假定编译器难以做出,即使它们不受语言保护。

用 Sqlite3 进行试验

有了足够的方法论和范例。那么 const 在三个真正的标识符库中有多大的影响呢?我将会在标识符库 Sqlite(版本:3.30.0)上做三个测试,即使:

它真正地使用了 const它不是三个单纯的标识符库(超过 20 万行标识符)作为三个数据库,它包括了字符串处理、数学计算、日期处理等一系列内容它能够在绑定 CPU 的情况下进行负载试验

此外,译者和贡献者们已经进行了多年的性能强化工作,因而我能确定他们没有错过任何有显著效果的强化。

配置

我做了三份 源码 拷贝,并且正常编译其中一份。而对于另一份拷贝,我插入了这个特殊的预处理标识符段,将 const 变成三个空操作:

#define const

(GNU) sed 可以将许多东西添加到每个文件的顶端,比如 sed -i 1i#define const *.c *.h。

在编译期间使用脚本生成 Sqlite 标识符稍稍有点复杂。幸运的是当 const 标识符和非 const 标识符混合时,C++会产生了大量的提醒,因而很容易发现它并调整脚本来包含我的反 const 标识符段。

直接比较编译结果毫无意义,即使任意微小的改变就会影响整个内存布局,这可能会改变整个标识符中的操作符和表达式初始化。因而,我用每个指令的二进制大小和编订标识符作为识别码(objdump -d libsqlite3.so.0.8.6)。举个范例,这个表达式:

000000000005d570 <sqlite3_blob_read>: 5d570: 4c 8d 05 59 a2 ff ff lea -0x5da7(%rip),%r8 # 577d0 <sqlite3BtreePayloadChecked> 5d577: e9 04 fe ff ff jmpq 5d380 <blobReadWrite> 5d57c: 0f 1f 40 00 nopl 0x0(%rax)

将会变成这样:

sqlite3_blob_read 7lea 5jmpq 4nopl

在编译时,我保留了所有 Sqlite 的编译设置。

分析编译结果

const 版本的 libsqlite3.so 的大小是 4,740,704 字节,大约比 4,736,712 字节的非 const 版本大了 0.1% 。在全部 1374 个导出表达式(不包括类似 PLT 里的底层辅助表达式)中,一共有 13 个表达式的识别码不一致。

其中的许多改变是由于插入的预处理标识符。举个范例,这里有三个发生了更改的表达式(已经删去许多 Sqlite 特有的定义):

#define LARGEST_INT64 (0xffffffff|(((int64_t)0x7fffffff)<<32)) #define SMALLEST_INT64 (((int64_t)-1) – LARGEST_INT64) static int64_t doubleToInt64(double r){ /* ** Many compilers we encounter do not define constants for the ** minimum and maximum 64-bit integers, or they define them ** inconsistently. And many do not understand the “LL” notation. ** So we define our own static constants here using nothing ** larger than a 32-bit integer constant. */ static const int64_t maxInt = LARGEST_INT64; static const int64_t minInt = SMALLEST_INT64; if( r<=(double)minInt ){ return minInt; }else if( r>=(double)maxInt ){ return maxInt; }else{ return (int64_t)r; } }

删去 const 使得这些自变量变成了 static 变量。我不明白为何会有不了解 const 的人让这些变量加上 static。同时删去 static 和 const 会让 GCC 再次指出它们是自变量,而他们将得到同样的编译输出。由于类似这样的局部的 static const 变量,使得 13 个表达式中有 3 个表达式产生假的变化,但我三个都不打算修复它们。

Sqlite 使用了很多全局变量,而这正是大多数真正的 const 强化产生的地方。通常情况下,它们类似于将三个变量比较代替成三个自变量比较,或者三个循环在部分展开的一步。( Radare toolkit 可以很方便的找出这些强化措施。)许多变化则令人失望。sqlite3ParseUri() 有 487 个指令,但 const 产生的唯一区别是进行了这个比较:

test %al, %al je <sqlite3ParseUri+0x717> cmp $0x23, %al je <sqlite3ParseUri+0x717>

并交换了它们的顺序:

cmp $0x23, %al je <sqlite3ParseUri+0x717> test %al, %al je <sqlite3ParseUri+0x717>

基准试验

Sqlite 自带了三个性能回归试验,因而我尝试每个版本的标识符执行一百次,仍然使用默认的 Sqlite 编译设置。以秒为单位的试验结果如下表所示:

为什么 const 无法让 C 代码跑得更快?

我从整个程序中删去 const,所以如果它有显著的差别,那么我希望它是显而易见的。但也许你关心任何微小的差异,即使你正在做许多绝对性能非常重要的事。那让他们试一下统计分析。

我喜欢使用类似 Mann-Whitney U 检验这样的东西。它类似于更著名的 T 检验,但对你在机器上计时时产生的复杂随机变量(由于不可预测的上下文切换、页错误等)更加健壮。下列是结果:

为什么 const 无法让 C 代码跑得更快?

U 检验已经发现统计意义上具有显著的性能差异。但是,令人惊讶的是,实际上是非 const 版本更慢——大约 60ms,0.5%。似乎 const 启用的少量“强化”不值得额外标识符的开销。这不像是 const 启用了任何类似于自动矢量化的重要的强化。当然,你的结果可能即使C++配置、C++版本或者标识符库等等而有所不同,但是我真的这已经说明了 const 是否能够有效地提高 C 的性能,他们现在已经看到答案了。

那么,const 有什么用呢?

尽管存在缺陷,C/C++ 的 const 仍有利于类型安全。特别是,结合 C++ 的移动语义和 std::unique_pointer,const 可以使操作符所有权显式化。在超过十万行标识符的 C++ 旧标识符库里,操作符所有权模糊是三个大难题,我对此深有感触。

但是,我从前常常使用 const 来实现有意义的类型安全。我曾听说过基于性能上的原因,最好是尽可能多地使用 const。我曾听说过当性能很重要时,重构标识符并添加更多的 const 非常重要,即使以降低标识符可读性的方式。当时真的这没问题,但后来我才晓得这并不对。

via: https://theartofmachinery.com/2019/08/12/c_const_isnt_for_performance.html

译者: Simon Arneaud 选题: lujun9972 译者: LazyWolfLin 校对: wxy

本文由 LCTT 原创编译, Linux中国 荣誉推出

点击“了解更多”可访问文内链接

相关文章

发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务