【C/C++】C/C++代码级性能优化

作者:wallace-lai
发布:2023-11-19
更新:2023-11-19

零、性能优化概述

性能优化中最重要的是什么?

性能优化中最重要的是找到值得的去优化的那些代码。不是所有的代码都值得我们去优化,按照90/10原则:一个程序花费90%的时间执行其中10%的代码。而这10%的代码块就是所谓的热点,是值得我们进行性能优化的对象。

为什么?阿姆达尔定律告诉我们,如果被优化的代码在程序整体运行时间中所占的比例不大,那么即使你对它的优化非常成功也是不值得的。这也是为什么高德纳说过早优化是万恶之源,程序员浪费了太多时间去思考和担忧程序中那些非关键部分的速度。考虑到调试和维护,这些为优化而进行的修改实际上是有很大负面影响的。

性能优化方法的核心思想

性能优化的两个核心思想是:(1)不做无用功;(2)空间换时间。

不做无用功

不要在代码里做一些对于达成目的而言毫无用处的工作,以下是常见的无用功:

(1)没有必要的初始化

char buf[64] = { 0 };
strcpy(buf, "foo");

(2)多余的函数调用

memset(buf, 0, sizeof(buf));
strcpy(buf, "foo");

(3)超出功能需要的“重量级”接口或者数据结构

比如用sprintf拷贝字符串,又比如在数据量很少的情况下使用红黑树、哈希表等“重量级”的数据结构。使用“重量级”数据结构所带来的收益远低于所需支付的成本,因此也是一种无用功。

空间换时间

以计算一个字节中比特位为1的数量,第一种也是最常见的方法是直接计算,如下所示:

int count_one_bits(unsigned char byte)
{
    int n = 0;

    for (int i = 0; i < 8; i++) {
        if ((byte >> i) & 0x01) {
            n++;
        }
    }

    return n;
}

尽管编译器可能会帮你将代码中的for循环进行循环展开优化,但它仍然是一种效率不高的方法。

第二个版本是使用空间换时间的方式,一个字节共有256种情况,打一个256大小的表,然后直接返回对应的值即可。

static int nr_one_bits[] = {
    0, 1, 1, 2, 1, 2, 2, 3,
    // ...
};

static inline int count_one_bits(unsigned char byte)
{
    return nr_one_bits[byte];
}

然而上述代码有个问题就是这张256大小的表占用内存过大(1KB)了,一个字节中最多有8个比特1,但是却使用了4个字节的int型存储,存在巨大的空间浪费。

最好的版本是下面这个,将一个字节拆分成高4位和低4位,表中的每一位用unsigned char表示,整张表只需要16B大小。

static unsigned char nr_one_bits_half_byte[] = {
    0, 1, 1, 2, 1, 2, 2, 3,
    1, 2, 2, 3, 2, 3, 3, 4,
};

static inline int count_one_bits(unsigned char byte)
{
    return nr_one_bits_half_byte[byte & 0x0F] +
        nr_one_bits_half_byte[(byte & 0xF0) >> 4];
}

打表以避免计算是利用空间换时间的典型方式。

一、整型数

1. 非负的情况下使用unsigned int

如果我们确定整数非负,那么就应该使用unsigned int而不是int。因为有些处理器处理无符号整型数的效率远远高于有符号整型数。因此,在一个紧密的循环语句中,声明一个整型的最好办法是:

register unsigned int variable_name;

使用register不能保证编译器一定使用寄存器变量,但这是通用的做法。

2. 能用整型运算就不用浮点运算

整型运算速度要高于浮点型,可以被处理器直接运算完成,不需要借助浮点运算单元或者第三方库。比如,我们需要结果精确到小数点后两位,可以将其乘以100,再取后两位的值。

二、除法

1. 除法和余数

在标准处理器中,对于分子和分母,一个32位的除法需要使用20至140次循环操作。这是一个消耗很大的操作,应该尽可能的避免执行。能用乘法代替除法就使用乘法

2. 合并除法和取余数

在既需要除法又需要取余数的情况下,编译器可以通过调用一次除法操作返回除法的结果和余数。因此,我们可以将除法和余数写在一次,一次运算即可得到结果。

unsigned int func1(unsigned int a, unsigned int b)
{
    return a / b;
}

unsigned int func2(unsigned int a, unsigned int b)
{
    return a % b;
}

void func3(unsigned int a, unsigned int b,
    unsigned int *ret1, unsigned int *ret2)
{
    *ret1 = a / b;
    *ret2 = a % b;
}

上述代码的汇编结果(结果来源于x86-64 gcc 13.2,经过了O2优化,下同)如下所示。可以看到除法和取余分开会导致执行两次div,但放一起就只需执行一次即可。

func1(unsigned int, unsigned int):
        mov     eax, edi
        xor     edx, edx
        div     esi
        ret
func2(unsigned int, unsigned int):
        mov     eax, edi
        xor     edx, edx
        div     esi
        mov     eax, edx
        ret
func3(unsigned int, unsigned int, unsigned int*, unsigned int*):
        mov     r8, rdx
        mov     eax, edi
        xor     edx, edx
        div     esi
        mov     DWORD PTR [r8], eax
        mov     DWORD PTR [rcx], edx
        ret

3. 通过2的幂次进行除法和取余数

如果除法中的除数是2的幂次,编译器使用移位操作来执行除法。因此,我们需要尽可能的设置除数为2的幂次(例如64而不是66)。

typedef unsigned int uint;

uint div1(uint a)
{
    return a / 32;
}

uint div2(uint a)
{
    return (a >> 5);
}

汇编结果如下,可以知道——如果编译器发现除数是2的幂次方,那么它将主动将除法优化成移位操作,与代码主动移位的结果相同。

div1(unsigned int):
        mov     eax, edi
        shr     eax, 5
        ret
div2(unsigned int):
        mov     eax, edi
        shr     eax, 5
        ret

并且依然记住,无符号unsigned整数除法执行效率高于有符号signed整形。

typedef unsigned int uint;
typedef int sint;

uint div1(uint a)
{
    return a / 32;
}

sint div2(sint a)
{
    return a / 32;
}

汇编结果如下所示,可以看到unsigned int的汇编结果比int简洁了不少。

div1(unsigned int):
        mov     eax, edi
        shr     eax, 5
        ret
div2(int):
        test    edi, edi
        lea     eax, [rdi+31]
        cmovns  eax, edi
        sar     eax, 5
        ret

三、数组

1. 使用数组下标

当你想根据输入不同的输入来设置不同的变量值,如果可以,请使用数组小标的形式。注意,这个案例非常符合我们平时所遇到的需求,因此该案例价值很高。

我们大概可以写出以下三种风格,从表明代码看不出谁性能更好。

char func1(unsigned num)
{
    char letter;

    switch (num) {
        case 0: letter = 'W'; break;
        case 1: letter = 'V'; break;
        case 2: letter = 'X'; break;
        default: letter = 'U'; break;
    }

    return letter;
}

char func2(unsigned num)
{
    char letter;

    if (num == 0) {
        letter = 'W';
    } else if (num == 1) {
        letter = 'V';
    } else if (num == 2) {
        letter = 'X';
    } else {
        letter = 'U';
    }

    return letter;
}

char func3(unsigned num)
{
    static const char *data = "WVXU";
    return data[num >= 3 ? 3 : num];
}

上述代码的汇编代码如下。可以看到switch和if的两种不同形式被优化成了同一段汇编代码(如果分支数多于某个值,二者汇编代码会不同),都是数组下标的方式。第三段代码也被编译成了数组下标方式,但更简洁。如果分支数很多,那么第三段代码和前面两段代码的性能优势就会更加明显。

func1(unsigned int):
        mov     eax, 85
        cmp     edi, 2
        ja      .L1
        mov     edi, edi
        movzx   eax, BYTE PTR CSWTCH.1[rdi]
.L1:
        ret
CSWTCH.1:
        .byte   87
        .byte   86
        .byte   88
func2(unsigned int):
        mov     eax, 85
        cmp     edi, 2
        ja      .L1
        mov     edi, edi
        movzx   eax, BYTE PTR CSWTCH.1[rdi]
.L1:
        ret
CSWTCH.1:
        .byte   87
        .byte   86
        .byte   88
.LC0:
        .string "WVXU"
func3(unsigned int):
        mov     eax, 3
        cmp     edi, eax
        cmova   edi, eax
        mov     edi, edi
        movzx   eax, BYTE PTR .LC0[rdi]
        ret

四、变量

1. 不要将全局变量放在重要的循环中

全局变量绝不会位于寄存器中。因此,编译器不能将全局变量的值缓存在寄存器中,以至于在使用全局变量时便需要额外的(常常是不必要的)读取和存储。所以,在重要的循环中我们不建议使用全局变量。

如果函数过多的使用全局变量,比较好的做法是拷贝全局变量的值到局部变量,这样它才可以存放在寄存器。这种方法仅仅适用于全局变量不会被我们调用的任意函数使用。例子如下:

int f(void);
int g(void);
int errs;
void test1(void)
{  
    errs += f();  
    errs += g();
} 
void test2(void)
{  
    int localerrs = errs;  
    localerrs += f();  
    localerrs += g();  
    errs = localerrs;
}

上述代码中,test1必须在每次操作时加载并存储全局变量errs,而test2中的局部变量localerrs存储与寄存器中的话只需要一个计算机指令即可完成加法。如果对errs的操作很多的话,test2的性能优势将会更明显。

2. 显式加载在运算过程中不变的量

考虑如下的例子:

void anyfunc(int, int);

void func1( int *data )
{    
    int i;     
    for(i=0; i<10; i++)    
    {          
        anyfunc( *data, i);    
    }
}

尽管*data的值可能从未被改变,但编译器并不知道anyfunc函数不会修改它,所以程序必须在每次使用它的时候从内存中读取它。如果我们知道变量的值不会被改变,那么就应该使用如下的编码:

void func2(int *data)
{
    int i;
    int local = *data;
    for (i = 0; i < 10; i++) {
        anyfunc(local, i);
    }
}

上述两函数的汇编代码如下,可以看到test1方向需要每次循环时都读取一遍*data,而test2方法只需要在循环开始前读取一遍即可。

func1(int*):
        push    rbp
        mov     rbp, rdi
        push    rbx
        xor     ebx, ebx
        sub     rsp, 8
.L2:
        mov     edi, DWORD PTR [rbp+0]
        mov     esi, ebx
        add     ebx, 1
        call    anyfunc(int, int)
        cmp     ebx, 10
        jne     .L2
        add     rsp, 8
        pop     rbx
        pop     rbp
        ret
func2(int*):
        push    rbp
        push    rbx
        xor     ebx, ebx
        sub     rsp, 8
        mov     ebp, DWORD PTR [rdi]
.L7:
        mov     esi, ebx
        mov     edi, ebp
        add     ebx, 1
        call    anyfunc(int, int)
        cmp     ebx, 10
        jne     .L7
        add     rsp, 8
        pop     rbx
        pop     rbp
        ret

同样的道理,还有我们最常见到的strlen()、vec.size()等。

void func1(char *src, char *buffer)
{
    int i;
    for (i = 0; i < strlen(src); i++) {
        buffer[i] = src[i] + 'A';
    }
}

void func2(char *src, char *buffer)
{
    int i;
    int len = strlen(src);
    for (i = 0; i < len; i++) {
        buffer[i] = src[i] + 'A';
    }
}
func1(char*, char*):
        push    r12
        mov     r12, rsi
        push    rbp
        mov     rbp, rdi
        push    rbx
        xor     ebx, ebx
        jmp     .L2
.L3:
        movzx   eax, BYTE PTR [rbp+0+rbx]
        add     eax, 65
        mov     BYTE PTR [r12+rbx], al
        add     rbx, 1
.L2:
        mov     rdi, rbp
        call    strlen
        cmp     rbx, rax
        jb      .L3
        pop     rbx
        pop     rbp
        pop     r12
        ret
func2(char*, char*):
        push    rbp
        mov     rbp, rsi
        push    rbx
        mov     rbx, rdi
        sub     rsp, 8
        call    strlen
        test    eax, eax
        jle     .L1
        lea     ecx, [rax-1]
        xor     eax, eax
.L3:
        movzx   esi, BYTE PTR [rbx+rax]
        lea     edx, [rsi+65]
        mov     BYTE PTR [rbp+0+rax], dl
        mov     rdx, rax
        add     rax, 1
        cmp     rcx, rdx
        jne     .L3
.L1:
        add     rsp, 8
        pop     rbx
        pop     rbp
        ret

编译器比较聪明,两者都只调用了一次strlen。

4. 变量类型

合理正确地使用变量类型至关重要,这有助于减少代码和数据的大小并增加程序的性能。

5. 局部变量尽量不使用char和short

我们应该尽可能的不使用char和short类型的局部变量。对于char和short类型,编译器需要在每次赋值的时候将局部变量减少到8或者16位。这对于有符号变量称之为有符号扩展,对于无符号变量称之为零扩展。

int wordinc (int a)
{   
    return a + 1;
}
short shortinc (short a)
{    
    return a + 1;
}
char charinc (char a)
{    
    return a + 1;
}
wordinc(int):
        lea     eax, [rdi+1]
        ret
shortinc(short):
        lea     eax, [rdi+1]
        ret
charinc(char):
        lea     eax, [rdi+1]
        ret

五、指针

1. 优先使用传指针的方式传递大对象

我们应该尽可能的使用引用值的方式传递结构数据,也就是说使用指针,否则传递的数据会被拷贝到栈中,从而降低程序的性能

2. 指针链

指针链经常被用于访问结构数据。例如,常用的代码如下:

typedef struct { int x, y, z; } Point3;
typedef struct { Point3 *pos, *direction; } Object;
 
void InitPos1(Object *p)
{
   p->pos->x = 0;
   p->pos->y = 0;
   p->pos->z = 0;
}

然而,这种的代码在每次操作时必须重复调用p->pos,因为编译器不知道p->pos->x与p->pos是相同的(真的吗?)。一种更好的方法是缓存p->pos到一个局部变量:

void InitPos2(Object *p)
{
   Point3 *pos = p->pos;
   pos->x = 0;
   pos->y = 0;
   pos->z = 0;
}

另一种方法是在Object结构中直接包含Point3类型的数据,这能完全消除对Point3使用指针操作(破坏了对象的封装性)。

可以看到两个方法的汇编是一样的,也即编译器没有愚蠢到看不懂两个方法其实是一样的。

InitPos1(Object*):
        mov     rax, QWORD PTR [rdi]
        mov     QWORD PTR [rax], 0
        mov     DWORD PTR [rax+8], 0
        ret
InitPos2(Object*):
        mov     rax, QWORD PTR [rdi]
        mov     QWORD PTR [rax], 0
        mov     DWORD PTR [rax+8], 0
        ret

六、表达式

1. 尽量将条件集中起来

如下所示,由于条件被集中在了一起,编译器能够将它们集中处理。

int g(int a, int b, int c, int d)
{
   if (a > 0 && b > 0 && c < 0 && d < 0)
   //  grouped conditions tied up together//
      return a + b + c + d;
   return -1;
}

2. 范围检查

以下的范围检查语句可以转换为等价的下面那个检查语句。

(xmin <= x && x <= xmax ) && (ymin <= y && y <= ymax)

当xmin或ymin等于0时,该语句的性能优势更明显。

(x - xmin) <= (xmax - xmin) && (y - ymin) <= (ymax - ymin)

3. 布尔表达式和零值比较

处理器的标志位在比较指令操作后被设置。标志位同样可以被诸如MOV、ADD、AND、MUL等基本算术和裸机指令改写。如果数据指令设置了标志位,N和Z标志位也将与结果与0比较一样进行设置。N标志表示结果是否是负值,Z标志表示结果是否是0。

C语言中,处理器中的N和Z标志位与下面的指令联系在一起:有符号关系运算x<0x>=0x==0x!=0;无符号关系运算x==0x!=0(或者x>0)。

C代码中每次关系运算符的调用,编译器都会发出一个比较指令。如果操作符是上面提到的,编译器便会优化掉比较指令。例如:

int aFunction(int x, int y)
{
   if (x + y < 0)
      return 1;
  else
     return 0;
}

尽可能的使用上面的判断方式,这可以在关键循环中减少比较指令的调用,进而减少代码体积并提高代码性能。C语言没有借位和溢出位的概念,因此,如果不借助汇编,不可能直接使用借位标志C和溢出位标志V。但编译器支持借位(无符号溢出),例如:

int sum(int x, int y)
{
   int res;
   res = x + y;
   if ((unsigned) res < (unsigned) x) // carry set?  //
     res++;
   return res;
}

4. 利用短路特性

在AND表达式中,务必让最可能较快出结果的条件放在第一个位置。

if (a > 10 && b == 4) {
	// ...
}

5. 利用switch替代if-else

在if()语句中,如果最后一条语句命中,之前的条件都需要被测试执行一次。Switch允许我们不做额外的测试。如果必须使用if…else…语句,将最可能执行的放在最前面。

编译器可能会自动将if-else优化成switch语句

6. 二分查找

在比较时尽量使用二分查找的形式,比如:

if(a==1) {
} else if(a==2) {
} else if(a==3) {
} else if(a==4) {
} else if(a==5) {
} else if(a==6) {
} else if(a==7) {
} else if(a==8) {
}

可以优化成:

if (a <= 4) {
	if (a == 1) {
		
	} else if (a == 2) {
		
	} else if (a == 3) {
		
	} else {
		
	}
} else {
	if (a == 5) {
		
	} else if (a == 6) {
		
	} else if (a == 7) {
		
	} else {
		
	}
}

7. switch vs 查找表

在很多场景下,switch语句都能被查找表代替。如果可以,尽量使用查找表去替代switch语句。

char * Condition_String1(int condition) {
  switch(condition) {
     case 0: return "EQ";
     case 1: return "NE";
     case 2: return "CS";
     case 3: return "CC";
     case 4: return "MI";
     case 5: return "PL";
     case 6: return "VS";
     case 7: return "VC";
     case 8: return "HI";
     case 9: return "LS";
     case 10: return "GE";
     case 11: return "LT";
     case 12: return "GT";
     case 13: return "LE";
     case 14: return "";
     default: return 0;
  }
}
 
char * Condition_String2(int condition) {
   if ((unsigned) condition >= 15) return 0;
      return
      "EQ\0NE\0CS\0CC\0MI\0PL\0VS\0VC\0HI\0LS\0GE\0LT\0GT\0LE\0\0" +
       3 * condition;
}

七、循环

循环是大多数程序中的常用的结构;程序执行的大部分时间发生在循环中,因此十分值得在循环执行时间上下一番功夫。

1. 循环终止

如果不加注意,循环终止条件的编写会导致额外的负担。我们应该使用计数到零的循环和简单的循环终止条件。简单的终止条件消耗更少的时间。看下面计算n!的两个程序。第一个实现使用递增的循环,第二个实现使用递减循环。

int fact1_func (int n)
{
    int i, fact = 1;
    for (i = 1; i <= n; i++)
      fact *= i;
    return (fact);
}
 
int fact2_func(int n)
{
    int i, fact = 1;
    for (i = n; i != 0; i--)
       fact *= i;
    return (fact);
}

循环终止条件是每次循环都需要执行的,第二段代码的循环终止条件更简单,因此第二段代码效率更高。

2. 更快的for循环

这是一个简单而高效的概念。通常,我们编写for循环代码如下:

for( i=0;  i<10;  i++){ ... }

i从0循环到9。如果我们不介意循环计数的顺序,我们可以这样写:

for( i=10; i--; ) { ... }

这样快的原因是因为它能更快的处理i的值–测试条件是:i是非零的吗?如果这样,递减i的值。对于上面的代码,处理器需要计算“计算i减去10,其值非负吗?如果非负,i递增并继续”。

简单的循环却有很大的不同。这样,i从9递减到0,这样的循环执行速度更快。

这里的语法有点奇怪,但确实合法的。循环中的第三条语句是可选的(无限循环可以写为for(;;))。如下代码拥有同样的效果:

for(i=10; i; i--){}

或者更进一步的:

for(i=10; i!=0; i--){}

这里我们需要记住的是循环必须终止于0(因此,如果在50到80之间循环,这不会起作用),并且循环计数器是递减的。使用递增循环计数器的代码不享有这种优化。

3. 合并循环

如果一个循环能解决问题坚决不用二个(循环任务简单的情况下)。但如果你需要在循环中做很多工作,这种情况下,两个分开的循环可能会比单个循环执行的更快 (复杂任务下分开可能会更快)。下面是一个例子

// original
for (i = 0; i < 100; i++) {
	stuff();
}
for (i = 0; i < 100; i++) {
	morestuff();
}
// it would be faster
for (i = 0; i < 100; i++) {
	stuff();
	morestuff();
}

4. 循环展开

简单的循环可以展开以获取更好的性能,但需要付出代码体积增大的代价。如果循环迭代次数只有几次,那么完全可以展开循环,以便消除循环带来的负担。

for (int i = 0; i < 3; i++) {
	something(i)
}
something(0);
something(1);
something(2);

有一种循环展开形式更加常见。以下是我们经常写出来的代码,但是它性能不高。

int limit = 33;
for (int i = 0; i < limit; i++) {
	printf("process(%d)\n", i);
}

我们可以让每次循环执行8次,也即每次展开8次作为一次循环,这样性能会更好。

#define BLOCKSIZE (8)
int i = 0;
int limit = 33;
int blockLimit = (limit / BLOCKSIZE) * BLOCKSIZE;
while (i < blockLimit) {
	printf("process(%d)\n", i);
	printf("process(%d)\n", i + 1);
	printf("process(%d)\n", i + 2);
	printf("process(%d)\n", i + 3);
	printf("process(%d)\n", i + 4);
	printf("process(%d)\n", i + 5);
	printf("process(%d)\n", i + 6);
	printf("process(%d)\n", i + 7);
	
	i += 8;
}

if (i < limit) {
	switch (limit - i) {
		case 7: printf("process(%d)\n", i); i++;
		case 6: printf("process(%d)\n", i); i++;
		case 5: printf("process(%d)\n", i); i++;
		case 4: printf("process(%d)\n", i); i++;
		case 3: printf("process(%d)\n", i); i++;
		case 2: printf("process(%d)\n", i); i++;
		case 1: printf("process(%d)\n", i); i++;
	}
}

同样的道理也可以运用在统计非零位的数量场景当中。

int count1(uint n)
{
	int bits = 0;
	while (n != 0) {
		if (n & 1) {
			bits++;
		}
		n >>= 1;
	}
	
	return bits;
}
int count2(uint n)
{
	int bits = 0;
	while (n != 0) {
		if (n & 1) bits++;
		if (n & 2) bits++;
		if (n & 4) bits++;
		if (n & 8) bits++;
		n >>= 4;
	}
	
	return bits;
}

5. 尽早终止循环

只要能够终止循环,就立刻终止循环,不要做额外的无用功。举个例子,在10000个整数中查找是否存在特殊值99。把代码写成一下的形式是绝对不合格的,无用功太多。

int found = 0;
for (int i = 0; i < 10000; i++) {
	if (list[i] == 99) {
		found = 1;
	}
}

if (found) {
	printf("Yes, we found it\n");
}

正确的写法应该是找到99时就立刻终止循环。

int found = 0;
for (int i = 0; i < 10000; i++) {
	if (list[i] == 99) {
		found = 1;
		break;
	}
}

if (found) {
	// ...
}

八、函数

设计小而简单的函数是个好习惯,这允许寄存器执行一些诸如寄存器变量申请的优化,这是非常高效的。

1. 函数调用的性能消耗

函数调用对于处理器的性能消耗是很小的,只占函数执行中性能消耗的一小部分。但是要让函数参数直接传入寄存器中则有一定的限制。要求参数必须是整型兼容的(char、shorts、ints和floats都可在一个字以内表示)或者小于四个字大小(double和long long占用2个字)。

如果系统对参数数量限制是4的话,那么从第5个参数开始后面参数会存储在栈上,这使得函数在调用时需要从栈上加载参数,增加了性能消耗。因此,函数参数尽量不要超过4个。

2. 降低函数参数传递的性能消耗

  • 尽量保证函数使用少于4个参数

  • 如果函数需要多于4个参数,尽量确保后面参数的价值高于让其存储于栈所付出的代价

  • 通过指针传递参数的引用而不是传递参数结构本身

  • 将参数放入一个结构体并通过指针传入函数,这样可以减少参数的数量并提高可读性

  • 尽量少用占用两个字大小的long类型,double同理,也需少用

  • 避免变参,变参函数的所有参数都放在栈中

3. 叶子函数

不调用任何函数的函数被称为叶子函数,叶子函数非常高效。因此,请尽可能地将经常调用的函数写成叶子函数。

4. 内联函数

内联函数禁用所有的编译选项,所有内联函数在调用处被直接替换为函数体。这样的代码调用更快,但增加了代码的大小。因此,仅对重要的简短函数使用inline。

5. 使用查找表

函数(部分)通常可以设计成查找表,这样可以显著提升性能。对于某些非常消耗计算性能的程序,如果不在意其精确性,可以用查找表的方式来加速计算过程。

6. 浮点运算

浮点运算无论是何种处理器都是非常耗时的,但有时又离不开浮点运算。对于浮点运算,有几点需要注意。

  • 浮点除法很慢。浮点除法比加法或者乘法都慢,如果可以,请使用乘法代替除法

  • 使用float代替double。如果精度够用,尽可能使用float

  • 避免使用先验函数。诸如sin、exp和log等先验函数,非常非常慢,尽量不使用它们

  • 简化浮点运算表达式。对于整型表达式3 * (x / 3),编译器通常可以将其优化成x。但是对于浮点数表达式,编译器无法优化。因此,有必要对浮点表达式进行手工优化。

其他

性能优化的一大思想是利用空间换时间。如果你能缓存经常用的数据而不是重新计算,比如使用sin和cos查找表,这些都可以显著提升程序性能。

最重要的性能优化手段是将编译器优化选项打开!其他的性能优化技巧:

  • 尽量不在循环中使用++--,这会导致编译器难以优化

  • 减少全局变量的使用(why?)

  • 除非想声明为全局变量,否则使用static修饰变量为文件内访问(不要出现在别的地方extern某个变量,这很糟糕)

  • 尽量使用一个字大小的变量(int、long等,而不是char、short、double等),使用一个字大小的变量,机器可以运行地更快

  • 能使用递推就不使用递归

  • 不在循环中使用sqrt开平方函数,计算平方根非常消耗性能

  • 一维数组比多维数组快地多

  • 避免将相关函数拆分到不通文件中,放一起,编译器可以更好地处理它们

  • float比double快

  • 浮点乘法比浮点除法更快,尽量使用浮点乘法代替浮点除法

  • 加法操作比乘法快,使用val + val + val而不是val * 3

  • put函数比printf函数快,但不灵活

  • 使用宏函数的方式替换常用的小函数

  • 二进制、未格式化的文件访问要比格式化的文件访问更快,因为程序不需要在ASCII和二进制之间进行转换。因此,如果你不需要阅读文件内容,那么将文件保存为二进制格式

  • 如果你的库支持mallopt()函数(用于控制malloc),尽量使用它。MAXFAST的设置,对于调用很多次malloc工作的函数由很大的性能提升。如果一个结构一秒钟内需要多次创建并销毁,试着设置mallopt选项