几个月前,我在一篇文章中提到
这是一个神话,好像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;
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()
的说明性示例实现:
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
对于优化几乎没有用,因为:
- 除少数例外,编译器被迫忽略它,因为某些代码可以合法地解开
const
。
- 在上述大多数例外中,编译器仍可以理解该变量是常量。
C ++
如果您使用C ++编写,则
const
会通过函数重载影响代码生成。 您可以具有相同函数的
const
和非
const
重载,并且可以优化非
const
(由程序员而不是编译器),例如,减少复制。
void foo(int *p) {
一方面,我认为在实践中这通常不用于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
。 如果我们同时删除
static
和
const
,那么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
,因此,如果存在明显差异,那么很容易注意到。 但是,如果性能对您极为重要,那么即使很小的加速度也可以取悦您。 让我们进行统计分析。
我喜欢使用Mann-Whitney U检验来执行此类任务,它与更著名的t检验相似,旨在确定组之间的差异,但更能抵抗在计算机上测量时间时发生的复杂随机变化(由于不可预测的上下文切换,错误)。内存页等)。 结果如下:
测试U发现在性能上有统计学上的显着差异。 但是-一个惊喜! -事实证明,没有
const
的版本要快60毫秒,即快0.5%。 似乎少量的“优化”不值得增加代码量。
const
不太可能激活任何主要的优化,例如自动矢量化。 当然,您的工作量可能取决于编译器中的各种标志,其版本,代码库或其他内容。 但是在我看来,老实地说,即使
const
改善了C的性能,我也没有注意到这一点。
那么const需要做什么呢?
尽管存在所有缺陷,但C / C ++中的
const
对于提供类型安全性很有用。 特别是,如果将
const
与move语义和
std::unique_pointer
结合使用,则可以实现显式指针所有权。 在超过100 KLOC的旧C ++代码库中,指针所有权的不确定性是一个巨大的问题,因此,我感谢
const
解决它。
但是,在我超越使用
const
来提供类型安全之前。 我听说尽可能积极地使用
const
来提高性能被认为是正确的。 我听说如果性能真的很重要,那么即使代码变得不太可读,您也必须重构代码以添加更多
const
。 当时听起来很合理,但是从那时起,我意识到事实并非如此。