计算机体系结构 计算机体系结构(computer architecture)研究的是如何构建执行指令的机器。本文件涵盖数制、IEEE 754、逻辑门、CPU 设计、指令集架构、流水线、存储层次和虚拟内存——这是每个程序、每个框架、每个 AI 模型最终运行其上的硬件基础。 每一个神经网络、每一次训练循环、每一次推理调用,最终都会变成一串在晶体管中流动的电信号。对认真的机器学习从业者来说,理解硬件不是可选项:它解释了为什么矩阵乘法那么快、为什么内存是瓶颈、为什么 GPU 统治着 AI 训练、为什么对缓存友好的代码可以比朴素代码快 100 倍。 数制 计算机把一切都表示成二进制(binary,基 2):由 0 和 1 组成的序列。每一位是一个比特(bit)。8 个比特组成一个字节(byte)。
计算机体系结构(computer architecture)研究的是如何构建执行指令的机器。本文件涵盖数制、IEEE 754、逻辑门、CPU 设计、指令集架构、流水线、存储层次和虚拟内存——这是每个程序、每个框架、每个 AI 模型最终运行其上的硬件基础。
计算机把一切都表示成二进制(binary,基 2):由 0 和 1 组成的序列。每一位是一个比特(bit)。8 个比特组成一个字节(byte)。一个二进制数 b_{n-1} b_{n-2} \ldots b_1 b_0 的值是 \sum_{i=0}^{n-1} b_i \cdot 2^i。
例如,1011_2 = 1 \cdot 8 + 0 \cdot 4 + 1 \cdot 2 + 1 \cdot 1 = 11_{10}。
**十六进制(hexadecimal,基 16)**是二进制的一种紧凑记法。每个十六进制数字代表 4 个比特:0\text{-}9 对应 0000\text{-}1001,A\text{-}F 对应 1010\text{-}1111。所以 \text{0xFF} = 1111\,1111_2 = 255_{10}。内存地址和颜色代码通常用十六进制书写。
**二进制补码(two's complement)**用来表示带符号整数。对一个 n 位数,最高位的权重是 -2^{n-1} 而不是 +2^{n-1}。一个 8 位二进制补码的取值范围是 -128 到 +127。取反一个数的方法是:所有位取反再加 1。这种表示让加法和减法用同一套硬件电路,这也是它被普遍采用的原因。
IEEE 754 浮点数把实数表示成 (-1)^s \times 1.m \times 2^{e-\text{bias}},其中 s 是符号位,m 是尾数(小数部分),e 是经过偏置的指数。
- **float32**(单精度):1 符号位 + 8 指数位 + 23 尾数位 = 32 位。范围:$\approx \pm 3.4 \times 10^{38}$,精度:约 7 位十进制有效数字。 - **float64**(双精度):1 + 11 + 52 = 64 位。范围:$\approx \pm 1.8 \times 10^{308}$,精度:约 15 位十进制有效数字。 - **float16**(半精度):1 + 5 + 10 = 16 位。范围和精度都有限,但只用一半的内存和带宽。在 ML 训练中广泛使用(混合精度,第 6 章)。 - **bfloat16**:1 + 8 + 7 = 16 位。指数范围与 float32 相同,但精度更低。由 Google 专为 ML 设计:完整的指数范围防止训练时溢出,而降低的精度对梯度更新来说可以接受。
所有计算最终都归结为逻辑门(logic gates):实现布尔运算(也就是第 1 节中的命题逻辑)的物理电路。
基本逻辑门:
**半加器(half adder)**用 XOR(和)和 AND(进位)把两个单比特相加。**全加器(full adder)**把两个比特加上一个进位输入相加,多个全加器串接起来就构成了 n 位加法器。CPU 执行整数加法就是这样:一串简单逻辑门的级联。
**多路选择器(multiplexer,MUX)**根据控制信号从多个输入中选一个。用 n 个控制位,可以从 2^n 个输入中选择。多路选择器是 if-else 链的硬件等价物,在 CPU 数据通路中被大量用于路由数据。
现代处理器里有数十亿个晶体管,每个都像一个微型开关。一个晶体管要么导通(导电,表示 1)要么截止(不导电,表示 0)。门由晶体管构成,加法器由门构成,ALU 由加法器构成,CPU 由 ALU 构成。整个计算体系就建立在这套基础之上。
**中央处理器(Central Processing Unit,CPU)**执行指令。它的核心部件包括:
ALU(算术逻辑单元,Arithmetic Logic Unit):执行整数算术(加、减、乘)和逻辑运算(AND、OR、XOR、移位)。这是真正进行计算的地方,由前面描述的逻辑门搭建而成。
寄存器(registers):CPU 内部极小、极快的存储位置。现代 CPU 有数十个通用寄存器,每个存放一个字(64 位 CPU 上是 64 位)。寄存器是系统中速度最快的存储:访问大约需要 ~0.3 纳秒。
程序计数器(Program Counter,PC):存放下一条要执行指令的内存地址。
控制单元(control unit):解码指令并指挥数据通路,告诉 ALU 执行什么运算、使用哪些寄存器。
指令周期(取指-译码-执行)每秒重复数十亿次:
一颗运行在 4 GHz 的 CPU 每秒执行 40 亿个周期。每个周期 0.25 纳秒。在这段时间里光大约走 7.5 厘米,所以芯片的物理尺寸很关键:信号无法在一个周期内穿越一块大芯片。
**指令集架构(Instruction Set Architecture,ISA)**是硬件与软件之间的契约:它定义了 CPU 能理解的指令、寄存器组、内存模型和编码格式。
CISC(复杂指令集计算机,Complex Instruction Set Computer):指令可以很复杂、长度可变,并可直接访问内存。一条指令可能把两个内存中的值相乘并把结果存回去。x86(Intel/AMD)是占据主导地位的 CISC ISA,驱动着大多数台式机和服务器。它的向后兼容性(现代 x86 CPU 仍能运行 1980 年代的代码)既是它的优势,也是它的负担。
RISC(精简指令集计算机,Reduced Instruction Set Computer):指令简单、定长,且只在寄存器上操作。访存需要单独的 load/store 指令。更简单的指令换来更快的时钟频率和更易于实现的流水线。
CISC 与 RISC 的界限已经模糊:现代 x86 CPU 内部会把复杂的 CISC 指令译码成更简单的微操作(本质上是内部 RISC),从而兼得两者之长。
**流水线(pipelining)**让指令的执行相互重叠,就像流水线一样。当指令 1 在执行时,指令 2 在译码,指令 3 在取指。一个 5 级流水线(取指、译码、执行、访存、写回)可以同时有 5 条指令在飞。
吞吐率接近每周期一条指令(尽管每条指令要花 5 个周期才能完成)。这与 ML 中的流水线是同一个原理:数据并行把计算和通信重叠起来(第 6 章)。
**冒险(hazards)**是流水线失效的情形:
数据冒险(data hazard):指令 2 需要指令 1 尚未产生的结果。"Add R1, R2, R3"后接"Sub R4, R1, R5"——第二条指令需要 R1,而第一条还在算它。**前递(forwarding,旁路)**通过把结果直接从一个流水级路由到另一个、不等写回阶段来解决这个问题。
控制冒险(control hazard):一条分支指令(if-else)意味着 CPU 在分支结果确定之前不知道接下来该取哪条指令。**分支预测(branch prediction)**猜测分支会走哪条路,并沿着预测路径投机地取指。现代预测器准确率超过 95%,使用历史表和类似神经网络的模式匹配。一次预测错误大约要付出 15 个周期的代价(必须冲刷并重启流水线)。
结构冒险(structural hazard):两条指令同时需要同一硬件资源(比如都要用内存端口)。通过复制资源或插入停顿(stall)来解决。
从最快到最慢,层次依次为:
寄存器与 RAM 之间的速度差距约为 300 倍。寄存器与磁盘之间约为 3000 万倍。缓存层次掩盖了这个鸿沟:如果 CPU 需要的数据在 L1 缓存中(一次缓存命中 cache hit),访问就很快。如果没有(一次缓存未命中 cache miss),CPU 就要停顿,去更慢的层级取数据。
**缓存相联度(cache associativity)**决定一个内存地址可以放在缓存的什么位置:
**缓存一致性(cache coherence)**保证所有 CPU 核看到一致的内存视图。当核 1 写入一个被核 2 缓存的地址时,一致性协议(如 MESI)会作废或更新核 2 的副本。这对并发编程(第 4 节)至关重要,也是共享内存并行之所以困难的原因之一。
对 ML 从业者,存储层次解释了为什么:
**虚拟内存(virtual memory)**给每个进程一种错觉:自己拥有一个又大又连续的内存空间,哪怕物理 RAM 有限且被多个进程共享。
地址空间被划分成定长的页(pages,通常 4 KB)。**页表(page table)**把虚拟页号映射到物理帧号。当程序访问虚拟地址 0x1234 时,CPU 通过查页表把它翻译成物理地址。
**转译后备缓冲器(Translation Lookaside Buffer,TLB)**是页表项的缓存。由于页表放在 RAM 中(慢),TLB 把最近用过的翻译存放在快速硬件里。一次 TLB 未命中需要遍历内存中的页表(走页表),耗费数百个周期。
当程序访问一个不在物理 RAM 中的页时就会发生缺页(page fault)。操作系统从磁盘把这页装入(交换),要花数百万个周期。过多的缺页(颠簸 thrashing)会摧毁性能。这也是为什么 ML 训练需要足够的 RAM 来容纳模型、优化器状态和一批合理大小的数据。
**页面置换(page replacement)**算法决定 RAM 满时该驱逐哪一页:
虚拟内存还提供隔离(isolation):每个进程有自己的虚拟地址空间。一个进程里的 bug 不会破坏另一个进程的内存,因为它们的虚拟地址映射到不同的物理帧。这是操作系统安全与稳定性的根基。
CPU 需要与外部世界通信:磁盘、网卡、键盘、GPU。这就是 I/O 子系统(I/O subsystem)。
程序控制 I/O(programmed I/O,轮询 polling):CPU 在一个循环里反复检查设备的状态寄存器,等数据就绪。简单但白白让 CPU 在原地空转,而不是去做有用的工作。
中断驱动 I/O(interrupt-driven I/O):数据就绪时设备发出一个硬件中断(interrupt)。CPU 继续正常执行,直到中断到来,然后运行一个**中断处理程序(interrupt handler,一个内核函数)**来处理数据。这比轮询高效得多,因为 CPU 在等待时不会闲置。
中断机制:
这与上下文切换(第 3 节)是同样的保存/恢复模式,只不过是由硬件而非定时器触发的。
DMA(直接内存访问,Direct Memory Access):对于大数据传输(读盘、网络包、GPU 内存拷贝),让 CPU 一个字节一个字节地拷贝太浪费。DMA 控制器直接在设备和 RAM 之间搬运数据,无需 CPU 介入。CPU 设置好这次传输(源、目的、大小),DMA 控制器去完成,结束时 CPU 收到一个中断。
DMA 对 ML 至关重要:当你调用 model.to('cuda') 时,数据就是经由 DMA 通过 PCIe 总线从系统 RAM 传到 GPU 内存的。训练时,GPU 之间的梯度同步使用基于 DMA 的 RDMA(远程 DMA)来实现高带宽、低延迟的传输(第 6 章)。
**总线(bus)**把 CPU 与内存和 I/O 设备连起来。现代系统对高速设备(GPU、NVMe SSD、网卡)使用 PCIe(Peripheral Component Interconnect Express)。PCIe 4.0 每个 x16 插槽提供约 32 GB/s;PCIe 5.0 翻倍。总线带宽常常成为 GPU 训练的瓶颈:GPU 算得比喂数据还快。
MMIO(内存映射 I/O,Memory-Mapped I/O):设备寄存器被映射到内存地址。CPU 用普通的 load/store 指令读写这些地址,硬件把访问路由到设备而不是 RAM。这把内存访问和 I/O 访问统一成一种机制,简化了硬件和软件。
import struct def float_to_bits(f): """Show the IEEE 754 binary representation of a float32.""" packed = struct.pack('>f', f) bits = ''.join(f'{byte:08b}' for byte in packed) sign = bits[0] exponent = bits[1:9] mantissa = bits[9:] return sign, exponent, mantissa for val in [1.0, -1.0, 0.1, 0.5, 3.14, float('inf'), float('nan')]: s, e, m = float_to_bits(val) print(f"{val:>10} sign={s} exp={e} ({int(e, 2) - 127:>4d}) mantissa={m[:10]}...")
def simulate_cache(accesses, cache_size=8, block_size=1): """Simulate a direct-mapped cache.""" cache = [None] * cache_size hits, misses = 0, 0 for addr in accesses: cache_line = addr % cache_size if cache[cache_line] == addr: hits += 1 status = "HIT " else: misses += 1 cache[cache_line] = addr status = "MISS" print(f" Access {addr:3d} → line {cache_line}: {status}") print(f"\nHits: {hits}, Misses: {misses}, Hit rate: {hits/(hits+misses):.1%}") # 顺序访问(局部性好) print("Sequential access:") simulate_cache([0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3]) # 跨步访问(冲突未命中) print("\nStrided access (stride = cache size):") simulate_cache([0, 8, 0, 8, 0, 8])
import jax.numpy as jnp a = jnp.float32(1e8) b = jnp.float32(1.0) c = jnp.float32(-1e8) left = (a + b) + c # (1e8 + 1) + (-1e8) right = a + (b + c) # 1e8 + (1 + (-1e8)) print(f"(a + b) + c = {left}") # 应为 1.0 print(f"a + (b + c) = {right}") # 可能丢掉那个 1.0 print(f"Equal: {left == right}") print(f"\nThe 1.0 is lost when added to 1e8 because float32 has only ~7 digits of precision")