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



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

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

根据开源软件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;
}
}
}

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

memset(buff, 0, size * ratio);

如果比率为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 secsn", getTS() - startTs);
}
int main(int argc, char *argv[]) {
test1(argc > 1);
return 0;
}

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

编辑2。我很想让sendfile(谢谢@marco-bonelli)像从缓冲区发送一样快,全部为0(通过calloc)。我想这很快就会成为我任务的要求。

我一直在运行各种测试来研究这个令人惊讶的结果。

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

#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;
}

在运行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)

时间与OP的观察结果一致。我发现了一个与观察到的时间一致的解释:

如果对页面的第一次访问是读取操作,则定时比第一次操作是写入访问要好。

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

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

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

最终更新:正如Peter Cordes所评论的,从未写入的匿名页面的读取将使写入时的每个页面副本都映射到相同的零物理页面,因此在读取时可以获得TLB未命中,但L1d缓存命中。(适用于.bssmmap(MAP_ANONYMOUS)中的内存,如glibccallocmalloc,用于大分配。)他用perf的结果为一个实验写了一些细节:为什么通过"std::vector"迭代比通过"std::array"迭代更快?

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

Linux使用虚拟内存,并仅在需要时使用物理内存(分配为页面)备份该虚拟内存。当您的两个示例程序请求";存储器";对于使用malloc()的缓冲区,实际上只分配了虚拟内存。只有当您的程序使用";存储器";(例如写入该内存缓冲区)将Linux分配物理内存页面以映射到虚拟页面。这允许Linux以与使用稀疏文件的文件系统分配非常相似的方式过度提交内存。

当您的任何一个程序使用memset()初始化分配的缓冲区时,将强制分配物理页以对应于虚拟内存。也许这会导致在套接字传输或缓冲区复制期间进行一些页面交换
但是,当内存缓冲区尚未初始化(并且尚未映射到物理页面)时,是否可以优化页面故障(由于I/O读取或复制操作),只从特殊页面访问(有点像从稀疏文件的未写入部分读取),而不执行页面分配?

根据您的问题,似乎确实存在某种针对页面错误的页面优化。因此,让我们假设读取尚未写入的虚拟内存不会触发物理内存的分配和映射。


为了检验这一假设,我们可以使用top实用程序来获得第二个程序的虚拟内存与物理内存的使用情况。topman页面描述了虚拟内存使用情况正在使用和/或保留的所有内容(所有象限)"常驻存储器的使用是">任何占用物理内存的东西,从Linux-4.5开始,是以下三个字段的总和:
RSan-象限1页面,如果修改,包括任何以前的象限3页面
RSMD-象限3和象限4页面
RSsh-象限2页面
">

当您的第二个程序初始化分配的缓冲区时,此慢速版本使用518.564 MB的虚拟内存和515.172 MB的常驻内存。类似的内存数似乎表明500MB的malloc()d缓冲区是用物理内存备份的(应该是这样)。

当您的第二个程序没有初始化分配的缓冲区时,这个快速版本使用相同的518.564 MB虚拟内存,但只有3.192 MB常驻内存。不同的内存数量很好地表明(如果不是全部的话)500 MB的malloc()缓冲区没有用物理内存备份。


所以这个假设似乎是有效的。

Peter Cordes的评论证实了存在这样的页面故障优化:从从未写入的匿名页面读取将使写入时的每个页面副本都映射到零的同一物理页面,因此您可以在读取时获得TLB未命中但L1d缓存命中;

因此,传输速率和复制时间的提高似乎是由于虚拟内存子系统中页面交换的开销减少以及处理器缓存命中率的提高。

最新更新