带你学习《深入理解计算机系统》程序性能优化探讨(1)——周期计数器与循环展开

 

        在前面汇编的章节,我们时不时会发现,编译器有意识的生成对C代码优化过的代码,那么这个章节我们重点探讨在C程序层面出发,有哪些值得程序员注意的优化技巧。

        在很多年前就有人提出,只要编译器足够强大,即便程序员的代码效率不高,也可以优化成更有效率的汇编。然而在实践中人们发现,问题没有那么简单,看下面的例子:

       

void twiddle4(int *xp, int *yp)

{

        *xp += *yp; 

        *xp += *yp;

//      *xp += 2* (*yp);

}

        函数的功能很简单,就是把yp上的值加到xp上两次,如果按照被我注释掉的语句执行,是不是更有效率呢?你想想,本来*xp要读写两遍,*yp要读两遍,如果按注释的语句实现,*xp只需读写一次,*yp也只读一次,这样就节约了的两次读一次写操作,效率明显提高一倍,如果编译器能做这样的优化,是不是表明它足够聪明呢?

        编译器确实足够聪明,非常有聪明的预见到这样优化的严重后果:如果xp和yp指向的是同一个变量呢(存储了同一个地址)?那么程序的计算方式就相当于让*xp = *xp + *xp执行两次;结果就是4倍的*xp,而用注释掉的语句执行,得出的却是3倍的*xp,你还敢这样优化么?

        类似的例子还有,你是执行原始的语句return f(x)+f(x),还是执行return 2*f(x)呢?编译器老老实实执行前者的原因就是,万一f(x)返回的是对全局变量或静态变量的++自加操作值,f(x)每出现一次就等于执行一次++,怎么办?

        上面的例子告诉我们,很多优化必须要在上层语言显性的做出来,而不能过多的依赖编译器。那么如何评判程序的优化度呢?无非就是看程序的时间复杂度和空间复杂度,简单说就是看谁的程序运行的快,耗费的CPU资源和存储总线资源少。这里我们先重点讨论时间复杂度,也就是说怎么样在保证程序功能的前提下,尽可能少用CPU资源。

 

一:关于中文进制命名的囧境

        CPU的性能常常用频率来表示,常见单位有MHz和GHz,MHz即兆赫兹,k是2的10次方,M就是2的20次方即百万级,G是2的30次方即十亿级,这是按该死的西方千位累进位方式来计算,西方自古地广人稀,无论是人口、土地还是物资数量总量都很小,因此才养成了千位累进位习惯。对古代的西方而言上万人的战场就是世界大战了。而中国自古规模宏大,技术当然更习惯万位进位,比如十万百万千万,就没有万万而是亿了,而西方人习惯千位进位。十千百千,没有千千而是million,即我们说的百万,数量级上可以理解成计算机里的M,而十M百M,没有千M而是billion,也就是我们说的十亿,数量级上也可以理解成计算机里的G。所以当你感觉怎么单词从百万直接跳到十亿有多奇怪时,其实就是用中国人万进位理解千进位一样扭曲,比如20,324,908,西方人瞬间能答出哦这是20million 324thousand 908hundred,中国人呢?20百万?md第一个两千万还要换算才反应过来,如果写成2032,4908多好,2032万 4908,多快?所以我强烈鄙视中文科技文献还去用西方人这种千位进位的计数法,绝大多数人必须重新换算甚至是个十百千的重新数位数,那你这逗号标了跟没标有啥区别?难道是为了帮助老外学中文时好认数?完全不能理解这么多年了为啥不改改。

        没办法西方人发明计算机,因此计算机单位我们也只能跟着他们走。我现在写博文用的这款骨灰级sony本本的CPU是Intel Core Duo P8400,频率是2.26GHz,由于赫兹(Hz)这个是每秒执行次数,因此可以理解成CPU每秒可以执行2.26G次计算,

2.26*2的30次方≈24,2665,6522,用我打的标志,瞬间答出24亿2665万6522次计算,谁按3位标记就是欠打,哼哼。

        但有个问题,如果我要计算CPU每次计算的时间(也就是CPU执行的周期值)怎么办?比如我要用纳秒这个单位,shit,纳秒就是十亿分之一秒……还是千位累进的单位⊙﹏⊙好吧,玩的是计算机只好认命了!既然G是十亿级的,纳秒又是十亿分之一秒,而周期就是频率的倒数,假设我的CPU刚好是2GHz,也就是近似为20亿赫兹,取倒数就是20亿分之一秒,换算成纳秒就是二分之一纳秒,也就是0.5纳秒;假设我显卡芯片是500MHz,取倒数就是500百万分之一秒,也就是0.5十亿分之一秒,换算成纳秒就是2纳秒。

 

二:周期计数器

        不同的CPU周期是不同的,那么CPU单次执行一条指令的时间也是不同的,因此在评估优化方案时,由于CPU频率的不同(即便同款CPU也有频率误差)单纯的计算程序执行实际时间并不能客观的反应执行效率,所以我们更关心执行周期,比如每个元素操作完成耗费的CPU周期数,可以作为程序优化的依据,这里引入每元素周期数(cycles per element, CPE)。看下面的两个例子:

void vsum1(int n)
{
    int i;

    for (i = 0; i < n; i++)
         c[i] = a[i] + b[i];
}

void vsum2(int n)
{
    int i;

    for (i = 0; i < n; i+=2) {  
         c[i]   = a[i]   + b[i];
         c[i+1] = a[i+1] + b[i+1];
    }
}

 

        很明显,这两个函数实现功能近似,都是循环数组计算赋值,后者采用循环展开的技巧,每次循环处理两个元素,当然这个版本只对n为偶数的情况有效,如果我们能分别计算出和两个函数给自需要的总CPU周期数,再除以n,就能得出CPE,判断效率优劣。但C语言并没有提供CPU运算周期数的功能,这里只能借助于汇编指令rdtsc,即IA32周期计数器。原来第一遍看书时没觉得有什么问题,但这次为了亲自在屏幕中打印出周期数值,我真的费了九牛二虎之力,找到并排除两个gcc bug,才终于得逞。下面就仔细欣赏整个过程。

        首先是对rdtsc的使用,周期计数器的原理,顾名思义肯定是CPU没执行一次运算,计数器就要加一,在写这些文字的时候,我还在想,计数器到底是从什么时候开始加的?我真够笨的,当然是CPU上电开始啦,难道是从出厂开始的么?难道芯片厂商还给CPU配个独立电池以维持计数器累加么?从这个例子我甚至开始怀疑自己适不适合搞研究:-(好了,周期计数器是一个64位的无符数,最大值2^64-1秒,也就是570年一个轮回,谁会忘关电脑让CPU连续运行570年然后担心它之后会因此出现bug?你交得起这个电费么?估计只有研制终结者时才会考虑到。总之现实生活是够用了。我们在某个函数运行的一头一尾分别获取计数器值,后者减去前者就得出消耗的总周期数,再除以元素个数就能得出CPE,至于每个元素平均耗时?也简单,你用答案除以CPU主频就可以了。

        好,为了使用rdtsc,我们专门构造一个名为access_counter,在里面嵌入汇编:

void access_counter(unsigned *hi, unsigned *lo)
{
    asm("rdtsc; movl %%edx,%0; movl %%eax,%1"   /* Read cycle counter */
: "=r" (*hi), "=r" (*lo)                /* and move results to */
: /* No input */                        /* the two outputs */
: "%edx", "%eax");
}

        rdtsc指令一运行,64周期计数器的值就会分别赋值到edx和eax中,前者高32位,后者低32位,分别赋给%0和%1也就是*hi和*lo,第四行是对两个寄存器备份保护的声明。

        接着再构造一个取高低位的函数start_counter(),cyc_hi, cyc_lo都用暂用全局变量(主要为了方便)

void start_counter()
{
    access_counter(&cyc_hi, &cyc_lo);
}

        最后我们还需要一个函数结尾,这个函数不但要获得结尾时的计数器值,还要负责把两次得到的值相减,然后构造出64位值传递给应用程序,那就是get_counter():

double get_counter()
{
    unsigned ncyc_hi, ncyc_lo;
    unsigned hi, lo, borrow;
    double result;

    /* Get cycle counter */
    access_counter(&ncyc_hi, &ncyc_lo);

    /* Do double precision subtraction */
    lo = ncyc_lo - cyc_lo;
    borrow = cyc_lo > ncyc_lo;
    hi = ncyc_hi - cyc_hi - borrow;
    result = (double) hi * (1 << 30) * 4 + lo;
    return result;
}

        我们发现,开头获得的计数器值用cyc_hi, cyc_lo两个全局变量暂存,结尾获得的计数器值用局部变量ncyc_hi, ncyc_lo暂存,然后对这两对值做一系列运算,最后用double类型result传返回给应用程序,麻烦开始了。首先是这段代码,到底是如何把计数器最终值转换成double的?lo的计算最直接,新低位减去旧低位。hi的计算,要多减去一个进位borrow,而进位又是旧值>新值的判断值,这步好理解,如果结尾的低位ncyc_lo比开头的低位cyc_lo还小,不考虑时空倒流,那么低位一定向高位产生了进位,因此borrow就变成了1,接着在计算hi时,要把这个进位给减回来……

        看到这是不是有点晕?减了不就没有进位了么?那么我现在告诉你,高位的进位确实被人工减掉了,然后把它又加回到低位了!怎么理解?秘密还是在lo = ncyc_lo - cyc_lo这句,你想想,当cyc_lo > ncyc_lo时,做减法应该是负值,但由于lo是无符数,当你把一个负值赋给无符,会发生隐式转换,最高位的符号位会变成正常的高位,回忆下第一章我们讲的内容,比如10000000,当有符数理解时,他是-2^(7),如果你当成无符数,它就变成了2^(7),两者相差2^(8),也就是说,当n位负值x变成无符数时,符号位的值从(-2^(n-1))变成2^(n-1),也就是原来的负值凭空增加2^(n),这恰恰和进位的值相等,具体原理参见第一章相关内容。反正我们知道,当lo被负值赋值时,它得到的值是比原值大一个相当于进位大小的数的,刚好可以抵消掉hi减去的borrow,当时我居然还在想一个问题,那万一不止进一位而是多个进位怎么办呢?唉!多的进位肯定会被ncyc_hi - cyc_hi 计算出来嘛!我真滴发现我好笨,居然会有这么笨的质疑%>_<%

        好了,成功获得差值的高地位,最后一步是转换成double并返回。低位lo是32位无符数,可以直接往double类型上加,关键是高32位hi,它代表的值比其作为无符数理解的实际值要大2^32倍,于是先把hi强制转换成double,然后乘以2^32,最后加上lo。注意两点,1、浮点数不能直接做移位操作,因为他们的编码格式与整型的纯位数编码相距胜远;2、直接计算1<<32可能编译会报警告,因为在C语言中,常数是有类型的!直接写的1被默认int型,你移动32位肯定是要越界的,所以才有这种变形乘法来代替。如果你一定要用1<<32位,可以考虑在1后面加ll或ull,他们分别表示long long和unsigned long long,都是8字节64位的类型,类似1ull<<32可以位移到32位以后。

 

        好了,编译运行:

void main(int argc, char **argv)

{

    double Start, End;

 

    start_counter()

    vsum1(atoi(argv[1]));

    Start = get_counter();

    printf("cycle1 = %.8g\n", Start );

 

 

    return;

}

        问题还是出现了,当我键入./a.out  34时,原本打算得到两个函数跑34个元素得到的周期数,谁想出来的结果居然都是0……。好吧,我在get_counter()函数里加一条打印语句,希望分别打印出result的高32位和低32位值,于是我采用强制转换和步进跳跃:

    printf("high: 0x%x, low: 0x%x\n", *((unsigned *)&result +1), *((unsigned *)&result);

    一运行,发现得到的值类似high:0x0, low:0xbff6a901。用double的编码规则(具体规则参见我写的第一章内容)判断,这是近似3.02....e-306,一个比1小很多很多的数,难道CPU连一次运算都不做就能完成34个元素的计算?肯定是哪点错了,这时我在这条printf语句的上面再加上一条语句:

 

 

    start_counter()

    vsum1(atoi(argv[1]));

    Start = get_counter();

    printf("result: %.8g\n",  result);

    printf("cycle1 = %.8g\n", Start );

 

打印出来结果:

 

result:78502

high:0x40f32a60, low:0x0

根据double编码规则,0x40f32a6000000000的确是78501.999999999999999999999999934。咦?难道说我多一条result打印语句,cpu就多消耗78502个周期,不打印result,就一个周期不消耗?这也太神奇了吧?这到底是gcc的bug还是printf的bug呢?这时我把编译优化选项-O2去掉,同时也去掉打印result的语句,再运行,神奇,得到了:

 

high:0x40c43000, low:0x0

根据double编码规则,result的值是10336,也就是说,这应该是gcc的一个优化bug,没想到-O2选项竟然能让printf出这种问题,实属罕见。

问题还是没有解决,main中返回的result仍然是0,由于get_counter()不在main.c中定义,于是我在main.c中增加了extern double get_counter();这句被某人认为即便不写也会被默认的语句,奇迹发生,返回值6834,这个是合理值!

 

        从上面的例子我们至少能看出,gcc的-O2在处理双精度的强制转换和跳变步进时,printf可能会出问题;同样在处理双精度函数类型时,不加extern声明,而仅仅把.C编译到一起的做法是很危险的。至于这两个是不是bug,反正我已经在gcc version 3.4.6和gcc version 4.445中用最简单的函数重现,随后将结果发到论坛,然后等待高人来解疑了。

        进一步测试发现在gcc  version 4.8.2中,第一个问题得到修复未能重现,但第二个问题照旧。通过简单的查看汇编,针对第二个问题,之前的gcc版本编译两个函数的版本几乎没有区别,所以不敢确定是gcc本身问题,还是动态库链接的问题。另据独家透露O(∩_∩)O~,unsigned long long也有类似的问题,哈哈,书归正传,我们接着往下做测试:

 

 

三:循环展开优化结果小结

void main(int argc, char** argv)
{
        double Start;
        vsum1(atoi(argv[1]));


        start_counter();
        vsum1(atoi(argv[1]));
        Start = get_counter();
        printf("vsum1:%.8g\n", Start);


        start_counter();
        vsum2(atoi(argv[1]));
        Start = get_counter();
        printf("vsum2:%.8g\n", Start);

        return;
}

 

        开头多调用一次vsum1只是为了让编译器优化时做好预先初始化,这样vsum1和vsum2的先后调用顺序不会影响测试结果。接着我们运行34个元素迭代: ./a.out 34

@ubuntu-13:~/src$ ./a.out 34
vsum1:442
vsum2:1149
@ubuntu-13:~/src$ ./a.out 34
vsum1:402
vsum2:604
@ubuntu-13:~/src$ ./a.out 34
vsum1:383
vsum2:738

        ……呃,咋号称优化过的vsum2的周期数更多呢?恩,可能是因为vsum2初始化消耗的周期数太多,所以我们增加以下元素再进行从测试:

@ubuntu-13:~/src$ ./a.out 1024
vsum1:6703
vsum2:6426
@ubuntu-13:~/src$ ./a.out 1024
vsum1:5632
vsum2:6228
@ubuntu-13:~/src$ ./a.out 1024
vsum1:5313
vsum2:5411

@ubuntu-13:~/src$ ./a.out 1024

vsum1:5994
vsum2:5526
        ……我们发现,1和2出现有时你比我多有时我比你多的情况,那我们再增加元素数:

@ubuntu-13:~/src$ ./a.out 2048
vsum1:13743
vsum2:12084
@ubuntu-13:~/src$ ./a.out 2048
vsum1:12951
vsum2:9407
@ubuntu-13:~/src$ ./a.out 2048
vsum1:14789
vsum2:11664
@ubuntu-13:~/src$ ./a.out 2048
vsum1:12975
vsum2:9544
@ubuntu-13:~/src$ ./a.out 2048
vsum1:11017
vsum2:8529
@ubuntu-13:~/src$ ./a.out 2048
vsum1:9753
vsum2:9417
@ubuntu-13:~/src$ ./a.out 2048
vsum1:9512
vsum2:8412

        哈哈,vsum2的周期数小于vsum1的结果几乎稳定了。为啥教材里只需要几十上百个元素就能得出结论,而我们要用上千呢?那是因为现在的CPU速度太快了,你一般的小优化要想出效果,必须增加元素个数。我们用最后一个为例,9512和8412,按理要计算CPE就让他们的值除以2048,分别约等于4.64和4.11,但这有个问题,vsum函数有初始化的操作,如果我们要想求出精确的CPE性能,就应该只考虑循环操作消耗的周期数,而不是vsum函数运行的总周期数。关于这点,教材运用的是统计法,把元素个数和周期数画成二维统计表,然后运用最小二乘法拟合,得出类似y = m+b*n的格式,用直线描述它的走向,算出大致精确的n值,这个值才是有价值的CPE,比如,假设vsum函数的初始化需要消耗2000个周期,那我们的两个测试结果值,可以类似看成y1 = 2000 + 3.67*2048和 y2 = 2000 + 3.13*2048,无论测试元素个数n有多大,他们都分别遵循

y = 2000+3.67*n和y = 2000+3.13*n,以n为横坐标,y为纵坐标,画成直线的话,y1的直线上升的更陡,而y2就相对平缓,那么随着n的增大,同一元素数n的前提下,周期差距会越来越大。

 

 

 

 

 

 

 

 

 

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值