注意:本文中 Windows 平台的 PageFaultCount 使用 GetProcessMemoryInfo 获取,该字段并不是 Page Fault 进入内核的次数,而是映射的页面数。详见 WRK 源码。

在 WRK 中,匿名内存并不会触发聚类,只有文件映射会触发。但在 Win11 10.0.26100 Build 26100 上观测到了匿名内存的类似机制,猜测新版本做了实现。

引言

我们都知道,不管是在 Linux 还是在 Windows,使用 mmapVirtualAlloc 返回的内存只是在 VMA/VAD 上做了记录,并没有被真正分配出来——没有从管理物理页面的结构体中把物理页面拿出来,没有把这个物理页面的 PFN 挂在虚拟地址对应的 PTE 上。这些操作被推迟到 访问这块内存的时候 进行。

但我们思考一个问题:现在的操作系统/编程语言有着各种操作来避免频繁进入内核。例如:

  • 操作系统、语言 (Go) 的 堆管理器:后端大量从操作系统批发,切分给前端使用
  • USER_SHARED_DATA:同一个物理内存映射在内核空间和用户空间,使得可以不进入内核就获取系统信息

我们可以推测出 进入内核的开销并不小。那么在缺页异常中呢?是否会有一种机制,使得用户访问一块地址后,为了减少进入内核的次数,一次性映射多个页面

答案是 有的,并且在不同的操作系统中有着不同的实现。


测试代码

测试代码位于 computer_enhancelisting_108listing_0112listing_0115

代码功能总结

文件 功能
listing_0112 正向触碰:从头到尾(0 → End)一页一页写入,观察缺页分配行为
listing_0113 反向触碰:从尾到头(End → 0)倒着写,观察预映射是否失效
listing_0114 反向测试封装:将反向写入封装成测试函数,供性能测试器调用
listing_0115 全面性能测试:对比正向和反向写入的速度

ReadOSPageFaultCount 的实现

这个函数负责读取操作系统记录的”缺页计数”,实现位于 listing_0108_platform_metrics.cpp

Windows 平台

  • API: GetProcessMemoryInfo (来自 <psapi.h>)
  • 读取字段: PROCESS_MEMORY_COUNTERS_EX.PageFaultCount
  • 含义: 进程自启动以来,发生的 页面映射总数(包含 OS 批量预映射的数量)

Linux / macOS 平台

  • API: getrusage
  • 读取字段: ru_minflt (软缺页) + ru_majflt (硬缺页)

Windows 测试结果

以下测试结果来自 Win11 10.0.26100 Build 26100。在 Win10 1703 上测试并没有出现 Page Fault Clustering 优化,可能是内核配置差异。

正向访问(2048 页)

局部图
Page Fault 局部图

全局图
Page Fault 全局图

具体数据

Page Count Touch Count Fault Count Extra Faults
32 0 0 0
32 1 1 0
32 2 2 0
32 3 3 0
32 4 4 0
32 5 5 0
32 6 6 0
32 7 7 0
32 8 8 0
32 9 9 0
32 10 10 0
32 11 11 0
32 12 12 0
32 13 13 0
32 14 14 0
32 15 15 0
32 16 16 0
32 17 32 15
32 18 32 14
32 19 32 13
32 20 32 12
32 21 32 11
32 22 32 10
32 23 32 9
32 24 32 8
32 25 32 7
32 26 32 6
32 27 32 5
32 28 32 4
32 29 32 3
32 30 32 2
32 31 32 1
32 32 32 0

可以看到,这个图并不是一条直线,而是 阶梯形 的。数据中出现了 单位为 16 的跳跃,说明存在一种”预分配”机制。


Windows 页面错误聚类(Page Fault Clustering)

查看 WRK 源码后,找到了这个机制:Page Fault Clustering

页面错误聚类是 Windows 内存管理器的一项优化技术:当发生页面错误时,不仅读入导致错误的页面,还会 预读取相邻的页面,以减少未来的页面错误次数。

这是一个 线程级别 的配置,在每个线程的 ETHREAD 中有如下字段:

1
2
3
ULONG ReadClusterSize;              // 每次页面错误时预读的页面数量
BOOLEAN ForwardClusterOnly; // 是否只向前聚类(不向后)
BOOLEAN DisablePageFaultClustering; // 是否完全禁用页面错误聚类

ReadClusterSize 的初始化(根据物理内存大小):

物理内存 ReadClusterSize
≤ 13MB 2 页
13MB - 19MB 4 页
> 19MB 7 页

:Win11 实测观察到 16 页 的聚类跳跃,与 WRK 的默认值(7 页)不同,可能是新版本调整了参数或针对匿名内存做了不同实现。


为什么每 512 页产生额外页面错误?

pagfault.c 第 2670 行左右,聚类计算基于页表内的位置:

1
2
MaxForwardPageCount = PTE_PER_PAGE - (BYTE_OFFSET(PointerPte) / sizeof(MMPTE));
MaxBackwardPageCount = PTE_PER_PAGE - MaxForwardPageCount;

其中 PTE_PER_PAGE = 512(4096 字节页表 ÷ 8 字节 PTE = 512 个条目)。

当触摸 512 个页面后,一个页表被填满,Windows 必须分配新的物理页面来存储下一个页表。Windows 的页面错误计数器将这个 页表页面的映射也计为”页面错误”,所以每 512 个用户页面就会看到 1 个额外的页面错误。

为什么预映射在页表边界停止?

pagfault.c 第 3926 行,向前聚类的核心循环:

1
2
3
4
5
6
7
8
9
while ((MiIsPteOnPdeBoundary(CheckPte) == 0) &&  // 关键检查!
(Page < EndPage) &&
(CheckPte < &Subsection->SubsectionBase[Subsection->PtesInSubsection]) &&
(CheckPte->u.Long == BasePte->u.Long)) {
ControlArea->NumberOfPfnReferences += 1;
ReadSize += PAGE_SIZE;
Page += 1;
CheckPte += 1;
}

MiIsPteOnPdeBoundary 宏检查 PTE 地址是否对齐到 4KB 边界。当 PTE 索引从 511 回绕到 0 时,循环立即终止。

Windows 故意设计成不跨越页表边界进行聚类,这是为了:

  • 避免跨页表的额外复杂度
  • 保持局部性原理(同一页表内的页面更可能连续访问)
  • 限制预取范围,避免过度消耗内存

反向访问(2048 页)

局部图
反向访问局部图

全局图
反向访问全局图

具体数据

Page Count Touch Count Fault Count Extra Faults
32 0 0 0
32 1 1 0
32 2 2 0
32 3 3 0
32 4 4 0
32 5 5 0
32 6 6 0
32 7 7 0
32 8 8 0
32 9 9 0
32 10 10 0
32 11 11 0
32 12 12 0
32 13 13 0
32 14 14 0
32 15 15 0
32 16 16 0
32 17 17 0
32 18 18 0
32 19 19 0
32 20 20 0
32 21 21 0
32 22 22 0
32 23 23 0
32 24 24 0
32 25 25 0
32 26 26 0
32 27 27 0
32 28 28 0
32 29 29 0
32 30 30 0
32 31 31 0
32 32 32 0

反向访问基本是完全的直线(每次访问都触发一次映射),除了在 PTE 用完时分配新页表会多出一个。

这说明当前 Windows 内核版本下,当前进程被判断为 只需要向前(小地址方向)聚类


综合性能测试

综合测试结果

  • malloc 的是每次都新分配内存
  • 不带的是空间复用

可以看到预取对于 正向访问的大幅优化


Linux 的类似机制

在 Linux 中存在一些类似的机制:

1. Fault Around(文件映射预映射)

这是最接近 Windows Page Fault Clustering 的机制。

位置mm/memory.c 第 5507-5537 行

仅针对文件映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
* do_fault_around() tries to map few pages around the fault address.
* The hope is that the pages will be needed soon and this will lower
* the number of faults to handle.
*
* This function doesn't cross VMA or page table boundaries.
* fault_around_pages defines how many pages we'll try to map.
*/
static vm_fault_t do_fault_around(struct vm_fault *vmf)
{
pgoff_t nr_pages = READ_ONCE(fault_around_pages); // 默认 16 页 (64KB)

// 计算映射范围 [from_pte, to_pte]
from_pte = max(ALIGN_DOWN(pte_off, nr_pages),
pte_off - min(pte_off, vma_off));
to_pte = min3(from_pte + nr_pages, (pgoff_t)PTRS_PER_PTE,
pte_off + vma_pages(vmf->vma) - vma_off) - 1;

// 调用 vm_ops->map_pages() 批量映射
ret = vmf->vma->vm_ops->map_pages(vmf, start_pgoff, end_pgoff);
}

2. Swap Readahead(换入预读)

当从 swap 换入页面时,Linux 会预读相邻的页面。

位置mm/swap_state.c 第 567-637 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* swap_cluster_readahead - swap in pages in hope we need them soon
*
* Primitive swap readahead code. We simply read an aligned block of
* (1 << page_cluster) entries in the swap area.
*/
struct folio *swap_cluster_readahead(swp_entry_t entry, gfp_t gfp_mask, ...)
{
mask = swapin_nr_pages(offset) - 1; // 动态计算预读页数

start_offset = offset & ~mask;
end_offset = offset | mask;

for (offset = start_offset; offset <= end_offset; offset++) {
folio = __read_swap_cache_async(...); // 异步读入
if (page_allocated)
swap_read_folio(folio, &splug);
}
}

3. 透明大页(THP)的匿名页面优化

对于匿名页面,Linux 使用 透明大页 而非 fault around 来减少缺页次数。

位置mm/memory.c 第 4915-4998 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static struct folio *alloc_anon_folio(struct vm_fault *vmf)
{
#ifdef CONFIG_TRANSPARENT_HUGEPAGE
// 获取可用的大页 orders(从 PMD_ORDER 到 order-0)
orders = thp_vma_allowable_orders(vma, vma->vm_flags, ...);

// 尝试分配最大可用的大页
while (orders) {
addr = ALIGN_DOWN(vmf->address, PAGE_SIZE << order);
folio = vma_alloc_folio(gfp, order, vma, addr);
if (folio) {
return folio; // 分配成功,映射整个大页
}
order = next_order(&orders, order); // 降级尝试更小的 order
}
#endif
// fallback 到普通单页
return folio_prealloc(vma->vm_mm, vma, vmf->address, true);
}

如果启用了 THP,一次匿名页面缺页可能会分配 2MB(512 页) 的大页,远超 Windows 的 16 页聚类。