Optimize Applications with gcc and glibc
by Ulrich Drepper
1. 介绍
=======
本文总结一些关于代码优化的经验, 这些经验是不完整的.
本文不是讨论编译器如何优化代码, 后者是完全不同的另外一个领域.
2. 编译时优化(Using Optimizations Performed at Compile-Time)
===========================================================
2.1 消除无用代码(Dead Code Elimination)
Dead Code指永远不会执行的代码.
例如:
long int
add(long int a, void* ptr, int type)
{
if (type == 0)
return a + *(int*)ptr;
else
return a + *(long int*)ptr;
}
这个函数根据type的值来判断ptr的类型, 从而求和.
优化1: 多数情况下int和long int是相同的, 因此可以优化为
long int
add(long int a, void* ptr, int type)
{
if (sizeof(int) == sizeof(long int) || (type == 0))
return a + *(int*)ptr;
else
return a + *(long int*)ptr;
}
sizeof运算总是在编译时进行, 因此增加的条件表达式总是在编译时计算.
如果long int和int确实相同, 那么这个函数就可以被编译器优化.
进一步优化, 利用 limits.h中定义的宏
#include <limits.h>
long int
add(long int a, void* ptr, int type)
{
#if LONG_MAX != INT_MAX
if (type == 0)
return a + *(int*)ptr;
else
#endif
return a + *(long int*)ptr;
}
这样, 即便在long int不同于int的平台上,该函数也被优化了
2.2 节省函数调用(Saving Function Calls)
很多函数很短小, 相对函数执行的时间, 函数调用的代价不可忽视. 例如
标准库中的字符串函数和数学函数. 解决办法有两个: 使用宏代替函数,
或者用inline函数.
一般而言, inline函数和宏一样快, 但是更安全. 但是如果用到alloca和
__builtin_constant_p的时候,可能要考虑用优先使用宏了
但是, 如果函数被声明为extern, inline并不总是有效了. 另外, 当gcc的
编译优化选项没有打开时, gcc不会展开inline函数.
如果inline函数是static的, 那么编译器总是会展开该函数, 不考虑是否
真的值得. 尤其是当使用 -Os(optimize for space)选项时, static
inline函数是否值得使用就是个问题了.
编写正确而又安全的宏并不容易. 要注意
a)正确使用括号括起参数,
例如
#define mult(a,b) (a*b) //错误
#define mult(a,b) ((a) * (b))
b)宏定义中的大括号引入新的block, 这有时侯会导致问题.
例如
#define scale(result, a, b, c) \
{ \
int c__ = (c); \
*(result) = (a)*c__ + (b)*c__; \
}
下面的代码编译会出现问题:
if(...)
scale(r, a, b, c); ///多余的分号导致编译错误else
else
{
}
正确的写法应该是:
#define scale(result, a, b, c) \
do{ \
int c__ = (c); \
*(result) = (a)*c__ + (b)*c__; \
}while(0)
c)如果参数是表达式并且在宏定义中出现多次, 尽量避免重复计算.
这也是上面例子中要引入变量c__的原因. 但这会限制变量c__的类型.
d)宏缺乏返回值
2.3 编译器内部函数(Compiler Intrinsics)
绝大部分C编译器都知道内部函数(Intrinsic functions). 它们是特殊的
inline函数, 由编译器提供使用. 这些函数用外部实现来代替.
gcc2.96的内部函数有
* __builtin_alloca:动态分配栈上内存
dynamiclly allocate memory on the stack
* __builtin_ffs:
find first bit set
* __builtin_abs, __builtin_labs:
absolute value of an integer
* __builtin_fabs, __builtin_fabsf, __builtin_fabsl
absolute value of floating-point vlaue
* __builtin_memcpy
copy memory region
* __builtin_memset
set memory region to give value
* __builtin_memcmp
compare memory region
* __builtin_strcmp
* __builtin_strcpy
* __builtin_strlen
* __builtin_sqrt, __builtin_sqrtf, __builtin_sqrtl
* __builtin_sin, __builtin_sinf, __builtin_sinl
* __builtin_cos, __builtin_cosf, __builtin_cosl
* __builtin_div, __builtin_ldiv
integer division with rest
* __builtin_fmod, __builtin_frem
module and remainder of floating-point value
不能保证所有内部函数在所有平台上都定义了.
关于intrinsic function, 有一个很有用的特性: 如果参数在编译时是
常数, 那么可以在编译时计算其值.
例如strlen("foo bar")有可能在编译时就计算好.
2.4 __builtin_constant_p
__builtin_constant_p并不属于intrinsic function, 它是一个类似于
sizeof的操作符.
__builtin_constant_p接收一个参数, 如果该参数在运行时是固定不变
的(constant at runtime), 那么就返回非0值, 表示这是一个常量.
例如, 前面的add函数可以在进一步优化:
#define add(a, ptr, type) \
(__extension__ \
( __buildtin_constant_p(type) \
? ((a) + ( (type)==0 \
? *(int*)(ptr) : \
*(long int*)(ptr))) \
: add(a, ptr, type)))
如果第三个参数为constant, 那么这个宏将改变add函数的行为; 否则
就调用真正的add函数. 这样尽量在编译时计算, 从而提高了效率.
2.5 type-generic macro
有时侯我们希望宏对不同的参数数据类型, 能正确处理不同数据类型并表现
相同的行为, 可以借助__typeof__
例如前面的scale
#define tgscale(result, a, b, c) \
do{ \
__externsion__ __typeof__ ((a)+(b)+(c)) c__ = (c); \
*(result) = (a)*c__ + (b)*c__; \
}while(0)
这里, c__自动拥有返回值类型,而不是前面固定写的int类型.
__typeof__ (o) 定义了与o相同的类型.
__typeof__的另外一个用途: 被ISO C9x 用于tgmath中, 从而
实现一些对任意数据类型(包括复数)都适用的数学函数.
错误示例:
#define sin(val) \
(sizeof (__real__ (val)) > sizeof (double) ? \
(sizeof(__real__(val)) == sizeof (val) ? \
sinl(val) : csinl(val) ) \
:(sizeof(__real__(val)) == sizeof(double) ? \
(sizeof(__real__(val)) == sizeof(val) ? \
: sin(val) : csin(val)) \
:(sizeof(__real__(val)) == sizeof(val)? \
sinf(val):csinf(val))))
上面这个宏的意思是:
如果val是虚数(即sizeof(__real__(val)) != sizeof(val)),
那么对val调用 csinl, csin和csinf
如果val是实数, 且比double精度高, 即
sizeof(__real__(val)) > sizeof(double)), 那么对val调
用sinl, 就long double, 否则调用sin或者sinf.
sinl: 相当于sin(long double)
sin: 相当于sin(double)
sinf:相当于sin(float)
csin: 对应的复数sin函数
但是这个宏是有错误的, 由于整个宏是一个表达式, 表达式是有静态
的类型的, 能代表该表达式的数据类型必须有足够的精度来表示各种值,
所以这个表达式的最终数据类型就是comple long double, 这并不是我们
期望的.
正确的实现方法是:
#define sin(val) \
(__extension__ \
({ __typeof__ (val) __tgmres; \
if (sizeof(__real__ (val)) > sizeof(double)) \
{ \
if(sizeof(__real__ (val)) == sizeof(val)) \
__tgmres = sinl(val); \
else \
__tgmres = csinl(val); \
} \
else if (sizeof(__real__ (val))== sizeof(double)) \
{ \
if(sizeof(__real__ (val)) == sizeof(val)) \
__tgmres = sin(val); \
else \
__tgmres = csin(val); \
} \
else \
{ \
if(sizeof(__real__ (val)) == sizeof(val)) \
__tgmres = sinf(val); \
else \
__tgmres = csinf(val); \
} \
__tgmres;}))
上面对__tgmres赋值的6个分支中, 真正会执行的那个分支是不存在精度
损失的; 其他分支都会作为deadcode被编译器优化掉
3. help the compiler
====================
GNU C编译器提供一些扩展来更清晰的描述程序, 从而帮助编译器生成代码.
3.1 不返回的函数(Functions of No Return)
大项目一般都至少有一个用于严重错误处理的函数, 这个函数体面的结束应用
程序. 这个函数一般情况下不会被编译器优化, 因为编译器不知道它不返回.
例如:
void fatal(...) __attribute__ ((__noreturn__));
void
fatal(...)
{
//print some message
exit(1);
}
// application code
{
if (d==0)
fatal(...);
else
a = b / d;
}
函数fatal保证不会返回, exit函数也不返回. 因此可以在
函数原型上加上 __attribute__ ((__noreturn__)).
如果没有noreturn的标记, gcc会把上面的代码翻译成
下面的形式(伪代码):
1) compare d with zero
2) if not zero jump to 5)
3) call fatal
4) jump to 6)
5) compute b/d and assign to a
6) ...
如果有noreturn标记, gcc可以优化代码, 省略4). 对应
的源代码为
{
if (d==0)
fatal(...);
a = b / d;
}
3.2 常值函数(constant value functions)
有些函数的值仅仅取决于传入的参数, 这种函数没有副作用, 我们称之
为pure function. 对于相同的参数, 这种函数有相同的返回值.
举例说明: htons函数要么返回参数(如果是big-endian计算机), 要么
交换字节顺序(如果计算机是little-endian). 这个函数没有副作用, 是
一个pure function. 那么下面的代码可以被优化:
{
short int server = ...
while(1)
{
struct sockaddr_in s_in;
memset(&s_in, 0, sizeof s_in);
s_in.sin_port = htons(serv);
......
}
}
优化后的结果为:
{
short int server = ...
serv = htons(serv);
while(1)
{
struct sockaddr_in s_in;
memset(&s_in, 0, sizeof s_in);
s_in.sin_port = serv;
......
}
}
从而减少循环中执行的代码, 节省CPU.
但是编译器并无法知道函数是否是pure function. 我们必须给
pure function显著的标记:
extern uint16_t htons(uint16_t __x) __attribute__ ((__const__));
__const__ 可以用来标记pure function.
3.3 Different Calling Conventions
每种平台都支持特定的calling conventions以便由不同语言和编译器写的
程序/库能够一起工作.
但是, 有时侯在某些平台上, 编译器支持一种更高效的calling convention.
在项目内部使用这种calling convention不会影响系统的其他部分.
尤其是在Intel ia32平台上, 编译器支持多种不同于标准Unix x86的calling
convention, 这有时侯会大大提高程序速度. GNU C编译器手册有更详细解释.
本节只讨论x86平台.
改变函数的calling convention的两个办法:
1) 命令行选项(command line option): 这种方法不安全, 所有函数(包括
exported function)都受到影响
2) 对单个函数设置function attribute.
3.3.1 __stdcall__
一般情况下, 函数参数是通过栈来传递的, 因此需要在某个位置调整栈指针.
ia32 unix平台上标准的calling convention是让调用方(caller)调整栈
指针; 因此可以延迟调整操作, 一次同时调整多个函数的栈指针.
如果函数被标记为__stdcall__, 这意味这个函数自己调整栈指针. 在ia32
平台上, 这不算是坏注意, 因为ia32体系结构提供一个指令, 能同时从函数
调用返回并调整栈指针.
示例:
int __attribute__ ((__stdcall__))
add(int a, int b)
{
return a+b;
}
int foo(int a)
{
return add (a, 42);
}
int bar(void)
{
return foo(100);
}
上面的代码翻译成汇编大致如下:
8 add:
9 0000 8B442408 movl 8(%esp), %eax
10 0004 03442404 addl 4(%esp), %eax
11 0008 C20800 ret $8
...
17 foo:
18 0010 6A2A pushl $42
19 0012 FF742408 pushl 8(%esp)
20 0016 E8E5FFFF call add
20 FF
21 001b C3 ret
...
27 bar:
28 0020 6A64 pushl $100
29 0022 E8E9FFFF call foo
29 FF
30 0027 83C404 addl $4, %esp
31 002a C3 ret
从上面的例子可以看出, add函数被标记为__stdcall__, foo
函数在调用add后直接返回,不需要调整栈指针, 因为add函数
已经调整来指针(ret $8指令完成返回和调整指针操作); 而
bar函数调用foo函数, 调用结束后必须调整栈指针.
由此可见, 使用__stdcall__是有好处的; 但是, 现代编译器都已经
很智能, 能作到一次性为多个函数调用调整栈指针, 从而使得生成的
代码更少速度更快. 此外, 以后的发展可能会出现更快的调用方式,
所以使用__stdcall__必须非常谨慎.
3.3.2 __regparm__
__regparm__只能在ia32平台上使用, 它能指明有多少个(最多3个)整数
和指针参数是通过寄存器来传递的, 而不是通过栈传递. 当函数体比较
短小, 而且参数立刻就能使用时, 这种方式效果很显著.
假设有下面的例子:
int __attribute__ ((__regparm__ (3)))
add(int a, int b)
{ return a+b; }
经过编译优化后, 生成的代码时
8 add:
9 0000 01D0 addl %edx, %eax
10 0002 C3 ret
这个代码比起3.3.1中add的代码更高效. 用寄存器传参数总是
很快.
3.4 Sibling Calls
经常有这样的代码: 一个函数最后结束时是在调用另外一个函数. 这种
情况下生成的伪代码如下:
// this is in function f1
n call function f2
n+1 execute code of f2
n+2 get return address from call in f1
n+3 jump back into function f1
n+4 optionally adjust stack pinter from call to f2
n+5 get return address from call to f1
n+6 jump back to caller of f1
经过优化, f1在调用f2结束后可以直接返回.
3.5 使用goto
goto有时侯提高效率
4. 了解库(Knowing the Libraries)
================================
4.1 strcpy vs. memcpy
strcpy: 两个参数src和dest, 逐个byte拷贝
memcpy: 三个参数, src,dest和size, 按word拷贝
strncpy: 3个参数: src, dest和length
退出条件: 遇到NUL字符或达到拷贝长度
逐个检查byte是否为NUL
追加NUL字符
非gcc内部函数
memcpy: 3个参数
退出条件: 达到拷贝长度
按word检查长度
不必追加NUL字符
gcc内部函数, 特殊优化
类似的, mem* 和对应的str*函数都存在差别.
mem*函数参数多些, 一般情况下这不是问题, 可以通过寄存器传参数;
但是当函数被inline的时候, 寄存器可能不够, 生成的代码可能稍微
复杂一些.
建议如下:
* 尽量别使用strncpy, 而使用strcpy
* 如果要拷贝的字符串很短, 用strcpy
* 如果字符串可能很长, 用memcpy
4.2 strcat和strncat
关于字符串操作的一个金口玉言(gold rule)是:
绝对不要使用strcat和strncat.
要使用这两个函数, 必须知道长度, 并准备足够的空间. 定型
代码如下:
{
char *buf =...;
size_t bufmax =...;
if (strlen(buf) + strlen(s) + 1 > bufmax)
buf = (char*) realloc(buf, (bufmax *= 2));
strcat(buf, s);
}
上面的代码中, 已经调用了strlen, strcat中会重复执行strlen
操作, 因此更高效的作法是:
{
char *buf =...;
size_t bufmax =...;
size_t slen;
size_t buflen;
slen = strlen(s)+1;
buflen = strlen(buf);
if (buflen + slen > bufmax)
buf = (char*) realloc(buf, (bufmax *= 2));
memcpy(buf+buflen, s, slen);
}
4.3 内存分配
malloc和calloc: 分配堆内存.
alloca分配栈内存.
malloc的实现: 从内核申请内存, 可能会调用sbrk系统调用; 在某些系统上
如果申请的内存很多, 可能会调用mmap来分配内存. malloc的内部实现会用
相关的数据结构来管理好申请内存, 以便释放或者重新申请. 因此调用malloc
的代价并不低.
alloca的实现相对简单得多, 起码编译器能直接把它作为inline来编译,
alloca只是简单修改一下栈指针就可以了. 而且, 调用alloca后不需要调用
free函数来释放内存. free函数的代价也是不小的.
但是, alloca申请的内存只能用在当前函数中, 而且alloca不适合用来申请
大量内存, 很多平台系统出于安全考虑对栈的大小有限制. malloc的实现和
内核相关, 能更好的处理大内存申请.
alloca总是成功的, 因为它只是执行修改栈指针操作而已. 因此alloca非常
适合在函数内部申请局部使用的内存, 不比检查申请释放成功, 也不必调用
free来释放内存, 不仅提高性能还简化来代码.
示例如下:
int tmpcopy(const int *a, int a)
{
int *tmp = (int*)malloc(n * sizeof(int));
int_fast32_t count;
int result;
if (tmp == NULL)
return -1;
for (count = 0; count < n; ++count)
tmp[count] = a[count]^0xffffffff;
result = foo(tmp, n);
free(tmp);
return result;
}
用alloca改良后的代码变简单了: 省略了free和指针检查.
int tmpcopy(const int *a, int a)
{
int *tmp = (int*)alloca(n * sizeof(int));
int_fast32_t count;
for (count = 0; count < n; ++count)
tmp[count] = a[count]^0xffffffff;
return foo(tmp, n);
}
GNU libc提供strdupa和strndupa, 就是用来局部临时拷贝字符串. 它们与
strdup和strndup的不同就是, 前者调用alloca, 后者调用malloc. 因此
strdupa和strndupa只能是宏, 而不能是函数.
下面是strdupa的错误实现:
// Please note this is WRONG!!!
# define strdupa(s) \
(__extension__ \
({ \
__const char *__old = (s); \
size_t __len = strlen (__old) + 1; \
(char *) memcpy (__builtin_alloca(__len), __old, __len); \
}))
上面的实现代码中, memcpy对alloca的调用是错误的, 因为alloca修改了
栈指针, 而在某些系统上, 传递给函数调用的参数也是放在栈上的, 这会导致
严重错误. 所以, 绝对不能在函数参数列表中调用alloca, 也不能在参数列表中
通过strdupa或其他方式隐式调用alloca.
上面的代码被编译成这样的伪代码:
1. push __len on the stack, change stack pointer
2. push __old in the stack, change stack pointer
3. modify stack pointer for newly allocated object
4. push current stack pointer on stack, change stack pointer
5. call memcpy
正确代码应该是这样的:
// Duplicate S, returning an identical alloca'd string.
# define strdupa(s) \
(__extension__ \
({ \
__const char *__old = (s); \
size_t __len = strlen (__old) + 1; \
char *__new = (char *) __builtin_alloca (__len); \
(char *) memcpy (__new, __old, __len); \
}))
4.4 其他内存相关问题
1)realloc代价相当高, 要执行malloc/memcpy/free三个操作
2)malloc并不一定每次都向系统内核申请内存, 它本身也管理了内存的申请和
释放; 释放的内存不一定还给系统, 而是留给下次申请
3)估算出程序需要的大致内存, 减少申请和分配次数, 可以提高效率
4)ISO C定义了函数calloc, 这个函数分配内存会全部用0填充, 它比调用malloc
和memset更高效, 因为对于通过内核mmap得到内存已经被清0了, calloc不会
再执行清0操作; 而对sbrk获得的内存, calloc会执行清0操作; 从而节省不必要
的清0操作.
4.5 用最合适的数据类型
ISO C9x定义了一个重要的新头文件: <stdint.h>. 其中定义了
int_least8_t, uint_least8_t, int_least16_t, ...
int_least64_t等数据类型.
int8_t确保正好有8bit; 而int_least8_t保证最少有8bit, 少于
8bit的整数可以安全存放再int_least8_t中.
例如, 有个整数数组, 每个元素包含16bit的值, 并且被频繁使用.
如果我们将其定义为 int_least16_t, 那么在有些平台上可能会
分配64bit, 从而更快的访问数组元素, 提高性能.
类似的, 假设有下面的代码:
{
short int n;
......
for(n = 0; n < 500; ++n)
c[n]=a[n]+b[n];
}
循环阀值500用16bit表示足够, 可以将循环计数器定义为
int_fast16_t类型, 编译器能识别该类型, 可能将它存放
在寄存器中, 从而提高性能.
4.6 非标准字符串函数
经常会有获得字符串结尾位置指针的需求, 最直接的作法是
char* s = ....;
s += strlen(s);
这种方法执行了多余的+操作, strlen其实已经遍历到结尾了.
方法2:
char *s = ...
s = strchr(s, '\0');
strchr直接返回了末尾指针位置, 但是这种作法增加了strchr
比较运算.
方法3:
非标准函数rawmemchr(const char*, char), 它在memchr的基础上
减少了size参数, 从而减少了比较运算, 提高了效率.
5. Write Better Code
=====================
5.1 正确编写和使用库函数
假设要实现strdup函数:
实现1:
char* duplicate(const char *s)
{
char *res = xmalloc(strlen(s)+1);
strcpy(res, s);
return res;
}
其中xmallc是GNU提供的failesafe的malloc实现.
改进实现2: 用memcpy代替strcpy
char* duplicate2(const char *s)
{
size_t len = strlen(s) + 1;
char* res = xmalloc(len)+1;
memcpy(res, s, len);
return res;
}
改进实现3: 直接返回memcpy的返回值. memcpy是有返回值的.
char* duplicate3(const char *s)
{
size_t len = strlen(s) + 1;
return memcpy(xmalloc(len), s, len);
}
改进实现4: 编译时优化
#define duplicate4(s) \
(__buildtin_constant_p(s) \
? duplicate_c(s, strlen(s)+1) \
: duplicate3(s))
char* duplicate_c(const char* s, size_t len)
{
return (char*) memcpy(xmalloc(len), s, len);
}
5.2 Computed goto
有些函数由于设计或者性能的原因, 难以分割成若干个小函数,
导致函数有许多条件分支, 从而降低执行性能. 解决办法就是
使用状态机. 实现状态机的最简单方法就是使用switch.
另外一个办法就是使用状态跳转表(goto).
示例如下:
{
....
switch(*cp)
{
case 'l':
islong = 1;
++cp;
break;
case 'h':
isshort = 1;
++cp;
break;
default:
}
switch(*cp)
{
case 'd':
...
break;
case 'h':
...
break;
default:
}
}
使用状态跳转表
{
static const void* jumps1[] =
{
['l'] = &&do_l,
['h'] = &&do_h,
['d'] = &&do_d,
['g'] = &&do_g
};
static const void* jumps2[] =
{
['d'] = &&do_d,
['g'] = &&do_g
};
goto *jmps1[*cp];
do_l:
islong = 1;
++cp;
goto jumps2[*cp];
do_h:
isshort = 1;
++cp;
goto jumps2[*cp];
do_d:
...
goto out;
do_g:
...
goto out;
out:
}
6. Profiling
============
对程序进行profile分两种:
1)基于时间: 找出最消耗时间的代码
2)基于调用关系: 找出函数调用次数和调用关系
有些函数很小巧, 可能被调用次数很多, 但执行时间并不多.
6.1 grof profiling
gcc编译程序的时候加上-pg选项,
gcc -c foo.c -o foo.o -pg
link的时候加上-profile选项
gcc -o foo foo.o -profile
6.2 sprof profiling
不需要重新编译, 只需设置环境
LD_PROFILE=libc.so.6
LD_PROFILE_OUTPUT=.
然后运行程序, 可以检查对库的调用.