当前位置: 首页 > 科技观察

揭秘代码效率真相

时间:2023-03-21 20:52:19 科技观察

本文转载自微信公众号《程序喵大师》,作者程序喵大师。转载本文请联系程序大师喵公众号。本题目将分析各种C++代码操作的效率,包括不同类型变量的存储效率,使用智能指针、循环、函数参数、虚函数、数组等的效率,以及如何做针对性的优化,或者选择更有效的备选计划。详细目录见下图:变量存储区:在C++中,存储变量的内存类型取决于开发者声明变量的方式。如果数据不连续,分成无数段,分散在内存中,会降低数据的缓存命中率。因此,了解变量的存储方式非常重要。栈空间栈空间通常用来存放局部变量、函数参数、函数返回地址以及函数返回前需要恢复的寄存器。每调用一次函数,系统就会分配一段栈空间来存放这些东西。当函数返回时,这一段栈空间会被回收,程序下次调用该函数时可以重用这一段栈空间。一般来说,程序的每个线程都有固定大小的栈空间,使用多少,回收多少,只是偏移多少的问题。栈空间效率特别高,因为同一个内存空间可以重复使用,内存很容易加载到Cache中,Cache命中率更高。我们可以更多地利用堆栈空间。所有变量最好在使用它们的函数中声明。在某些情况下,可以在花括号{}内声明变量,以尽可能缩小变量的范围。全局或静态空间任何函数都可以访问的全局变量存储在内存的静态空间中。static关键字声明的变量、浮点常量、字符串常量、虚函数表等都存放在静态空间中。静态空间的好处是可以在程序启动前就初始化为想要的值。缺点是即使变量只使用一次,或者只在程序的一小部分使用,在整个程序运行过程中它的内存都会被占用,从而降低Cache的效率。尽量不要将变量声明为全局变量。如果一个变量被多个函数使用,可以考虑将其作为参数使用,但是参数传递是有开销的。如果我们想避免这种开销,是否应该将其声明为全局变量??其实我们也可以在类对象中存储变量,多个函数可以访问类对象中的变量成员。在某些情况下,可以考虑共享static和const,比如声明一个static常量查找表:floatSomeFunction(intx){staticconstfloatlist[]={1.1,2.2,3.4,4.4,5.5};returnlist[x];}这样优点是每次调用函数时不需要初始化列表。static声明是指在第一次调用初始化后,后面的初始化就不再需要了,但是这样效率比较低,因为需要检查是第一次调用还是已经调用过。添加const声明可以告诉编译器不需要检查是否是第一次调用。所以最好加上static和const的声明,这样编译器可以更好的优化。字符串常量和浮点数常量也常存放在静态空间,例如:a=b*3.5;c=d+3.5;这里,常量3.5会被存放在静态空间中,大多数编译器会认为这两个常量是一样的,所以只需要存放一份常量。将整个程序中所有相同的常量链接在一起,以优化常量在程序中的占用空间。寄存器存储寄存器是CPU中用于临时存储的小块内存。访问寄存器中的变量是非常快的,但是寄存器的数量是有限的,存储的变量也是有限的。编译器在优化的时候,会自动选取函数中最常用的变量存入寄存器。程序中的局部变量非常适合存储在寄存器中。寄存器的数量是有限的,大约6个整数寄存器在32位x86系统上用于通用用途,在64位系统上使用14个。虽然浮点变量使用不同的寄存器,但在32位系统中大约有8个浮点寄存器可用,在64位系统中有16个,当在64位系统中启用更高级的指令集时可能更多可用浮点寄存器。volatile这里需要特别注意volatile关键字,意思是它修改的变量可以被另一个线程改变,防止编译器做一些过度优化。例如:volatileintseconds;voidDelayFiveSeconds(){seconds=0;while(seconds<5){//donothingwhilesecondscountto5}}在此示例中,DelayFiveSeconds()将等待另一个线程将秒递增到5。如果秒未声明为易变的,则编译器可能会过度优化,假设秒数在while循环中保持为0,并且循环内的任何内容都不能更改该值。循环将是while(0<5){},这将是一个无限循环。关键字volatile的作用是保证变量一直保存在内存中,而不是寄存器中,防止所有对变量的优化。请注意,volatile不保证原子性,它不会阻止两个线程同时尝试写入。在其他线程递增秒数时尝试将秒数设置为0可能会失败。一个线程只读取秒数并等待该值更改更安全。线程局部存储在C++11中,thread_local关键字可用于声明线程局部变量。在C++11之前,还有其他的声明方式。修改后的变量每个线程都有一个副本,保证线程安全。线程本地存储效率较低,因为它是通过全局指针访问的。我们应该尽量避免线程局部存储,把变量多存放在线程自己的栈中,也就是在线程自己的函数中声明变量。堆内存堆内存主要通过operatorsnew和delete动态分配,或者使用函数malloc和free。如果按随机顺序分配和释放不同大小的内存,很容易产生内存碎片,这些内存碎片分散在堆内存的不同地方,内存分配频繁,开销比较大。尽量避免动态分配内存,还是用JeMalloc代替一波?还是内存池?整数变量和算术整数,不同的类型可能有不同的大小。下图总结了不同整数的大小和最大值和最小值。:在不同的平台上,声明特定大小的整数的方法是不同的。我们可以使用标准头文件stdint.h来声明一个特定大小的整数。这种方法还可以跨平台和移植。大多数情况下,整数运算速度非常快,但如果整数大于可用寄存器大小,则效率会降低。例如,在32位系统上,使用64位整数效率较低,尤其是乘法或除法。如果声明了int类型,但未指定具体大小,则编译器将始终选择最有效的整数大小。较小的整数,如char、shortint等,效率可能略低。在很多情况下,编译器在执行计算时会将这些类型转换为默认大小的整数,然后只使用结果的低8位。或者低16位。在64位系统上,只要我们不做除法,使用32位整数和64位整数的效率差别不大。在做整数运算的时候,我们需要考虑中间计算的结果,看是否会造成溢出。比如表达式a=b+c+d,即使b、c、d都小于最大整数值,但是b+c可能会造成整数溢出,需要时刻注意。有符号和无符号整数在大多数情况下,使用有符号和无符号整数在速度上没有区别,但有一些特殊情况:常量除法,除以常数时,无符号整数比有符号整数效率高,类似于取模操作。对于大多数指令集,从有符号整数到浮点数的转换比从无符号整数的转换更快。有符号和无符号整数的溢出行为不同:无符号整数的溢出产生低正结果,有符号整数的溢出是正式未定义的。对于有符号整数,正常行为是将正溢出转换为负值,但编译器可能会在假设不会发生溢出的情况下进行一些优化。在没有任何开销的情况下在有符号和无符号整数之间进行转换。这无非是对同一个符号位的不同解释。当转换为无符号时,负整数将被解释为非常大的正数。inta,b;doublec;b=(unsignedint)a/10;//Converttounsignedintegerforfasterdivisionc=a*2.5;//Signedintegerisimplicitlyconvertedtodouble在上面的例子中,aConvertingtounsignedcanmake分裂更快。当然,这种转换只有在a永远不为负时才有可能。最后一行,在与常量2.5相乘之前,会隐式地将a转换为double,因为后者是double,其中a转换为有符号整数,效率更高。请注意,在执行比较操作(例如<操作)时,不要混合使用有符号和无符号整数。比较有符号和无符号整数可能会产生意想不到的结果。整数运算整数运算通常非常快。简单的整数运算,如加法、减法、比较、位运算等,在大多数微处理器上只需要一个时钟周期。乘法和除法需要更长的时间。整数乘法在Pentium4CPU上需要11个时钟周期,大多数情况下乘法需要3-4个时钟周期,整数除法需要40-80个时钟周期,具体取决于CPU。自增自减运算++i和i++一样快。仅用于自增变量时,使用++i和i++没有区别,效果完全一样。例如:for(i=0;i浮点数及其运算现代x86家族的CPU有两种不同类型的浮点寄存器,对应不同类型的浮点指令,每种类型各有优缺点。x87寄存器x87寄存器是传统的浮点运算方式,而且这些寄存器有长双精度,多个寄存器组成一组寄存器栈。使用寄存器栈的好处是:所有的计算都是双精度的,不同精度之间的转换不需要额外的时间。对于数学函数,例如对数函数和三角函数,有一些内置指令。代码紧凑,在代码缓存中占用的空间很小。寄存器堆栈也有缺点:很难由于寄存器堆栈的组织方式,编译器会创建寄存器变量。浮点比较很慢,除非更高指令集被启用。使用双精度、除法、平方根和数学函数时,计算时间会更长。向量寄存器也称为向量寄存器。有XMM、YMM或ZMM等寄存器。这是一种相对较新的执行浮点运算的方法。浮点运算以单精度或双精度执行。中间结果的计算精度始终与操作数相同,使用向量寄存器的优点是:容易制作浮点型寄存器变量。向量运算可用于对向量寄存器中的多个变量执行并行计算。它也有缺点:不支持长双打。计算具有混合精度的表达式需要精度转换指令,这可能非常耗时。数学函数必须使用库,但这通常也比内置的硬件函数更快。x87浮点寄存器几乎可用于任何能够进行浮点运算的系统,而XMM、YMM和ZMM寄存器分别需要SSE、AVX和AVX512指令集。现代编译器通常使用向量寄存器进行浮点运算。很少有编译器可以混合两种不同类型的浮点运算,并且不能为每种运算选择最佳类型。大多数情况下,不使用向量运算时,单精度浮点运算和双精度浮点运算速度差不多,不管精度如何,加减乘乘等运算都是同样的速度。但是,如果打开矢量运算,则在使用XMM等矢量寄存器时,单精度除法、平方根和数学函数的计算速度可能比双精度快。如果确实对精度要求高,可以使用双精度浮点数,速度上也不用太担心。但是如果我们可以很好的利用向量运算,或者有一个很大的浮点数数组,想要充分利用Cache,那么我们可以考虑使用单精度浮点数。浮点加法需要3-6个时钟周期,乘法需要4-8个时钟周期,除法需要14-45个时钟周期,具体取决于CPU。在使用传统的x87浮点寄存器时,浮点比较操作和浮点到整数转换操作的效率较低。注意:不要在同一个表达式中混用单精度和双精度浮点数。尽量避免整数和浮点数转换。矢量寄存器支持多种模式,可以根据实际需要设置不同的模式,比如flush-to-zeromode,denormals-are-zeromode等。一个整数。请注意,枚举值的名称可能与某些变量或函数的名称冲突。可以在头文件中将枚举设置为更长且唯一的名称,或者将其放在命名空间中,或者使用枚举类来声明枚举。布尔运算的布尔顺序优化布尔运算符&&和||的操作数评估如下。如果&&的第一个操作数为假,则根本不会计算第二个操作数,因为无论第二个操作数的值如何,结果都是假的。同样,如果||的第一个操作数为真,则不评估第二个操作数,因为结果必须为真。因此,我们可以将通常为真的操作数放在&&表达式的末尾,或者放在||的最开头。表达。例如,假设a在50%的时间内为真,b在10%的时间内为真。表达式a&&b需要在a为真时评估b,这是50%的时间。等价表达式b&&a只需要在b为真时计算a,这只需要10%的时间。如果一个操作数比另一个更可预测,则将最可预测的操作数放在第一位。如果一个操作数的求值速度快于另一个,则求值最快的操作数放在第一位。但是,在更改布尔操作数的顺序时必须小心:如果操作数的计算有副作用,并且如果第一个操作数用于确定第二个操作数是否有效,则不能交换操作数。例如:unsignedinti;constintARRAYSIZE=100;floatlist[ARRAYSIZE];if(i3){...}这里不能换序,因为当i>ARRAYSIZE时,list[i]操作是非法的,另一个例子:if(handle!=INVALID_HANDLE_VALUE&&WriteFile(handle,...)){...}同样,我们不能在这里交换顺序。Boolean布尔变量存储为8位整数,0表示假,1表示真。从这个意义上说,布尔变量是由多种因素决定的,即所有以布尔变量为输入的运算符可能不仅是0和1,而以布尔变量为输出的运算符也只能输出0或1值。将布尔变量作为输入进行操作可能效率较低。例如,对于boola,b,c,d;c=a&&b;d=a||b;编译器可以这样实现它:boola,b,c,d;if(a!=0){if(b!=0){c=1;}else{gotocfalse;}}else{cfalse:c=0;}if(a==0){if(b==0){d=0;}else{gotodtrue;}}else{dtrue:d=1;}当然,这不是最优的方式。在预测错误的情况下,分支也可能需要很长时间。如果可以确定操作数除了0和1之外没有其他值,布尔运算的效率会高得多。编译器不做这种假设的原因是,如果变量未初始化,或者来源未知,然后他们可以有其他值。如果a和b已经初始化为有效值,或者它们来自产生布尔输出的值,则可以优化上述代码。优化后的代码如下所示:chara=0,b=0,c,d;c=a&b;d=a|b;在这里,我们可以使用char(或int)而不是bool,以便能够使用按位运算符(&和|)而不是布尔运算符(&&和||)。按位运算符是仅占用一个时钟周期的单条指令。即使a和b的值不为0或1,OR运算符(|)也能工作。但是AND运算符(&)和XOR运算符(^)可能会产生不一致的结果,如果操作数的值不是0和1。注意这里有一个坑,我们不能用~代替!,相反,如果确定输入是0或1,我们就可以得到!的值。通过与1进行异或运算。例如:boola,b;b=!a;可以优化为:chara=0,b;b=a^1;指针和引用见代码:voidFuncA(int*p){*p=*p+2;}voidFuncB(int&r){r=r+2;}这两段代码分别用到了指针和引用。他们实际上做同样的事情。具体来说,我们可以看到它们编译后生成的代码。其实他们的汇编代码完全是一样的,区别只是编程风格的问题。指针优于引用的原因是:直接看上面的函数体,很明显p是指针,但不清楚r是引用还是简单变量。使用指针可以让阅读代码的人更清楚地知道发生了什么。指针的指向可以改变,使用更灵活,指针也可以用于算术运算。引用优于指针的原因是:引用比指针更安全,因为在大多数情况下,引用必须指向一个有效的地址,并且引用点不能改变。对于指针,如果没有初始化,指针运算超出有效地址范围,或者指针类型转换为错误的类型,则指针可能无效,导致致命错误。引用对于复制构造函数和重载运算符很有用。声明为const引用的函数参数可以接受表达式作为参数,而指针和非常量引用需要变量。使用指针或引用访问变量可能与直接访问一样快。所有在函数中声明的非静态变量都存储在栈中,实际上也是通过栈指针来寻址的。同样,类中声明的所有非静态变量也是通过隐式的this指针来访问的,所以大部分变量实际上是通过指针来访问的。使用指针或引用也有缺点,需要额外的寄存器来保存指针或引用的值,而寄存器是一种稀缺资源,尤其是在32位模式下。如果你没有足够的寄存器,你每次使用它时都必须从内存中加载指针,而且你会更慢。注意指针有个坑:指针的算术运算:structA{inta;intb;};A*a;a=++a;a=++(char*)a;++aand++(void*)a,它们的运算结果不同,++a中加的值是8,因为A的大小是8,++(void*)a中加的值实际上是1,因为A的大小(字符)是1。函数指针如果可以预测目标地址,通过函数指针调用函数会比直接调用函数多花费几个时钟周期。如果函数指针的值与上次执行语句时相同,则目标地址预测成功,如果函数指针的值发生变化,则目标地址很可能被错误预测,预测失败将导致多个时钟周期。延迟。智能指针智能指针是一种行为类似于指针的对象。C++11之后,智能指针基本上有两种,unique_ptr和shared_ptr。unique_ptr的特点是只有一个指针拥有分配的对象,只有一个对象指针拥有对象的所有权。shared_ptr的特点是可以有多个指针一起指向同一个对象,显然shared_ptr比unique_ptr开销更大。选择智能指针时,可以多选择unique_ptr。编译器可以在简单的情况下优化去掉大部分或全部unique_ptr的开销,这样效率就和直接用new和delete基本一样了。一般在一个函数中new需要另一个函数中delete的场景,可以考虑使用智能指针。但是如果new和delete在同一个函数中,而且函数体没有太多的分支,也许就没有必要使用智能指针了。有时程序可能需要很多动态分配的小对象,而每个对象都有自己的智能指针。这成本高吗?你有什么解决办法?可以在留言板留言!