从未初始化的缓冲区复制比从初始化的缓冲区复制要快得多

pac*_*tie 6 c sockets linux x86 linux-kernel

我的任务是开发一个测试软件,在一台 32GB RAM 的机器上的 Linux(X86-64,内核 4.15)上通过 1 个 TCP 套接字生成 100Gbps 的流量。

我开发了类似以下代码(为了简单起见,删除了一些健全性检查)来在一对 veth 接口(其中一个位于不同的 netns 中)上运行。

bmon根据开源软件,它在我的 PC 上生成大约 60Gbps 。令我惊讶的是,如果我删除该语句memset(buff, 0, size);,我会得到大约 94Gbps。这非常令人费解。

void test(int sock) {
    int size = 500 * 0x100000;
    char *buff = malloc(size);
    //optional
    memset(buff, 0, size);
    int offset = 0;
    int chunkSize = 0x200000;
    while (1) {
        offset = 0;
        while (offset < size) {
            chunkSize = size - offset;
            if (chunkSize > CHUNK_SIZE) chunkSize = CHUNK_SIZE;
            send(sock, &buff[offset], chunkSize, 0);
            offset += chunkSize;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

我做了一些实验,用memset(buff, 0, size);以下内容替换(初始化一部分buff),

memset(buff, 0, size * ratio);
Run Code Online (Sandbox Code Playgroud)

如果比率为 0,则吞吐量最高,约为 94Gbps,当比率升至 100% (1.0) 时,吞吐量将下降至 60Gbps 左右。如果比率为 0.5 (50%),则吞吐量约为 72Gbps

感谢您对此提供的任何线索。

编辑 1 . 这是一个相对完整的代码,显示了在初始化缓冲区上进行复制的效果似乎更慢。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/stat.h>

int size = 500 * 0x100000;
char buf[0x200000];

double getTS() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + tv.tv_usec/1000000.0;
}

void test1(int init) {
    char *p = malloc(size);
    int offset = 0;
    if (init) memset(p, 0, size);
    double startTs = getTS();
    for (int i = 0; i < 100; i++) {
        offset = 0;
        while (offset < size) {
            memcpy(&buf[0], p+offset, 0x200000);
            offset += 0x200000;
        }
    }
    printf("took %f secs\n", getTS() - startTs);
}

int main(int argc, char *argv[]) {
    test1(argc > 1);
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

在我的电脑(Linux 18.04、Linux 4.15,32GB RAM)上,在没有初始化的情况下尝试了两次,花了 1.35 秒。初始化后,花费了 3.02 秒。

编辑 2 . 希望能够像从缓冲区发送全 0 一样快地获取 sendfile(感谢 @marco-bonelli)(通过 calloc)。我认为这很快就会成为我的任务的要求。

chq*_*lie 3

我一直在进行各种测试来调查这个令人惊讶的结果。

我编写了下面的测试程序,结合了初始化阶段和循环中的各种操作:

#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/stat.h>

int alloc_size = 500 * 0x100000;  // 500 MB
char copy_buf[0x200000];    // 2MB

double getTS() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + tv.tv_usec/1000000.0;
}

// set a word on each page of a memory area
void memtouch(void *buf, size_t size) {
    uint64_t *p = buf;
    size /= sizeof(*p);
    for (size_t i = 0; i < size; i += 4096 / sizeof(*p))
        p[i] = 0;
}

// compute the sum of words on a memory area
uint64_t sum64(const void *buf, size_t size) {
    uint64_t sum = 0;
    const uint64_t *p = buf;
    size /= sizeof(*p);
    for (size_t i = 0; i < size; i++)
        sum += p[i];
    return sum;
}

void test1(int n, int init, int run) {
    int size = alloc_size;
    char msg[80];
    int pos = 0;
    double times[n+1];
    uint64_t sum = 0;
    double initTS = getTS();
    char *p = malloc(size);

    pos = snprintf(msg + pos, sizeof msg - pos, "malloc");
    if (init > 0) {
        memset(p, init - 1, size);
        pos += snprintf(msg + pos, sizeof msg - pos, "+memset%.0d", init - 1);
    } else
    if (init == -1) {
        memtouch(p, size);
        pos += snprintf(msg + pos, sizeof msg - pos, "+memtouch");
    } else
    if (init == -2) {
        sum = sum64(p, size);
        pos += snprintf(msg + pos, sizeof msg - pos, "+sum64");
    } else {
        /* leave p uninitialized */
    }
    pos += snprintf(msg + pos, sizeof msg - pos, "+rep(%d, ", n);
    if (run > 0) {
        pos += snprintf(msg + pos, sizeof msg - pos, "memset%.0d)", run - 1);
    } else
    if (run < 0) {
        pos += snprintf(msg + pos, sizeof msg - pos, "sum64)");
    } else {
        pos += snprintf(msg + pos, sizeof msg - pos, "memcpy)");
    }
    double startTS = getTS();
    for (int i = 0; i < n; i++) {
        if (run > 0) {
            memset(p, run - 1, size);
        } else
        if (run < 0) {
            sum = sum64(p, size);
        } else {
            int offset = 0;
            while (offset < size) {
                memcpy(copy_buf, p + offset, 0x200000);
                offset += 0x200000;
            }
        }
        times[i] = getTS();
    }
    double firstTS = times[0] - startTS;
    printf("%f + %f", startTS - initTS, firstTS);
    if (n > 2) {
        double avgTS = (times[n - 2] - times[0]) / (n - 2);
        printf(" / %f", avgTS);
    }
    if (n > 1) {
        double lastTS = times[n - 1] - times[n - 2];
        printf(" / %f", lastTS);
    }
    printf(" secs  %s", msg);
    if (sum != 0) {
        printf("  sum=%016llx", (unsigned long long)sum);
    }
    printf("\n");
    free(p);
}

int main(int argc, char *argv[]) {
    int n = 4;
    if (argc < 2) {
        test1(n, 0, 0);
        test1(n, 0, 1);
        test1(n, 0, -1);
        test1(n, 1, 0);
        test1(n, 1, 1);
        test1(n, 1, -1);
        test1(n, 2, 0);
        test1(n, 2, 1);
        test1(n, 2, -1);
        test1(n, -1, 0);
        test1(n, -1, 1);
        test1(n, -1, -1);
        test1(n, -2, 0);
        test1(n, -2, 1);
        test1(n, -2, -1);
    } else {
        test1(argc > 1 ? strtol(argv[1], NULL, 0) : n,
              argc > 2 ? strtol(argv[2], NULL, 0) : 0,
              argc > 3 ? strtol(argv[2], NULL, 0) : 0);
    }
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

在运行 Debian Linux 3.16.0-11-amd64 的旧 Linux 机器上运行它,我得到了这些计时:

这些列是

  • 初始阶段
  • 循环的第一次迭代
  • 第二次到倒数第二次迭代的平均值
  • 循环的最后一次迭代
  • 操作顺序
0.000071 + 0.242601 / 0.113761 / 0.113711 secs  malloc+rep(4, memcpy)
0.000032 + 0.349896 / 0.125809 / 0.125681 secs  malloc+rep(4, memset)
0.000032 + 0.190461 / 0.049150 / 0.049210 secs  malloc+rep(4, sum64)
0.350089 + 0.186691 / 0.186705 / 0.186548 secs  malloc+memset+rep(4, memcpy)
0.350078 + 0.125603 / 0.125632 / 0.125531 secs  malloc+memset+rep(4, memset)
0.349931 + 0.105991 / 0.105859 / 0.105788 secs  malloc+memset+rep(4, sum64)
0.349860 + 0.186950 / 0.187031 / 0.186494 secs  malloc+memset1+rep(4, memcpy)
0.349584 + 0.125537 / 0.125525 / 0.125535 secs  malloc+memset1+rep(4, memset)
0.349620 + 0.106026 / 0.106114 / 0.105756 secs  malloc+memset1+rep(4, sum64)  sum=ebebebebebe80000
0.339846 + 0.186593 / 0.186686 / 0.186498 secs  malloc+memtouch+rep(4, memcpy)
0.340156 + 0.125663 / 0.125716 / 0.125751 secs  malloc+memtouch+rep(4, memset)
0.340141 + 0.105861 / 0.105806 / 0.105869 secs  malloc+memtouch+rep(4, sum64)
0.190330 + 0.113774 / 0.113730 / 0.113754 secs  malloc+sum64+rep(4, memcpy)
0.190149 + 0.400483 / 0.125638 / 0.125624 secs  malloc+sum64+rep(4, memset)
0.190214 + 0.049136 / 0.049170 / 0.049149 secs  malloc+sum64+rep(4, sum64)
Run Code Online (Sandbox Code Playgroud)

时间安排与 OP 的观察结果一致。我找到了一个与观察到的时间一致的解释:

如果对页的第一次访问是读操作,则时序明显于第一次操作是写访问

以下是与此解释一致的一些观察结果:

  • malloc()对于一个大的 500MB 块,只是进行系统调用来映射内存,它不会访问该内存,并且calloc可能会执行完全相同的操作。
  • 如果您不初始化该内存,出于安全原因,它仍会在 RAM 中映射为零初始化页。
  • 当您使用 初始化内存时memset,对整个块的第一次访问是写访问,然后循环的时间会变慢
  • 将内存初始化为所有字节1会产生完全相同的时序
  • 如果我使用memtouch,仅将第一个单词写入零,我会在循环中得到相同的计时
  • 相反,如果我不初始化内存,而是计算校验和,则结果为零(这不是保证的,而是预期的),并且循环中的计时更快。
  • 如果没有对该块执行访问,则循环中的计时取决于所执行的操作:memcpysum64更快,因为第一次访问是读访问,而memset更慢,因为第一次访问是写访问。

这似乎是 Linux 内核特有的,我在 macOS 上没有观察到相同的差异,但使用的是不同的处理器。此行为可能特定于较旧的 Linux 内核和/或 CPU 和桥架构。

最终更新:正如Peter Cordes所评论的那样,从从未写入的匿名页面进行读取将使每个页面的写时复制映射到相同的零物理页面,因此在读取时您可能会出现 TLB 未命中但 L1d 缓存命中的情况。(适用于.bss、 和来自 的内存mmap(MAP_ANONYMOUS),例如 glibccallocmalloc用于大型分配。)他写了一些详细信息,并附perf有实验结果:Why is iterating through `std::vector`比迭代`std::array`更快?

这解释了为什么memcpy从仅隐式初始化为零的内存中读取比从显式写入的内存中读取要快。对于稀疏数据使用+calloc()代替的一个很好的理由。malloc()memset()