Yebangyu's Blog

Fond of Concurrency Programming, Distributed System and Performance Optimization.

性能优化的那些传说和迷思

相信你在很多书籍中见到很多代码调优(tuning code)的建议和方法。这些书籍可能包括《编程珠玑》、《深入理解计算机系统》、《程序设计实践》、《Optimized C++》等等。坦白说,这些书我都看过,它们确实提供了不少有意思的性能调优的方法,那么我们的问题是,这些建议和方法有效吗?所谓有效,一种衡量途径是,假如我们不那么做,是否编译器已经会自动优化了呢?

本文我们举几个例子,然后开启编译器优化选项后,看看发生了什么。

本文环境为:Ubuntu 14.04 32bit + Intel I7 CPU + GCC 4.8

生成汇编代码的语句是:g++ -S -O2 code.cpp

循环展开

对于下面的函数

1
2
3
4
5
6
void f1(int *a)
{
  for (int i = 0; i < 3;i++) {
    a[i] = a[i] + 2;
  }
}

它们建议可以将循环展开,变成这样:

1
2
3
4
5
6
void f2(int *a)
{
  a[0] = a[0] + 2;
  a[1] = a[1] + 2;
  a[2] = a[2] + 2;
}

OK,我们看看f1函数的反汇编代码:

1
2
3
4
5
    movl    4(%esp), %eax
    addl    $2, (%eax)
    addl    $2, 4(%eax)
    addl    $2, 8(%eax)
    ret

嗯,编译器已经自动帮你循环展开了。

练习:那么对于以下这种情况呢?是否有优化效果?

1
2
3
4
5
6
7
8
void f1(int *a)
{
  for (int i = 0; i < 3n; i += 3) {
    a[i] = a[i] + 2;
    a[i + 1] = a[i + 1] + 2;
    a[i + 2] = a[i + 2] + 2;
  }
}

循环条件去重

对于以下函数

1
2
3
4
5
6
7
8
int g1(char *p)
{
  int xx = 0;
  for (int i = 0 ; i < strlen(p); i++) {
    xx += p[i];
  }
  return xx;
}

它们建议你可以将第4行中的strlen(p)调用抽离出来放在循环开始前:

1
2
3
4
5
6
7
8
9
int g2(char *p)
{
  int xx = 0;
  int len = strlen(p);
  for (int i = 0 ; i < len; i++) {
    xx += p[i];
  }
  return xx;
}

OK,我们看看g1函数的反汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.LFB26:
        pushl   %ebx
        subl    $24, %esp
        movl    32(%esp), %ebx
        movl    %ebx, (%esp)
        call    strlen //只有一次调用,不在循环里
        movl    %ebx, %edx
        leal    (%ebx,%eax), %ecx
        xorl    %eax, %eax
        jmp     .L2
.L3:
        movsbl  (%edx), %eax
        addl    $1, %edx
        addl    $9977, %eax
.L2:
        cmpl    %ecx, %edx
        jne     .L3
        addl    $24, %esp
        popl    %ebx
        ret

编译器已经帮你做了优化了。Why?本质原因是什么?

这是因为,首先,strlen函数的原型是:

size_t strlen(const char *p)

看到没,const char,也就是说这个函数不会改变输入参数,并且该函数不会对全局状态做一些设置和改变。而在函数g1内部,也只有对p的读,没有写,因此编译器可以放心地、大胆的做优化,把它当固定量。如果该函数可能有副作用,编译器是不会做这样的优化的。

因此,如果这里不是strlen,而是memcpy这样的函数,显然编译器无法做优化。如果这里不是strlen,而是你的一个辅助函数,比如说help,它的函数签名除了名字之外,和strlen一模一样。为了帮助gcc施展优化,你可以给gcc提供更多更好的提示和信息,比如说:

1
int attribute ((pure)) help(const char *p);

根据gcc文档,pure属性是用来修饰这样的函数:该函数除了返回一些值之外,不会产生其他作用和影响;并且它的返回值只依赖于它的输入参数和一些全局变量,比如说strlen和memcmp。

练习:实现一个简单的help函数,参数分别是const char *pvolatile char *p,查看gcc策略的差异。这个练习有助于让你发现,volatile是如何阻止编译器优化的。

移位代替简单除法

对于除以2,它们建议用移位来代替。比如说,对于下面的函数:

1
2
3
4
unsigned int h1(unsigned int a)
{
  return a / 2;
}

它们建议改为:

1
2
3
4
unsigned int h2(unsigned int a)
{
  return a >> 1;
}

查看h1函数的反汇编代码:

1
2
3
    movl    4(%esp), %eax
    shrl    %eax //移位指令
    ret

也就是说,编译器已经帮你做了优化。

练习:对于以下函数和“优化”,查看是否必要,是否有效

1
2
3
4
unsigned int h1(unsigned int a)
{
  return a * 9;
}

对应的“优化”版本:

1
2
3
4
unsigned int h2(unsigned int a)
{
  return (a << 3) + a; //括号不能丢哦。
}

那么,你可能会说了,什么是有效的呢?什么是编译器可能无法自动主动做的呢?你可以参考我的这篇博客。

写在最后

1,本文的演示仅仅是抛砖引玉,更重要的目的则有二:一是授人以鱼不如授人以渔,用一种方法和大家一起分析。二是,尽信书不如无书,必须报着怀疑的态度去读书,去验证,去思考。

2,教科书自然是强调原理的,强调思想的,它和实际有脱节这是无法避免的,也是必须这样的。无可厚非。我并不是说教科书说的这些优化不好,相反,如你所见,非常好。本文的目的是,很可能编译器已经帮你做了。

3,不同的编译器策略可能不同;同一个编译器不同版本策略可能也不同;同一个编译器的同一个版本在不同的上下文对于同一个函数的策略也可能不同。读者诸君务必不要过于相信本文,如我第一点所说:质疑!!!实践!!!