内存映射/分配是懒惰的(Lazy):对一个页面的第一次访问将导致出现页故障异常,例如x86上的#PF
。这包括BSS,以及像可执行文件的文本段这样的文件支持映射。这些页故障是“有效”的,所以它们不会导致发送SIGSEGV
;相反,如果必要,内核将分配物理页面并连接硬件页表,以便加载或存储可以重新运行而不会导致第二次故障。
这是很昂贵的,特别是如果内核不“规避故障”并在一个页故障期间准备多个页面。(特别是当Spectre + Meltdown防护使得当前x86-64硬件上的用户<->内核往返更加昂贵时。)
你在动态分配后让std::vector
的构造函数将零写入数组中。std::vector
在你的计时循环之外完成所有的页故障操作。这发生在主函数之前,当实现正在运行静态对象的构造函数时。
但是数组被零初始化,因此它被放置在BSS中。第一次访问它的是你的循环。你的array<>
循环为计时区域内所有的页故障付出了代价。
如果你使用new int[n]
来动态分配但不初始化内存块,你会看到与静态array<>
相同的行为。(如果Linux更愿意为动态分配而不是BSS映射使用透明大页,则可能会稍微好些。)
脚注1:在libstdc++和libc++中的std::vector
太愚蠢了,不能利用从操作系统获取已经清零的页面,就像如果它使用calloc
或等效函数一样。如果库提供了一个新/删除兼容的分配器以获取清零内存,那么这是可能的。
C++的new/delete
与malloc/free/calloc/realloc相比要弱化。我不知道为什么ISO C++省略了calloc和realloc:两者对于大型分配特别有用,特别是对于可平凡复制对象的std::vector进行大小调整的realloc,因为它可能有增长其映射而不复制的空间。但由于new/delete
不能保证与malloc/free
兼容,而new
是可替换的,因此即使在内部,库也很难使用calloc
和realloc
。
另一个因素:只读叶页映射到相同的物理零页面
当读取(而非写入)时触发惰性分配,它将读取为零。 (BSS页面读取为零,来自
mmap(MAP_ANONYMOUS)
的新页面全部读取为零。)
软缺页处理程序将硬件页表连接起来的时候,不需要实际分配物理页面(也就是页面帧)来支持虚拟页面。相反,Linux 将干净(未写入)的匿名页面映射到单个物理清零页面。(这适用于所有任务。)
如果我们对数组进行多次遍历,那么我们会遇到一个有趣的情况:由于我们有多个虚拟页面指向同一物理位置,这会导致TLB缺失但是L1d或L3命中(这取决于是否使用 hugepage )。
(某些CPU,例如 AMD Ryzen,在L1d高速缓存中使用微标记来节省成本,从而使高速缓存仅能针对单个虚拟地址命中,即使相同的内存映射到多个虚拟地址。Intel CPU使用真正的 VIPT L1d 高速缓存,并且确实可以获得此效果)。
我为Linux编写了一个测试程序,该程序将使用
madvise(MADV_HUGEPAGE)
(以鼓励内核为 hugepages 整理内存)或
madvise(MADV_NOHUGEPAGE)
(即使对于只读情况也禁用 hugepages)。
由于某种原因,Linux BSS页面在写入时不使用 hugepages。 只有读取它们才会使用 2M 的 hugepages(这对于 L1d 或 L2 来说太大了,但符合 L3。但我们确实获得所有TLB命中)。 在
/proc/PID/smaps
中很难看到这一点,因为未编写的内存根本不显示为“驻留”。 (记住,它是由系统范围的零共享区域物理支持的)。
我对您的基准代码进行了一些更改,以便在初始化进行读取或写入的情况下多次重新运行 sum 循环之后进行重复循环,根据命令行参数进行更改。重复循环使其运行时间更长,从而可以获得更精确的定时,并摊销初始化,以便我们从 perf 中获得有用的结果。
#include <vector>
#include <array>
#include <stdio.h>
#include <chrono>
#include <sys/mman.h>
using namespace std;
constexpr int n = 100'000'000;
alignas(4096) array<int, n> v;
__attribute__((noinline))
int toucharray(volatile int *vv, int write_init) {
int res=vv[0];
for(int i=32 ; i<n ; i+=128)
if(write_init)
vv[i] = 0;
else
res += vv[i];
return res;
}
template <class T>
__attribute__((noinline,noclone))
int sum_container(T &vv) {
unsigned int res=0;
for(int x : vv)
res += x;
__attribute__((used)) static volatile int sink;
sink = res;
return res;
}
int main(int argc, char**argv)
{
int write_init = 0;
int hugepage = 0;
if (argc>1) {
hugepage = argv[1][0] & 1;
write_init = argv[1][0] & 2;
}
int repcount = 1000;
if (argc>2)
repcount = atoi(argv[2]);
madvise(v.data(), n*sizeof(v[0]), MADV_SEQUENTIAL);
madvise(v.data(), n*sizeof(v[0]), hugepage ? MADV_HUGEPAGE : MADV_NOHUGEPAGE);
madvise(v.data(), n*sizeof(v[0]), MADV_WILLNEED);
toucharray(v.data(), write_init);
int res = 0;
auto start = chrono::steady_clock::now();
for(int i=0; i<repcount ; i++)
res = sum_container(v);
auto end = chrono::steady_clock::now();
double elapsed =
std::chrono::duration_cast<
std::chrono::duration<double, std::milli>
>(end - start).count();
printf("result: %d\ntime: %f\n", res, elapsed);
}
最佳情况:clang++ -O3 -march=native(Skylake)实际上可以使用多个累加器展开,而gcc -funroll-loops则做得很傻。
在我的Skylake i7-6700k上,DDR4-2666 DRAM配置为最大睿频4.2GHz和governor=performance -
taskset -c 3 perf stat -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles,instructions,mem_load_retired.l2_hit:u,mem_load_retired.l1_hit:u,mem_inst_retired.stlb_miss_loads:u ./touchpage-array-argc.clang 0 1000
result: 0
time: 1961.952394
Performance counter stats for './touchpage-array-madv-nohuge-argc.clang 0 1000':
2,017.34 msec task-clock:u
50 context-switches
0 cpu-migrations
97,774 page-faults
8,287,680,837 cycles
14,500,762,859 instructions
13,688 mem_load_retired.l2_hit:u
12,501,329,912 mem_load_retired.l1_hit:u
144,559 mem_inst_retired.stlb_miss_loads:u
2.017765632 seconds time elapsed
1.979410000 seconds user
0.036659000 seconds sys
注意到了相当多的TLB未命中(mem_inst_retired.stlb_miss_loads:u
表示用户空间中的二级TLB未命中次数)。并且有97k页错误。这几乎正好覆盖了100M * 4 = 400MB数组所需的4k页面数,因此我们每个页面都出现了1个错误,没有预先错误/围绕错误。
幸运的是,Skylake有两个页行走单元,因此可以同时执行两个推测页行走操作。此外,所有数据访问都会命中L1d,因此页表将至少保持在L2中,加速页行走。
taskset -c 3 perf stat -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles,instructions,mem_load_retired.l2_hit:u,mem_load_retired.l1_hit:u,mem_inst_retired.stlb_miss_loads:u ./touchpage-array-argc.clang 1 1000
result: 0
time: 5947.741408
Performance counter stats for './touchpage-array-argc.clang 1 1000':
5,951.40 msec task-clock:u
9 context-switches
0 cpu-migrations
687 page-faults
24,377,094,416 cycles
14,397,054,228 instructions
2,183,878,846 mem_load_retired.l2_hit:u
313,684,419 mem_load_retired.l1_hit:u
13,218 mem_inst_retired.stlb_miss_loads:u
5.951530513 seconds time elapsed
5.944087000 seconds user
0.003284000 seconds sys
通知:TLB缺失率仅为1/10,但在相同的约12G内存加载中,只有2G能够在L2中命中,这可能要归功于成功的硬件预取。 (其余的确实命中了L3。)并且我们仅有687个页面错误;故障绕过和大页面的组合使这更加高效。
请注意,由于L3带宽瓶颈,所需时间增加了3倍。
数组的写入初始化让我们拥有最糟糕的两种情况:
taskset -c 3 perf stat -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles,instructions,mem_load_retired.l2_hit:u,mem_load_retired.l1_hit:u,mem_inst_retired.stlb_miss_loads:u ./touchpage-array-argc.clang 3 1000
result: 0
time: 16510.222762
Performance counter stats for './touchpage-array-argc.clang 3 1000':
17,143.35 msec task-clock:u
341 context-switches
0 cpu-migrations
95,218 page-faults
70,475,978,274 cycles
17,989,948,598 instructions
634,015,284 mem_load_retired.l2_hit:u
107,041,744 mem_load_retired.l1_hit:u
37,715,860 mem_inst_retired.stlb_miss_loads:u
17.147615898 seconds time elapsed
16.494211000 seconds user
0.625193000 seconds sys
大量页面错误。也有更多的TLB未命中。
std::vector版本基本上与数组相同:
strace
显示madvise没有起作用是因为我没有对齐指针。glibc / libstdc ++new
倾向于返回一个页面对齐+ 16的指针,并在前16个字节中保留分配器记录。对于数组,我使用alignas(4096)
来确保我可以将其传递给madvise。
madvise(0x7f760d133010, 400000000, MADV_HUGEPAGE) = -1 EINVAL (Invalid argument)
总之,由于我的内核调优设置,它只尝试在madvise上对巨大页面的内存进行碎片整理,而目前内存相当分散。所以它最终没有使用任何巨大页面。
taskset -c 3 perf stat -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles,instructions,mem_load_retired.l2_hit:u,mem_load_retired.l1_hit:u,mem_inst_retired.stlb_miss_loads:u ./touchpage-vector-argv.clang 3 1000
result: 0
time: 16020.821517
Performance counter stats for './touchpage-vector-argv.clang 3 1000':
16,159.19 msec task-clock:u
17 context-switches
0 cpu-migrations
97,771 page-faults
66,146,780,261 cycles
15,294,999,994 instructions
217,426,277 mem_load_retired.l2_hit:u
842,878,166 mem_load_retired.l1_hit:u
1,788,935 mem_inst_retired.stlb_miss_loads:u
16.160982779 seconds time elapsed
16.017206000 seconds user
0.119618000 seconds sys
我不确定为什么TLB缺失率比THP只读测试高这么多。也许是由于内存访问争用和/或通过触及更多内存来驱逐缓存页表而导致页面行走变慢,因此TLB预取无法跟上。
在大约12G的加载中,硬件预取能够使其中约1G的数据在L1d或L2缓存中命中。
std::array
进行比较,例如auto pv = std::make_unique<std::array<int, n>>(); auto& v = *pv;
。 - Benarray
和vector
具有静态存储(对于vector
来说并不完全正确)的情况。@PeterCordes的评论清楚地解释了在我们计时的代码部分之前会发生许多事情。这突显了我们应该在实际计时之前进行干跑,或者至少计时多次以摊销这些外围成本的事实。 - prog-fh