计算机体系结构¶
计算机体系结构是我们构建执行指令的机器的方式。本文涵盖数制、逻辑门、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位。范围和精度有限,但内存和带宽减半。广泛应用于机器学习训练(混合精度,第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执行什么操作以及使用哪些寄存器。
-
-
指令周期(取指-译码-执行)每秒重复数十亿次:
- 取指:从PC指示的内存地址读取指令。
- 译码:确定指令的作用(加法?从内存加载?分支?)以及它使用哪些操作数。
- 执行:执行操作(ALU计算、内存访问或分支)。
- 增加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执行时,取指和译码单元处于空闲状态。
-
流水线重叠执行指令,就像装配线一样。当指令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在等待时不会空闲。
-
中断机制:
- 设备通过硬件线路发送中断信号。
- CPU完成当前指令,将当前状态(寄存器、程序计数器)保存到栈中。
- CPU在中断向量表(函数指针表,每个中断类型一个)中查找中断处理程序的地址。
- 处理程序在内核模式下运行,处理I/O,然后返回。
- 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)¶
-
探索 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]}...") -
模拟一个直接映射缓存。跟踪一系列内存访问的命中与未命中。
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]) -
演示浮点数运算为何不满足结合律。展示 \((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")