Skip to content

计算机体系结构

计算机体系结构研究如何构建能执行指令的机器。本文涵盖数制系统、logic gate、CPU 设计、指令集架构、pipeline、memory 层次结构和 virtual memory——这是所有程序、框架和 AI 模型最终运行所依赖的硬件基础。

  • 每一个神经网络、每一个训练循环、每一次推理调用,最终都变成在晶体管中流动的一系列电信号。对于认真的 ML 从业者来说,理解硬件并非可选项:它能解释为何矩阵乘法速度快、为何 memory 是瓶颈、为何 GPU 主导 AI 训练,以及为何对 cache 友好的代码比朴素代码快 100 倍。

数制系统

  • 计算机用 binary(二进制,基数 2)表示一切:由 0 和 1 组成的序列。每一位称为 bit,8 个 bit 构成一个 byte。binary 数 \(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)是 binary 的紧凑表示法。每个十六进制位代表 4 个 bit:\(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\) 是偏置指数。

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

- **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\) 没有精确的 binary 表示,正如 \(1/3\) 没有精确的十进制表示一样。在数百万次运算中累积这些误差(如梯度下降)可能导致数值不稳定,这就是为什么存在 loss scaling(第 6 章)和 Kahan 求和等技术。

Logic Gate

  • 所有计算都归结为 logic gate:实现布尔运算的物理电路(即文件 1 中的命题逻辑)。

  • 基本 gate:

    • AND:仅当两个输入都为 1 时输出 1。
    • OR:至少一个输入为 1 时输出 1。
    • NOT(反相器):翻转输入。
    • NAND(NOT-AND):通用 gate。任何其他 gate 都可以仅用 NAND gate 构建,这就是 NAND 成为数字电路基本构件的原因。
    • XOR(异或):输入不同时输出 1。对于加法(binary 加法的和位就是 XOR)和密码学至关重要。
  • 半加器用 XOR(和)和 AND(进位)将两个单 bit 相加。全加器将两个 bit 加上进位输入,通过级联构成 \(n\) 位加法器。这就是 CPU 执行整数加法的方式:一系列简单的 logic gate 的级联。

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

  • 现代处理器包含数十亿个晶体管,每个都像一个微小的开关。晶体管要么导通(代表 1),要么截止(代表 0)。gate 由晶体管构成,加法器由 gate 构成,ALU 由加法器构成,CPU 由 ALU 构成。整个计算层次结构都建立在这一基础之上。

CPU 架构

  • 中央处理器(CPU)执行指令。其核心组件:

    • ALU(算术逻辑单元):执行整数算术(加、减、乘)和逻辑运算(AND、OR、XOR、移位)。实际计算在此发生,由上述 logic gate 构建而成。

    • Register:CPU 内部的微小超高速存储位置。现代 CPU 有数十个通用 register,每个存储一个字(64 位 CPU 上为 64 位)。Register 是系统中最快的存储:访问仅需约 0.3 纳秒。

    • 程序计数器(PC):保存下一条要执行的指令的内存地址。

    • 控制单元:解码指令并协调数据通路,告知 ALU 执行哪种运算以及使用哪些 register。

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

    1. 取指:从 PC 所指的内存地址读取指令。
    2. 译码:确定指令的功能(加法?从内存加载?分支?)及其使用的操作数。
    3. 执行:执行操作(ALU 计算、内存访问或分支)。
    4. 递增 PC(除非指令是分支/跳转)。
  • 运行在 4 GHz 的 CPU 每秒执行 40 亿个周期。每个周期耗时 0.25 纳秒。在这段时间内,光大约传播 7.5 厘米,这就是为什么芯片的物理尺寸至关重要:信号无法在一个周期内穿越一块大芯片。

指令集架构

  • 指令集架构(ISA)是硬件与软件之间的契约:定义 CPU 所理解的指令、register 集合、内存模型和编码格式。

  • CISC(复杂指令集计算机):指令可以是复杂的、可变长度的,并且可以直接访问内存。单条指令可能将两个内存值相乘并将结果存储。x86(Intel/AMD)是最主流的 CISC ISA,为大多数台式机和服务器提供动力。其向后兼容性(现代 x86 CPU 仍能运行 1980 年代的代码)既是其优势,也是其负担。

  • RISC(精简指令集计算机):指令简单、固定长度,仅对 register 进行操作。内存访问需要单独的 load/store 指令。更简单的指令可实现更高的时钟频率和更容易的 pipeline。

    • ARM:移动设备最主流的 RISC ISA,在服务器和笔记本电脑上的使用也日益增多(Apple M 系列芯片就是 ARM)。ARM 的功耗效率使其非常适合电池供电和热量受限的设备。
    • RISC-V:开源 RISC ISA。任何人都可以设计 RISC-V 芯片,无需支付许可费。在嵌入式系统、研究和 AI 加速器领域的应用日益增长。
  • CISC 与 RISC 的区别已经模糊:现代 x86 CPU 在内部将复杂的 CISC 指令解码为更简单的微操作(本质上是内部使用 RISC),从而兼顾了两者的优势。

Pipeline

  • 没有 pipeline 时,CPU 在开始下一条指令之前会完整地执行完当前指令。这浪费了硬件资源:当 ALU 在执行时,取指和译码单元处于空闲状态。

CPU pipeline:取指、译码、执行、访存和写回各阶段的指令重叠执行

  • Pipeline(流水线)像装配线一样重叠执行指令。当指令 1 在执行时,指令 2 在译码,指令 3 在取指。5 级 pipeline(取指、译码、执行、访存、写回)可以同时运行 5 条指令。

  • 吞吐量接近每周期一条指令(即使每条指令需要 5 个周期才能完成)。这与 ML 中的 pipeline 原理相同:数据并行将计算与通信重叠(第 6 章)。

  • 冒险(Hazard)是使 pipeline 中断的情况:

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

    • 控制冒险:分支指令(if-else)意味着 CPU 在分支解析完成之前不知道下一条要取的指令。分支预测猜测分支走向,并沿预测路径推测性地取指。现代预测器的准确率超过 95%,使用历史表和类似神经网络的模式匹配。预测错误会浪费约 15 个周期(pipeline 必须被清空并重新启动)。

    • 结构冒险:两条指令同时需要同一硬件资源(例如,两者都需要内存端口)。通过复制资源或插入停顿来解决。

Memory 层次结构

  • 计算机存储的基本矛盾:快速的 memory 价格昂贵且容量小,廉价的 memory 速度慢但容量大。Memory 层次结构通过利用局部性来弥合这一差距:程序倾向于反复访问相同的数据(时间局部性)并访问相邻的数据(空间局部性)。

Memory 层次结构金字塔:顶部是 register(快速、小容量),底部是 HDD(慢速、大容量)

  • 从最快到最慢的层次结构:

    • Register:访问约 0.3 纳秒,总容量约 KB。位于 CPU 内部。
    • L1 cache:约 1 纳秒,每核 32-64 KB。分为指令 cache 和数据 cache。
    • L2 cache:约 4 纳秒,每核 256 KB-1 MB。
    • L3 cache:约 10 纳秒,各核共享 8-64 MB。
    • RAM(DRAM):约 50-100 纳秒,8-512 GB。主存。
    • SSD:约 10-100 微秒,256 GB-8 TB。持久性存储。
    • HDD:约 5-10 毫秒,1-20 TB。机械存储,随机访问非常慢。
  • Register 与 RAM 之间的速度差距约为 300 倍。Register 与磁盘之间则约为 3000 万倍。Cache 层次结构弥补了这一差距:如果 CPU 所需的数据在 L1 cache 中(cache 命中),访问速度很快;否则(cache 未命中),CPU 会停顿,等待从较慢的层级获取数据。

  • Cache 关联性决定了内存地址可以存储在 cache 的哪个位置:

    • 直接映射:每个地址只能映射到一个固定的 cache 行。简单但会导致冲突。
    • 全关联:任何地址都可以存储在任意位置。灵活但搜索代价高。
    • 组关联\(k\) 路):每个地址映射到包含 \(k\) 个位置的组中。这是实际 CPU 中使用的实用折中方案(通常为 4 路或 8 路)。
  • Cache 一致性确保所有 CPU 核心看到一致的内存视图。当核心 1 写入核心 2 已缓存的地址时,一致性协议(如 MESI)会使核心 2 的副本失效或更新。这对并发编程(文件 4)至关重要,也是共享内存并行编程困难的原因之一。

  • 对于 ML 从业者,memory 层次结构解释了为什么:

    • 矩阵运算应顺序访问内存(行主序与列主序的布局很重要)。
    • Batch size 影响性能:更大的 batch 能分摊内存延迟。
    • 混合精度(float16/bfloat16)使有效内存带宽翻倍,而带宽通常是瓶颈所在。

Virtual Memory

  • Virtual memory 为每个 process 提供拥有自己的大型连续内存空间的假象,即使物理 RAM 有限且被多个 process 共享。

  • 地址空间被划分为固定大小的 page(通常为 4 KB)。Page table 将虚拟 page 编号映射到物理帧编号。当程序访问虚拟地址 0x1234 时,CPU 通过查找 page table 将其转换为物理地址。

  • TLB(Translation Lookaside Buffer)是 page table 条目的 cache。由于 page table 存储在 RAM 中(速度慢),TLB 在快速硬件中存储最近使用的转换。TLB 未命中需要遍历内存中的 page table,耗费数百个周期。

  • 当程序访问不在物理 RAM 中的 page 时,会发生 page fault。OS 从磁盘加载该 page(换入),这需要数百万个周期。过多的 page fault(thrashing)会严重降低性能。这就是为什么 ML 训练需要足够的 RAM 来容纳模型、优化器状态和合理的数据 batch。

  • Page 置换算法决定 RAM 满时驱逐哪个 page:

    • LRU(最近最少使用):驱逐最长时间未被访问的 page。对大多数工作负载来说实践中表现最优。在硬件中用 clock 算法近似实现(带有参考位的循环链表)。
    • FIFO:驱逐最旧的 page。简单但可能驱逐频繁使用的 page。
    • 最优(Bélády 算法):驱逐最长时间内不会被使用的 page。无法实现(需要未来知识),但可用作理论基准。
  • Virtual memory 还提供隔离:每个 process 都有自己的虚拟地址空间。一个 process 中的 bug 无法破坏另一个 process 的内存,因为它们的虚拟地址映射到不同的物理帧。这是 OS 安全性和稳定性的基础。

I/O、Interrupt 和 DMA

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

  • 程序 I/O(轮询):CPU 在循环中反复检查设备的状态 register,等待数据就绪。简单但会浪费 CPU 周期做空转而不是执行有用工作。

  • Interrupt 驱动的 I/O:当数据就绪时,设备发送硬件 interrupt。CPU 继续正常执行,直到 interrupt 到来,然后运行 interrupt handler(内核函数)来处理数据。这比轮询效率高得多,因为 CPU 在等待时不会空闲。

  • Interrupt 机制:

    1. 设备通过硬件线路发送 interrupt 信号。
    2. CPU 完成当前指令,将当前状态(register、program counter)保存到 stack 上。
    3. CPU 在 interrupt 向量表(每种 interrupt 类型一个函数指针的表)中查找 interrupt handler 地址。
    4. Handler 在内核模式下运行,处理 I/O 并返回。
    5. CPU 恢复保存的状态,继续执行被中断的程序。
  • 这与 context switch(文件 3)相同的保存/恢复模式,但由硬件而非计时器触发。

  • DMA(直接内存访问):对于大数据传输(磁盘读取、网络数据包、GPU 内存拷贝),让 CPU 逐字节复制数据是浪费的。DMA controller 在不涉及 CPU 的情况下直接在设备和 RAM 之间传输数据。CPU 设置传输参数(源、目标、大小),DMA controller 处理传输,完成后 CPU 收到 interrupt。

  • DMA 对 ML 至关重要:当你调用 model.to('cuda') 时,数据通过 PCIe 总线经由 DMA 从系统 RAM 传输到 GPU memory。在训练期间,跨 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):设备 register 被映射到内存地址。CPU 使用普通的 load/store 指令读写这些地址,硬件将访问路由到设备而不是 RAM。这将内存和 I/O 访问统一为单一机制,简化了硬件和软件。

编程任务(使用 CoLab 或 notebook)

  1. 探索 IEEE 754 浮点数表示。将浮点数转换为其 binary 表示,并观察符号位、指数位和尾数位。

    import struct
    
    def float_to_bits(f):
        """显示 float32 的 IEEE 754 binary 表示。"""
        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. 模拟直接映射 cache。追踪一系列内存访问的命中和未命中情况。

    def simulate_cache(accesses, cache_size=8, block_size=1):
        """模拟直接映射 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"  访问 {addr:3d} → 行 {cache_line}: {status}")
    
        print(f"\n命中:{hits},未命中:{misses},命中率:{hits/(hits+misses):.1%}")
    
    # 顺序访问(良好的局部性)
    print("顺序访问:")
    simulate_cache([0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3])
    
    # 跨步访问(冲突未命中)
    print("\n跨步访问(步长 = cache 大小):")
    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"相等:{left == right}")
    print(f"\n当 1.0 与 1e8 相加时会丢失,因为 float32 只有约 7 位十进制精度")