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

以下有三个版本的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语言。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值