您的位置:首页 > 其它

从几个版本的memcpy的测速过程学习一点底层的东西

2011-04-03 18:48 423 查看
以下有三个版本的memcpy,对于版本3,很多人都很熟悉,它很经典,很多人面试都写这个,可是我不得不说一句,对于类似的问题,最好的回答有两个:一是调用c库,二是使用汇编。用这一类的问题来考察应聘者的c语言能力,真的很菜!如果真的要考察c语言能力,还不如给几个if,switch-case,for语句呢。

版本1.linux内核中的实现,其实glibc也是如此实现的,省略了不少内容,真正的实现很巧妙,将n个字节分为了三部分(前导字节-对齐到页面+页面+后续字节-页面对齐后的游离字节)进行分块拷贝(linux内核是绝对不会卖弄c语言的,基本的底层函数最终为了高效大多数都用汇编):

char *memcpy1(char *to, char *from, size_t n)

{

long esi, edi;

int ecx;

esi = (long)from;

edi = (long)to;

asm volatile("rep ; movsl"

: "=&c" (ecx), "=&D" (edi), "=&S" (esi)

: "0" (n / 4), "1" (edi), "2" (esi)

: "memory"

);

return to;

}

版本2.memcpy2为经典的C面试者写法:

char *memcpy2(char *dest, char *src, size_t n)

{

int i = 0;

while (i < n)

{

dest[i] = src[i];

i ++;

}

return dest;

}

版本3.glibc的写法:

...

仅仅这几个memcpy版本,如何确定哪个更高效呢?以下是一个测试的例子,请看例子1。

例子1:

#include <stdlib.h>

#include <stdio.h>

#include <time.h>

#define COUNTS 6000000

long long getcyclenow()

{

unsigned int h, l;

long long bs, cc;

asm("rdtsc /n/t");

asm("movl %%eax, %0/n/t":"=g"(l));

asm("movl %%edx, %0/n/t":"=g"(h));

bs = h;

cc = (bs << 32)|l;

return cc;

}

int main(int argc, char **argv)

{

time_t st0, st1, st2, st3;

long long l0, l1, l2, l3;

int i = 0;

char *src = (char *)calloc(1, 109);

strcpy(src, "iiiiabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz");

char *dest0 = (char *)calloc(1, 109);

char *dest1 = (char *)calloc(1, 109);

char *dest2 = (char *)calloc(1, 109);

l0 = getcyclenow();

dest0 = memcpy(dest0, src, 108);

l1 = getcyclenow();

dest2 = memcpy2(dest2, src, 108);

l2 = getcyclenow();

dest1 = memcpy1(dest1, src, 108);

l3 = getcyclenow();

printf("%X/t%X/t%X/n", l1-l0, l2-l1, l3-l2);



memset(dest0, 0, 109);

memset(dest1, 0, 109);

memset(dest2, 0, 109);

st0 = time(NULL);

for (i = 0; i < COUNTS; i++)

dest0 = memcpy(dest0, src, 108);

st1 = time(NULL);

for (i = 0; i < COUNTS; i++)

dest2 = memcpy2(dest2, src, 108);

st2 = time(NULL);

for (i = 0; i < COUNTS; i++)

dest1 = memcpy1(dest1, src, 108);

st3 = time(NULL);

printf("t0:%f, t1:%f, %f/n", difftime(st1, st0), difftime(st2, st1), difftime(st3, st2));

/*

memset(dest0, 0, 109);

memset(dest1, 0, 109);

memset(dest2, 0, 109);



l0 = getcyclenow();

dest0 = memcpy(dest0, src, 108);

l1 = getcyclenow();

dest2 = memcpy2(dest2, src, 108);

l2 = getcyclenow();

dest1 = memcpy1(dest1, src, 108);

l3 = getcyclenow();

printf("%X/t%X/t%X/n", l1-l0, l2-l1, l3-l2);

*/

return 0;

}

执行这个程序,看第一个printf输出,发现memcpy是最慢的,而汇编写的memcpy1是最快的,而经典的面试写法memcpy2位居中间,可是看c库的memcpy源码或者其反汇编代码,它也是使用了rep ; movsX指令,那为什么它慢呢?考虑到了数据缓存问题,将memcpy和memcpy1以及memcpy2的调用互换了位置,仍然是c库的memcpy最慢,这就给了很多涉业不久的毕业生十足的理由继续往笔试卷上写memcpy2之类的。附带一句,后面的for循环的COUNTS次测速为了得到一个统计值,这次发现memcpy2最慢,memcpy比memcpy1慢一点点,但是都比memcpy2快很多,这名显与通过rdtsc测试出来的结果不对应,于是肯定哪里出了问题。

实际上这是cpu指令预取和指令cache以及缺页引起的,而和c库以及c库实现的memcpy没有关系,要知道c库大家都在用,不会有如此明显的性能问题的,再者说,随便懂点cpu知识和汇编的人都知道memcpy2是最不可取的,因为它里面有大量的条件判断和条件跳转,这对于cpu的流水线是很不利的,并且通过c语言语义上语句实现拷贝增加了指令数,从而也就是增加了执行时间,x86处理器猛就猛在其是从cisc演变过来的,包含了大量的指令,于是movsX加repeat前缀肯定是不二的选择啦。当第一次调用memcpy的时候,cpu不得不从其所在的“地址”处将指令取到cpu,然后就cache到那里了,除非越来越多的别的指令将它冲掉(flush),它就一直在那里。

另外tlb也是一个重要的因素,tlb缓存了虚拟地址和物理地址的映射,在cpu用虚拟地址访存时,需要通过mmu得到物理地址,tlb可以缓存这个映射从而以后不再需要查询页表,直接从tlb中得到物理地址。对于指令的寻址,cpu也是按照虚拟地址来的,第一次访问时要完全查找页表,并且还可能缺页,接下来从最慢的磁盘将数据读到次慢的内存,然后将物理地址-虚拟地址的映射读入tlb,指令进入icache,因此接下来的调用就会很快了。有个问题需要解释,c库的函数不是一直cache在内存中的吗?是的,但是第一它没有cache在处理器中,第二它虽然缓存在了内存,但是对于当前task却不一定建立了页表项,而处理器dcache/icache(数据缓存/指令缓存)是通过物理地址寻址的(而tlb是虚拟地址寻址的),因此会导致两个不命中:cpu cache(icache/dcache/tlb)不命中和页表项不命中,然后发生缺页,内核准备读磁盘之前首先要看一下它是否在内存中,如果在的话直接建立页表项映射就可以了,对于c库的函数,一般是在内存的(前提是内存足够大),因此第一次调用c库函数只会有缺页-建立页表项的开销,而大多数情况下不会有读磁盘的开销。

下面通过例子2分析问题到底出在哪里!

例子2:

#include <stdlib.h>

#include <stdio.h>

#define ALIGN 0X800

int test()

{

return 1;

}

void __attribute__ ((aligned (ALIGN))) stub()

{

return;

}

long long getcycle()

{

unsigned int h, l;

long long bs, cc;

asm("rdtsc /n/t");

asm("movl %%eax, %0/n/t":"=g"(l));

asm("movl %%edx, %0/n/t":"=g"(h));

bs = h;

cc = (bs << 32)|l;

return cc;

}

int main(int argc, char **argv)

{

long long t1, t2, t3, t4;

printf("%p/t%p/t%p/n", test, stub, main);

t1 = getcycle();

test();

t2 = getcycle();

test();

t3 = getcycle();

test();

t4 = getcycle();

printf("%X/t%X/t%X/n", t2-t1, t3-t2, t4-t3);

printf("%p/t%p/t%p/n", getcycle, test, main);

return 0;

}

stub函数设置了aligned这个attribute,为了在test函数和main之间制造一点“距离”(用nop指令填充),这样的话,第一,cpu的指令预取(instruction prefetch)就取不到了,毕竟cpu的指令缓存是有限的;第二,执行main之前main函数一定要映射进页表项,由于mmu按页大小(4096字节)映射,拉开一定距离就不会让test也映射进入,在下一个例子中我们会调整这个距离来表明缺页对执行性能的影响。控制ALIGN的值,使得test函数和main的距离变化,当ALIGN很小的时候,test和main离的很近(小于一个页面且在同一页面),cpu可能会根据局部性原理加载main的时候将test的指令取入cache,并且由于test和main在一个页面,test也会进入内存且建立页表映射,从而tlb中也会有test的物理地址,然而如果ALIGN很大,比如是0x10000,这样test和main很远,cpu既取不到test的指令,又不会建立页表映射,在第一次执行test的时候,会缺页,会花费很多时间,以下讨论不再考虑cpu预取,仅考虑缺页和cpu的icache,也不再考虑tlb,因为在访问一组连续地址(页面)的第一个字节时,地址映射信息就会进入tlb,以下一个页面内的连续访问几乎都会命中,1个字节带来的优势对比一个页面字节来讲,效果不容易表现出来(测试不容易)。因此在ALIGN为0x10000的时候,执行结果如下:

b36 3f 38

0x450017 0x420006 0x450045

第一次和第二次调用test足有40多倍的延迟。由于指令已经cache了,第二次和第三次执行就很快了。为了证明是缺页的的影响很大,把getcycle的定义放到test之前:

long long getcycle()

{

...

}

int test()

{

return 1;

}

void __attribute__ ((aligned (ALIGN))) stub()

{

return;

}

在执行test之前要先执行一个t1 = getcycle();,该调用会触发getcycle缺页,由于getcycle和test在同一页面,也就是说会建立test的页表映射,执行结果如下:

3f 38 3f

0x420000 0x420034 0x450017

虽然main和test离的仍然很远,然而getcycle和test很近,在调用getcycle的时候会建立页表项,因此速度加快很多,以后就从指令缓存icache中取指令,为了再次证实这个事情且不影响getcycle的执行,将getcycle放回原位,然后在test之前放一个stub0,然后在第一个调用getcycle之前调用stub0,然后看结果,三次执行test的时间仍然很接近。看一下linux的do_page_fault的代码,的确很多,这些都要算在t2-t1中,怪不得缺页情形下多了那么多的时间。

因此前一个例子中c库的memcpy慢的原因就找到了,将例子1中main函数最后的注释放开,再次编译执行(-O0参数,不优化,前面的例子2也要这样编译),发现memcpy1仍然是最快的,而c库的memcpy位居第二,和memcpy1相差不多,而面试版本的memcpy2最慢。接下来看例子3,展示缺页的影响。

例子3:

#include <stdlib.h>

#include <stdio.h>

#include <string.h>

#define ALIGN_test 0X800

#define ALIGN_main 0X800

long long getcycle()

{

unsigned int h, l;

long long bs, cc;

asm("rdtsc /n/t");

asm("movl %%eax, %0/n/t":"=g"(l));

asm("movl %%edx, %0/n/t":"=g"(h));

bs = h;

cc = (bs << 32)|l;

return cc;

}

void __attribute__ ((aligned (0x10000))) stub0() //为了让test和main分布在两个页面中

{

return;

}

int __attribute__ ((aligned (ALIGN_test))) test(char *buf)

{ //增加一些代码,让它像真正的函数。要使用-O0编译

int i = 2, j=3;

if (buf[0] < i)

j += 3;

for (j; j < 10; i += j+buf[i])

j = buf[j];

if (buf[0] < i)

j += 3;

for (j; j < 10; i += j+buf[i])

j = buf[j];

if (buf[0] < i)

j += 3;

for (j; j < 10; i += j+buf[i])

j = buf[j];

return i*j;

};

int __attribute__ ((aligned (ALIGN_main))) main(int argc, char **argv)

{

long long t1, t2, t3, t4;

char b[25] = {0};

t1 = getcycle();

memcpy(b, "ddddd", 5);

t2 = getcycle();

memcpy(b, "ddddd", 5);

t3 = getcycle();

memcpy(b, "ddddd", 5);

t4 = getcycle();

printf("%llx/t%llx/t%llx/n", t2-t1, t3-t2, t4-t3);

t1 = getcycle();

test("ddddd/n");

t2 = getcycle();

test("ddddd/n");

t3 = getcycle();

test("ddddd/n");

t4 = getcycle();

printf("%llx/t%llx/t%llx/n", t2-t1, t3-t2, t4-t3);

printf("%p/t%p/t%p/n", getcycle, printf2, main);

}

ALIGN_main和ALIGN_test均为0x800的情况下,上述的代码将把test和main分布在两个页面中,间隔0x800,执行main时并不会为test建立映射,因此第一次执行test函数时将会慢很多,如果将ALIGN_main和ALIGN_test分别调整为0x800和0x1000,那么test和main就会分布在同一个页面,第一次执行test明显会快很多,这就是缺页带来的影响,可见页面调入实在太耗时了。在结束rdtsc指令的第一个影响因素之前再看一个例子,理解一下共享库的作用。

例子4:

share.h:

int test1();

share.c

int test1()

{

return 3;

}

test1.c:

#include <stdlib.h>

#include <stdio.h>

#include "share.h"

long long getcycle()

{

unsigned int h, l;

long long bs, cc;

long long interval, start, end;

asm("rdtsc /n/t");

asm("movl %%eax, %0/n/t":"=g"(l));

asm("movl %%edx, %0/n/t":"=g"(h));

bs = h;

cc = (bs << 32)|l;

return cc;

}

int main(int argc, char **argv)

{

time_t st0, st1, st2, st3;

long long l0, l1, l2, l3;

l0 = getcycle();

test1();

l1 = getcycle();

test1();

l2 = getcycle();

test1();

l3 = getcycle();

printf("%llx/t%llx/t%llx/n", l1-l0, l2-l1, l3-l2);

}

test2.c:

#include <stdlib.h>

#include <stdio.h>

#include "shar.h"

int main(int argc, char **argv)

{

while(1) {

test1();

}

}

编译:

gcc -fPIC -shared share.c -o libshare.so -O0

gcc test1.c -o test1 -L. -lshare

gcc test2.c -o test2 -L. -lshare

1.连续执行test1:

28b 46 3f

2.执行sysctl -w vm.drop_caches=3后执行一次test1:

391 54 46

3.在另一tty上执行test2,然后执行test1:

29a 46 38

4.在另一tty上执行test2,执行sysctl -w vm.drop_caches=3后执行test1:

2c8 46 38

测试1说明第一次执行test1函数时缺页,建立页表映射消耗大量时间,连续执行后即使test1退出linux也可能将so缓存在了内存,不再需要读磁盘,因此得到了一个大致0x28b的数值,测试2由于刷新了文件缓存,缺页时需要读磁盘,时间消耗明显增加,测试3和测试1不相上下,测试4并没有重现测试2的噩梦,因此test2在循环执行中,即使刷新了磁盘缓存,test2也会将libshare.so读入磁盘,由于libshare.so是共享的,因此test1在第一次执行test1函数时只需要建立页面映射即可,不必再读磁盘了,这就是共享库的好处。

知道了cache和缺页带来的影响,rdtsc就可以无忧得使用了吗?不是这样的!如果getcycle包围的待测试代码本身执行时间过长或者导致了进程调度,那么两个getcycle调用的差值并不是真正的执行时间,比如:

t1 = getcycle();

test() //很长,可能会sleep

t2 = getcycle();

如此一来t2-t1可能会把其它进程执行的时间都算上,因为rdtsc获得的是cpu全局的嘀嗒信息,而我们需要测试的执行时间是基于本进程的统计时间。因此实际上我们是不能信任rdtsc测试出来的结果的。哪一次在test()执行过程中发生调度几乎是用户态决定不了的(除非设置实时进程优先级并且没有IO)。

总结:

1.编写代码时,主要的调用函数要尽量紧凑的放在一个页面中,注意向物理页面起始地址对齐,不要离的太远,这样可以提高执行效率。

2.使用共享库,虽然可能因此增加几条指令(比如got机制的代价),然而却省去了很多缺页时读磁盘的开销。

3.测量长函数执行一次的时间,不要相信rdtsc,因此中间如果发生调度,别的进程时间也会计算进去,rdtsc是全局的。

4.挖掘机器的特性,在指令级别寻找办法,不要太迷恋c语言。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: