为什么const不加速C / C ++代码?


几个月前,我在一篇文章中提到这是一个神话,好像const帮助启用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() ,处理器必须通过指针从内存中检索*x 。 显然, constByArg()的执行可能会稍快一些,因为编译器知道*x是一个常数,因此在constFunc()完成后constFunc()再次加载其值。 对不对 让我们看看由GCC生成并启用了优化的汇编代码:

 $ 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 # The only instruction that's different in 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本身没有区别。

好的,那是海湾合作委员会。 也许我们需要一个更智能的编译器。 说C声。

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

这是中间代码。 它比汇编程序更紧凑,并且我将删除这两个函数,以便您理解我的意思是“除了调用之外没有区别”:

 ; 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 on the local variable printf("%d\n", x); constFunc(&x); printf("%d\n", x); } 

constLocalVar()的汇编代码,其中包含在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 # not in constLocalVar() xorl %eax, %eax movl $1, %edi leaq .LC0(%rip), %rsi # not in 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中间件更加干净。 在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 ,但是您可能会注意到一些奇怪的事情:在主体constLocalVar()constLocalVar()constLocalVar()的调用相同。 如果编译器可以确定constFunc()没有在constLocalVar()修改*x ,那么为什么它不能理解同一函数调用没有在constLocalVar()修改*x呢?

这就是为什么C中的const不能用作优化的原因。 在C中, const本质上具有两个可能的含义:

  • 这可能意味着变量是某些数据的只读假名,它可以是常量,也可以不是常量。
  • 或可能意味着该变量确实是一个常数。 如果将const从指针解const到一个常量值,然后对其进行写入,则将获得未定义的行为。 另一方面,如果const是指向非常量值的指针,则不会有问题。

这是constFunc()的说明性示例实现:

 // x is just a read-only pointer to something that may or may not be a constant void constFunc(const int *x) { // local_var is a true constant const int local_var = 42; // Definitely undefined behaviour by C rules doubleIt((int*)&local_var); // Who knows if this is UB? doubleIt((int*)x); } void doubleIt(int *x) { *x *= 2; } 

constFunc() localVar()constFunc()了指向非const变量的const指针。 由于变量最初不是const ,因此constFunc()可能是骗子,可以在不启动UB的情况下强行修改变量。 因此,编译器不能假定返回constFunc()变量将具有相同的值。 constLocalVar()的变量确实是const ,因此编译器不能假定它不会被更改,因为这一次对于constFunc() 它将是 UB,因此编译器将取消绑定const并将其写入变量。

第一个示例中的byArg()constByArg()函数毫无希望,因为编译器无法确定*x是否为const

但是不一致之处是从哪里来的呢? 如果编译器可以假定constFunc()constLocalVar()调用时不更改其参数,那么它可以对constFunc()调用应用相同的优化,对吗? 不行 编译器无法假设将永远调用constLocalVar() 。 而且如果不是(例如,因为它只是代码生成器或宏操作的一些其他结果),那么constFunc()可以安静地更改数据而无需启动UB。

您可能需要多次阅读以上示例和说明。 不用担心,这听起来很荒谬。 不幸的是,写const变量是最糟糕的UB:大多数情况下,编译器甚至不知道它是否会是UB。 因此,当编译器看到const ,应该从有人可以在某个地方更改它的事实出发,这意味着编译器无法使用const进行优化。 实际上,这是正确的,因为许多真实的C代码都以“我知道我在做什么”的样式包含对const的拒绝。

简而言之,在许多情况下,不允许编译器使用const进行优化,包括使用指针从另一个作用域检索数据,或将数据放置在堆上。 甚至更糟的是,通常在编译器无法使用const情况下,这不是必需的。 例如,任何自重的编译器都可以在没有const情况下理解,在此代码中x是常量:

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

因此const对于优化几乎没有用,因为:

  1. 除少数例外,编译器被迫忽略它,因为某些代码可以合法地解开const
  2. 在上述大多数例外中,编译器仍可以理解该变量是常量。

C ++


如果您使用C ++编写,则const会通过函数重载影响代码生成。 您可以具有相同函数的const和非const重载,并且可以优化非const (由程序员而不是编译器),例如,减少复制。

 void foo(int *p) { // Needs to do more copying of data } void foo(const int *p) { // Doesn't need defensive copies } int main() { const int x = 42; // const-ness affects which overload gets called foo(&x); return 0; } 

一方面,我认为在实践中这通常不用于C ++代码。 另一方面,为了使它真正起作用,程序员必须做出编译器无法使用的假设,因为该语言无法保证它们。

试用SQLite3


足够的理论和牵强的例子。 const对实际代码库const什么影响? 我决定尝试使用SQLite DB(3.30.0版),因为:

  • 它使用const.
  • 这是不平凡的代码库(超过200 KLOC)。
  • 作为数据库,它包括许多机制,从处理字符串值开始到以数字到日期的转换结束。
  • 可以在处理器有限的负载下进行测试。

另外,参与开发的作者和程序员已经花费了数年时间来提高生产力,因此我们可以假设他们没有遗漏任何明显的东西。

准备工作


我制作了两个源代码副本。 一个以普通模式编译,第二个使用hack进行预处理,将const转换为空闲命令:

 #define const 

(GNU) sed可以使用命令sed -i '1i#define const' *.c *.h将其添加到每个文件的顶部。

SQLite使用脚本在构建过程中生成代码,使事情变得有些复杂。 幸运的是,在将代码与const和不带const混合使用时,编译器会引入很多噪声,因此您可以立即注意到并配置脚本以添加我的反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程序集设置。

编译代码分析


对于libSQLite3.so,具有const的版本占用了4,740,704字节,比没有const的版本拥有4,736,712字节大约多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 。 如果我们同时删除staticconst ,那么GCC将再次将它们视为常量,并且我们将获得相同的结果。 由于此类static const变量,在13个函数中对3个函数的更改被证明是错误的,但我没有解决它们。

SQLite使用许多全局变量,大多数真正的const优化与此相关:例如用变量比较代替常量来比较,或者将循环部分回滚一步(要了解做了什么样的优化,我使用Radare )。 一些变化不值得一提。 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
没有const
最低要求
10,658
10,803
中位数
11,571
11,519
最大值
11,832
11,658
平均值
11,531
11,492

就个人而言,我看不出有什么不同。 我从整个程序中删除了const ,因此,如果存在明显差异,那么很容易注意到。 但是,如果性能对您极为重要,那么即使很小的加速度也可以取悦您。 让我们进行统计分析。

我喜欢使用Mann-Whitney U检验来执行此类任务,它与更著名的t检验相似,旨在确定组之间的差异,但更能抵抗在计算机上测量时间时发生的复杂随机变化(由于不可预测的上下文切换,错误)。内存页等)。 结果如下:

const没有const
ñ100100
中类别(平均等级)121.3879.62
曼惠特尼2912
ž-5.10
2面p值<10 -6
平均差异是HL
-0.056秒
95%置信区间
-0.077至-0.038 s

测试U发现在性能上有统计学上的显着差异。 但是-一个惊喜! -事实证明,没有const的版本要快60毫秒,即快0.5%。 似乎少量的“优化”不值得增加代码量。 const不太可能激活任何主要的优化,例如自动矢量化。 当然,您的工作量可能取决于编译器中的各种标志,其版本,代码库或其他内容。 但是在我看来,老实地说,即使const改善了C的性能,我也没有注意到这一点。

那么const需要做什么呢?


尽管存在所有缺陷,但C / C ++中的const对于提供类型安全性很有用。 特别是,如果将const与move语义和std::unique_pointer结合使用,则可以实现显式指针所有权。 在超过100 KLOC的旧C ++代码库中,指针所有权的不确定性是一个巨大的问题,因此,我感谢const解决它。

但是,在我超越使用const来提供类型安全之前。 我听说尽可能积极地使用const来提高性能被认为是正确的。 我听说如果性能真的很重要,那么即使代码变得不太可读,您也必须重构代码以添加更多const 。 当时听起来很合理,但是从那时起,我意识到事实并非如此。

Source: https://habr.com/ru/post/zh-CN464777/


All Articles