计算机体系结构


文档摘要

计算机体系结构 计算机体系结构(computer architecture)研究的是如何构建执行指令的机器。本文件涵盖数制、IEEE 754、逻辑门、CPU 设计、指令集架构、流水线、存储层次和虚拟内存——这是每个程序、每个框架、每个 AI 模型最终运行其上的硬件基础。 每一个神经网络、每一次训练循环、每一次推理调用,最终都会变成一串在晶体管中流动的电信号。对认真的机器学习从业者来说,理解硬件不是可选项:它解释了为什么矩阵乘法那么快、为什么内存是瓶颈、为什么 GPU 统治着 AI 训练、为什么对缓存友好的代码可以比朴素代码快 100 倍。 数制 计算机把一切都表示成二进制(binary,基 2):由 0 和 1 组成的序列。每一位是一个比特(bit)。8 个比特组成一个字节(byte)。

计算机体系结构

计算机体系结构(computer architecture)研究的是如何构建执行指令的机器。本文件涵盖数制、IEEE 754、逻辑门、CPU 设计、指令集架构、流水线、存储层次和虚拟内存——这是每个程序、每个框架、每个 AI 模型最终运行其上的硬件基础。

  • 每一个神经网络、每一次训练循环、每一次推理调用,最终都会变成一串在晶体管中流动的电信号。对认真的机器学习从业者来说,理解硬件不是可选项:它解释了为什么矩阵乘法那么快、为什么内存是瓶颈、为什么 GPU 统治着 AI 训练、为什么对缓存友好的代码可以比朴素代码快 100 倍。

数制

  • 计算机把一切都表示成二进制(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{-}1001A\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 是经过偏置的指数。

IEEE 754 float32 布局:1 位符号、8 位指数、23 位尾数

- **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 设计:完整的指数范围防止训练时溢出,而降低的精度对梯度更新来说可以接受。
  • 浮点运算不是精确的。在 float64 中 0.1 + 0.2 \neq 0.3(结果是 0.30000000000000004)。这是因为 0.1 没有精确的二进制表示,就像 1/3 没有精确的十进制表示一样。这些误差在数百万次运算中累积(比如梯度下降),可能引发数值不稳定,所以才有了损失缩放(loss scaling,第 6 章)和 Kahan 求和这类技术。

逻辑门

  • 所有计算最终都归结为逻辑门(logic gates):实现布尔运算(也就是第 1 节中的命题逻辑)的物理电路。

  • 基本逻辑门:

    • AND:仅当两个输入都为 1 时输出才为 1。
    • OR:只要至少一个输入为 1 就输出 1。
    • NOT(反相器):翻转输入。
    • NAND(NOT-AND):通用门。任何其它门都可以只用 NAND 门搭出来。这也是 NAND 成为数字电路基本构件的原因。
    • XOR(异或):当两个输入不同时输出 1。对加法至关重要(二进制加法的本位和就是 XOR),也是密码学的基石。
  • **半加器(half adder)**用 XOR(和)和 AND(进位)把两个单比特相加。**全加器(full adder)**把两个比特加上一个进位输入相加,多个全加器串接起来就构成了 n 位加法器。CPU 执行整数加法就是这样:一串简单逻辑门的级联。

  • **多路选择器(multiplexer,MUX)**根据控制信号从多个输入中选一个。用 n 个控制位,可以从 2^n 个输入中选择。多路选择器是 if-else 链的硬件等价物,在 CPU 数据通路中被大量用于路由数据。

  • 现代处理器里有数十亿个晶体管,每个都像一个微型开关。一个晶体管要么导通(导电,表示 1)要么截止(不导电,表示 0)。门由晶体管构成,加法器由门构成,ALU 由加法器构成,CPU 由 ALU 构成。整个计算体系就建立在这套基础之上。

CPU 体系结构

  • **中央处理器(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 执行什么运算、使用哪些寄存器。

  • 指令周期(取指-译码-执行)每秒重复数十亿次:

    1. 取指(fetch):从 PC 所指地址的内存中读出指令。
    2. 译码(decode):判断这条指令做什么(加法?从内存装载?分支?)以及使用哪些操作数。
    3. 执行(execute):完成运算(ALU 计算、访存或分支)。
    4. 递增 PC(除非这条指令是分支/跳转)。
  • 一颗运行在 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 指令。更简单的指令换来更快的时钟频率和更易于实现的流水线。

    • ARM:移动设备上占主导的 RISC ISA,在服务器和笔记本上也越来越常见(Apple M 系列芯片就是 ARM)。ARM 的能效使它非常适合电池供电和散热受限的设备。
    • RISC-V:一种开源的 RISC ISA。任何人都可以免授权费地设计 RISC-V 芯片。在嵌入式系统、研究和 AI 加速器中采用日益增多。
  • CISC 与 RISC 的界限已经模糊:现代 x86 CPU 内部会把复杂的 CISC 指令译码成更简单的微操作(本质上是内部 RISC),从而兼得两者之长。

流水线

  • 没有流水线时,CPU 必须把一条指令完整执行完才开始下一条。这会浪费硬件:ALU 在执行时,取指和译码单元却闲置着。

CPU 流水线:指令在取指、译码、执行、访存、写回各阶段间重叠

  • **流水线(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)来解决。

存储层次

  • 计算机存储中存在一个根本矛盾:快的存储又贵又小,便宜的存储又慢又大。**存储层次(memory hierarchy)通过利用局部性(locality)**来弥合这个鸿沟:程序倾向于反复访问同一批数据(时间局部性),也倾向于访问彼此相近的数据(空间局部性)。

存储层次金字塔:顶端是寄存器(快、小),底部是 HDD(慢、大)

  • 从最快到最慢,层次依次为:

    • 寄存器:访问 ~0.3 ns,总量 ~KB。位于 CPU 内部。
    • L1 缓存:~1 ns,每核 32-64 KB。分为指令缓存和数据缓存。
    • L2 缓存:~4 ns,每核 256 KB-1 MB。
    • L3 缓存:~10 ns,多核共享 8-64 MB。
    • RAM(DRAM):~50-100 ns,8-512 GB。主存。
    • SSD:~10-100 μs,256 GB-8 TB。持久存储。
    • HDD:~5-10 ms,1-20 TB。机械式,随机访问非常慢。
  • 寄存器与 RAM 之间的速度差距约为 300 倍。寄存器与磁盘之间约为 3000 万倍。缓存层次掩盖了这个鸿沟:如果 CPU 需要的数据在 L1 缓存中(一次缓存命中 cache hit),访问就很快。如果没有(一次缓存未命中 cache miss),CPU 就要停顿,去更慢的层级取数据。

  • **缓存相联度(cache associativity)**决定一个内存地址可以放在缓存的什么位置:

    • 直接映射(direct-mapped):每个地址只能映射到唯一一条缓存行。简单但容易冲突。
    • 全相联(fully associative):任何地址可放在任何位置。灵活但搜索代价高。
    • 组相联(set-associative,k 路):每个地址映射到 k 个位置组成的一个组。这是真实 CPU 采用的实用折中(通常 4 路或 8 路)。
  • **缓存一致性(cache coherence)**保证所有 CPU 核看到一致的内存视图。当核 1 写入一个被核 2 缓存的地址时,一致性协议(如 MESI)会作废或更新核 2 的副本。这对并发编程(第 4 节)至关重要,也是共享内存并行之所以困难的原因之一。

  • 对 ML 从业者,存储层次解释了为什么:

    • 矩阵运算应当顺序地访问内存(行主序与列主序的布局差异很重要)。
    • batch size 会影响性能:更大的 batch 能摊薄内存延迟。
    • 混合精度(float16/bfloat16)让有效内存带宽翻倍,而带宽往往是瓶颈。

虚拟内存

  • **虚拟内存(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 满时该驱逐哪一页:

    • LRU(最近最少使用,Least Recently Used):驱逐最久未被访问的页。对大多数工作负载而言在实践中接近最优。在硬件中用时钟算法(clock algorithm)(一个带引用位的循环列表)来近似。
    • FIFO:驱逐最老的页。简单但可能驱逐频繁使用的页。
    • 最佳(optimal,Bélády 算法):驱逐将来最长时间不会被用到的页。无法实现(需要预知未来),但作为理论基准很有用。
  • 虚拟内存还提供隔离(isolation):每个进程有自己的虚拟地址空间。一个进程里的 bug 不会破坏另一个进程的内存,因为它们的虚拟地址映射到不同的物理帧。这是操作系统安全与稳定性的根基。

I/O、中断与 DMA

  • 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 在等待时不会闲置。

  • 中断机制:

    1. 设备通过一根硬件线发出中断信号。
    2. CPU 完成当前指令,把当前状态(寄存器、程序计数器)保存到栈上。
    3. CPU 在**中断向量表(interrupt vector table,一张函数指针表,每种中断一个)**中查到中断处理程序地址。
    4. 处理程序在内核态运行,处理 I/O,然后返回。
    5. 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 访问统一成一种机制,简化了硬件和软件。

编程练习(使用 CoLab 或 notebook)

  1. 探索 IEEE 754 浮点表示。把一个 float 转成它的二进制表示,观察符号位、指数和尾数字段。
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]}...")
  1. 模拟一个直接映射缓存。对一串内存访问统计命中和未命中。
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])
  1. 演示浮点运算为何不满足结合律。展示某些情形下 (a + b) + c \neq a + (b + c)
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")

作者与出处
原作者: HenryNdubuaku
来源:HenryNdubuaku
许可证:Apache-2.0
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: HenryNdubuaku 转发
评论区 (0)
U