昨天,我兴致勃勃地推出了埋头苦干废寝忘食悬梁刺股孜孜不倦夜以继日凿壁偷光写了半个月的2021程序喵开年大作——优化系列

结果:阅读量 居!然!翻!车!了!

 

求求了,所有人都给我点进来看!_代码效率分析

 

 

刚刚看了眼阅读量, 


求求了,所有人都给我点进来看!_代码效率分析_02

求求了,所有人都给我点进来看!_代码效率分析_03


求求了,所有人都给我点进来看!_代码效率分析_04

 

我安慰自己,一定是信息流算法把我的内容屏蔽了,才让大家刷不到的,所以喜欢我文章的朋友们,让你们看到我的文章!

 

求求了,所有人都给我点进来看!_代码效率分析_05

 

好了,翻车归翻车,文章该发还是得发,

求求了,所有人都给我点进来看!_代码效率分析_06

 


 

本篇文章我们将继续分析C++各种操作的效率,包括不同类型变量的存储效率,使用智能指针、循环、函数参数、虚函数、数组等的效率,以及如何做针对性优化,或选择更有效的替代方案。

 

详细目录看下图:
求求了,所有人都给我点进来看!_代码效率分析_07

 

 

数组

 

数组很简单,就是多个元素连续的存储在内存中,但没有存储关于数组大小的信息,这样尽管在C++中使用数组比其它语言更快,但是它更不安全,所以我们可以使用带边界检查的数组,例如std::array,这样更安全一些,或者根据特殊需求自定义一个:

  •  
template <typename T, unsigned int N> class SafeArray {protected:    T a[N];public:    SafeArray() {        memset(a, 0, sizeof(a));    }      int Size() {        return N;    }    T& operator[] (unsigned int i) {        if (i >= N) {            return *(T*)0;        }        return a[i];    }};

此自定义数组通过指定类型和大小作为模板形参来声明,如下面的示例所示。使用方括号索引访问它,就像访问普通数组一样。

  •  
SafeArray<float, 100> arr;for (int i = 0; i < arr.Size(); ++i) {    cout << arr[i] << "\n";}

如果数组索引超出范围,[]运算符会检测到错误,并通过这种非常规的方式返回个空引用,当然,我们最好创建自己的错误消息函数。

 

类型转换

 

C++语法有多种方式进行类型转换:

  •  
int i; float f;f = i; // 隐式类型转换f = (float)i; // C风格类型转换f = float(i); // 构造风格的类型转换f = static_cast<float>(i); // C++类型转换

这几种方法都实现了相同的效果。

 

这些方法有完全相同的效果。使用哪种方法是编程风格的问题。下面将讨论不同类型转换的时间消耗:

 

有符号/无符号转换:

  •  
int i;if ((unsigned int)i < 10) {...}

有符号整数和无符号整数之间的转换,其实只是使编译器以不同的方式解释整数的符号位。不需要检查是否溢出,代码也不需要额外的时间。我们可以自由地使用这种转换,不必担心性能。

 

整数大小转换:

  •  
int i; short int s;i = s;

将整数转换为更长的整数,如果整数是有符号的,则扩展符号位,如果是无符号的,则扩展零位。如果源是算术表达式,这通常需要一个时钟周期。如果变量是从内存种读取,并进行大小转换,这通常不需要额外的时间,例子如下:

  •  
short int a[100];int i, sum = 0;for (i=0; i<100; i++) sum += a[i];

将整数转换为更小的大小只需忽略较高的位即可,不会检查溢出。

  •  
int i;short int s;s = (short int)i;

这种转换不需要额外的时间,它只存储32位整数的低16位。

 

浮点精度转换:

当使用浮点x87寄存器时,float、double和long double之间的转换不需要额外的时间。当使用向量寄存器时,它可能需要2到15个时钟周期(取决于处理器)。例如:

  •  
float a; double b;a += b;

在本例中,如果使用了向量寄存器,那么转换的成本会很高。为了避免这种情况,a和b最好是同一类型,最好避免不同浮点精度的类型转换。

 

整数到浮点数的转换:

有符号整数到浮点数的转换需要4 - 16个时钟周期,这取决于处理器和所使用的寄存器类型。除非启用了AVX512指令集,否则无符号整数的转换比有符号整数需要更长的时间。如果没有溢出风险,进行整数到浮点数的转换,可以首先将无符号整数转换为有符号整数,速度会更快。

  •  
unsigned int u; double d;d = (double)(signed int)u; // 更快,但是有溢出风险

有时可以通过用浮点数变量替换整数变量,来避免整数到浮点数的转换,举例:

  •  
float a[100]; int i;for (i=0; i<100; i++) a[i]=2*i;

在本例中,可以通过添加一个浮点变量来避免将i转换为float:

  •  
float a[100]; int i; float i2;for (int i = 0, i2 = 0; i < 100; i++, i2 += 2.0f) a[i] = i2;

 

浮点数到整数的转换:

除非启用了SSE2或更高的指令集,否则浮点数转换为整数需要较长时间。通常需要50 - 100个时钟周期。

 

如果在代码的重要部分有浮点数到整数的转换,我们是否可以:

  1. 通过使用不同类型的变量来避免类型转换。

  2. 通过将中间结果存储为浮点数,将转换移出最内层的循环。

  3. 使用64位模式或启用SSE2指令集(需要CPU支持)。

  4. 使用汇编语言创建一个舍入函数,使用舍入代替截断(后续介绍)。

 

指针类型转换:

指针可以转换为其它类型的指针。可以将指针转换为整数,也可以将整数转换为指针,这里,整数需要有足够的位来保存指针,一般都是使用unsigned long 来表示指针。

 

这些转换只是用不同的方式解释相同的位,不会花费额外的时间,但这种转换是不安全的,需要我们程序员自己确保结果有效。

 

C++中有四种类型转换,可以看我之前的文章:《C++为什么非要引入那几种类型转换》

 

分支if-else和switch语句

 

我们需要首先了解分支预测的概念:可以看我之前写的文章《少写点if-else吧,它的效率有多低你知道吗?》

 

在CPU做出正确分支预测的情况下,指令执行通常需要0 - 2个时钟周期。分支错误预测后,时间大约是12 - 25个时钟周期,具体时间取决于CPU,这称为分支错误预测惩罚。

 

for循环和while循环,也是一种分支。在每次迭代之后,它判断是继续迭代还是退出循环。如果总是相同的重复次数,并且次数很小,循环分支通常可以很好地预测。一般CPU可以预测的最大循环次数大约是9到64之间。嵌套循环就有点难,只有在某些CPU上才能很好的预测。多数CPU都不能很好地预测包含多个分支的循环。

 

switch语句有些特殊:如果case后跟随一个序列,例如case 1,case 2, case 3连续情况下,那么switch语句是最有效的,因为它可以实现为跳表。如果switch语句中case的值彼此相距很远,例如case 1, case 200,那效率就会很低,因为编译器会将其转换为分支树,效率与if-else类似。

 

在某些情况下,可以用表查找来替换难以预测的分支,例如:

  •  
float a; bool b;a = b ? 1.5f : 2.6f;

这里的?:运算符是一个分支。如果它很难预测,那可用表查找方式来替换它:

  •  
float a; bool b = 0;const float lookup[2] = {2.6f, 1.5f};a = lookup[b];

如果bool值被用作数组索引,那么有必要确保它已经初始化,确保它没有除0或1以外的其他值。某些情况下,编译器可以自动优化掉分支。

 

循环

 

循环的效率取决于CPU预测循环分支的效率,对于次数少而且次数固定的循环分支,CPU可以完美预测。而且循环尽量不要嵌套,一般的CPU对嵌套循环预测的不是很好。

在某些情况下,对某些循环我们可以展开,例如:

  •  
int i;for (i = 0; i < 20; i++) {if (i % 2 == 0) {        FuncA(i);    } else {        FuncB(i);    }    FuncC(i);}

这个循环重复20次,并交替调用FuncA和FuncB,然后调用FuncC。可以这样展开循环:

  •  
int i;for (i = 0; i < 20; i += 2) {    FuncA(i);    FuncC(i);    FuncB(i+1);    FuncC(i+1);}

这样做的优点有:

  1. i<20循环次数是10次,比之前少了一半,大多数CPU可以完美预测。

  2. 消除了if分支

 

但也有缺点:

  1. 展开循环后,占用了更多的代码缓存。

  2. 许多CPU都有一个环形缓冲区,可以提高非常小的循环的性能,展开的循环可能不太适合环形缓冲区。

  3. 如果重复次数是奇数,那么必须在循环之外额外执行再做一次迭代。

 

如果循环判断条件依赖于循环内的计算,那么效率较低。下例将以零结束的ASCII字符串转换为小写字母:

  •  
char string[100], *p = string;while (*p != 0) *(p++) |= 0x20;

如果字符串的长度已经知道,那么使用循环计数器会更有效:

  •  
char string[100], *p = string;int i, stringLength;for (i = stringLength; i > 0; i--) *(p++) |= 0x20;

这种方法的优点是,CPU可以提前确定循环次数,有利于分支预测。

 

循环计数器最好是一个整数。如果一个循环需要一个浮点计数器,那么我们可以再加一个整数计数器,如:

  •  
double x, n, factorial = 1.0;for (x = 2.0; x <= n; x++) factorial *= x;

这可以通过添加一个整数计数器并在循环控制条件中使用整数来改进:

  •  
double x, n, factorial = 1.0; int i;for (i = (int)n-2, x = 2.0; i >= 0; i--, x++) factorial *= x;

拷贝或者清空数组有些人会使用循环控制,例如复制数组或将数组设置为全0,使用循环可能不是最佳选择:

  •  
const int size = 1000; int i;float a[size], b[size];for (i = 0; i < size; i++) a[i] = 0.0;for (i = 0; i < size; i++) b[i] = a[i];

使用memset和memcpy通常更快:

  •  
cont int size = 1000;float a[size], b[size];memset(a, 0, sizeof(a));memcpy(b, a, sizeof(b));

大多数编译器都会通过调用memset和memcpy来自动替换这样的循环,至少在简单的情况下是这样,显式调用对性能提高肯定更保险一些,谁知道编译器究竟做不做优化呢。

 

函数调用

 

函数调用可能会使程序执行速度变慢,原因如下:

  1. 函数调用会跳转到一个不同的代码地址,然后再返回,这可能需要4个时钟周期。

  2. 代码会被分割成多份,并分散在内存中,代码缓存的工作效率会降低。

  3. 将参数存储在栈上,再次读取它们需要额外的时间。如果参数依赖于其它部分,那么延迟更明显。

  4. 需要额外的时间来建立栈帧,保存和恢复寄存器等。

  5. 每个函数调用语句,都在分支目标缓冲区(BTB)中占用空间,如果程序的关键部分有许多调用和分支,则BTB中的争用可能导致分支错误预测。

 

那如何优化?

减少函数调用:

很多编码规范和编程书籍都建议,每个函数体不要超过多少多少行,如果超过了那需要将其拆分成多个函数,这里可能过于绝对了,在性能至关重要的部分我们可以考虑减少函数的拆分,特别是最内层循环,我们最好保证循环体里少一些函数调用。虽然函数很长会导致程序逻辑可能不是特别清晰,但如果可以获取更高的性能,也可以接受,毕竟顾此失彼,很多性能优化后的代码可读性都不高。

 

声明为内联函数:

内联函数会被编译器像宏一样展开,因此调用该函数的每个语句都被函数体替换。如果使用inline关键字,或者函数体是在类定义中定义的,那么函数通常是内联的。而在某些情况下,如果内联导致某些技术问题或性能问题,编译器可能会忽略内联函数的请求提示。

 

避免在最内层循环中调用嵌套函数:

调用其他函数的函数称为frame函数,而不调用任何其他函数的函数称为leaf函数。leaf函数比frame函数效率更高,如果程序的关键内层循环有对frame函数的调用,则可以通过内联frame函数或尽可能将frame函数改为leaf函数。

 

使用宏:

用#define声明的宏肯定会被内联。但是要注意,宏参数在每次使用时都会被求值。例如:

  •  
#define MAX(a, b) (a > b ? a : b)y = MAX(f(x), g(x));

在这个例子中,f(x)或g(x)被计算了两次,因为宏引用了它两次。我们可以通过使用内联函数代替宏来避免这个问题。如果想让函数可以使用任何类型的形参,那就把它做成模板:

  •  
// 模板替代宏template <typename T>static inline T max(T const &a, T const &b) {    return a > b ? a : b;}

宏的另一个问题是名称不能重载,也不能限制作用域,宏不管其作用域或命名空间,会干扰具有相同名称的任何函数或变量,因此,在头文件中,宏需要有较长且唯一的命名。

 

将函数设为本地函数:

只在同一个模块(即当前的.cc文件)中使用的函数,应该被设置为本地函数。这样编译器更容易内联函数和优化函数的调用。有三种方法可以使函数变为本地函数:

  1. 向函数声明中添加关键字static。这是最简单的方法,但它不能用于类成员函数,因为static有不同的含义。

  2. 将函数或类放入匿名命名空间。

  3. gnu编译器中使用:"__attribute__((visibility("hidden")))".

 

fastcall调用约定:

fastcall调用约定,它改变32位模式下的函数调用方法,使前两个整数参数在寄存器中传输,而不是通过栈传输,这可以提高带有整数参数的函数的速度。

成员函数中,隐式的this指针也被视为形参,因此可能只剩下一个空闲寄存器来传递剩余的形参。因此,在使用fastcall时,我们尽量保证最关键的整数参数放在首位。默认情况下,函数参数在64位模式下在寄存器中传输,无需声明调用约定。

 

使用64位模式:

64位模式比32位模式更高效,因为有更多寄存器。而且同为64位,Linux比Windows更有效率,因为在64位Linux中,前6个整数参数和前8个浮点参数在寄存器中传输,总共有14个寄存器参数。而在64位Windows中,无论它们是整数还是浮点数,只有前4个参数在寄存器中传输。所以,如果函数有四个以上的参数,64位Linux比64位Windows更有效。

 

函数参数

在大多数情况下,函数参数都是按值传递的,参数的值会被复制到一个局部变量中。这对于简单类型,如int、float、double、bool、enum以及指针和引用,都没啥问题。对于数组,除非它们被包装到类或结构中,否则总是作为指针传递。

 

如果参数包含复合类型(如结构或类),则情况更为复杂。将复合对象传递给函数的首选方法,就是使用const引用,const引用确保原始对象不会被修改。与指针或非const引用不同,const引用允许函数的实参是表达式或匿名对象。如果是内联函数,那么编译器可以很容易地优化掉const引用。我们也可以将大的对象作为类的成员。

 

函数返回类型

函数的返回类型最好是简单类型、指针、引用或void。如果函数返回的是复合对象,那情况会更加复杂,而且效率很低。

 

多数情况下,函数将复合对象复制到特定位置,该位置由隐藏指针指向。在复制对象时调用拷贝构造函数,在销毁对象时调用析构函数。简单的情况下,编译器可以通过在其最终目的地上构造对象,进而避免对复制构造函数和析构函数的调用,但不要对此抱太大希望。

 

与其返回一个复合对象,我们可以尝试一些备选方案:

  1. 将函数作为对象的构造函数。

  2. 让函数修改一个现有对象,而不是创建一个新的对象。现有的对象可以通过指针或引用作为参数,传递给函数,或者对象作为类的成员。

  3. 让函数返回一个指向函数内部定义的静态对象的指针或引用。

  4. 函数内new一个对象,并返回指向该对象的指针,这样效率较低,而且还容易产生内存泄漏。

 

尾部函数调用

 

尾部调用是优化函数调用的一种有效的方法。如果一个函数的最后一条语句,是对另一个函数的调用,那么编译器可以通过跳转到第二个函数来替换该调用。编译器优化后,第二个函数不会返回第一个函数中,而是直接返回第一个函数被调用的地方。这样更有效,因为它消除了返回值。例如:

  •  
void func2(int x);void func1(int y) {    func2(y+1);}

这里,通过直接跳转到func2,进而消除从func1中返回的问题。如果函数有返回值,也可以这样做:

  •  
int func2(int x);int func1(int y) {    return func2(y+1);}

注意,只有当两个函数具有相同的返回类型时,尾部调用优化才有效。

 

递归函数

 

我们都知道递归函数极其浪费栈空间,递归层级过深还容易出现栈空间溢出问题,一个常见的递归函数的教科书例子是阶乘函数:

  •  
unsigned long int factorial(unsigned int n) {if (n < 2) return 1;    return n * factorial(n - 1);}

这种实现非常低效,因为n的所有实例和所有返回地址,都占用了栈上的存储空间,使用循环更有效:

  •  
unsigned long int factorial(unsigned int n) {    unsigned long int product = 1;    while (n > 1) {        product *= n;        n--;    }    return product;}

尽管递归尾部调用比其他递归调用效率更高,但仍然比循环效率低。

 

新手程序员有时会调用main来重新启动他们的程序,这样有问题,因为每次对main进行递归调用时,栈空间都会被所有局部变量的新实例所填满,重新启动程序的正确方法是在main中做一个循环。

 

参考资料

https://www.agner.org/optimize/