memset对memcpy耗费时间的影响

[原文阅读]

前言:
源于之前几天的一个测试程序,作用是用来测试某个系统的内存访问能力,主体测试策略是分配一个缓冲区,然后使用memcpy在分配的缓冲区之间拷贝若干次,计算拷贝时间,然后在多线程的环境下运行多个拷贝程序,再次分别计算每个线程花费的时间,以此来估算系统的内存访问能力、线程调度性能以及带宽分配性能。然而,测试过程中却发生了很多问题,从而也引发了不少的思考,下面细细道来。

一次测试

测试目的

计算CPU的Dram访问带宽(设置的理论值与统计的实际值做比较);估测多核多线程情况下的带宽分配调度能力与线程调度能力。测试例程源码见文章结尾,其拷贝函数的主要结构如下:

fun DoMemoryCopy
{
    malloc(dst_addr, 8M);
    malloc(src_addr, 8M);
    memset(dst_addr, 0, 8M); memset(src_addr, 0, 8M); // 测试时可选
    gettimeofday(); // copy start time
    while loop 1000 times
        memcpy(dst_addr, src_addr, 8M);
    gettimeofday(); // copy end time
    printf(timeval);
}

测试方法

主机环境:双核i5 2.5GHz(CPU)、双通道Dram 798MHz(Dram)、GCC 32位 4.6.3版本(采用默认的编译选项)。

  • 主进程拷贝
    在mian函数里面调用DoMemoryCopy函数查看打印。
  • 多线程拷贝
    main函数里面创建多个线程,并在每一个线程里面调用DoMemoryCopy函数,查看打印。
  • 未初始化数据拷贝
    使用malloc申请空间之后,不进行memset操作,然后调用DoMemoryCopy函数,查看打印。

测试结果

线程数 是否初始化 单个线程耗费时间 总时间 拷贝总大小
1 1048ms 1048ms 8M
2 2133ms 4255ms 16M
4 4100ms 16658ms 32M
16 16601ms 258784ms 128M
1 697ms 697ms 8M
2 1185ms 2371ms 16M
4 2764ms 11012ms 32M
16 10194ms(时间差距较大) 160743ms 128M

需要注意的是,表里面说的[总时间]是把[各个线程耗费的时间]简单相加得到,并不是现实中的客观时间流逝长度,因为多核的情况下部分线程可以并行运行,所以实际上消耗现实世界中的时间不是每个线程耗费时间的简单的叠加关系,而是互相覆盖的,下面也要区分下这两个时间的差别。

结论

现象

  1. 在使用memset初始化了malloc申请的内存空间时,每个线程拷贝耗费的时间是与线程数量成正比的,并且基本随着线程数量的增加,每个线程拷贝时间也在线性增加(不过有线程数量上限,达到这个上限之后,增加就不再是线性的了)。
  2. 未使用memset时比使用memset时每个线程所耗费的时间少了很多。
  3. 未使用memset时随着线程数量的增加,每个线程拷贝耗费的时间并不是线性增加的,并且当线程数量到达16的时候(接近16之前且4之后的没测),各个线程拷贝花费的时间差别较大。
  4. 使用memset初始化,在线程达到一定数量的时候,每个线程拷贝耗费时间也不再是线性的增加,并且每个线程的时间耗费差别也会慢慢变大。
  5. 随着线程数目的增加,线程个数如果线性增加,所有线程拷贝总时间的增加是指数的。

另外:注意到在windows上面使用TDM-GCC编译运行,不管是否显式地调用memset初始化都是一样的结果(都跟使用memset初始化这种结果一致),也就是说上述表格中的对应相同线程数的其它结果可以合并为使用memset初始化这种结果。

问题

  1. 为什么加与不加memset,拷贝时间会有那么大的差别?
  2. 为什么线程数量增加,拷贝耗费总时间会是指数形式增加?
  3. 为什么线程数量达到一定程度,各个线程拷贝耗时差距会慢慢变大,并且总时间耗费的增量会越来越大?
  4. 为什么拷贝时间的理论计算值与实际打印值不太相符?

问题之探究

  • 为什么加与不加memset,拷贝时间会有那么大的差别?
    • 这个问题很重要,可以看到测试程序里面计时的片段只有拷贝循环过程,并没有将memset函数调用列入计时片段之内。这个问题是因为malloc的机制造成的。在大多数32位系统中,malloc函数只是返回一个虚拟地址空间范围,这段地址是由应用程序进程来维护的,每一个进程都有自己的4GB寻址空间(虚拟地址寻址空间),这4GB寻址空间中大概有2GB是可以用来malloc的,也就是说malloc可以对这2GB的虚拟地址空间进行标记,标记过的就是已经被占用的空间,未标记的就可以使用malloc来进行占用并标记。但要注意的是,malloc只是管理这2GB的虚拟地址空间地址,它们并不具体对应物理内存上面的实际物理地址空间,问题就出在这里,malloc返回应用程序进程的空闲的、未被标记的虚拟地址空间范围(表示对进程可用,还未被映射到物理内存),然后在进程第一次想要对这块虚拟地址空间进行写操作的时候,会触发系统缺页中断,此时才会为malloc申请的虚拟地址空间分配真正的物理内存,并对两者进行映射,至此为止,才真正有物理内存可用。
    • 回到这个问题上面,在memcpy之前调用memset就代表对内存进行写操作,此时会触发缺页中断,真正为虚拟地址分配物理内存,然后拷贝的时候就按照正常的逻辑从源地址物理内存读取数据,然后写入到目的物理内存,整个过程是:读取源数据->写入数据到目的地址这里简单认为是一读一写两步操作在不调用memset对内存进行初始化时,此时虚拟地址空间并没有实际对应的物理内存,memcpy中没有对源数据的写操作,自然从头到位源数据对应的虚拟地址空间就没有实际的物理内存可用,那么问题来了,既然没有实际的源地址物理内存可供读取数据,那memcpy将什么数据写入到目的地址了呢?答案是memcpy时系统会将0填充到目的地址对应的物理内存当中,这个0暂时不清楚是由编译器产生的还是linux系统产生的,不过我更倾向于认为这个0是由系统产生的。整个过程是:产生0数据->写入到目的地址这整个过程可以简单看作只有一个写入的过程,所以时间会比上一种情况少大概一半。
    • 也可以注释掉memset(src_addr, 0, 8M);保留memset(dst_addr, 0, 8M);,再次进行实验,会发现跟将它们两个全部注释掉的效果是一样的。至于时间为什么不是严格的一半,这个跟程序运行时系统的负载、CPU的cache机制、取指令时间等等都是有关系的,所以不是严格的一半的关系。

另,早期的malloc对多线程支持并不好,因为同一个进程当中的malloc都会用到一个共享的freelist(空闲虚拟地址链表),这样在多线程程序当中调用malloc在同一时刻只能够有一个malloc可以真正的访问freelist并进入临界区,其它的需要等待,这样会降低在多线程中的malloc分配效率。后来引入了另一种方法,每个线程有自己的freelist(线程有自己独立的堆-heap段),这就避免了多线程时资源独占的问题,从而提高malloc对多线程的支持。在现在的linux上面,进程以及线程的heap段也是动态创建分配的,在第一次malloc函数调用时发生,可以通过查看/proc/pid/maps信息来验证。当然也不是开几个线程就为几个线程都分配独立的heap,这个是有限制的,具体与CPU核心数量是有关的。

  • 为什么线程数量增加,拷贝耗费总时间会是指数形式增加?
    这个跟系统的带宽有关系,CPU访问Dram的能力是有限度的,不管是CPU的频率限制还是Dram的频率限制,总归来说,CPU读写Dram的速度是有上限值的,此为带宽。单线程拷贝时CPU可以全速访问Dram来拷贝同一份数据,在多线程的时候,比如两个线程,总的拷贝数据量翻倍,但是拷贝的总时间却翻了4倍,这个就是因为CPU对Dram的访问带宽决定的。考虑如下情况,Dram的访问带宽是1GB/s,忽略取指、cache等的影响,单线程的1GB数据拷贝需要2s(一次读取、一次写入),双线程情况下,虽然CPU核可以满足两个线程并行同步运行,但是Dram只有一个,此时就需要对CPU进行带宽分配,假设带宽是平均分配的,那么对于每个线程(CPU)来说就是0.5GB/s的带宽访问能力,那么单个线程一次拷贝的时间就是1x2/0.5=4s(一次读、一次写),双线程合起来就是8s,也就是单线程情况下的4倍,当然最重要的是,在主观上看,两个线程实际上花费现实世界的4s就将数据拷贝完毕了,因为两个线程是并行运行的嘛。一旦线程数超过CPU可以并行处理的极限,这个现实主观花费的时间就不是随着线程数线性增加了,此时时间花费在达到一定线程数量节点之后急剧上升。

  • 为什么线程数量达到一定程度,各个线程拷贝耗时差距会慢慢变大,并且总时间耗费的增量会越来越大?
    这个问题从上面一点也可以得到答案,一个原因是Dram带宽分配能力随着线程数的增加会变的吃力,另一个就是CPU无法很好的处理超过硬件限制数量的线程调度,优化再好的系统在多线程数量超过了可以并行处理的节点之后,也会随着线程数量的再次增加而变得越来越慢,这个是跟调度机制有关的。

  • 为什么拷贝时间的理论计算值与实际打印值不太相符?
    这是因为在不同时刻运行测试程序时系统的负载是不一样的,可能负载重,此时拷贝时间就会比理论预期要长,有时候负载轻,那么实际耗费时间值就会更接近理论值点。并且拷贝过程不单纯的是一次读一次写的过程,中间会有读指令、执行的过程,也有硬件cache的影响,还有系统的调度机制影响等等,还有for循环带来的时间耗费。但是最后,我并没有找到一个方法可以非常精确的计算出来memcpy循环过程中每一毫秒都花费在哪里?。

附:完整的测试程序

#include 
#include 
#include 
#include 
#include 

#define COPY_SIZE 3840*2160
#define COPY_TIME 1000
#define THREAD_NUM 2

void DoMempryCopy(void)
{
    int i = 0;
    char *dst_addr;
    char *src_addr;
    struct timeval tNewTm;
    struct timeval tOldTm;

    dst_addr = malloc(COPY_SIZE);
    if (NULL == dst_addr) {
        printf("no memory to alloc for dst_addr\n");
        goto dst_alloc_err;
    }

    src_addr = malloc(COPY_SIZE);
    if (NULL == src_addr) {
        printf("no memory to alloc for src_addr\n");
        goto src_alloc_err;
    }

    memset(dst_addr, 0, COPY_SIZE);
    memset(src_addr, 0, COPY_SIZE);

    gettimeofday(&tOldTm, NULL);
    for (i = 0; i < COPY_TIME; i++) {
        memcpy(dst_addr, src_addr, COPY_SIZE);
    }
    gettimeofday(&tNewTm, NULL);
    printf("copy time is[%ld]ms\n",
        (tNewTm.tv_sec*1000+tNewTm.tv_usec/1000) - (tOldTm.tv_sec*1000+tOldTm.tv_usec/1000));

    free(src_addr);
src_alloc_err:
    free(dst_addr);
dst_alloc_err:
    return;
}

void *CopyThread(void *pArg)
{
    DoMempryCopy();

    pthread_exit(NULL);
}

int main(int argc, char **argv)
{
    int ret = 0;
    int i = 0;
#if 0
    DoMempryCopy();
#else
    pthread_t tTid[THREAD_NUM];
    for (i = 0; i < THREAD_NUM; i ++) {
        pthread_create(&tTid[i], NULL, CopyThread, NULL);
    }

    for (i = 0; i < THREAD_NUM; i ++) {
        pthread_join(tTid[i], NULL);
    }
#endif

    return 0;
}

如果觉得本文章不错,请关注微信公众号-YellowMax多多支持,查看更多文章

关注微信查看更多干货

你可能感兴趣的:(C语言)