Skip to content

计算机体系结构

计算机体系结构是我们构建执行指令的机器的方式。本文涵盖数制、逻辑门、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位尾数

- **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位。范围和精度有限,但内存和带宽减半。广泛应用于机器学习训练(混合精度,第6章)。
- **bfloat16**:1 + 8 + 7 = 16位。指数范围与float32相同,但精度较低。由谷歌专门为机器学习设计:完整的指数范围防止训练中的溢出,降低的精度对于梯度更新是可以接受的。
  • 浮点数运算不精确。在float64中,\(0.1 + 0.2 \neq 0.3\)(它等于 \(0.30000000000000004\))。这是因为 \(0.1\) 没有精确的二进制表示,就像 \(1/3\) 没有精确的十进制表示一样。在数百万次运算(如梯度下降)中累积这些误差可能导致数值不稳定,这就是为什么存在损失缩放(第6章)和Kahan求和等技术。

逻辑门

  • 所有计算归结为逻辑门:实现布尔运算(来自文件1的命题逻辑)的物理电路。

  • 基本门电路:

    • 与门:仅当两个输入都为1时输出为1。
    • 或门:至少一个输入为1时输出为1。
    • 非门(反相器):翻转输入。
    • 与非门(NOT-AND):通用门。仅用与非门就可以构建任何其他门。这就是为什么与非门是数字电路的基本构建块。
    • 异或门:当输入不同时输出为1。对于加法(二进制加法的和位是异或)和密码学至关重要。
  • 半加器使用异或(和)和与门(进位)相加两个单位比特。全加器相加两个比特以及一个进位输入,将它们级联起来形成一个 \(n\) 位加法器。这就是CPU执行整数加法的方式:简单逻辑门的级联。

  • 多路选择器(MUX) 根据控制信号从多个输入中选择一个。使用 \(n\) 个控制位,可以从 \(2^n\) 个输入中选择。多路选择器是硬件上等效于 if-else 链的元件,并广泛用于CPU数据通路中以路由数据。

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

CPU 架构

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

    • ALU(算术逻辑单元):执行整数算术(加、减、乘)和逻辑运算(与、或、异或、移位)。这就是实际计算发生的地方,由上述逻辑门构建而成。

    • 寄存器: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仍然可以运行20世纪80年代的代码)既是它的优势,也是它的负担。

  • RISC(精简指令集计算机):指令简单、长度固定,并且仅对寄存器进行操作。内存访问需要单独的加载/存储指令。更简单的指令使CPU能够实现更高的时钟频率和更容易的流水线。

    • ARM:移动设备的主流RISC ISA,并越来越多地用于服务器和笔记本电脑(苹果M系列芯片是ARM)。ARM的能效使其成为电池供电和热受限设备的理想选择。
    • RISC-V:开源的RISC ISA。任何人都可以在无需授权费用的情况下设计RISC-V芯片。在嵌入式系统、研究和AI加速器中应用日益增长。
  • CISC与RISC的区别已经模糊:现代x86 CPU在内部将复杂的CISC指令解码为更简单的微操作(本质上内部是RISC),从而达到两全其美。

流水线

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

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

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

  • 吞吐量接近每周期一条指令(尽管每条指令需要5个周期才能完成)。这与机器学习中的流水线原理相同:数据并行重叠计算和通信(第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)至关重要,也是共享内存并行困难的原因之一。

  • 对于机器学习从业者来说,内存层次结构解释了:

    • 矩阵操作应该顺序访问内存(行优先与列优先布局很重要)。
    • 批处理大小影响性能:较大的批次摊薄了内存延迟。
    • 混合精度(float16/bfloat16)使有效内存带宽翻倍,而这通常是瓶颈。

虚拟内存

  • 虚拟内存给每个进程一种错觉,即它拥有自己大的、连续的内存空间,尽管物理RAM是有限的并且在进程之间共享。

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

  • 转换后备缓冲器(TLB) 是页表项的缓存。由于页表位于RAM(慢)中,TLB将最近使用的转换存储在快速硬件中。TLB未命中需要遍历内存中的页表,耗费数百个周期。

  • 缺页故障发生在程序访问不在物理RAM中的页面时。操作系统从磁盘加载页面(交换),这需要数百万个周期。过多的缺页故障(颠簸)会严重破坏性能。这就是为什么机器学习训练需要足够的RAM来容纳模型、优化器状态和合理批量的数据。

  • 页面置换算法决定当RAM满时驱逐哪个页面:

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

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控制器在不涉及CPU的情况下直接在设备和RAM之间传输数据。CPU设置传输(源、目标、大小),DMA控制器处理它,完成后CPU收到中断。

  • DMA对机器学习至关重要:当你调用 model.to('cuda') 时,数据通过DMA通过PCIe总线从系统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 或 notebook)

  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("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])
    

  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"\nThe 1.0 is lost when added to 1e8 because float32 has only ~7 digits of precision")