翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。 第01章 向量 | 第02章 矩阵 | 第03章 微积分 第04章 统计学 | 第05章 概率论 | 第06章 机器学习 第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音 第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络 第13章 计算与操作系统 | 第14章 数据结构与算法 第15章 生产级软件工程 | 第16章 SIMD与GPU编程 第17章 AI推理 | 第18章 ML系统设计 第19章 应用人工智能 | 第20章 前沿人工智能 翻译说明: - 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留 - mkdocs.yml 配置中文导航 + language: zh - README.md 已翻译为中文(兼 docs/index.md) - docs/ 目录包含指向各章文件的 symlink - 约 29,000 行中文内容,排除 .cache/ 构建缓存
17 KiB
计算机体系结构
计算机体系结构是关于如何构建执行指令的机器。本文涵盖数制、逻辑门、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执行什么操作以及使用哪些寄存器。
-
-
指令周期(取指-译码-执行)每秒重复数十亿次:
- 取指:从PC中的地址读取指令。
- 译码:确定指令的功能(加法?从内存加载?分支?)及其使用的操作数。
- 执行:执行操作(ALU计算、内存访问或分支)。
- 增加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在等待时不会空闲。
-
中断机制:
- 设备通过硬件线路发出中断信号。
- CPU完成当前指令,将当前状态(寄存器、程序计数器)保存到堆栈。
- CPU在中断向量表(每个中断类型对应一个函数指针的表)中查找中断处理程序地址。
- 处理程序在内核模式下运行,处理I/O,然后返回。
- 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或笔记本)
- 探索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("顺序访问:")
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])
- 演示为什么浮点算术不满足结合律。展示
(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 位精度")