编写高效的 C 和 C 代码优化






4.74/5 (76投票s)
2004年2月22日
27分钟阅读

797815

9
在这篇文章中,我收集了所有经验和信息,这些经验和信息可以应用于优化 C 代码的速度和内存。
引言
在一个开发轻量级 JPEG 库的项目中,该库足以在移动设备上运行,同时不影响移动设备上的图形质量,我发现并实践了许多使给定计算机程序运行更快的方法。在这篇文章中,我收集了所有经验和信息,这些经验和信息可以应用于优化 C 代码的速度和内存。
尽管有许多关于 C 代码优化的指导方针,但彻底了解你所编程的编译器和机器是无可替代的。
通常,加快程序运行速度也会导致代码大小增加。代码大小的增加也可能对程序的复杂性和可读性产生不利影响。如果你正在为手机、PDA 等内存受严格限制的小型设备编程,这是不可接受的。因此,在优化过程中,我们的宗旨应该是以这样一种方式编写代码,即内存和速度都得到优化。
声明
事实上,在我的项目中,我使用了 这篇文章 中的技巧来优化 ARM,因为我的项目是在 ARM 平台上进行的,但我也使用了互联网上的许多其他文章。并不是每篇文章中的所有技巧都有效,所以我只收集了那些非常有用和高效的技巧。此外,我还对其中一些进行了修改,使其几乎适用于除 ARM 之外的所有环境。
我所做的只是收集了来自各个网站的信息,但主要是来自我上面提到的那个 PDF 文件。我从不声称这些是我的原创发现。我已在本文末尾的“参考资料”部分提到了所有信息来源。
它在哪里需要?
没有这一点,讨论就无法开始。优化计算机程序的第一部分也是最重要的一部分是找出在哪里进行优化,程序的哪个部分或哪个模块运行缓慢或使用了大量内存。如果每个部分都单独进行优化,那么整个程序将自动更快。
优化应该在程序运行最多的部分进行,特别是那些程序可能拥有的各种内部循环重复调用的方法。
对于经验丰富的程序员来说,通常很容易找出程序最需要优化关注的部分。但是也有很多工具可以用来检测程序的这些部分。我使用了 Visual C++ IDE 内置的性能分析器来找出程序在哪些地方花费了最多的“点击技巧”。我使用的另一个工具是 Intel Vtune,它是一个非常好的性能分析器,用于检测程序中最慢的部分。根据我的经验,通常是一个特定的内部或嵌套循环,或者对某些第三方库方法的调用,是导致程序运行缓慢的主要原因。
整数
如果我们知道值永远不会是负数,我们应该使用 unsigned int
而不是 int
。某些处理器处理无符号整数算术比有符号整数算术快得多(这也是一个好习惯,有助于编写自文档代码)。
因此,在紧密循环中,int
变量的最佳声明将是
register unsigned int variable_name;
尽管不能保证编译器会注意到 register
,并且 unsigned
可能对处理器没有任何影响。但这可能不适用于所有编译器。
请记住,整数算术比浮点算术快得多,因为它通常可以直接由处理器完成,而不是依赖外部 FPU 或浮点数学库。
我们需要精确到两位小数(例如,在简单的会计软件包中),将所有内容放大 100 倍,并尽可能晚地将其转换回浮点数。
除法和余数
在标准处理器中,根据分子和分母,32 位除法需要 20-140 个周期才能执行。除法函数需要一个常数时间加上每个位除法的时间。
Time (numerator / denominator) = C0 + C1* log2 (numerator / denominator)
= C0 + C1 * (log2 (numerator) - log2 (denominator)).
当前版本对于 ARM 处理器需要大约 20 + 4.3N 个周期。作为一项昂贵的操作,应尽可能避免它。有时,可以通过将除法替换为乘法来重写此类表达式。例如,如果已知 b
为正且 b *c
适合整数,则 (a / b) > c
可以重写为 a > (c * b)
。通过确保其中一个操作数是无符号的来使用无符号除法会更好,因为这比有符号除法更快。
结合除法和余数
在某些情况下,股息 (x / y
) 和余数 (x % y
) 都需要。在这种情况下,编译器可以通过调用除法函数一次来组合两者,因为它总是返回股息和余数。如果两者都需要,我们可以将它们写在一起,如下例所示
int func_div_and_mod (int a, int b) { return (a / b) + (a % b); }
被二的幂除和取余
如果除法操作中的除数是二的幂,我们可以使除法更加优化。编译器使用移位来执行除法。因此,我们应该尽可能地将比例因子安排为二的幂(例如,64 而不是 66)。如果是无符号的,那么它会比有符号除法更快。
typedef unsigned int uint; uint div32u (uint a) { return a / 32; } int div32s (int a){ return a / 32; }
两种除法都将避免调用除法函数,无符号除法将比有符号除法使用更少的指令。有符号除法将花费更多时间执行,因为它向零舍入,而移位向负无穷大舍入。
模运算的替代方案
我们使用取余运算符来提供模运算。但有时可以使用 if
语句检查来重写代码。
考虑以下两个例子
uint modulo_func1 (uint count) { return (++count % 60); } uint modulo_func2 (uint count) { if (++count >= 60) count = 0; return (count); }
使用 if
语句而不是取余运算符是更可取的,因为它会产生更快的代码。请注意,新版本仅在已知输入计数范围为 0-59 时才有效。
使用数组索引
如果你希望根据某个值将变量设置为特定字符,你可能会这样做
switch ( queue ) { case 0 : letter = 'W'; break; case 1 : letter = 'S'; break; case 2 : letter = 'U'; break; }
或者可能是
if ( queue == 0 ) letter = 'W'; else if ( queue == 1 ) letter = 'S'; else letter = 'U';
一个更简洁(和更快)的方法是简单地将值用作字符数组的索引,例如
static char *classes="WSU"; letter = classes[queue];
全局变量
全局变量永远不会分配到寄存器中。全局变量可以通过间接使用指针或通过函数调用来更改。因此,编译器无法将全局变量的值缓存到寄存器中,导致在使用全局变量时产生额外的(通常是不必要的)加载和存储。因此,我们不应该在关键循环中使用全局变量。
如果一个函数大量使用全局变量,将这些全局变量复制到局部变量中以便它们可以分配到寄存器中是有益的。这只有在这些全局变量不被任何被调用的函数使用的情况下才可能实现。
例如
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
存储在寄存器中,只需要一条指令。
使用别名
考虑以下示例 -
void func1( int *data ) { int i; for(i=0; i<10; i++) { anyfunc( *data, i); } }
即使 *data
可能永远不会改变,编译器也不知道 anyfunc ()
没有改变它,所以程序每次使用它时都必须从内存中读取它——它可能是其他地方改变的某个变量的别名。如果我们知道它不会被改变,我们可以这样编写代码
void func1( int *data ) { int i; int localdata; localdata = *data; for(i=0; i<10; i++) { anyfunc ( localdata, i); } }
这为编译器提供了更好的优化机会。
活跃变量和溢出
由于任何处理器都有一组固定的寄存器,因此在程序的任何一个点上,可以保存在寄存器中的变量数量是有限的。
一些编译器支持活跃范围拆分,其中一个变量可以分配给不同的寄存器,也可以分配给函数中不同部分的内存。变量的活跃范围定义为变量的最后一次赋值和下一次赋值之前变量的最后一次使用之间的所有语句。在此范围内,变量的值是有效的,因此它是活跃的。在活跃范围之间,变量的值不需要:它是死的,所以它的寄存器可以用于其他变量,允许编译器将更多变量分配给寄存器。
可分配给寄存器的变量所需的寄存器数量至少是函数中每个点上重叠活跃范围的数量。如果这超出了可用寄存器的数量,一些变量必须暂时存储到内存中。这个过程称为溢出。
编译器首先溢出使用频率最低的变量,以最大程度地减少溢出成本。可以通过以下方式避免变量溢出
- 限制活跃变量的最大数量:这通常通过保持表达式简单和小巧,并且不在函数中使用过多变量来实现。将大型函数细分为更小、更简单的函数也可能有所帮助。
- 将寄存器用于常用变量:这告诉编译器寄存器变量将经常使用,因此应以非常高的优先级将其分配给寄存器。但是,这种变量在某些情况下仍可能溢出。
变量类型
C 编译器支持基本类型 char
、short
、int
和 long
(有符号和无符号)、float
和 double
。为变量使用最合适的类型非常重要,因为它可以减少代码和数据大小并显著提高性能。
局部变量
如果可能,最好避免使用 char
和 short
作为局部变量。对于 char
和 short
类型,编译器需要在每次赋值后将局部变量的大小减小到 8 或 16 位。这对于有符号变量称为符号扩展,对于无符号变量称为零扩展。它通过将寄存器左移 24 或 16 位,然后右移相同数量的有符号或无符号移位来实现,需要两条指令(无符号 char
的零扩展需要一条指令)。
通过对局部变量使用 int
和 unsigned int
可以避免这些移位。这对于首先将数据加载到局部变量中然后处理局部变量中的数据的计算尤为重要。即使数据作为 8 位或 16 位量输入和输出,也值得考虑将其作为 32 位量进行处理。
考虑以下三个示例函数
int wordinc (int a) { return a + 1; } short shortinc (short a) { return a + 1; } char charinc (char a) { return a + 1; }
结果将是相同的,但第一个代码段将比其他代码段运行更快。
指针
如果可能,我们应该通过引用传递结构,即传递指向结构的指针,否则整个结构将被复制到堆栈并传递,这会降低速度。我见过一些程序按值传递大小为几千字节的结构,而一个简单的指针就能完成同样的事情。
接收结构指针作为参数的函数,如果该函数不打算修改结构的内容,则应将其声明为常量指针。例如
void print_data_of_a_structure ( const Thestruct *data_pointer) { ...printf contents of the structure... }
此示例告知编译器该函数不修改外部结构的内容(因为它使用常量结构指针),并且无需每次访问时都重新读取内容。它还确保编译器将捕获您的代码对只读结构进行写入的任何意外尝试,并为结构内容提供额外的保护。
指针链
指针链经常用于访问结构中的信息。例如,常见的代码序列是
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; }
另一种可能性是将 Point3
结构包含在 Object
结构中,从而完全避免指针。
条件执行
条件执行主要应用于 if
语句的主体,但它也用于评估带有关系运算符(<
、==
、>
等)或布尔运算符(&&
、!
等)的复杂表达式。对于包含函数调用的代码序列,条件执行是禁用的,因为函数返回时标志会被销毁。
因此,保持 if
和 else
语句的主体尽可能简单是有益的,以便它们可以被条件化。关系表达式应该分组为类似条件块。
以下示例显示了编译器如何使用条件执行
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; }
由于条件已分组,编译器能够将它们条件化。
布尔表达式和范围检查
常见的布尔表达式用于检查变量是否在某个范围内,例如,检查图形坐标是否在窗口内
bool PointInRectangelArea (Point p, Rectangle *r) { return (p.x >= r->xmin && p.x < r->xmax && p.y >= r->ymin && p.y < r->ymax); }
有一种更快的方法可以实现这一点:(x >= min && x < max)
可以转换为 (unsigned)(x-min) < (max-min)
。如果 min
为零,这将特别有益。优化后的相同示例
bool PointInRectangelArea (Point p, Rectangle *r) { return ((unsigned) (p.x - r->xmin) < r->xmax && (unsigned) (p.y - r->ymin) < r->ymax); }
布尔表达式和与零比较
处理器标志在比较(即 CMP
)指令之后设置。标志也可以通过其他操作设置,例如 MOV
、ADD
、AND
、MUL
,这些是基本的算术和逻辑指令(数据处理指令)。如果数据处理指令设置了标志,则 N
和 Z
标志的设置方式与结果与零比较时相同。N
标志指示结果是否为负,Z
标志指示结果是否为零。
处理器上的 N
和 Z
标志对应于 C 语言中的有符号关系运算符 x < 0
、x >= 0
、x == 0
、x != 0
,以及无符号 x == 0
、x != 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; }
惰性求值利用
在 if(a>10 && b=4)
这种情况下,确保 AND 表达式的第一部分最有可能给出错误答案(或者最容易/最快计算),因此第二部分执行的可能性会降低。
switch() 代替 if...else...
对于涉及 if
...else
...else
... 的大型决策,像这样
if( val == 1) dostuff1(); else if (val == 2) dostuff2(); else if (val == 3) dostuff3();
使用 switch
可能会更快
switch( val ) { case 1: dostuff1(); break; case 2: dostuff2(); break; case 3: dostuff3(); break; }
在 if()
语句中,如果需要最后一个 case,则所有前面的 case 都将首先被测试。switch
允许我们省去这些额外的工作。如果你必须使用一个大的 if
..else
.. 语句,请首先测试最可能的情况。
二元分解
以二元方式分解事物,例如,不要有一个列表
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 if(a==4) { } } else { if(a==5) { } else if(a==6) { } else if(a==7) { } else if(a==8) { } }
甚至
if(a<=4) { if(a<=2) { if(a==1) { /* a is 1 */ } else { /* a must be 2 */ } } else { if(a==3) { /* a is 3 */ } else { /* a must be 4 */ } } } else { if(a<=6) { if(a==5) { /* a is 5 */ } else { /* a must be 6 */ } } else { if(a==7) { /* a is 7 */ } else { /* a must be 8 */ } } }
Slow and Inefficient |
Fast and Efficient |
---|---|
c=getch(); switch(c){ case 'A': { do something; break; } case 'H': { do something; break; } case 'Z': { do something; break; } } |
c=getch(); switch(c){ case 0: { do something; break; } case 1: { do something; break; } case 2: { do something; break; } } |
两个 Case 语句之间的比较
Switch 语句 vs. 查找表
switch
语句通常用于以下原因之一
- 调用多个函数中的一个。
- 设置变量或返回值。
- 执行几个代码片段中的一个。
如果 case
标签是密集的,在 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; }
第一个例程总共需要 240 字节,第二个例程只需要 72 字节。
循环
循环是大多数程序中的常见构造;大部分执行时间通常花费在循环中。因此,值得关注时间关键的循环。
循环终止
如果编写不小心,循环终止条件可能会导致显著的开销。我们应该始终编写倒计时到零的循环并使用简单的终止条件。如果终止条件简单,执行将花费更少的时间。考虑以下两个计算 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); }
因此,第二个 fact2_func
将比第一个更快。
更快的 for() 循环
这是一个简单但有效的概念。通常,我们习惯于这样编写一个简单的 for()
循环
for( i=0; i<10; i++){ ... }
[i
循环遍历值 0,1,2,3,4,5,6,7,8,9]
如果我们不需要关心循环计数器的顺序,我们可以这样做
for( i=10; i--; ) { ... }
使用此代码,i
循环遍历值 9,8,7,6,5,4,3,2,1,0,并且循环应该更快。
这是因为处理 i--
作为测试条件更快,它表示“i
是否非零?如果是,则递减它并继续”。对于原始代码,处理器必须计算“将 i
从 10 中减去。结果是否非零?如果是,则递增 i
并继续。” 在紧密循环中,这会产生相当大的差异。
语法有点奇怪,但完全合法。循环中的第三个语句是可选的(无限循环将写为 for( ; ; )
)。通过编码也可以获得相同的效果
for(i=10; i; i--){}
或者(进一步扩展)
for(i=10; i!=0; i--){}
我们唯一需要注意的是,要记住循环在 0 处停止(因此如果需要从 50-80 循环,这将不起作用),并且循环计数器是倒退的。如果你的代码依赖于升序循环计数器,很容易出错。
我们还可以使用寄存器分配,这会导致函数中其他地方的代码更高效。这种将循环计数器初始化为所需迭代次数,然后递减到零的技术,也适用于 while
和 do
语句。
循环合并
永远不要在可以用一个循环的地方使用两个循环。但是,如果你在循环中做了大量工作,它可能不适合你的处理器指令缓存。在这种情况下,两个单独的循环实际上可能更快,因为每个循环都可以在缓存中完全运行。这是一个例子。
//Original Code : for(i=0; i<100; i++){ stuff(); } for(i=0; i<100; i++){ morestuff(); } |
//It would be better to do: for(i=0; i<100; i++){ stuff(); morestuff(); } |
函数循环
函数在被调用时总是存在一定的性能开销。不仅程序指针必须改变,而且正在使用的变量必须被压入堆栈,并且分配新变量。因此,可以对程序函数的结构做很多事情以提高程序的性能。但是,必须注意保持程序的可读性,同时保持程序的大小可管理。
如果一个函数经常在循环中被调用,那么可以将该循环放在函数内部,以减少重复调用函数的开销,例如:
for(i=0 ; i<100 ; i++) { func(t,i); } - - - void func(int w,d) { lots of stuff. }
可能会变成……
func(t); - - - void func(w) { for(i=0 ; i<100 ; i++) { //lots of stuff. } }
循环展开
小型循环可以展开以提高性能,缺点是代码大小会增加。当循环展开时,循环计数器更新的频率降低,执行的分支也更少。如果循环只迭代几次,则可以完全展开,这样循环开销就完全消失了。
这可能会产生巨大差异。众所周知,展开循环可以节省大量成本,例如
for(i=0; i<3; i++){ something(i); } //is less efficient than |
something(0); something(1); something(2); |
因为代码每次循环都必须检查并递增 i
的值。编译器通常会展开像这样简单的循环,其中涉及固定次数的迭代,但像这样
for(i=0;i< limit;i++) { ... }
不太可能被展开,因为我们不知道会有多少次迭代。但是,可以展开这种循环并利用可以获得的速度提升。
以下代码(示例 1)显然比简单的循环大得多,但效率更高。块大小 8 仅用于演示目的,任何合适的尺寸都可以——我们只需重复相同数量的“循环内容”。在此示例中,循环条件每 8 次迭代测试一次,而不是每次迭代都测试。如果我们知道我们将处理特定大小的数组,您可以使块大小与数组大小相同(或可被数组大小整除)。但是,此块大小取决于机器缓存的大小。
//Example 1 #include<STDIO.H> #define BLOCKSIZE (8) void main(void) { int i = 0; int limit = 33; /* could be anything */ int blocklimit; /* The limit may not be divisible by BLOCKSIZE, * go as near as we can first, then tidy up. */ blocklimit = (limit / BLOCKSIZE) * BLOCKSIZE; /* unroll the loop in blocks of 8 */ 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); /* update the counter */ i += 8; } /* * There may be some left to do. * This could be done as a simple for() loop, * but a switch is faster (and more interesting) */ if( i < limit ) { /* Jump into the case at the place that will allow * us to finish off the appropriate number of items. */ 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); } } }
位计数 - 计算已设置的位数
这个例子 1 通过提取最低位并计数,然后移出该位,有效地测试了单个位。例子 2 首先展开了四次,然后可以通过将 n 的四次移位组合成一次来应用优化。展开经常为优化提供了新的机会。
//Example - 1 int countbit1(uint n) { int bits = 0; while (n != 0) { if (n & 1) bits++; n >>= 1; } return bits; } //Example - 2 int countbit2(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; }
提前跳出循环
通常不需要处理整个循环。例如,如果我们在数组中搜索特定项目,一旦找到所需项目,就立即跳出循环。示例:此循环搜索一个包含 10000 个数字的列表,以查看其中是否有 -99。
found = FALSE; for(i=0;i<10000;i++) { if( list[i] == -99 ) { found = TRUE; } } if( found ) printf("Yes, there is a -99. Hooray!\n");
这很好,但会处理整个数组,无论搜索项出现在哪里。更好的方法是一旦找到所需条目就中止搜索。
found = FALSE; for(i=0; i<10000; i++) { if( list[i] == -99 ) { found = TRUE; break; } } if( found ) printf("Yes, there is a -99. Hooray!\n");
如果项目在位置 23,循环将立即停止,并跳过剩余的 9977 次迭代。
函数设计
将函数保持小巧简单是一个好主意。这使得编译器能够更有效地执行其他优化,例如寄存器分配。
函数调用开销
处理器上的函数调用开销很小,并且相对于被调用函数执行的工作量通常也很小。函数可以通过寄存器传递的参数字数有一些限制。这些参数可以是整数兼容的(char
、short
、int
和 float
都占用一个字),或者是最多四个字的结构(包括两个字的 double
和 long long
)。如果参数限制是 4,那么第五个及后续的字将通过堆栈传递。这增加了在调用函数中存储这些字并在被调用函数中重新加载它们的成本。
在下面的示例代码中
int f1(int a, int b, int c, int d) { return a + b + c + d; } int g1(void) { return f1(1, 2, 3, 4); } int f2(int a, int b, int c, int d, int e, int f) { return a + b + c + d + e + f; } ing g2(void) { return f2(1, 2, 3, 4, 5, 6); }
第五个和第六个参数存储在 g2
中的堆栈上,并在 f2
中重新加载,每个参数需要两次内存访问。
最小化参数传递开销
为了最小化将参数传递给函数的开销
- 尽量确保小型函数接受四个或更少的参数。这些函数不会使用堆栈进行参数传递。
- 如果一个函数需要四个以上的参数,请尽量确保它完成大量工作,从而抵消传递堆栈参数的成本。
- 传递结构指针而不是传递结构本身。
- 将相关参数放入结构中,并将指向结构的指针传递给函数。这将减少参数数量并提高可读性。
- 最小化
long
参数的数量,因为它们占用两个参数字。如果启用了软件浮点,这也适用于double
。 - 避免使用部分在寄存器中传递,部分在堆栈中传递(拆分参数)的函数。当前编译器无法有效处理这些函数:所有寄存器参数都压入堆栈。
- 避免使用可变数量参数的函数。这些函数实际上将其所有参数都通过堆栈传递。
叶子函数
不调用任何其他函数的函数称为叶子函数。在许多应用程序中,所有函数调用中大约有一半是对叶子函数的调用。叶子函数在每个平台上都编译得非常高效,因为它们通常不需要执行通常的寄存器保存和恢复操作。与复杂到需要四个或五个以上寄存器的叶子函数所做的有用工作相比,入口时推入一些寄存器和出口时弹出它们的花费非常小。如果可能,我们应该尽量将经常调用的函数安排为叶子函数。函数被调用的次数可以通过使用性能分析工具确定。有几种方法可以确保函数作为叶子函数进行编译
- 避免调用其他函数:这包括转换为 C 库调用的任何操作(例如除法,或使用软件浮点库时的任何浮点操作)。
- 对于从它调用的小型函数使用
__inline
(内联函数接下来讨论)。
内联函数
对于所有调试选项,函数内联都被禁用。带有关键字 __inline
的函数会导致对内联函数的每次调用都被其主体替换,而不是进行正常调用。这会产生更快的代码,但会不利地影响代码大小,特别是当内联函数很大且经常使用时。
__inline int square(int x) { return x * x; } #include <MATH.H> double length(int x, int y){ return sqrt(square(x) + square(y)); }
使用内联函数有几个优点
- 没有函数调用开销。
由于代码是直接替换的,因此没有开销,例如保存和恢复寄存器。
- 较低的参数评估开销。
参数传递的开销通常较低,因为不需要复制变量。如果某些参数是常量,编译器可以进一步优化生成的代码。
内联函数的最大缺点是,如果函数在许多地方使用,代码大小会增加。这会根据函数的大小以及使用它的位置数量而显著变化。
明智的做法是只内联几个关键函数。请注意,如果操作得当,内联可能会减小代码大小:调用通常需要几条指令,但内联代码的优化版本可能会转换为更少的指令。
使用查找表
函数通常可以使用查找表进行近似,这会显著提高性能。表格查找通常不如正确计算值准确,但对于许多应用程序来说,这并不重要。
许多信号处理应用(例如,调制解调器解调软件)大量使用 sin
和 cos
函数,这些函数的计算成本很高。对于精度不重要的实时系统,sin
/cos
查找表可能是必不可少的。使用查找表时,尽量将尽可能多的相邻操作组合成一个查找表。这比使用多个查找表更快,并且占用空间更少。
浮点运算
尽管浮点运算对于任何类型的处理器来说都很耗时,但有时在实现信号处理应用程序时我们需要使用它。但是,在编写浮点代码时,请记住以下几点
- 浮点除法很慢。
除法通常比加法或乘法慢两倍。将除以常数重写为乘以倒数(例如,
x = x / 3.0
变为x = x * (1.0/3.0)
。常数在编译期间计算。)。 - 使用
float
而不是double
。浮点变量占用更少的内存和寄存器,并且由于其精度较低而效率更高。当其精度足够时,请使用
float
。 - 避免使用超越函数。
超越函数,如
sin
、exp
和log
,是使用一系列乘法和加法(使用扩展精度)实现的。因此,这些操作至少比正常的乘法慢十倍。 - 简化浮点表达式。
编译器无法将对整数执行的许多优化应用于浮点值。例如,
3 * (x / 3)
无法优化为x
,因为浮点运算通常会导致精度损失。甚至求值顺序也很重要:(a + b) + c 与 a + (b + c) 不同。因此,如果已知它们是正确的,手动执行浮点优化是有益的。
然而,浮点性能仍有可能无法达到特定应用程序所需的水平。在这种情况下,最好的方法可能是从使用浮点算术改为定点算术。当所需值的范围足够小时,定点算术比浮点算术更准确,速度也快得多。
杂项提示
通常,可以通过牺牲内存来提高速度。如果你能缓存任何常用的数据,而不是重新计算或重新加载它,这将有所帮助。这方面的例子包括正弦/余弦表,或伪随机数表(开始时计算 1000 个一次,如果你不需要真正的随机数,只需重新使用它们)。
- 避免在循环表达式中使用
++
和--
等。例如:while(n--){}
,因为这有时更难优化。 - 最小化全局变量的使用。
- 将文件(函数外部)中的任何内容声明为静态,除非它旨在是全局的。
- 如果可以的话,使用字大小的变量,因为机器可以更好地处理这些变量(而不是
char
、short
、double
、位域等)。 - 不要使用递归。递归可能非常优雅和简洁,但会创建更多的函数调用,这可能会成为巨大的开销。
- 避免在循环中使用
sqrt()
平方根函数——计算平方根非常占用 CPU。 - 一维数组比多维数组快。
- 编译器通常可以优化整个文件——避免将密切相关的函数拆分到单独的文件中,如果编译器能够同时看到它们(例如,它可能能够内联代码),它会做得更好。
- 单精度数学运算可能比双精度快——通常有一个编译器开关可以实现此目的。
- 浮点乘法通常比除法快——使用
val * 0.5
代替val / 2.0
。 - 加法比乘法快——使用
val + val + val
代替val * 3
。puts()
比printf()
快,尽管灵活性较差。 - 使用
#define
定义的宏代替常用的小函数——有时 CPU 使用的大部分可以追溯到在紧密循环中被调用数千次的小型外部函数。用执行相同工作的宏替换它将消除所有这些函数调用的开销,并允许编译器在其优化中更具侵略性。 - 二进制/未格式化文件访问比格式化访问快,因为机器不必在人类可读的 ASCII 和机器可读的二进制之间进行转换。如果你实际上不需要自己读取文件中的数据,请考虑将其设为二进制文件。
- 如果你的库支持
mallopt()
函数(用于控制malloc
),请使用它。MAXFAST
设置可以显著改进进行大量malloc
工作的代码。如果某个特定结构每秒创建/销毁多次,请尝试将mallopt
选项设置为最适合该大小。
最后但并非最不重要的一点是——打开编译器优化!这似乎很明显,但在最后时刻匆忙发布产品时常常被遗忘。编译器将能够比在源代码中更低的级别进行优化,并执行针对目标处理器的特定优化。
参考文献
- 为 ARM 编写高效 C 代码
- 文档编号:ARM DAI 0034A
- 发布日期:1998年1月
- 版权所有 Advanced RISC Machines Ltd. (ARM) 1998
- Richard 的 C 优化页面 或者:如何通过少量努力使你的 C、C++ 或 Java 程序运行更快。
- 使用 GNU C 编译器进行代码优化 作者:Rahul U Joshi。
- 在 Linux 上更快地编译 C [Christopher W. Fraser (Microsoft Research),David R. Hanson (Princeton University)]
- 代码优化 - 编译器 [1] [2]
[感谢 Craig Burley 提出的精彩评论。感谢 Timothy Prince 对具有指令级并行性的架构的说明]。
- GNU C 优化的演化分析 [使用自然选择研究软件复杂性,作者:Scott Robert Ladd。更新日期:2003 年 12 月 16 日]