# 计算机体系结构

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

- 每个神经网络、每个训练循环、每次推理调用最终都会变成流经晶体管的电信号序列。对于严肃的机器学习从业者来说，理解硬件不是可选的：它解释了为什么矩阵乘法很快，为什么内存是瓶颈，为什么GPU主导AI训练，以及为什么缓存友好的代码可以比朴素代码快100倍。

## 数制

- 计算机将所有内容表示为**二进制**（基2）：0和1的序列。每个数字是一个**比特**。8个比特为一组称为一个**字节**。二进制数 $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}$。

- **十六进制**（基16）是二进制的紧凑表示法。每个十六进制数字代表4个比特：$0\text{-}9$ 映射到 $0000\text{-}1001$，$A\text{-}F$ 映射到 $1010\text{-}1111$。因此 $\text{0xFF} = 1111\,1111_2 = 255_{10}$。内存地址和颜色代码通常用十六进制书写。

- **补码**表示有符号整数。对于$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个尾数位](../images/ieee754_float.svg)

    - **float32**（单精度）：1个符号 + 8个指数 + 23个尾数 = 32位。范围：$\approx \pm 3.4 \times 10^{38}$，精度：$\approx 7$位十进制数字。
    - **float64**（双精度）：1个符号 + 11个指数 + 52个尾数 = 64位。范围：$\approx \pm 1.8 \times 10^{308}$，精度：$\approx 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$没有精确的十进制表示一样。在数百万次操作（如梯度下降）中积累这些误差可能导致数值不稳定，这就是为什么存在像损失缩放（第6章）和Kahan求和法这样的技术。

## 逻辑门

- 所有计算都可以归结为**逻辑门**：实现布尔运算（来自文件1的命题逻辑）的物理电路。

- 基本门：
    - **与门**（AND）：仅当两个输入都为1时输出为1。
    - **或门**（OR）：至少一个输入为1时输出为1。
    - **非门**（NOT，反相器）：翻转输入。
    - **与非门**（NAND，NOT-AND）：通用门。任何其他门都可以仅由与非门构建。这就是为什么与非门是数字电路的基本构建块。
    - **异或门**（XOR，异或）：输入不同时输出为1。对于加法（二进制加法的和位就是XOR）和加密至关重要。

- **半加器**使用XOR（和）和AND（进位）相加两个单比特。**全加器**相加两个比特加上一个进位输入，可以串联起来创建$n$位加法器。这就是CPU执行整数加法的方式：一系列简单逻辑门的级联。

- **多路选择器**（MUX）根据控制信号从多个输入中选择一个。使用$n$个控制位，可以从$2^n$个输入中选择。多路选择器是if-else链的硬件等价物，广泛用于CPU数据通路中路由数据。

- 现代处理器包含数十亿个晶体管，每个晶体管充当一个微小的开关。晶体管要么导通（导电，表示1），要么不导通（不导电，表示0）。门由晶体管构成，加法器由门构成，ALU由加法器构成，CPU由ALU构成。整个计算层级就建立在这个基础之上。

## CPU架构

- **中央处理器（CPU）**执行指令。其核心组件：

    - **ALU**（算术逻辑单元）：执行整数算术（加、减、乘）和逻辑运算（AND、OR、XOR、移位）。这里是实际计算发生的地方，由上述逻辑门构建而成。

    - **寄存器**：CPU内部微小、超快的存储位置。现代CPU有数十个通用寄存器，每个寄存器保存一个字（在64位CPU上为64位）。寄存器是系统中速度最快的存储器：访问时间约~0.3纳秒。

    - **程序计数器（PC）**：保存下一条要执行指令的内存地址。

    - **控制单元**：解码指令并编排数据通路，告诉ALU执行什么操作以及使用哪些寄存器。

- **指令周期**（取指-译码-执行）每秒重复数十亿次：

    1. **取指**：从PC中的地址读取指令。
    2. **译码**：确定指令的功能（加法？从内存加载？分支？）及其使用的操作数。
    3. **执行**：执行操作（ALU计算、内存访问或分支）。
    4. 增加PC（除非指令是分支/跳转）。

- 运行在4 GHz的CPU每秒执行40亿个周期。每个周期耗时0.25纳秒。在这段时间内，光传播约7.5厘米，这就是芯片物理大小重要的原因：信号无法在一个周期内穿过大芯片。

## 指令集架构

- **指令集架构（ISA）**是硬件和软件之间的契约：它定义了CPU能理解的指令、寄存器集、内存模型和编码格式。

- **CISC**（复杂指令集计算机）：指令可以复杂、变长，并可以直接访问内存。一条指令可以乘法两个内存值并存储结果。**x86**（Intel/AMD）是占主导地位的CISC ISA，驱动着大多数桌面和服务器。其向后兼容性（现代x86 CPU仍然运行1980年代的代码）既是优势也是负担。

- **RISC**（精简指令集计算机）：指令简单、定长，且仅操作寄存器。内存访问需要单独的加载/存储指令。更简单的指令可实现更快的时钟速度和更易实现的流水线。

    - **ARM**：移动设备的主要RISC ISA，并越来越多地用于服务器和笔记本电脑（Apple M系列芯片就是ARM）。ARM的能效使其非常适合电池供电和热受限设备。
    - **RISC-V**：一个开源的RISC ISA。任何人都可以设计RISC-V芯片而无需许可费。在嵌入式系统、研究和AI加速器中的采用正在增长。

- CISC与RISC的区别已经模糊：现代x86 CPU内部将复杂的CISC指令解码为更简单的微操作（本质上是内部RISC），从而获得两方面的优势。

## 流水线

- 没有流水线时，CPU完全完成一条指令后才开始下一条。这会浪费硬件：当ALU执行时，取指和译码单元处于空闲状态。

![CPU流水线：指令在取指、译码、执行、访存和写回阶段重叠](../images/cpu_pipeline.svg)

- **流水线**使指令执行重叠，如同装配线。当指令1在执行时，指令2在译码，指令3在被取指。一个5级流水线（取指、译码、执行、访存、写回）可以同时有5条指令在执行中。

- 吞吐量接近每周期一条指令（尽管每条指令需要5个周期才能完成）。这与ML中的流水线原理相同：数据并行性使计算和通信重叠（第6章）。

- **冒险**是流水线被破坏的情况：

    - **数据冒险**：指令2需要指令1尚未产生的结果。"Add R1, R2, R3"后跟"Sub R4, R1, R5"——第二条指令需要R1，而第一条指令仍在计算。**转发**（旁路）通过将结果直接从一级流水线路由到另一级，无需等待写回阶段来解决这个问题。

    - **控制冒险**：分支指令（if-else）意味着CPU在分支解析之前不知道应该取指哪条下一条指令。**分支预测**猜测分支将走哪条路径，并推测性地沿预测路径取指。现代预测器准确率超过95%，使用历史表和类似神经网络的模式匹配。一次预测错误代价约~15个周期（流水线必须被清空并重启）。

    - **结构冒险**：两条指令同时需要相同的硬件资源（例如，都需要内存端口）。通过复制资源或插入停顿来解决。

## 存储器层次结构

- 计算机内存中的根本矛盾：快速内存昂贵且容量小，廉价内存缓慢但容量大。**存储器层次结构**通过利用**局部性**来弥合这一差距：程序倾向于重复访问相同的数据（时间局部性）并访问附近的数据（空间局部性）。

![存储器层次结构金字塔：寄存器在顶部（快速、小）到HDD在底部（慢、大）](../images/memory_hierarchy.svg)

- 层次结构，从最快到最慢：

    - **寄存器**：~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倍。寄存器和磁盘之间约为30,000,000倍。缓存层次结构隐藏了这一差距：如果CPU需要的数据在L1缓存中（**缓存命中**），访问很快。如果不在（**缓存未命中**），CPU停顿，同时从更慢的层级获取数据。

- **缓存关联度**决定内存地址可以存储在缓存中的位置：
    - **直接映射**：每个地址映射到恰好一个缓存行。简单但会导致冲突。
    - **全关联**：任何地址可以放在任何位置。灵活但搜索成本高。
    - **组关联**（$k$路）：每个地址映射到一组$k$个位置。实际CPU中使用的实用折衷方案（通常为4路或8路）。

- **缓存一致性**确保所有CPU核心看到一致的内存视图。当核心1写入一个核心2已缓存的内存地址时，一致性协议（如MESI）会使核心2的副本失效或更新。这对并发编程（文件4）至关重要，也是共享内存并行性困难的原因之一。

- 对于ML从业者，存储器层次结构解释了为什么：
    - 矩阵运算应按顺序访问内存（行优先与列优先的布局很重要）。
    - 批量大小会影响性能：更大的批次分摊内存延迟。
    - 混合精度（float16/bfloat16）使有效内存带宽翻倍，而内存带宽往往是瓶颈。

## 虚拟内存

- **虚拟内存**使每个进程仿佛拥有自己独立、连续的大内存空间，即使物理RAM是有限的并在进程间共享。

- 地址空间被划分为固定大小的**页**（通常为4 KB）。**页表**将虚拟页号映射到物理帧号。当程序访问虚拟地址0x1234时，CPU通过查找页表将其转换为物理地址。

- **转译后备缓冲器（TLB）**是页表项的缓存。由于页表位于RAM中（慢速），TLB在快速硬件中存储最近使用的转译结果。TLB未命中需要遍历内存中的页表，耗费数百个周期。

- 当程序访问一个不在物理RAM中的页时，发生**缺页**。OS从磁盘加载该页（交换），耗费数百万个周期。过多的缺页（**系统颠簸**）会严重损害性能。这就是为什么ML训练需要足够的RAM来容纳模型、优化器状态和合理的数据批次。

- **页面置换**算法决定当RAM满时应换出哪个页面：
    - **LRU**（最近最少使用）：换出最长时间未被访问的页面。在实践中对大多数工作负载最优。在硬件中通过**时钟算法**（带引用位的循环链表）近似实现。
    - **FIFO**：换出最旧的页面。简单但可能换出频繁使用的页面。
    - **最优**（Bélády算法）：换出将在最长时间内不被使用的页面。无法实现（需要未来知识）但可作为理论基准。

- 虚拟内存还提供了**隔离**：每个进程都有自己的虚拟地址空间。一个进程中的错误不会破坏另一个进程的内存，因为它们的虚拟地址映射到不同的物理帧。这是OS安全性和稳定性的基础。

## I/O、中断和DMA

- CPU需要与外部世界通信：磁盘、网卡、键盘、GPU。这就是**I/O子系统**。

- **程序控制I/O**（轮询）：CPU在一个循环中反复检查设备的状态寄存器，等待数据就绪。简单但浪费CPU周期做空转而不是有用工作。

- **中断驱动I/O**：设备在数据就绪时发送一个硬件**中断**。CPU继续正常执行直到中断到达，然后运行一个**中断处理程序**（内核函数）来处理数据。这比轮询高效得多，因为CPU在等待时不会空闲。

- 中断机制：
    1. 设备通过硬件线路发出中断信号。
    2. CPU完成当前指令，将当前状态（寄存器、程序计数器）保存到堆栈。
    3. CPU在**中断向量表**（每个中断类型对应一个函数指针的表）中查找中断处理程序地址。
    4. 处理程序在内核模式下运行，处理I/O，然后返回。
    5. CPU恢复保存的状态并恢复被中断的程序。

- 这与上下文切换（文件3）的保存/恢复模式相同，但由硬件而非定时器触发。

- **DMA**（直接存储器访问）：对于大数据传输（磁盘读取、网络数据包、GPU内存复制），让CPU逐字节复制数据是浪费的。**DMA控制器**直接在设备和RAM之间传输数据，无需CPU参与。CPU设置传输（源地址、目标地址、大小），DMA控制器处理传输，完成后CPU收到一个中断。

- DMA对ML至关重要：当你调用 `model.to('cuda')` 时，数据通过PCIe总线上的DMA从系统RAM传输到GPU内存。在训练期间，跨GPU的梯度同步使用基于DMA的RDMA（远程DMA）进行高带宽、低延迟传输（第6章）。

- **总线**将CPU连接到内存和I/O设备。现代系统使用**PCIe**（快速外设组件互连）连接高速设备（GPU、NVMe SSD、网卡）。PCIe 4.0在每个x16插槽上提供约~32 GB/s；PCIe 5.0将其翻倍。总线带宽通常是GPU训练的瓶颈：GPU的计算速度可能快于数据送达的速度。

- **MMIO**（内存映射I/O）：设备寄存器被映射到内存地址。CPU使用普通的加载/存储指令对这些地址进行读写，硬件将访问路由到设备而不是RAM。这统一了内存和I/O访问为一个单一机制，简化了硬件和软件。

## 编程任务（使用CoLab或笔记本）

1. 探索IEEE 754浮点数表示。将浮点数转换为二进制表示，观察符号、指数和尾数字段。
```python
import struct

def float_to_bits(f):
    """显示float32的IEEE 754二进制表示。"""
    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]}...")
```

2. 模拟直接映射缓存。跟踪一系列内存访问的命中与未命中。
```python
def simulate_cache(accesses, cache_size=8, block_size=1):
    """模拟直接映射缓存。"""
    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("顺序访问：")
simulate_cache([0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3])

# 跨步访问（冲突未命中）
print("\n跨步访问（stride = cache size）：")
simulate_cache([0, 8, 0, 8, 0, 8])
```

3. 演示为什么浮点算术不满足结合律。展示 $(a + b) + c \neq a + (b + c)$ 的情况。
```python
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"\n当 1.0 加到 1e8 上时被丢失，因为 float32 只有约 7 位精度")
```
