Files
flykhan 2536c937e3 feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)
翻译自英文原版 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/ 构建缓存
2026-05-03 10:23:20 +08:00

17 KiB
Raw Permalink Blame History

计算机体系结构

计算机体系结构是关于如何构建执行指令的机器。本文涵盖数制、逻辑门、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位。范围和精度有限,但使用一半的内存和带宽。广泛用于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(复杂指令集计算机):指令可以复杂、变长,并可以直接访问内存。一条指令可以乘法两个内存值并存储结果。x86Intel/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执行时,取指和译码单元处于空闲状态。

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

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

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

  • 冒险是流水线被破坏的情况:

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

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

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

存储器层次结构

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

存储器层次结构金字塔:寄存器在顶部(快速、小)到HDD在底部(慢、大)

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

    • 寄存器0.3 ns访问,总容量KB。位于CPU内。
    • L1缓存~1 ns,每核心32-64 KB。分为指令缓存和数据缓存。
    • L2缓存~4 ns,每核心256 KB-1 MB。
    • L3缓存~10 ns,跨核心共享8-64 MB。
    • RAMDRAM~50-100 ns8-512 GB。主内存。
    • SSD~10-100 μs256 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]}...")
  1. 模拟直接映射缓存。跟踪一系列内存访问的命中与未命中。
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])
  1. 演示为什么浮点算术不满足结合律。展示 (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 位精度")