const 并不能加快 C 代码的运行速度?

2022-12-21 0 740

const 对 C、C++ 来说,究竟意味著甚么?

const 并不能加快 C 代码的运行速度?

译者 | Simon Arneaud

翻译者 | 弯月,白眉林 | 屠敏

公司出品 | CSDN(ID:CSDNnews)

下列为原文:

三个月前,我曾在一则该文中说“const有利于强化C和C++的C++”而已三个民间传说。我真的我如果嘿嘿,不光原因在于从前我他们也曾一度指出这是不可否认的历史事实。在责任编辑中,我将从许多方法论和范例著手,接着在三个或者说的标识符库Sqlite上展开试验和基准试验。

const 并不能加快 C 代码的运行速度?

单纯的试验

让他们思索三个最单纯的范例,曾我误以为那个范例中的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必须通过指针从内后生成的汇编标识符:

$ 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 thats different in constFoomovl (%rbx), %edxleaq .LC0(%rip), %rsixorl %eax, %eaxmovl $1, %edipopq %rbx.cfi_def_cfa_offset 8jmp __printf_chk@PLT.cfi_endproc

比较byArg和constByArg ,GCC生成的汇编标识符中唯一的区别在于,constByArg中的调用是call constFunc@PLT,跟源标识符写的一样。const本身实际上没有带来任何差异。

那么好,这是GCC的结果。也许他们需要三个更加智能的C++,那么他们来看看Clang是不是会更好一点。

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

$ view test.ll

生成的IR(中间标识符表示形式)如下表所示所示,比汇编标识符更为紧凑,所以我把三个表达式的标识符都罗列出来了,你可以看到我说的一点都没错:“除了那个调用之外,二者毫无区别”。

; 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= loadi32, 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 并不能加快 C 代码的运行速度?

也许能提高效率的标识符

如下表所示标识符中的const确实能提高效率:

void localVar

{

int x = 42

;

printf(“%d\n”, x

);

constFunc(&x

);

printf(“%d\n”, x

);

}

void constLocalVar

{

const int x = 42; //const on thelocal

variable

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 # not in constLocalVarxorl %eax, %eaxmovl $1, %edileaq .LC0(%rip), %rsi # not in constLocalVarcall __printf_chk@PLTmovq 8(%rsp), %raxxorq %fs:40, %raxjne .L9addq $24, %rsp.cfi_remember_state.cfi_def_cfa_offset 8ret.L9:.cfi_restore_statecall __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, align4%2 = bitcast i32* %1 to i8

*

call void @llvm.lifetime.start.p0i8(i64 4, i8* non %2) #4store 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* non %1) #4%4 = load i32, i32* %1, align 4, !tbaa !2%5= calli32 (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* non %2) #4

ret void

}

所以说,constLocalVar成功地省略了重新加载*x的步骤,但是你也产生了许多疑惑:localVar和constLocalVar中调用的都是constFunc。如果C++可以推断出constLocalVar中的constFunc没有修改*x,那么为甚么无法推断出localVar中的同三个函数也不会修改*x呢?

为了搞清楚那个问题,他们需要先思索另三个问题的实质:为甚么他们不可以把C中的const当作强化工具?实际上,C中的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 undefinedbehaviourby

C rules

doubleIt((int*)&local_var);

// Who knows if this is

UB?

doubleIt((int*)x);

}

void doubleIt(int *x)

{

*x *= 2

;

}

localVar给了constFunc 三个指向非const变量的const指针。因为变量原本不是const,所以constFunc可能会撒谎,在不触发“未定义的行为”的情况下强制修改它。因此C++无法假定在constFunc返回后,那个变量的值仍然不变。而constLocalVar中的变量确实是const,所以C++可以假定它的值不会改变——因为在这种情况下,如果constFunc 去掉const,并重新赋值,那么必然会触发“未定义的行为”。

第三个范例中的byArg和constByArg表达式不会被强化,因为C++不知道*x是否真的是const。

补充:很多读者指出,对const int *x来说,那个指针本身算不上const,它而已打着常量旗号的数据,只有写成const int * const extra_const,才能表明指针和数据都是const。但原因在于指针本身的常量性质与引用数据的常量性质无关,所以结果是相同的。如果extra_const = 0指向某个定义为const对象的话,那么*(int*const)extra_const = 0依然会触发“未定义的行为”。(实际上*(int*const)extra_const = 0是最糟糕的情况)。说究竟,他们而已为了区分或者说的const指针,和另一种指针——那个指针本身可能是常量也可能不是常量,而它指向的对象可能是常量也可能不是常量,好拗口(呼~),所以我还是泛泛地称之为“const指针”吧。

那么为甚么会产生这种不一致呢?如果C++假定在constLocalVar的调用中,constFunc不会修改它的参数,那么就可以同样强化constFunc调用了,对不对?然而,并不是。因为C++甚至无法假定constLocalVar有没有运转。如果没有运转(例如,它而已标识符生成器或宏生成的许多不会被用到的代码),那么constFunc就有可能会偷偷修改数据,而不会触发“未定义的行为”。

你可能需要反复阅读上述说明和示例,即便你感觉这很荒谬也没关系,因为这确实很荒谬。然而,不幸的是,给const变量赋值触发的“未定义的行为”最为糟糕:因为在大多数情况下,C++甚至不知道这究竟是不是“未定义的行为”。因此,在大多数情况下,当C++看到const时,它不得不假定某个人会在某个地方去掉那个const,因此编译器无法使用const强化标识符。实际上这种情况的确会发生,因为现实世界中许多C标识符都本着“我知道他们在干甚么”的态度用强制转换去掉const。

简来说之,很多时候C++都无法强化const,包括通过指针从另三个作用域接收数据,或者在堆中分配数据。更有甚至,在大多数情况下,即便你不指定const,C++也会使用常量。例如,优秀的C++都知道如下表所示标识符中的x是常量,即便x的定义中没有指定const:

int x = 42, y = 0

;

printf(“%d %d\n”, x, y

);

y += x

;

printf(“%d %d\n”, x, y);

总之,const达不到强化目的的原因主要包括:

除了个别特殊情况外,C++不得不忽略const,因为其他标识符可能会合法地抛弃const。

在第1条之外的情况下,大多数时候C++能自动分辨出某个变量是不是常量。

const 并不能加快 C 代码的运行速度?

C++

如果你使用C++,那么const可能会通过其他途径影响标识符的生成:表达式重载。针对同三个表达式,你可以使用const和非const三种形式的重载,并且还可以通过强化非const(由程序员而非C++完成)减少复制或其他工作。

void foo(int *p)

{

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

一方面,我指出实际的C++中不会大量使用上述标识符。另一方面,为了观察到或者说的差异,程序员必须做出C++无法做出的假定,因为C++语言没有这方面的保证。

const 并不能加快 C 代码的运行速度?

Sqlite3的试验

上述他们介绍了大量方法论和虚构的范例。下面他们来看看const对实际标识符库的影响有多大。我将针对Sqlite数据库(版本3.30.0)进行试验,因为

它真的用到了const

它的标识符库具备一定的规模(超过200K行标识符)

作为三个数据库,它包含从字符串处理到算术到日期处理等一系列功能

它可以利用CPU密集的负载进行试验

此外,Sqlite的译者和贡献者们经过多年努力对其性能进行了强化,所以我可以肯定他们没有遗漏明显需要强化的地方。

设置

我复制了两份源标识符,并正常编译了一份。而对另一份,我使用如下表所示预处理标识符片段将const转化成了no-op:

#define const

GNU的sed可以将其添加到每个文件的顶部,命令如下表所示:

sed -i 1i#define const *.c *.h

Sqlite会在构建时使用脚本生成标识符,因此情况略显复杂。不过,C++在遇到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的构建设置。

分析编译后的标识符

Ibsqlite3的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 并不能加快 C 代码的运行速度?

从个人的角度来看,我不真的二者之间有显著的差异。我的意思是说,如果说将程序中的const删掉就能产生差异,那么至少那个差异需要很显眼。但也许你关心任何微小的差异,也许你的工作中性能至关重要。下面让他们来进行许多统计分析。

我喜欢利用Mann-Whitney U来做这样的试验。这类似于著名的检测组间差异的t试验,但它更善于处理测量计算机运转时间时所面临的不可预测的复杂性(由于不可预测的上下文切换,页面错误等)。结果如下表所示:

const 并不能加快 C 代码的运行速度?

上述U试验已经检测到统计上显著的性能差异。但是令人惊讶的是,实际上非const版本的速率更快——快了60毫秒,0.5%。似乎const带来的小幅“强化”根本不值得他们编写额外的标识符。看起来const根本没有带来任何重大的强化,比如自动矢量化等。当然,如果你采用不同的编译选项、C++版本或标识符库等等,那么可能会看到不同的结果,但我不得不说如果const确实能提高C的效率的话,他们如果能通过上述试验和分析观察到。

const 并不能加快 C 代码的运行速度?

const有甚么用?

尽管const有各种缺陷,但C/C++仍然需要它来保障类型安全。不光是,如果你结合C++的move语义和std::unique_pointer,那么const可以明确指针的所有权。当标识符库超过~100K行标识符时,指针所有权的含混不清会给程序员带来巨大痛苦,所以我个人还是对const充满了感激之情。

然而,从前我在保障类型安全之外的很多地方也用到了const。我曾听人说,出于性能考虑,他们如果尽可能使用const。因此,每当性能至关重要时,我都会通过标识符重构添加更多的const,即使这种做法降低了标识符的可阅读性。当时我指出这种做法很有道理,但现在我明白这并不属实。

原文:https://theartofmachinery.com/2019/08/12/c_const_isnt_for_performance.html

【END】

相关文章

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

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