任务:把一个小头(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
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;
}