如何优化程序

本文为CSAPP第五章学习笔记。

编写高效的程序需要:

  1. 选择合适的数据结构和算法
  2. 编写出编译器能够有效优化以转换成高效可执行代码的源代码
  3. 对于计算量较大的任务,可以将其分解为若干小的代码段,然后并行计算

优化代码:

  1. 减少不必要的内容,让代码尽可能简单的执行期望的工作。如消除不必要的函数调用、条件测试和存储器引用。
  2. 利用处理器提供的指令集并行能力,同时执行多条指令。根据代码的各项操作的时序特性做出合理安排,以避免不必要的等待。

在优化代码的时候,要保证代码的简洁和可读性,因为代码终归需要维护和扩展。

1 减少存储器调用

考虑如下两个函数:

void twiddle1(int *xp, int *yp)
{
    *xp += *yp;
    *xp += *yp; 
}

void twiddle2(int *xp, int *yp)
{
    *xp += 2* *yp;
}

twiddlw1()twiddle2()有着相似的计算行为。但是,twiddle2()只有三次寄存器调用:*xp的读取,*yp的读取以及*xp的写入。twiddle1()则有六次寄存器调用,实际计算更为麻烦。

twiddlw1()twiddle2()也不完全相同。试考虑,*xp = *yptwiddlw1()*xp实际等于4* *xp,而twiddlw2()*xp实际等于3* *xp。这种两个指针指向统一存储器的情况,也称作存储器别名使用

2 减少函数调用

考虑如下两个函数:

void cycle1(int *xp, int *yp)
{
    return f() + f() + f() + f();
}

void cycle2(int *xp, int *yp)
{
    return 4*f();
}

cycle1()函数调用了f()函数四次,需要在寄存器执行4次 建立栈帧 -> 计算f() -> 恢复栈帧 过程。而cycle2()函数只需要一次。

当然,作为特殊情况,需要考虑f()会改变全局变量的可能。如果确实改变,那么cycle1()函数就会改变全局变量4次,而cycle2()函数只会改变1次。

3 每元素周期数CPE

考虑一个计算向量的前置和(prefix sum)的过程。

向量P=(a1, a2, a3, .., ai,..., an)的前置和p(i)的计算过程可写为:

p(1) = a1;
p(i) = p(i-1) + ai; # i > 1

代码1

void combine(vec_pointer ptr, data_t *dest)
{
    long int i;                     
    *dest = 0;
    for (i = 0; i < length(ptr); i++) { /* length(ptr)返回得是ptr所指向的向量的所含元素个数  */
        data_t val;                     /* 新建一个内存空间 */
        get_vec_element(ptr, i, &val);  /* 将向量指针ptr所指的向量的第i项存入val */
        *dest = *dest + val;            /* *dest累加val,求的前置和 */
    }
}

代码1使用for循环迭代计算前置和。无论ptr所指向量有多大,每次调用函数都会执行建立栈帧恢复栈帧,这一段代码的耗时是一个定值S。随着循环次数n的变化,总的耗时T=S + nL,其中L为for循环的单位循环执行时间,在本书中又称作每元素周期数CPE*。

4 消除循环的低效率——代码移动

代码1在for循环中,每次循环都会调用length()获取向量的所含元素个数。然而这个值通常都是一个定值,如果将其存储在一个局部变量中,降低调用频率,可以有效改善代码运行效率。

代码2

void combine(vec_pointer ptr, data_t *dest)
{
    long int i;                     
    *dest = 0;
    long int v_length = length(ptr);   /* 将length(ptr)返回值存储在局部变量中 */
    for (i = 0; i < v_length; i++) { 
        data_t val;                     
        get_vec_element(ptr, i, &val);  
        *dest = *dest + val;            
    }
}

5 减少过程调用

代码2的for循环中,每次都要掉用get_vec_element()来获得第i位元素,也同样代价巨大。一个合理的替代方案是:直接获取向量,存入局部变量中,然后按需调用。

代码3

/*
 * 已知vec_pointer结构体的定义 
 */
typedef struct  {
    long int len;
    data_t *data;
} vec_rec, vec_pointer;

/*
 * 新建函数
 */
data_t *get_vec_start(vec_pointer ptr)
{
    return ptr->data;                   /* 直接获得ptr所指向量的数据部分的头段指针 */
}

void combine(vec_pointer ptr, data_t *dest)
{
    long int i;                     
    *dest = 0;
    long int v_length = length(ptr);    
    data_t *data = get_vec_start(ptr);  /* 将ptr数据存入数组 */
    for (i = 0; i < v_length; i++) { 
        *dest = *dest + data[i];        
    }
}

6 消除不必要的存储器引用

下面是代码3中for循环的汇编代码:

/*
 * code3: data_t = float
 * i 位于 %rdx, data 在 %rax, dest 在 %rbp, 越界标志 limit 在 %r12
 */

.L498:
    movss (%rbp), %xmm0           /* 取出dest,存入 %xmm0 */
    mulss (%rax, %rdx, 4), %xmm0  /* 取出data[i], 并与dest相乘 */
    movss %xmm0, (%rbp)           /* 将结果存入dest */
    addq  $1, %rdx                /* i加一 */
    cmpq  %rdx, %r12              /* 比较i是否越界 */
    jg    .L498                   /* 如果没有越界,就再次循环 */

可以看出代码3的for循环中,每次计算加法都会先引用寄存器中*dest所指向的空间,然后加和,最后将计算结果存入寄存器。但比较浪费的是,每次读取的值都是上次的计算结果。

合理的解决办法是,将累加值存入局部变量中,当计算结束后再把最终结果存入寄存器。

代码4

void combine(vec_pointer ptr, data_t *dest)
{
    long int i; 
    long int v_length = length(ptr);    
    data_t *data = get_vec_start(ptr);  
    data_t acc = 0;                     /* 局部变量存储累加值 */
    for (i = 0; i < v_length; i++) { 
        acc = acc + data[i];            /* 用累加器acc累加data[i],求前置和 */
    }
    *dest = acc;                        /* 将结果存入寄存器 */
}

其对应汇编代码

/*
 * code4: data_t = float
 * i 位于 %rdx, data 在 %rax, 越界标志 limit 在 %rbp, acc 在 %xmm0
 */

.L488:
    mulss (%rax, %rdx, 4), %xmm0  /* 取出data[i], 并与acc相乘 */
    addq  $1, %rdx                /* i加一 */
    cmpq  %rdx, %rbp              /* 比较i是否越界 */
    jg    .L488                   /* 如果没有越界,就再次循环 */

7 循环展开

至此,for循环内部代码已经足够简洁。然而,循环本身也存在开销,如果能够在保证计算结果足够精准的情况下,减少循环次数,也能产生明显的改善效果。

循环展开,就是一种程序变换,通过增加每次迭代计算的元素的数量,来减少循环的迭代次数。循环展开从两方面改善了程序的性能:

  1. 减少了不直接有助于程序结果的操作的数量,如循环索引计算、条件分支;
  2. 可以进一步变化代码,减少整个计算的关键路径上的操作数量。

关键路径:在循环的反复执行过程中形成的数据相关链。

代码5

void combine(vec_pointer ptr, data_t *dest)
{
    long int i; 
    long int v_length = length(ptr);         
    long int limit = v_length - 1;      
    data_t *data = get_vec_start(ptr);       
    data_t acc = 0;                          
    
    /*循环1*/
    for (i = 0; i < limit; i+=2) {           /* 步进为2 */
        acc = (acc + data[i]) + data[i+1];   /* acc累加下两个data[i],求前置和 */
    }
    
    /*循环2*/
    for (; i < length; i++) {                /* 累加剩余元素 */
        acc = acc + data[i];
    }
    *dest = acc;                             
}

观察代码5,有两个for循环:

  1. 对于第一个循环,要保证循环不会越界(特别是data[i+1]);
  2. 要保证当循环索引i满足i<n-1实才会执行循环,因此最大索引i+1满足i+1<(n-1)+1=n

8 提高并行性

观察代码4的循环中的计算:每次计算acc之前必须等前一循环的acc计算完成后才能继续。

同样代码5中循环1的计算行:每次计算acc之前先算acc + data[i],然后计算+ data[i+1],同样需要等待前已循环的acc计算完毕。

也就是说,acc的计算构成了一个单序列的计算流程,也就是一条关键路径。如果能够将这个流程拆分,就可以利用CPU的乱序特性,同时计算,提高效率。

一个可行的方法就是:先分别计算奇数位元素、偶数位元素的和,然后将两者加和。

代码6

void combine(vec_pointer ptr, data_t *dest)
{
    long int i; 
    long int v_length = length(ptr);         
    long int limit = v_length - 1;      
    data_t *data = get_vec_start(ptr);       
    data_t acc1 = 0;
    data_t acc2 = 0;                          
    
    /*循环1*/
    for (i = 0; i < limit; i+=2) {           
        acc1 = acc1 + data[i];     /* 仅奇数位元素求前置和*/
        acc2 = acc2 + data[i+1];   /* 仅偶数位元素求前置和 */
    }
    
    /*循环2*/
    for (; i < length; i++) {      /* 累加剩余元素 */
        acc1 = acc1 + data[i];
    }
    *dest = acc1 + acc2;           /* 两个累加器求和 */                            
}

代码6中有两个关键路径。

9 重新结合变换

考虑对代码5中循环1的计算行:

acc = (acc + data[i]) + data[i+1];

做出变换:

acc = acc + (data[i] + data[i+1]);

尽管只是对计算式更改了括号的位置,但这对计算性能有了很大的提高。前式的第一次加法acc + data[i]前仍然需要等待前一循环acc计算完毕,而后式的第一次加法data[i] + data[i+1]则无此要求。利用CPU的乱序特性,可以在对于后式计算:可以在计算前一循环acc的同时,去计算后一循环的data[i] + data[i+1],从而提高了效率。

代码7

void combine(vec_pointer ptr, data_t *dest)
{
    long int i; 
    long int v_length = length(ptr);         
    long int limit = v_length - 1;      
    data_t *data = get_vec_start(ptr);       
    data_t acc = 0;                          
    
    /*循环1*/
    for (i = 0; i < limit; i+=2) {          
        acc = acc + (data[i] + data[i+1]);   /* 重新结合变换 */
    }
    
    /*循环2*/
    for (; i < length; i++) {                
        acc = acc + data[i];
    }
    *dest = acc;                             
}

10 一些问题

  1. 循环的并行度不能无限提高。一旦平行度超过了可用的寄存器数量,编译器就会把多余的变量存入栈内——从而性能巨减。

  2. 现代CPU都有预测分支并提前执行的能力。但是,一旦CPU预测错误,就会造成巨大的性能损失。

    • 首先,不要过多的关注可预测的分支(P361);
    • 其次,对于难以预测的情况,尽可能使用条件数据传送,而不是条件控制转移

v = test-expr ? then-expr : else-expr;的汇编代码可能产生下面两种结果:

/* 
 *条件数据传送 
 */
vt = then-expr;
v = else-expr;
t = test-expr;
if (t) v = vt;        
/* 
 * 条件控制转移 
 */
      if (!test-expr)
          goto false;
      v = then-expr;
      goto done;
  false:
      v = else-expr;
done:
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容