跳转至

# 计算机体系结构

计算机体系结构是关于如何构建执行指令的机器。本文涵盖数制、逻辑门、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\)是移码指数。

    - **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执行时，取指和译码单元处于空闲状态。

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

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

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

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

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

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

## 存储器层次结构

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

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

  - 寄存器：~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浮点数表示。将浮点数转换为二进制表示，观察符号、指数和尾数字段。

       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. 模拟直接映射缓存。跟踪一系列内存访问的命中与未命中。

       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)\) 的情况。

       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 位精度")