虚拟内存、分页与 TLB
环境:Windows 11 x64(
RtlGetVersion读到 build=26200)、GCC 13.1.0(MinGW-w64,D:/Qt/Tools/mingw1310_64/bin/gcc.exe),命令行gcc -std=gnu11 -O2 -static p_vm.c -o p_vm.exe -lpsapi。i5-11400H(6 物理核 / 12 逻辑处理器),QueryPerformanceCounter频率 10000000 Hz(1 个计数 = 100 纳秒),同时用__rdtsc记 TSC tick 作对照。探针p_vm.c单次运行 0.5 秒,本机连跑了多次;下文所有原始输出与两列对照数字都取自最后两次完整运行(记作运行 A、运行 B),个别地方引用更早几次的值时会在句子里写明。同期机器上还有别的 agent 在编译,所以绝对时间只当区间看,跨运行不一致的地方两个值都写;结构性数字(页数、字节数、缺页计数、错误码、Flags 位)每次都一样。本机没有 WSL / Linux / 容器:/proc/<pid>/smaps、mincore/madvise/mlock、透明大页、perf的dTLB-load-misses、swap 与 PSI/OOM killer 全部未在本机实测,涉及处明写。
一、页表有几级:这台机器只能反推,大页要特权
pageSize=4096 B allocationGranularity=65536 B GetLargePageMinimum=2097152 B 逻辑处理器=12
应用地址区间 = 0000000000010000 .. 00007ffffffeffff
上界+1 = 2^47(140737488289792)→ 用户态虚拟地址有效位=47,减去 12 位页内偏移 = 35 位页号
派生:每级页表 512 项占 9 位,35 位页号需要 4 级(4096B 页表 × 512 项/页)
GetLargePageMinimum()=2097152 是这台机器给的唯一一个”硬件页粒度”线索:2 MB 正好是一张第三级页表的覆盖范围(512 项 × 4 KB)。走到这一级就停,页表遍历少两级——这是大页能救 TLB 的全部原因,没有什么魔法。
大页在这台机器上试不了:
VirtualAlloc(2097152 B, MEM_LARGE_PAGES) -> 0000000000000000 错误码=1314
对照:同尺寸不加 MEM_LARGE_PAGES -> 00000205b9f10000 错误码=0
重复一次(确认错误码稳定)-> 0000000000000000 错误码=1314
1314 = ERROR_PRIVILEGE_NOT_HELD(“客户端没有所需的特权”),两次都是它,而同一尺寸不加 MEM_LARGE_PAGES 就成功(错误码 0)。另一次权限盘点(recon.exe,只读 GetTokenInformation)给出了原因:TokenElevation=0、本进程持有特权数=5、[无] SeLockMemoryPrivilege。所以”4 KB 页 vs 2 MB 大页的 TLB 差距”本节未在本机实测——不是不想测,是拿不到那张 2 MB 的页。同理,Linux 的透明大页(THP)、madvise(MADV_HUGEPAGE)、/proc/<pid>/smaps 里的 AnonHugePages 在本机没有对应物,只能按语义讲。
反向页表也测不了:它是内核数据结构,用户态既拿不到 CR3 也拿不到”物理页 → 哪些虚拟页”这张表。本节唯一能碰到的”页表元数据”是提交配额那个数字(第八节);页表本身占的内存(每映射 2 MB 需要一张 4 KB 的 PTE 页)是算出来的,不是测出来的。
二、TLB 装得下多少个 4 KB 页:三级台阶
方法是把”影响单次访问代价的因素”只剩一个:依赖载入链(每次 load 的地址依赖上一次的结果,编译器与硬件预取都帮不上忙),每个 4 KB 页里只放一个 64 字节槽,于是数据足迹 = 页数 × 64 B,而地址足迹 = 页数 × 4 KB。区域是 8192 页(32 MB)全部先落好物理页,之后只读;每档固定走 262144 次访问,3 次取中位:
| 涉及页数 | 数据足迹 | 运行 A ns/次 | 运行 B ns/次 | A 相对 32 页 | B 相对 32 页 |
|---|---|---|---|---|---|
| 1 | 0 KB | 1.54 | 1.52 | 0.41x | 0.38x |
| 8 | 0 KB | 1.49 | 1.48 | 0.39x | 0.37x |
| 16 | 1 KB | 3.74 | 3.76 | 0.99x | 0.95x |
| 64 | 4 KB | 4.55 | 3.77 | 1.20x | 0.95x |
| 128 | 8 KB | 5.38 | 5.59 | 1.42x | 1.41x |
| 256 | 16 KB | 5.36 | 5.66 | 1.42x | 1.43x |
| 512 | 32 KB | 16.54 | 16.28 | 4.38x | 4.11x |
| 1024 | 64 KB | 16.24 | 15.51 | 4.29x | 3.91x |
| 2048 | 128 KB | 31.99 | 28.77 | 8.46x | 7.26x |
| 4096 | 256 KB | 83.00 | 75.37 | 21.95x | 19.02x |
| 8192 | 512 KB | 111.60 | 96.62 | 29.52x | 24.38x |
台阶出现在三个位置,而且数据足迹一直小到能待在 L2(这颗核 1280 KB)里,所以台阶不是缓存造成的:
- ≤64 页 → 1.5~4.6 纳秒:整条链的 TLB 项都在最里层。
- 128
256 页 → 5.45.7 纳秒(约 1.4 倍):第一层 TLB 装不下,开始走第二层。 - 512 页(= 2 MB 地址)→ 16.3
16.5 纳秒(4.14.4 倍):这一跳最陡。2 MB 正好是”第二层 TLB 用 4 KB 粒度能覆盖的地址量”,和第一节GetLargePageMinimum()是同一个数——不是巧合,第二层表项就是按 2 MB 一组组织的。 - 4096 页起 → 75
83 纳秒(1922 倍),8192 页 97112 纳秒(2430 倍):彻底装不下了,每次访问都补一次页表遍历。
对照《内存分层与地址空间》里那张表:“32 MB 工作集、跨 4 KB 页访问 9.32 纳秒/次”——那是缓存和 TLB 混在一起的代价;把缓存按住只留 TLB,真实数字是 75~112 纳秒。页表遍历比”出 L2”贵一个数量级。
三、同一个页摸几次:TLB 是”每次访问”收费,不是”每页”收费
把 512 页固定住,改的是每页连续命中几次(同一页的多个链节点全塞进同一个 64 字节缓存行,所以缓存足迹恒定 32 KB):
每页命中 1 次(跨页概率 1.000):15.59 ns/次 (37.32 ticks) 总访问 262144 次 缓存足迹 32 KB
每页命中 2 次(跨页概率 0.500):7.84 ns/次 (21.07 ticks) 总访问 262144 次 缓存足迹 32 KB
每页命中 4 次(跨页概率 0.250):4.78 ns/次 (12.63 ticks) 总访问 262144 次 缓存足迹 32 KB
每页命中 8 次(跨页概率 0.125):3.12 ns/次 (8.33 ticks) 总访问 262144 次 缓存足迹 32 KB
每页命中 16 次(跨页概率 0.062):2.56 ns/次 (6.88 ticks) 总访问 262144 次 缓存足迹 32 KB
运行 A 的同一组是 14.39 / 7.82 / 4.99 / 3.13 / 2.26 纳秒——两次运行形状一致:每页命中次数从 1 到 16,单价降 6.1~6.4 倍。数据一个字节没变、缓存足迹一个字节没变,只是”页跨越频率”降到 1/16。
这条对写代码的直接含义:结构体大一点、把常一起摸的字段放同一页,比把它拆开更省。“每页 1 个对象”的池子在 TLB 上是最贵的排布(14.3915.59 纳秒/次),“每页 16 个”只要 2.262.56 纳秒/次。
四、改一次保护位到底付了什么钱
《内存分层与地址空间》量到 VirtualProtect 是”固定成本 300 纳秒 + 每页 5~6 纳秒”,并把每页那笔归给”刷新 TLB”。这次把两件事拆开,用同一批 256 页的追逐链(热态单价 5.47 纳秒/次,两次运行相同):
热单圈(追逐集=前 256 页,走一圈 256 次访问):5.47 ns/次,这一圈缺页+0
改无关的 256 页保护位后再走追逐集:8.98 ns/次(1.6x 热)这一圈缺页+0
(链完整性核对:一圈走到 256 个不同的页,应为 256)
改追逐集自身 256 页保护位后:654.69 ns/次(119.7x 热)这一圈缺页+256,两次改保护位期间工作集变化 -1048576 B
MEM_DECOMMIT 256 页再 MEM_COMMIT:重提交成功,工作集 -1048576 B
之后走一圈(链刚被重建写过):5.47 ns/次
三个结论,强度不一样:
- 改被追的那 256 页 = 页被踢出工作集,不是 TLB 的事。证据有两条硬账:缺页计数 +256(每页一次,一次不少),工作集 -1048576 B(正好 256 × 4096,六次独立运行都是这个值)。回来后单价 654.69(A)/ 667.58(B)纳秒/次,更早四次是 645.31 / 656.25 / 691.80 / 943.36——热态的 118
172 倍,也比第二节最惨的页表遍历(75112 纳秒)贵 6~8 倍,因为它是一次真缺页。所以”改保护位让后面第一次访问要走页表遍历”这个说法在这台机器上是错的:它让后面第一次访问要缺页。 - **改无关的页会不会连带冲刷本进程的 TLB 条目?本机判不出来。**六次运行分别是 1.0x、1.0x、1.1x、1.6x、3.6x、6.3x。如果 Windows 每次都按进程做全局失效,这组应该稳定 >1;如果完全按页精确失效,应该稳定 ≈1。既然两次跑出两个方向的结果,就都写上:没有可重复的证据,别拿它当结论。
MEM_DECOMMIT+ 重新MEM_COMMIT同样把 256 页(-1048576 B)丢掉,但”之后走一圈”很快(5.47 / 8.98 纳秒),因为重建链的时候已经把页摸回去了——这是”测量被自己的准备工作污染”的现场样本。
顺带一个自证:(链完整性核对:一圈走到 256 个不同的页,应为 256)——如果页被丢弃后内容变成零,链会在第一个节点就死循环,数字会远小于 256。内容没丢,丢的是映射,这正是”私有脏页要么在内存里、要么在页面文件里,不会凭空消失”的口径。
五、一次缺页的三种价:匿名、重触碰、文件映射
同一份 32 MB(8192 页),四次动作分别记账:
提交后未触碰:提交配额+33619968 B 工作集+0 B 私有工作集+0 B 缺页+0
首次触碰 8192 页:3.82 ms,缺页+8208 → 466 ns/页 (1252 ticks/页)
热触碰同 8192 页:8 ns/页 (21 ticks/页) 缺页+0 校验和=0
EmptyWorkingSet 返回=1 用时 8.09 ms:工作集 67 MB→0 MB,提交配额仍 64 MB
丢工作集后再触碰同 8192 页:944 ns/页 (2537 ticks/页) 缺页+8192
运行 B 的同一组:首触碰 468 纳秒/页(1257 ticks)、热 8 纳秒/页、EmptyWorkingSet 7.77 毫秒、再触碰 817 纳秒/页。首触碰 466468、重触碰 817944、热 8:重触碰比首触碰贵 1.7~2.0 倍,因为内核要把页从备用列表里捞出来重新建映射,而首触碰只是拿一张零页。注意两个缺页计数:首触碰 +8208(比页数多 16 次,那是遍历代码自己踩到的页),重触碰 +8192(一次不差)。
文件映射视图(同一个 32 MB 文件,WriteFile 写完 FlushFileBuffers 后再映射):
写 33554432 B 返回=1(19.7 ms → 1623 MB/s)FlushFileBuffers 返回=1(7.5 ms)err=0(操作成功完成。)
视图首次触碰 8192 页:10.64 ms → 1299 ns/页 (3492 ticks/页) 缺页+8207 工作集+32 MB 私有工作集+0 KB 提交配额+0 B
视图二次触碰同 8192 页:9 ns/页 (25 ticks/页) 缺页+0
运行 B:WriteFile 15.8 毫秒(2029 MB/s)、首触碰 8.24 毫秒 → 1006 纳秒/页、二次 8 纳秒/页。映射页的首触碰是匿名页的 2.2~2.8 倍(10061299 vs 466468 纳秒/页),但记账方式完全不同:工作集 +32 MB,而私有工作集 +0、提交配额 +0——这些页挂在系统页缓存上,谁都不算进本进程的私有额度。
一个必须留的口子:那 1006~1299 纳秒/页不能宣称”读盘”。文件是刚写进去的,页很可能还在系统缓存里,这一趟只是”从页缓存建映射”。要区分就得先清掉系统缓存,本机没有安全的办法(EmptyWorkingSet 只管本进程工作集,第四节已经证明它会顺带把别的页一起丢掉)。“真·硬缺页速率”本节未在本机实测。
六、COW:写的时候不花钱,因为副本在读的时候就建好了
CreateFileMapping(PAGE_WRITECOPY) + MapViewOfFile(FILE_MAP_COPY),8 MB / 2048 页(运行 A):
视图A=00000205bbf10000 State=4096(4096=COMMIT) AllocProtect=0x8 Protect=0x8(8=WRITECOPY) Type=0x40000(40000=MAPPED)
视图A 只读 2048 页:2.09 ms 缺页+2052 提交配额+0 B 工作集+8192 KB 私有工作集+232 KB(校验和=6144)
视图A 写每页 1 字节(2048 页):3.34 ms → 1629 ns/页 缺页+0 提交配额+0 B 工作集+0 KB
FlushViewOfFile 返回=1 err=0
重开文件读前 16 字节:返回=1 读到=16 字节 首 8 字节=03 0A 11 18 1F 26 2D 34(原始应为 03 0A 11 18 ...)
结论核对:文件第 0 字节仍等于原值 = 1(=1 说明 COW 写没有落进文件)
同 section 的只读视图B:[0]=03(原值 03,A 写的值 E7)[4096]=03(原值 03)
视图C(新 COW 视图) 上来就写 2048 页:5.65 ms → 2761 ns/页 缺页+2052 提交配额+0 B 工作集+8192 KB
视图C 读自己的 [0]=99(0x99=自己写的)
视图A 再读 [0]=E7(0xE7=A 自己写的,说明没被 C 影响)
[子] 只读视图=00000156ba480000 Protect=0x2 Type=0x40000,现在写它的第一个字节
父进程看子进程写只读视图:WaitForSingleObject=0 子进程退出码=0xC0000005
运行 B 同一组:只读 1.85 毫秒 / 缺页 +2052,写 2.86 毫秒 → 1394 纳秒/页 / 缺页 +0,视图 C 上来就写 4.98 毫秒 → 2429 纳秒/页 / 缺页 +2052。四件事被这两次一致的输出钉住了:
- A 的写一遍缺页 +0。如果 COW 是”写时才复制”,这里应该 +2052。所以 Windows 的 write-copy 视图是在首次触碰(读缺页)时就把页变成可写私有副本,写只是命中已有映射。代价体现在读那一遍的 2052 次缺页(1.85
2.09 毫秒 / 2048 页 ≈ 0.91.0 微秒/页,和第五节映射页首触碰同量级)。 - 写没有落进文件:
FlushViewOfFile之后重新打开文件读前 16 字节,仍是03 0A 11 18 1F 26 2D 34(buf[i]=i*7+3的原值)。 - 每个 COW 视图各一份副本:同 section 的只读视图 B 读到原值
03;视图 C 自己写0x99,视图 A 仍是自己写的0xE7。 - 写只读视图是异常不是失败:让子进程去写
Protect=0x2(PAGE_READONLY)的视图,父进程看到的退出码就是异常码0xC0000005。
页级的证据用 kernel32!K32QueryWorkingSetEx(MinGW 头里没声明,按 SDK 的位布局自己声明,只信 bit0 Valid 与 bit1-3 那位 3 位封顶的 ShareCount;两次运行逐字相同):
对照:已触碰私有页 Flags=0x5000041(Valid=1 Share=0) / 未触碰提交页 Flags=0x0(Valid=0 Share=0)
页0: v1(00000205b9bf0000) Valid=1 Share=7 Flags=0x4000802f | v2(00000205b9e50000) Valid=1 Share=7 Flags=0x4000802f
页3: v1(00000205b9bf3000) Valid=1 Share=7 Flags=0x4000802f | v2(00000205b9e53000) Valid=1 Share=7 Flags=0x4000802f
私有页的 ShareCount 字段是 0,两个只读视图指向的页是 7(3 位字段封顶,含义是”≥7 个引用”),未触碰的提交页整字为 0(Valid=0,压根不在工作集里)。所以”共享”在页表里的表现就是同一个页帧被多条 PTE 引用——这也是第一节”2 MB 大页少两级”之外的另一半:共享页靠引用计数,不靠复制。
顺带一个”内核帮你记脏页”的接口(两次运行相同):VirtualAlloc(..., MEM_WRITE_WATCH) 后写 16 页,GetWriteWatch 返回 16 个地址、粒度=4096 B;带 GWW_RESET_COUNTER 那一次返回 16 个、紧接着再查返回 0 个;再写 1 页后返回 1 个,地址正好等于基址 + 7×4096。Linux 的 soft-dirty(clear_refs + /proc/<pid>/pagemap 位 55)是同一层机制,本机未实测。
Linux 的 fork() COW(写缺页时复制并记进子进程的匿名额度)、madvise(MADV_DONTFORK)、/proc/<pid>/smaps 的 Private_Dirty/Shared_Dirty 未在本机实测;两者的可观测差别(Windows 副本挂在 section 上、进程私有提交配额不涨)只能按语义对照。
七、用户态自己实现请求分页要付多少钱
内核做”按需”是第五节的 466~468 纳秒/页。自己用 MEM_RESERVE + PAGE_NOACCESS + 一个 VEH 处理程序提交,同一个 8 MB / 2048 页:
用户态按需提交 2048 页:12.1 ms → 5892 ns/页,VEH 进入 2048 次,进程缺页计数+2054,校验和=0
第二遍(页已提交):9 ns/页
运行 B 是 10.3 毫秒 → 5029 纳秒/页、VEH 进入 2048 次、缺页 +2054、第二遍 9 纳秒/页。50295892 纳秒/页 = 内核按需提交的 10.812.6 倍:一次异常派发(《操作系统在做什么》量到 2638 纳秒)+ 一次 VirtualQuery + 一次 VirtualAlloc 系统调用(82~110 纳秒的陷入底价)+ 用户态来回。VEH 进入 2048 次说明没有多余开销,纯粹是”每页一趟用户态”。这就是”手写内存池/护栏式按需提交”在原理上必然输给内核自带机制的量化理由。
八、交换与 OOM:这台机器能看到的只有”提交账本”
申请:成功 本进程提交配额 32→545 MB
系统:提交总额=81032 MB 可用提交 22183→21669 MB;物理 65371 MB 总 / 35900 MB 可用;内存负载 45%
释放后:本进程提交配额=32 MB,系统可用提交=22182 MB(与申请前差 0 MB)
物理工作集侧:私有工作集从 340 KB → 340 KB(提交不等于占物理内存)
读法:申请 512 MB(一个字节都没碰)系统”可用提交”就从 22183 掉到 21669 MB(运行 B:22141→21628;两次的差分别是 514 和 513 MB,多出来那 1~2 MB 是同期其他进程的噪声),释放后回到 22182 / 22141。ullTotalPageFile=81032 MB 不是磁盘上有 81 GB 的页文件,而是”RAM + 页文件上限”这一本账;ullAvailPageFile 就是还能批多少。OOM 判定看的是这本账,不看物理内存(物理还空着 35.9 GB,负载 45%)。
再强调一次第五节那个反差:提交 512 MB,本进程私有工作集 340 KB → 340 KB 一动不动。
以下这些本节未在本机实测,原因是硬约束不是没试:真正的换出/换入速率(要把内存压到系统开始换页,本机同期还有别的 agent 在跑,不该这么做;也不改页文件设置)、madvise(MADV_FREE)/MADV_PAGEOUT、mincore、mlock、cgroup 内存上限与 memory.high 的节流曲线、PSI(/proc/pressure)、OOM killer 的 oom_score 计算。Windows 侧唯一勉强能碰的”被挤出去再摸回来”就是第四节的 654.69943.36 纳秒/页和第五节的 817944 纳秒/页,但那是工作集修剪,不是换页,两者不能互相替代。
小结
- 页表:
GetLargePageMinimum()=2097152、应用地址上界0x7ffffffeffff(2^47)→ 35 位页号 → 4 级;2 MB = 一张页表的覆盖范围。大页在本机要SeLockMemoryPrivilege,MEM_LARGE_PAGES两次都返回 1314,因此”大页对 TLB 的收益”未实测;反向页表需要内核视角,未实测。 - TLB 容量是量出来的:数据足迹压在 L2 内、只变页数 → 64 页 3.77
4.55 纳秒/次,512 页 16.2816.54(4.14.4 倍),2048 页 28.7731.99,4096 页 75.3783.00(1922 倍),8192 页 96.62111.60(2430 倍)。同一批页每页命中 1→16 次,单价 15.59→2.56 纳秒(6.1~6.4 倍):TLB 按访问收费,把数据塞进同一页是真优化。 - 冲刷:改被追的 256 页保护位 → 缺页 +256、工作集 -1048576 B、回来后 645
943 纳秒/次(热态 5.47 的 118172 倍),是”页被踢出工作集”而不是”TLB 条目没了”;改无关页是否连带冲刷本机判不出来(六次运行 1.0/1.0/1.1/1.6/3.6/6.3 倍)。MEM_DECOMMIT同样丢 1048576 B。 - 缺页分账:提交 32 MB 缺页 0;匿名首触碰 466~468 纳秒/页,重触碰 817~944,热 8;文件映射首触碰 1006~1299 纳秒/页(工作集 +32 MB、私有工作集与提交配额都 +0);“真读盘”的硬缺页速率未实测。
- COW:
FILE_MAP_COPY视图写 2048 页 缺页 +0、文件原字节未变(03 0A 11 18 1F 26 2D 34)、只读视图仍是原值、两个 COW 视图互不影响;QueryWorkingSetEx给出私有页ShareCount=0、共享页=7(封顶)、未触碰页Flags=0;写只读视图 = 子进程退出码 0xC0000005;GetWriteWatch粒度 4096 B、16 页记满、RESET 后归零。 - 用户态自己做请求分页 5029
5892 纳秒/页,是内核的 10.812.6 倍。OOM 看提交账本:提交 512 MB → 系统可用提交掉 514 MB,本进程私有工作集 340 KB 不变;真正的换页与 OOM 行为本机未实测。