程序性能的初步优化与分析(以 C++ 为例)

去年圣诞节浏览了 Milo Yip 的文章“如何用 C 语言画一棵圣诞树” 后,对这个圣诞树生成算法很感兴趣:

#include <math.h>
#include <stdio.h>
#include <stdlib.h>

#define PI 3.14159265359

float sx, sy;

float sdCircle(float px, float py, float r) {
    float dx = px - sx, dy = py - sy;
    return sqrtf(dx * dx + dy * dy) - r;
}

float opUnion(float d1, float d2) {
    return d1 < d2 ? d1 : d2;
}

#define T px + scale * r * cosf(theta), py + scale * r * sin(theta)

float f(float px, float py, float theta, float scale, int n) {
    float d = 0.0f;
    for (float r = 0.0f; r < 0.8f; r += 0.02f)
        d = opUnion(d, sdCircle(T, 0.05f * scale * (0.95f - r)));

    if (n > 0)
        for (int t = -1; t <= 1; t += 2) {
            float tt = theta + t * 1.8f;
            float ss = scale * 0.9f;
            for (float r = 0.2f; r < 0.8f; r += 0.1f) {
                d = opUnion(d, f(T, tt, ss * 0.5f, n - 1));
                ss *= 0.8f;
            }
        }

    return d;
}

int ribbon() {
    float x = (fmodf(sy, 0.1f) / 0.1f - 0.5f) * 0.5f;
    return sx >= x - 0.05f && sx <= x + 0.05f;
}

int main(int argc, char* argv[]) {
    int n = argc > 1 ? atoi(argv[1]) : 3;
    float zoom = argc > 2 ? atof(argv[2]) : 1.0f;
    for (sy = 0.8f; sy > 0.0f; sy -= 0.02f / zoom, putchar('\n'))
        for (sx = -0.35f; sx < 0.35f; sx += 0.01f / zoom) {
            if (f(0, 0, PI * 0.5f, 1.0f, n) < 0.0f) {
                if (sy < 0.1f)
                    putchar('.');
                else {
                    if (ribbon())
                        putchar('=');
                    else
                        putchar("............................#j&o"[rand() % 32]);
                }
            }
            else
                putchar(' ');
        }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61

将代码保存为 milo_tree.c (如上),拿到电脑上跑一遍,感觉圣诞树的生成速度很慢,在 Linux 下编译,用 time 指令看看具体时间:

$ gcc milo_tree.c -o milotree -lm

$ time ./milotree
                                   .
                                   .
                                   .
                                  ==.
                                . o.. .
                            .  ......#..  .
                         ..... ....o.... .....
                                  ..=
                               == ==j ..
                            . .   &..   . .
                          .  .. ....... j#  .
                         ..&... # .&. & ..#...
                       . # . . .....==== = = = #
                              ======....#
                          .   .& ..... ..   .
                        . . ##  ...&...  #. . .
                     .. ...... . ..... . .....= ==
                  . j o    . & .#...==== = =    . . .
                            =    ===.&    .
                     =    .. .  ....&.#  . ..    .
                     .  . .... .....#... ...j .  .
                   . .  #o...... ..... ......j=  = =
                 .#o. .   ..........=========   . .&..
                .    .  .#===  =====....  ....&  .    .
                     =    ..j   .......   ...    .
              =  .   .  #......................  .   o  .
              . .  . .&.#.....................==== =  = &
              .........  ... .. ..&.=== == ===  .......&.
         #..       .  . ..=== = ====... . ..&.& .  .       ...
             . .=  =  == =.. . .......#. . ... ..  .  &. .
             =       ...  ...................  .o.       .
         .   . &#. ....................#.#..#.====== === .   .
     . .  .  ..#.....o...  .. . o...=== = ==  ............  .  . .
    .. .........  .. j..  ===  =====....  .o.  ... ..  ......... ..
...  .  .     . ==== == = .... ....&.... .... . .. .... .     .  .  ...
      =   =         .    &..  ......o....  ...    .         .   .
                         ..    .........    ..
                               .........
                               .........
                               .........
                               .........

./milotree  18.34s user 0.00s system 99% cpu 18.384 total
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46

18384 毫秒,很慢的速度。扫一眼代码,里面的嵌套 for 和大量的浮点数运算可能是生成速度慢的原因。但对于新手来说,靠猜测去判断程序的瓶颈在哪里是很不靠谱的。

这里就需要使用专门的性能分析工具,Linux 平台可使用 gprof,Windows 平台的 Visual Studio 也有类似的功能。

这里我在 Linux 平台下演示,使用 grpof 需要在编译时加上 -pg 参数,然后运行一遍,才可以分析出来。如下指令:

$ gcc milo_tree.c -lm -pg -o milotree

$ ./milotree && gprof ./milotree

Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  ms/call  ms/call  name    
 54.98      1.74     1.74     2911     0.60     1.03  f
 26.53      2.58     0.84 224976635     0.00     0.00  sdCircle
 13.51      3.00     0.43 230460959     0.00     0.00  opUnion
  5.40      3.17     0.17                             frame_dummy
  0.00      3.17     0.00      702     0.00     0.00  ribbon
...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

可以看到,代码内的 f 函数是性能瓶颈,sdCircle()opUnion() 函数的结构很简单,但由于调用次数接近 2 亿,也存在一定开销。

性能分析结束,开启 GCC 标准优化选项 O2 编译一遍:

$ gcc -O2 milo_tree.c -o milotree -lm && time ./milotree

...
./milotree  1.85s user 0.00s system 99% cpu 1.860 total
  • 1
  • 2
  • 3
  • 4

瞬间缩短到 1860 毫秒,9 倍左右的速度提升,这时 gprof分析一下,结果是:

Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  Ts/call  Ts/call  name    
100.57      1.68     1.68                             f

             Call graph (explanation follows)


granularity: each sample hit covers 2 byte(s) for 0.60% of 1.68 seconds

index % time    self  children    called     name
                             5484324             f [1]
[1]    100.0    1.68    0.00       0+5484324 f [1]
                             5484324             f [1]
-----------------------------------------------

Index by function name

   [1] f
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

可以看到,即使没有对 sdCircle() 和 opUnion() 加 inline 关键字,它们也被编译器自动展开了。

一般到这里,“优化” 就可以结束了。可一切都是编译器替我们做的,而且可能还期待执行速度可以更快一些。

这时,稍有一点经验的程序员可以从程序的具体实现入手。

接下来,从简单的函数开始,来分析优化点,看 sdCircle() 函数的代码:

float sdCircle(float px, float py, float r) {
    float dx = px - sx, dy = py - sy;
    return sqrtf(dx * dx + dy * dy) - r;
}
  • 1
  • 2
  • 3
  • 4

这里使用了 C 标准库的平方根算法的单精度浮点版本 sqrtf() 。这里提一下,标准库的实现都是稳健的,但不是所有都是高效率的。

在特定硬件平台,有专门的指令集提供这类数学上的运算指令,例如可以使用 C 的内联汇编来实现一个平方根运算:

// GCC 版本内联汇编,使用 SSE 指令集
float asm_sse_sqrtf(float x)
{
    float ret;

    // GCC inline assembly
    __asm__ __volatile__ (
        "rsqrtss %1, %%xmm0\n\t"
        "rcpss %%xmm0, %%xmm0\n\t"
        "movss %%xmm0, %0"
        : "=m"(ret)
        : "m"(x));

    return ret;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

但是这个程序并不需要精确到小数点后几十位的程度,精度只要足够即可,否则只是浪费处理器的时钟周期。

这时,回忆起雷神之锤 3 经典的快速平方根倒数算法,里面利用了一个魔数 0x5f3759df 求平方根倒数的近似算法。当然,平方根也可以用魔数来快速运算,这篇文章提供了十几种快速平方根算法,其中就有几个是利用了魔数运算。

这里,我把上篇文章中的第 7 个算法简化,实现一个快速平方根运算函数,因为是为了优化,所以我定义成 inline 函数:

typedef unsigned int u32;

inline float f_sqrt(float x)
{
    u32 i = (*(u32 *) &x + 0x3f800000) >> 1;
    return *(float*) &i;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

因为至少需要 32 位的无符号整型才能满足运算的数值范围,由于现代编译器在 32 位和 64 位操作系统上,int 型都是 32 位,所以用 int 的无符号版本即可。 
另外,此算法可以通过牛顿迭代法来提高计算的精度,不过在此程序里没什么必要。

这时,再看一看代码,里面有个很慢的随机数算法 rand(),同样它是 C 标准库提供的。要优化这个算法,可以浏览这篇 Intel 的快速随机数算法文章 ,里面介绍的 LCG 算法比标准库实现快 5 倍左右。

为了一些便利,我用 C++ 替代 C 来写 LCG 算法,并用 C 标准库 time() 的返回值作为种子值,确保每次运行时圣诞树的装饰都不一样:

...
#include <ctime>

typedef unsigned int u32;

class LCG {
public:
    LCG(u32 seed) : mSeed(seed) {}
    u32 operator()() {
        mSeed = mSeed * 214013U + 2531011U;
        return (mSeed >> 16U) & 0x7FFFU;
    }
private:
    u32 mSeed;
};

...
int main(int argc, char *argv[])
{
...
    LCG rng(time(0));
...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

编译、运行:

$ g++ i.cc -O2 && time ./a.out

./a.out  1.67s user 0.00s system 99% cpu 1.682 total
  • 1
  • 2
  • 3

可以看到比原来的代码快了 200 毫秒左右。

这时候,再对 f 函数下手,浏览一下此函数的代码。第一个 for 循环进行 40 次迭代,虽然看似很简单,但是把宏

#define T px + scale * r * cos(theta), py + scale * r * sin(theta)
  • 1

替换进去,可以看到里面包含了平方根,正弦,余弦的运算,实际整个函数的瓶颈就在此处,应该重点优化。但由于这个 for 循环是浮点数运算,且每次迭代都会用到上一次的结果,想要使用 OpenMP 做并行很难,且效果差。 
但稍微理解下 f() 函数的用意后,可以利用 opUnion() 函数做模拟并行,具体代码如下:

#define T px + scale * r * f_cos(theta), py + scale * r * f_sin(theta)

#define T2 px + scale * (r + 0.02f) * cos(theta), py + scale * \
        (r + 0.02f) * sin(theta)

float f(float px, float py, float theta, float scale, int n)
{
    float d = 0.0f, di = 0.0f;
    float ret;

    for (float r = 0.0f; r < 0.8f; r += 0.04f) {
        d = opUnion(d, sdCircle(T, 0.05f * scale * (0.95f - r)));
        di = opUnion(di, sdCircle(T2, 0.05f * scale * (0.93f - r)));
    }

    ret = opUnion(d, di);

    if (n > 0)
        for (int t = -1; t <= 1; t += 2) {
            float tt = theta + t * 1.8f;
            float ss = scale * 0.9f;

            for (float r = 0.2f; r < 0.8f; r += 0.1f) {
                ret = opUnion(ret, f(T, tt, ss * 0.5f, n - 1));
                ss *= 0.8f;
            }
        }

    return ret;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

接下来对 sin() 和 cos() 动手,虽然不知道有没有魔数的方法来求近似值,但学过高数的同学会知道这两个函数都可以用泰勒级数来求近似值。由于上面提到精度足够即可,在具体实现中我只迭代了 10 次。具体实现如下:

// sin(x) = x - (x^3 / 3!) + (x^5 / 5!) - (x^7 / 7!) + ...
template <typename T>
T f_sin(const T& x)
{
    const T x2 = x * x;
    T power = x;
    T facter = 1;
    T sign = 1;
    T sum = 0;
    const int loop = 22;  // 10 times loop

    for (int i = 3; i < loop; i += 2) {
        sign *= -1;
        power *= x2;
        facter *= i * (i - 1);
        sum += sign * power / facter;
    }
    return sum + x;
}

// cos(x) = 1 - (x^2 / 2!) + (x^4 / 4!) - (x^6 / 6!) + ...
template <typename T>
T f_cos(const T& x)
{
    const T x2 = x * x;
    T power = 1;
    T facter = 1;
    T sign = 1;
    T sum = 0;
    const int loop = 21;  // 10 times loop

    for (int i = 2; i < loop; i += 2) {
        sign *= -1;
        power *= x2;
        facter *= i * (i - 1);
        sum += sign * power / facter;
    }
    return sum + 1;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

因为这个两个函数实现可用于整型运算,所以我写成模板的形式。可能有更优雅的实现方式,而我只是按照公式写出实现。但这没什么,一切都可以交付给强大的编译器,去优化我的代码。

这时,如果用的是 GCC 编译器,可以

  • 开启 -ffast-math 选项来加速浮点数运算;
  • 开启 -march=native 来让编译器做本地处理器架构优化;
  • 开启最高等级优化选项 -O3,O3 和 fast-math 可以合写成 -Ofast
$ g++ -Ofast -march=native i.cc && time ./a.out

$ gcc -Ofast -march=native milo_tree.c -o milotree -lm && time ./milotree

./a.out  0.35s user 0.00s system 99% cpu 0.350 total

./milotree  0.95s user 0.01s system 96% cpu 0.990 total
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

Milo Yip 的版本是 88 毫秒,优化后的是 35 毫秒,可能会觉得没有太大提升,这时把圣诞树大小放大 10 倍,来直观感受一下两者执行速度的差距:

$ g++ -Ofast -march=native i.cc && time ./a.out 3 10

$ gcc -Ofast -march=native milo_tree.c -o milotree -lm && time ./milotree 3 10

./a.out 3 10  34.01s user 0.01s system 99% cpu 34.139 total

./milotree 3 10  90.25s user 0.01s system 99% cpu 1:30.32 total
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

Milo Yip 的版本是 1 分 30 秒 32 
优化后的版本是 34 秒 139

因此可以说一个好的算法,或者说一个适合的算法,在程序中发挥着重要作用。因此我们可以得出以下结论:

  • 如果程序对性能没有极高的需求,就直接用编译器来为我们做优化;
  • 在运算量较大的场合,可以适当取近似值来优化运算的开销;
  • 为具体需求选择合适的代码实现

当然,编译器优化并非百利而无一害,比如对运算精度需求极高的程序,千万不可开启 -ffast-math ; 
在较底层的代码中,会因为过度依赖编译器的优化选项,而造成现有代码无法直接迁移到编译器的更新版本上(比如 Linux 内核的某些底层实现、硬件驱动等,就过度依赖 GCC 的某一版本),去年我在 Linux 3.4 内核中也发现过类似问题

对性能做到良好取舍,写出合适的代码,都不是一日之功,希望日后可以有更多机会继续深入地学习相关方面的知识。

注:
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值