计算机硬件与内存层次
Computer Hardware & Memory Hierarchy
从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。
CPU 的组成(卷 I 3.4.2–3.4.4)
- 冯·诺依曼结构:CPU 和内存通过总线相连,外设通过 I/O 端口或扩展槽连接。
- CPU 包含:ALU(整数运算和移位)、VPU(向量单元,即 SIMD)、内存控制器/MMU、寄存器组、控制单元 CU(取指、译码、分派;在流水线和超标量 CPU 中还负责分支预测和乱序调度)。如今的 CPU 实际上已没有独立的 FPU,标量浮点运算也由 VPU 完成,省下的晶体管用于更大的缓存和乱序逻辑。整数与浮点之间的转换已不像 FPU 时代那样昂贵,但仍应尽量避免。
- 寄存器用多端口 SRAM 实现,有名字而没有地址。除通用寄存器外,还有专用寄存器:指令指针 IP、栈指针 SP、基址指针 BP、状态寄存器(Z、C 等标志)。FPU 和 VPU 的寄存器更宽,比如 SSE2 的 128 位。
- 时钟:一条指令不一定只需一个周期。有的指令由多个 µ-op 组成;N 级流水线的单条指令延迟至少是 N 个周期,但每周期可以完成一条。所以 MIPS 和 FLOPS 只是平均值,主频不能直接换算成算力,只能靠基准测试来衡量。书中的例子是:3 GHz 的串行 CPU,如果一次浮点乘法平均要 6 个周期,理论上约为 0.5 GFLOPS。
内存、总线与字(卷 I 3.4.5–3.4.6)
ROM 与 RAM;SRAM 与 DRAM(DRAM 由电容构成,需要定期刷新,读取会破坏数据);多端口、SDRAM、DDR。地址总线宽度决定可寻址空间:16 位是 64 KiB,32 位是 4 GiB,64 位是 16 EiB。数据总线宽度决定每次能传输多少;读取比总线窄的数据仍要读完整的宽度,再掩码移位。“字 (word)”和“n 位机”的含义都依语境而定,例如 8088 内部是 16 位,但数据总线只有 8 位。
机器语言与汇编(卷 I 3.4.7)
ISA(指令集架构)与 ABI(调用约定等更高层的协议)不同。常见指令类别有:移动(load/store)、算术、位运算、移位/循环移位、比较(做减法只设标志位)、跳转与分支、压栈与出栈、调用与返回、中断、NOP。指令由操作码、操作数和选项(寻址方式)组成;RISC 多为定长编码,CISC 多为变长编码,VLIW 则把多个操作打包进一条很宽的指令。寻址方式有寄存器、立即数、直接、寄存器间接(即指针解引用)、相对(即数组下标访问)等。书中用 x86 汇编示范了 if (a > b) return a + b; else return 0;(cmp、jle、add、xor eax, eax)。
内存映射与虚拟内存(卷 I 3.5.1–3.5.2)
- 内存映射:把地址空间的不同区段映射到 ROM、RAM、内存映射 I/O 设备或显存(VRAM);另一种方式是端口映射 I/O。PS4 和 Xbox One 上 CPU 与 GPU 共享统一内存;PC 的显存在显卡上,通过 PCIe 与主存往来,这道物理隔阂正是图形 API 复杂的主要原因之一。
- 虚拟内存:程序使用虚拟地址,CPU 借助操作系统维护的页表把它重映射到物理页、磁盘交换文件,或者标记为未映射。这样既能使用多于物理内存的空间,也隔离了各个进程。页大小是 2 的幂,通常为 4 或 8 KiB。以 4 KiB 页为例,低 12 位是页内偏移,高 20 位是页号;MMU 查页表把虚拟页号换成物理页号,再与偏移拼起来。
- 缺页 (page fault):页表显示该页未映射到物理内存时,MMU 触发中断通知操作系统。访问未分配的页通常会崩溃并生成转储;访问被换出的页时,操作系统挂起程序、从交换文件读回该页,然后继续执行,程序本身察觉不到。通常只在内存压力大时才换出页,且尽量只换出最不常用的页,以免程序在内存页与磁盘页之间反复“颠簸 (thrashing)”。
- TLB:页表很大,所以在 MMU 内用一块小缓存保存最近用过的映射,其原理与普通缓存相同。
内存鸿沟与缓存层次(卷 I 3.5.3–3.5.4)
访问延迟取决于存储技术(SRAM 比 DRAM 快但更贵)、端口数和物理距离。8086 时代访问内存约 4 个周期,与执行指令相当;如今 Core i7 上寄存器指令只需 1–10 个周期,访问主存却要约 500 个周期,这就是内存鸿沟 (memory gap)。应对方法有三类:把更小更快的存储放在离核心更近的地方(缓存);在等待内存时让 CPU 做别的事(延迟隐藏);合理组织数据以减少访存。后两者见卷 I 第 4 章。
下图是书中图 3.28 的双核示意,延迟数值为书中给出的近似值:
flowchart LR C0["核心 0"] --> L10["L1(约 4 周期)"] C1["核心 1"] --> L11["L1(约 4 周期)"] L10 --> L2["共享 L2(约 25 周期)"] L11 --> L2 L2 --> RAM["主存(150+ 周期)"]
越往右容量越大、延迟越高;命中率与延迟之间存在根本的权衡。注意书中两处数字口径不同:3.5.3.1 正文说 Core i7 访问主存“约 500 个周期”,图 3.28 标注“150+ 周期”,都是数量级上的近似,具体取决于硬件。
- 局部性分为空间局部性和时间局部性,因此缓存以连续的缓存行 (cache line) 为单位搬运数据。
- 地址拆分:设缓存共 字节、行大小 字节,主存地址的低 位是行内偏移,接下来的 位是行索引,其余高位是标签 (tag),用来标记这一行来自主存中哪个缓存大小的块。以 32 KiB 缓存、128 字节行为例,共 256 行,主存地址
0x203落在第 行。 - 直接映射容易出现两个无关的块互相“乒乓”驱逐的病态情况;N 路组相联让每个地址可以映射到 N 行之一,路数越多通常越好。替换策略有 NMRU(不驱逐最近使用的那一路)、FIFO(直接映射时只能如此)、LRU、LFU、伪随机。
- 多级缓存:L1 小而快,L2 更大更慢,有的 PC 还有 L3、L4。L1 一定把指令缓存 I 分开,更高层的缓存一般不分。
- 写策略:写直达 (write-through) 每次写都同步到主存;写回 (write-back) 只在脏行被驱逐或显式刷新时才写回主存。
- 一致性:多核时要保证各核的缓存与主存“看起来”一致,常见协议有 MESI、MOESI、MESIF(第 4 章详述 MESI)。
- 避免缓存未命中:对 D,热循环的代码要尽量小,内层循环少调用函数,内联要适度,因为过度内联会让代码膨胀,装不进缓存。
UMA 与 NUMA(卷 I 3.5.5)
- UMA:所有核心看到同一块主存,靠缓存层次缓解延迟,但核心会争用共享缓存和主存。例如 PS4 有两个各 4 核的簇,每簇共享一个 L2。
- NUMA:每个核心有一块显式访问的本地存储 (local store),与主存之间通过 DMA 传输。PS3 是典型:PPU 独占 256 MiB 主存,RSX 独占 256 MiB 显存,每个 SPU 有 256 KiB 本地存储,各地址空间完全隔离(8 个 SPU 中只有 6 个可供游戏使用)。
- PS2 scratchpad:EE 芯片上有 16 KiB 内存映射、不经缓存的区域。它的主要好处不在于低延迟,而在于访问时不占用系统总线:DMA 在主存和 VU 之间传输数据时,EE 可以同时在 scratchpad 上计算。
与其他来源的关系
GPP 的 数据局部性 从模式的角度给出了同样的结论(连续数组、冷热分离、避免指针追逐),GEA 则从硬件机制上解释了原因,两者一致。GPU 一侧的显存与带宽问题见 RTR4 的 GPU 内存、缓冲压缩与多重缓冲。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3 章 3.4–3.5 节