性能优化 - 之一 (C/C++)

性 能优化 - 之一 (C/C++) (Optimization)


任务:把一个小头(little endian)的整型(32bit)转化为大头(big endian)。


我 们需要这样一个函数 void foo(unsigned int &u); 用来颠倒整数u的字节序。类似于socket函数htonl()或者ntohl()。也就是说,在以某个整数u为参数调用foo以后,u小头变大头,或者 反过来。这无所谓,因为小头和大头是对称的。

我发现对这个简单的任务,采用不同的作法,效率能差到很多,这两天研究了一下, 写一点心得出来与同好分享。


第一种作法:
extern "C"  void f1(unsigned int &u)
{
       unsigned int v = u;
       char *src = ((char *)&v + 3);
       char *dst = (char *)&u;

       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
}
这是我最早想到的一种作法,也是最直观的作法。我当时的考虑是这样只 有简单的赋值操作,避免了移位(>>或者<<),效率*应该*会比较高。但是测试的结果令人沮丧, 执行一千万次所需要的时间平均下来有390毫秒。

为何如此?经过一番思索,我认为一定和内存访问有关。要知道v是一个局部变量,本来一个 优化的编译器完全可以把v放入某个寄存器中,那么后续对v值的引用就无需再访问内存,但是注意到在上面的代码中,有一个对v求地址的操作: char *src = ((char *)&v + 3); 而寄存器是没有地址的,所以编译器只能选择为此生成效率较低的代码,也就是,把v放入堆栈。

在 优化打开的情况下,编译器会把src和dst放入寄存器而不是堆栈,所以这样一来,对于语句:
*dst ++ = *src --;
来 说,需要访问两次内存。其中*src需要访问一次,得到其所指地址的值,然后再把这个值写回到*dst所指向的内存又是一次。。反汇编得到的代码也验证了 这一点:

       pushl   %ebp
       movl    %esp, %ebp
       subl    $4, %esp
       movl    8(%ebp), %edx
       movl    (%edx), %eax
       movl    %eax, -4(%ebp)
       movzbl  -1(%ebp), %eax
       movb    %al, (%edx)
       movzbl  -2(%ebp), %eax
       movb    %al, 1(%edx)
       movzbl  -3(%ebp), %eax
       movb    %al, 2(%edx)
       movzbl  -4(%ebp), %eax
       movb    %al, 3(%edx)
       leave
       ret

一共有13次内存访问的指令。

这时候,我考虑如何让编译器把v变量放到寄存器里。根据上面的分 析,很显然,办法是不要有对v求地址的操作,那么为了得到v各个byte的值,要执行移位动作就不可避免了。但是考虑到v在寄存器里,那么对它的移位操作 也不过就是一条指令而已,比访问内存要快的多了。这样我就得到了第二种做法:

第二种做法:
extern "C"  void f2(unsigned int &u)
{
       unsigned int v = u;
       char *dst = (char *)&u;

       *dst ++ = (v >> 24);
       *dst ++ = ((v >> 16) & 0xFF);
       *dst ++ = ((v >> 8) & 0xFF);
       *dst ++ = (v & 0xFF);
}

那 么现在让我们假定v是某个寄存器,对于上面的4条赋值语句,每一条都只需要访问内存一次,看看反汇编生成的代码(v相当于ecx,而保存v移位生成的临时 变量用的是eax):

       pushl   %ebp
       movl    %esp, %ebp
       movl    8(%ebp), %edx
       movl    (%edx), %ecx
       movl    %ecx, %eax
       shrl    $24, %eax
       movb    %al, (%edx)
       movl    %ecx, %eax
       shrl    $16, %eax
       movb    %al, 1(%edx)
       movl    %ecx, %eax
       shrl    $8, %eax
       movb    %al, 2(%edx)
       movb    %cl, 3(%edx)
       popl    %ebp
       ret

只需要访问8次内存。测试的结果是喜人的,现在执行一 千万次该函数调用,只需要200毫秒,效率几乎提高了一倍。看来消除访问内存的努力确实有效果。这时候代码中的dst指针又变成了目标,如果消除掉它改成 寄存器访问,我们又可以减少4次内存引用,减去一次把寄存器内容写回u的访存指令,一共就可以减少3次内存访问。这样我就得到了第三个版本:


第三种做法:
extern "C" void f3(unsigned int &u)
{
       unsigned int v = u;

       u = ((v >> 24) |
               (((v >> 16) & 0xFF) << 8) |
               (((v >> 8) & 0xFF) << 16) |
               (v << 24));
}

首先反汇编:

       pushl   %ebp
       movl    %esp, %ebp
       pushl   %ebx
       movl    8(%ebp), %ebx
       movl    (%ebx), %ecx
       movl    %ecx, %eax
       movl    %ecx, %edx
       shrl    $8, %eax
       andl    $65280, %eax
       shrl    $24, %edx
       orl     %eax, %edx
       movzbl  %ch, %eax
       sall    $16, %eax
       orl     %eax, %edx
       sall    $24, %ecx
       orl     %ecx, %edx
       movl    %edx, (%ebx)
       popl    %ebx
       popl    %ebp
       ret

因为有太多的临时变量,寄存器已经不够用了,编译器必须使用ebx,而ebx不属于“调用者保存”的寄存器。所以如果函数内部要使用它,必须自 己保存再恢复,这样就多了两条push ebx和pop ebx的指令,那么这个函数需要访问内存7次,看上去不是很理想。不过测试结果却更加喜人,简直是令人惊异。一千万次函数调用,现在竟然只需要80毫秒! 效率提高了一倍有余。我这里只能猜测第二种做法里面大量的movb,在32位的机器上,可能比movl要慢很多。否则这个现象很难解释。


80毫秒的测试结果令我非常满意,因为最简单的函数:
void simple(unsigned int &u)
{
       ++ u;
}
调用一千万次都需要40几毫秒,我认为几乎已经是极限了,但是...事实 显然并非如此。


我们还有第四种做法:
extern "C" void f4(unsigned int &u)
{
       __asm__("bswap %0" : "=r" (u) : "0" (u));
}

从80486开始,为了方 便网络程序的处理,主要就是htonl()和ntohl()啦,Intel特意添加了一条专门用来转换大头小头的指令,也就是 BSWAP ,它可以在一条指令中,完成上面我辛辛苦苦实现出来的全部功能,而且速度,你可以想象,应该和上面那个void simple(unsigned int &u)相当。事实也是如此,一千万次对f4()的调用,确实只需要40几毫秒。

不过对我的需求来说,80毫秒的战绩已经很足够 了。而引入内嵌汇编 BSWAP 来实现,有两个麻烦处,最主要的是不同的编译器,有不同的内嵌汇编格式,我主要用gcc和vc,维护两份汇编码太累,而且今后如果要和别的编译器兼容,也 很讨厌。其二是这个指令只在80486以后才有,虽然我可以断定我的代码绝对不会运行在386上面:-),但是对于追求“形式完美”的程序员,比如说鄙 人,来说,是不太能接受的:-)


两个结论:
1. 尽量以一种方便编译器优化的方式使用局部变量,比如说,不要对局部变量求地址。
2. 尽量定义和机器字长相同的变量,正如上面所猜测的,movb比movl要慢很多。


注1:测试结果中具体的的数值,会根据机器性能 的不同而不同。但是在不同的机器上,4种方法所消耗时间的比例,应该大体上是一致的。
注2:第四种方法来自于参考linux kernel中对htonl()函数的实现。


附测试代码test.cpp,请使用gcc编译,带-O2选项:

#include <iostream>
#include <windows.h>

extern "C"  void f1(unsigned int &u)
{
       unsigned int v = u;
       char *src = ((char *)&v + 3);
       char *dst = (char *)&u;

       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
}

extern "C"  void f2(unsigned int &u)
{
       unsigned int v = u;
       char *dst = (char *)&u;

       *dst ++ = (v >> 24);
       *dst ++ = ((v >> 16) & 0xFF);
       *dst ++ = ((v >> 8) & 0xFF);
       *dst ++ = (v & 0xFF);
}

extern "C" void f3(unsigned int &u)
{
       unsigned int v = u;

       u = ((v >> 24) |
               (((v >> 16) & 0xFF) << 8) |
               (((v >> 8) & 0xFF) << 16) |
               (v << 24));
}

extern "C" void f4(unsigned int &u)
{
       __asm__("bswap %0" : "=r" (u) : "0" (u));
}

int main()
{
       using std::cout;
       using std::endl;

       const unsigned cnt = 100 * 100 * 100 * 10;
       unsigned int u = 1024;

       unsigned int tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f1(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f1() cost " << tk << " ms" << endl;


       tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f2(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f2() cost " << tk << " ms" << endl;


       tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f3(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f3() cost " << tk << " ms" << endl;


       tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f4(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f4() cost " << tk << " ms" << endl;

       return 0;
}

你可能感兴趣的:(C++,性能,职场,休闲)