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/ 构建缓存
This commit is contained in:
2026-05-03 10:23:20 +08:00
commit 2536c937e3
400 changed files with 49040 additions and 0 deletions
@@ -0,0 +1,253 @@
# 离散数学
*离散数学是关于可数、分离结构的数学,是计算构建的基础。本文涵盖命题逻辑与谓词逻辑、证明技巧、集合、关系、函数、图论基础以及递推关系。*
- 在前面的章节中,我们研究了连续数学:微积分(第3章)、概率分布(第5章)以及实值参数的优化(第6章)。但计算机本质上是**离散**机器。它们存储比特(0或1),处理整数,遵循分支逻辑,并操作有限数据结构。**离散数学**提供了推理这些结构的形式化语言。
- 本章所有内容都建立在离散数学之上:处理器逻辑门是布尔代数,调度算法需要正确性证明,内存管理使用集合运算,算法分析需要递推关系。
## 命题逻辑
- **命题逻辑**是真假语句的代数。一个**命题**是一个要么为真(T)要么为假(F)的陈述,绝不会两者兼有。"天在下雨"是一个命题。"现在几点了?"则不是(它是一个问句,不是具有真值的陈述)。
- 命题可以通过**逻辑连接词**进行组合:
- **与**(合取,$p \wedge q$):仅当$p$和$q$都为真时为真。
- **或**(析取,$p \vee q$):当$p$或$q$至少一个为真时为真。
- **非**(否定,$\neg p$):翻转真值。
- **蕴含**(蕴涵,$p \to q$):仅当$p$为真且$q$为假时为假。"如果下雨,地就是湿的"只有在下了雨而地却是干的时候才被违反。
- **当且仅当**(双条件,$p \leftrightarrow q$):当两者真值相同时为真。
- **真值表**穷举列出所有可能的输入组合及相应的输出。对于$n$个命题,该表有$2^n$行。这就是我们验证逻辑等价性的方式:
| $p$ | $q$ | $p \wedge q$ | $p \vee q$ | $p \to q$ |
|-----|-----|--------------|------------|-----------|
| T | T | T | T | T |
| T | F | F | T | F |
| F | T | F | T | T |
| F | F | F | F | T |
- 蕴含行中$p$为假的情况值得关注:$F \to q$无论$q$为何值都为真。这就是**空真**。"如果猪会飞,那我就是英国国王"在逻辑上为真,因为前提为假。这看起来违反直觉,但对数学推理至关重要。
- **逻辑等价式**是对所有真值都成立的恒等式:
- **德摩根定律**$\neg(p \wedge q) \equiv \neg p \vee \neg q$ 和 $\neg(p \vee q) \equiv \neg p \wedge \neg q$。要否定一个AND,分别否定每个部分并切换为OR(反之亦然)。这些直接出现在编程中:`!(a && b)` 等价于 `(!a || !b)`
- **逆否命题**$p \to q \equiv \neg q \to \neg p$。"如果下雨,地就是湿的"等价于"如果地不是湿的,那么就没下雨。"这是一个强大的证明技巧。
- **双重否定**$\neg(\neg p) \equiv p$。
- **分配律**$p \wedge (q \vee r) \equiv (p \wedge q) \vee (p \wedge r)$。
- 一个总是为真(对所有真值指派)的公式是**重言式**。总是为假的公式是**矛盾式**。有时真有时假的公式是**偶然式**。例如,$p \vee \neg p$是重言式,$p \wedge \neg p$是矛盾式。
## 谓词逻辑与量词
- 命题逻辑无法表达关于集合中*所有*或*某些*元素的陈述。"每个大于2的素数都是奇数"需要**谓词逻辑**,它用变量、谓词和量词扩展了命题逻辑。
- **谓词**是依赖于变量的陈述:$P(x)$ = "$x$是偶数。"当给定$x$一个具体值时,它成为一个命题:$P(4)$为真,$P(7)$为假。
- **量词**表达范围:
- **全称量词**$\forall$):"对于所有。" $\forall x \, P(x)$ 表示"$P(x)$对论域中的每一个$x$成立。"
- **存在量词**$\exists$):"存在。" $\exists x \, P(x)$ 表示"至少存在一个$x$使得$P(x)$为真。"
- 否定量词会翻转它们:$\neg(\forall x \, P(x)) \equiv \exists x \, \neg P(x)$。"不是所有人都通过了"意味着"有人没通过。"而 $\neg(\exists x \, P(x)) \equiv \forall x \, \neg P(x)$。"没有完美的算法"意味着"每个算法都有缺陷。"
- 嵌套量词表达复杂关系。$\forall x \, \exists y \, (y > x)$ 表示"对于每个数,都有一个更大的数"(对整数成立)。顺序很重要:$\exists y \, \forall x \, (y > x)$ 表示"存在一个比所有其他数都大的数"(对整数不成立)。
- 谓词逻辑是形式化规约的语言。当我们说一个算法是"正确"的,意味着 $\forall \text{输入} \, x, \, \text{输出}(x) = \text{期望输出}(x)$。当我们说它"终止",意味着 $\forall x \, \exists t \, \text{终止}(x, t)$。
## 证明技巧
- **证明**是确立一个陈述真理性、毫无疑义的逻辑论证。与经验证据(仅展示在某些测试案例下有效)不同,证明保证在所有情况下成立。这是计算机科学中正确性的标准。
- **直接证明**:假设前提,通过逻辑步骤推导出结论。要证明"如果$n$是偶数,那么$n^2$是偶数":假设$n = 2k$对于某个整数$k$,则$n^2 = 4k^2 = 2(2k^2)$,这是偶数。
- **反证法**:假设该陈述为假,推导出矛盾。要证明$\sqrt{2}$是无理数:假设$\sqrt{2} = a/b$(已约简)。那么$2 = a^2/b^2$,所以$a^2 = 2b^2$,意味着$a^2$是偶数,所以$a$是偶数,设$a = 2c$。那么$4c^2 = 2b^2$,所以$b^2 = 2c^2$,意味着$b$也是偶数。但我们已经假设$a/b$是约简形式——矛盾。
- **归纳证明**:通过证明以下两点来证明一个陈述对所有自然数成立:(1)**基础情形**成立(通常$n = 0$或$n = 1$),和(2**归纳步骤**:如果陈述对$n = k$成立(归纳假设),那么它对$n = k + 1$也成立。
- 例如,证明 $\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$
- 基础情形:$n = 1$$1 = \frac{1 \cdot 2}{2} = 1$。成立。
- 归纳步骤:假设 $\sum_{i=1}^{k} i = \frac{k(k+1)}{2}$。那么 $\sum_{i=1}^{k+1} i = \frac{k(k+1)}{2} + (k+1) = \frac{k(k+1) + 2(k+1)}{2} = \frac{(k+1)(k+2)}{2}$。这正是$n = k+1$时的公式。证明完成。
- 归纳法是证明递归算法和数据结构性质的主力工具。每个递归算法都暗含一个归纳正确性证明:基础情形是终止条件,归纳步骤是递归调用。
- **强归纳法**假设该陈述对所有不大于$k$的值都成立(不仅仅是$k$),然后证明它对$k + 1$成立。当递归依赖于多个之前的值时,这很有用。
- **鸽巢原理**:如果把$n+1$个物体放入$n$个盒子中,至少有一个盒子包含两个物体。简单但出奇地强大。它证明了在任何13个人中,至少有两个人出生月份相同。在网络中,它证明了当项目数超过桶数时,哈希冲突是不可避免的。
## 集合
- **集合**是不同元素的无序收集。集合是数学中最原始的数据结构,支撑着从类型系统到数据库查询的一切。
- **集合运算**(联系第5章,我们在那里用这些进行概率计算):
- **并集** $A \cup B$:在$A$或$B$或两者中的元素。
- **交集** $A \cap B$:同时在$A$和$B$中的元素。
- **补集** $\bar{A}$:不在$A$中的元素(相对于一个全集)。
- **差集** $A \setminus B$:在$A$中但不在$B$中的元素。
- **笛卡尔积** $A \times B$:所有有序对$(a, b)$,其中$a \in A, b \in B$。
- **幂集** $\mathcal{P}(A)$ 是$A$的所有子集构成的集合。如果 $|A| = n$,那么 $|\mathcal{P}(A)| = 2^n$。对于 $A = \{1, 2\}$$\mathcal{P}(A) = \{\emptyset, \{1\}, \{2\}, \{1, 2\}\}$。
- **基数**衡量集合大小。有限集具有整数基数。无限集有不同的大小:自然数$\mathbb{N}$和有理数$\mathbb{Q}$是**可数无穷**(可以列举),而实数$\mathbb{R}$是**不可数无穷**(无法列举,由康托尔的对角线论证证明)。这种区别在可计算性理论中很重要:存在不可数多个函数,但只有可数多个程序,因此大多数函数是不可计算的。
## 关系
- 集合$A$上的**关系**$R$是$A \times A$的一个子集:指定哪些元素相关联的有序对集合。例如,整数上的$\leq$是集合 $\{(a, b) : a \leq b\}$。
- 关系的重要性质:
- **自反性**:每个元素与自身相关。对所有$a$有$a R a$。例:$\leq$(每个数$\leq$自身)。
- **对称性**:如果$a R b$则$b R a$。例:"是……的兄弟姐妹。"
- **反对称性**:如果$a R b$且$b R a$则$a = b$。例:$\leq$。
- **传递性**:如果$a R b$且$b R c$则$a R c$。例:$<$、$\leq$、"是……的祖先。"
- **等价关系**是自反、对称且传递的。它将集合划分为**等价类**,其中同一类中的所有元素彼此相关,但与不同类中的元素无关。模运算是一个等价关系:$a \equiv b \pmod{n}$ 将整数划分为$n$个类。编程语言中的类型等价是一个等价关系。
- **偏序**是自反、反对称且传递的。它定义了一个"小于等于"结构,可能会使某些元素不可比较。文件系统目录构成一个偏序(父-子),但同级目录是不可比较的。**全序**是每一对元素都可比较的偏序(如整数上的$\leq$)。
- 偏序在并发中至关重要:事件上的"先于发生"关系是一个偏序。不由先于发生关系排序的事件是并发的,可能以任意相对顺序执行。
## 函数
- **函数** $f: A \to B$ 将$A$(定义域)中的每个元素映射到$B$(陪域)中的恰好一个元素。函数是确定性计算的数学模型:给定一个输入,恰好有一个输出。
- **单射**(一对一):不同的输入总是产生不同的输出。$f(a) = f(b) \implies a = b$。无损压缩是单射的:不同的输入必须压缩成不同的输出(否则无法唯一解压)。
- **满射**(到上):$B$中的每个元素都被$A$中的某个元素命中。值域等于陪域。将字符串映射到256位哈希的哈希函数,如果字符串数少于可能的哈希数,则不是满射。
- **双射**:既是单射又是满射。$A$和$B$之间的一一对应。双射具有逆函数。加密必须是双射的:每个明文映射到唯一的密文,而解密函数就是逆函数。
- **复合** $(g \circ f)(x) = g(f(x))$:先应用$f$,再应用$g$。函数复合是可结合的(第2章:就像矩阵乘法是可结合的一样)。软件中的管道就是函数复合:数据流经一系列变换。
## 图论基础
- 我们在第12章(图神经网络)中广泛介绍了图,包括邻接矩阵、图类型、拉普拉斯矩阵和谱理论。这里我们专注于与CS相关的**算法**和**结构**性质。
- **树**是没有环的连通图。等价地,它有$n$个节点和$n-1$条边。树是文件系统、XML/HTML文档、决策过程和递归分解的结构。**有根树**有一个指定的根节点;每个其他节点恰好有一个父节点。
- 图$G$的**生成树**是包含$G$所有节点并使用其边子集的一棵树。**最小生成树(MST)**最小化总边权。Kruskal算法(对边排序,贪心地添加不形成环的最轻边)和Prim算法(从起始节点开始扩展树,总是添加连接到新节点的最轻边)都能在$O(|E| \log |V|)$内找到MST。
- **平面性**:如果一个图可以画在平面上而边不相交,则是平面图。根据**欧拉公式**,对于连通平面图:$|V| - |E| + |F| = 2$,其中$|F|$是面的数量(区域,包括外部面)。这意味着平面图的$|E| \leq 3|V| - 6$,因此平面图是稀疏的。电路板布线和地图着色利用了平面性。
- **图着色**为节点分配颜色,使得没有两个相邻节点共享相同的颜色。所需的最小颜色数是**色数** $\chi(G)$。**四色定理**指出任何平面图的 $\chi(G) \leq 4$。在CS中,图着色模拟寄存器分配(将变量分配到CPU寄存器,使得同时活跃的变量获得不同的寄存器)和调度(将任务分配到时间槽,使得冲突的任务不重叠)。
- **欧拉路径**恰好访问每条边一次。当且仅当图中恰好有0个或2个奇数度节点时,欧拉路径存在。**哈密顿路径**恰好访问每个节点一次。确定哈密顿路径是否存在是NP完全的——这是CS中的经典难题之一。这种对比(欧拉:多项式,哈密顿:NP完全)说明了听起来相似的问题可能具有截然不同的计算复杂度。
## 递推关系
- **递推关系**定义一个序列,其中每一项依赖于前面的项。它们自然地从递归算法中产生。
- 最简单的例子:$T(n) = T(n-1) + 1$,其中 $T(0) = 0$。展开:$T(n) = T(n-1) + 1 = T(n-2) + 2 = \cdots = n$。这是$O(n)$,即简单循环的时间复杂度。
- **归并排序**给出 $T(n) = 2T(n/2) + O(n)$:将数组分成两半(两个大小为$n/2$的子问题),递归排序每一半,然后合并($O(n)$工作)。解为 $T(n) = O(n \log n)$。
- **主定理**求解形式为 $T(n) = aT(n/b) + O(n^d)$ 的递推式:
- 如果 $d > \log_b a$$T(n) = O(n^d)$(每层的工作占主导)
- 如果 $d = \log_b a$$T(n) = O(n^d \log n)$(工作在各层间平衡)
- 如果 $d < \log_b a$$T(n) = O(n^{\log_b a})$(子问题的数量占主导)
- 对于归并排序:$a = 2, b = 2, d = 1$。由于 $d = \log_2 2 = 1$,我们处于平衡情况:$T(n) = O(n \log n)$。
- **斐波那契递推** $F(n) = F(n-1) + F(n-2)$,其中 $F(0) = 0, F(1) = 1$,封闭形式解为 $F(n) = \frac{\phi^n - \psi^n}{\sqrt{5}}$,其中 $\phi = \frac{1+\sqrt{5}}{2}$(黄金比例)且 $\psi = \frac{1-\sqrt{5}}{2}$。这表明斐波那契数列以 $O(\phi^n)$ 指数增长,这就是为什么朴素递归斐波那契指数级慢。
- **组合数学**(排列、组合、二项式定理和容斥原理)在第5章(概率)中介绍。这些计数技术对算法分析至关重要(有多少种可能的输入?需要多少次比较?),但我们在此不再重复。
## 可计算性
- 并非所有事情都能被计算。这是整个数学中最深刻的结论之一,它设定了计算机能力的基本极限。
- **图灵机**是计算的抽象模型:一条无限长的单元格磁带(每个单元格包含一个符号),一个读写头,以及一组带转移规则的有限状态。尽管简单,图灵机可以计算任何实际计算机能计算的任何东西。这就是**邱奇-图灵论题**:任何有效可计算的函数都可以由图灵机计算。
- 每种编程语言(Python、C、Haskell)都是**图灵完备**的:它可以模拟图灵机,从而计算任何可计算的东西。语言之间的区别在于便利性、速度和安全性,而不在于它们根本上能计算什么。
- **停机问题**询问:给定一个程序和一个输入,该程序最终会停止,还是永远运行?图灵(1936)证明不存在能普遍解决这个问题的算法。证明采用反证法:假设存在一个停机检测器 $H(P, x)$。构造一个程序 $D$,它运行 $H(D, D)$ 并做与 $H$ 所说的相反的事。如果 $H$ 说 $D$ 停机,$D$ 就永远循环。如果 $H$ 说 $D$ 循环,$D$ 就停机。矛盾。
- 这不是当前技术的局限;这是一个数学上的不可能性。无论多少计算、多少聪明才智、或多少人工智能,都无法普遍解决停机问题。它是哥德尔不完备定理在计算机科学中的类比。
- 实际后果:你无法编写一个完美的死锁检测器、一个完美的病毒扫描器或一个完美的优化编译器。每一个都需要通用地解决停机问题(或一个等价的不判定问题)。实际工具使用启发式方法和近似方法,在常见情况下有效,但不能保证对所有输入都正确。
- 如果一个问题存在一个总是能给出正确是/否答案并终止的算法,则它是**可判定的**。如果不存在这样的算法,则是**不可判定的**。停机问题是不可判定的。素数测试是可判定的。大多数编程语言中的类型检查是可判定的(通过设计)。
## 复杂度理论
- 即使在可计算的问题中,有些也远比其他的难。**复杂度理论**根据解决问题所需的资源(时间、空间)随输入增长而分类问题。
![P、NP和NP完全:P包含在NP中,NP完全位于边界处,P是否等于NP是核心开放问题](../images/p_np_complexity.svg)
- **P**(多项式时间):能在 $O(n^k)$ 时间内解决的问题,$k$为某个常数。排序($O(n \log n)$)、最短路径($O(|V|^2)$)、矩阵乘法($O(n^3)$)。这些被认为是"高效"或"可处理的。"
- **NP**(非确定性多项式时间):一个拟议的解答能在多项式时间内**验证**的问题,即使**找到**解答可能需要指数时间。例如,给定一个声称的哈密顿路径,你可以通过检查每条边在 $O(n)$ 时间内验证它。但找到一条可能需尝试指数多个可能性。
- P中的每个问题也在NP中(如果你能快速解决它,你当然能快速验证一个解答)。核心问题是 $P = NP$ 是否成立:每个能快速验证解答的问题是否也能快速求解?这是计算机科学中最重要的开放问题,获得克莱数学研究所100万美元的千禧年大奖。
- 大多数专家相信 $P \neq NP$,意味着有些问题本质上比验证更难解决。如果 $P = NP$,密码学将崩溃(破解加密属于NP),而优化、调度和药物设计将变得异常简单。
- **NP完全**问题是NP中最难的问题。一个问题如果是NP完全的,则:(1)它在NP中,且(2)所有其他NP问题可以在多项式时间内**归约**到它。如果你能高效解决任何一个NP完全问题,你就能解决所有NP完全问题(从而 $P = NP$)。
- **归约**将一个问题转换为另一个问题。如果问题A归约到问题B,那么B至少和A一样难。Cook(1971)证明了**SAT**(布尔可满足性:给定一个逻辑公式,是否存在使公式为真的变量赋值?)是NP完全的。Karp(1972)通过将SAT归约到每个问题,证明了其他21个经典问题是NP完全的。
- 著名的NP完全问题:
- **旅行商问题(TSP)**:找到访问所有城市恰好一次的最短路线。
- **图着色**:用$k$种颜色为节点着色,使得没有相邻节点共享同一颜色($k \geq 3$)。
- **子集和问题**:给定一组整数,是否存在一个子集其和等于目标值?
- **布尔可满足性(SAT)**:是否存在使逻辑公式为真的真值赋值?
- **哈密顿路径**(上文图论中提到的)。
- 当你在实践中遇到NP完全问题时,你不会对大规模输入精确求解。相反,你使用:**近似算法**(找到保证在最优解一定倍数范围内的解)、**启发式方法**(贪心、局部搜索、模拟退火)或**特例求解器**(许多NP完全问题对受限输入很容易)。例如,现代SAT求解器尽管在最坏情况下是指数复杂度,但通过利用实际实例中的结构,通常能解决拥有数百万变量的实例。
- **NP困难**问题至少和NP完全问题一样难,但可能不在NP中(它们的解甚至可能不能在多项式时间内验证)。NP完全问题的优化版本通常是NP困难的:"找到最短TSP路线"是NP困难的,而"是否存在一条长度小于$k$的TSP路线?"是NP完全的。
## 编程任务(使用CoLab或笔记本)
1. 构建一个真值表生成器。给定一个逻辑表达式,枚举所有输入组合并计算结果。
```python
import itertools
def truth_table(n_vars, expr_fn):
"""为一个n_vars个变量的布尔函数生成真值表。"""
headers = [f"p{i}" for i in range(n_vars)]
print(" | ".join(headers + ["result"]))
print("-" * (len(headers) * 4 + 10))
for vals in itertools.product([False, True], repeat=n_vars):
result = expr_fn(*vals)
row = [str(v)[0] for v in vals] + [str(result)[0]]
print(" | ".join(f"{r:>2}" for r in row))
# 德摩根定律:NOT(p AND q) == (NOT p) OR (NOT q)
print("德摩根定律验证:")
truth_table(2, lambda p, q: (not (p and q)) == ((not p) or (not q)))
```
2. 通过归纳法证明求和公式——对多个值进行数值验证,然后实现封闭形式解。
```python
import jax.numpy as jnp
# 验证求和公式:sum(1..n) = n(n+1)/2
for n in [1, 5, 10, 100, 1000, 10000]:
brute = sum(range(1, n + 1))
formula = n * (n + 1) // 2
print(f"n={n:5d} sum={brute:>10d} formula={formula:>10d} match={brute == formula}")
```
3. 使用主定理求解归并排序递推关系,并通过计数操作进行经验验证。
```python
import jax.numpy as jnp
def merge_sort_ops(n):
"""统计归并排序中的比较次数(递推:T(n) = 2T(n/2) + n)。"""
if n <= 1:
return 0
half = n // 2
return merge_sort_ops(half) + merge_sort_ops(n - half) + n
for n in [8, 64, 512, 4096, 32768]:
ops = merge_sort_ops(n)
predicted = n * jnp.log2(n)
ratio = ops / predicted
print(f"n={n:5d} ops={ops:>10d} n log n={int(predicted):>10d} ratio={ratio:.3f}")
```
@@ -0,0 +1,233 @@
# 计算机体系结构
*计算机体系结构是关于如何构建执行指令的机器。本文涵盖数制、逻辑门、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个尾数位](../images/ieee754_float.svg)
- **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**(复杂指令集计算机):指令可以复杂、变长,并可以直接访问内存。一条指令可以乘法两个内存值并存储结果。**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执行时,取指和译码单元处于空闲状态。
![CPU流水线:指令在取指、译码、执行、访存和写回阶段重叠](../images/cpu_pipeline.svg)
- **流水线**使指令执行重叠,如同装配线。当指令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在底部(慢、大)](../images/memory_hierarchy.svg)
- 层次结构,从最快到最慢:
- **寄存器**:~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 ms1-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浮点数表示。将浮点数转换为二进制表示,观察符号、指数和尾数字段。
```python
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. 模拟直接映射缓存。跟踪一系列内存访问的命中与未命中。
```python
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])
```
3. 演示为什么浮点算术不满足结合律。展示 $(a + b) + c \neq a + (b + c)$ 的情况。
```python
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 位精度")
```
@@ -0,0 +1,258 @@
# 操作系统
*操作系统是硬件与应用程序之间的软件层,负责管理资源、提供抽象并实施隔离。本文涵盖操作系统的功能、进程、线程、CPU调度、内存管理、文件系统和系统调用。*
- 没有操作系统的计算机就像一个没有厨师的厨房:食材(硬件)都在那里,但没有人协调谁使用炉灶、餐具放在哪里、或者如何防止两个人同时抓同一把刀。**OS**就是那个协调者。
- 对于ML从业者,操作系统的概念解释了:为什么 `nvidia-smi` 显示每个进程的GPU内存使用量、为什么训练因"内存不足"而崩溃、为什么 `fork()` 会复制你的Python进程、以及为什么Docker容器提供隔离环境。
## 操作系统做什么
- OS有三个核心职责:
- **抽象**:将硬件复杂性隐藏在简洁的接口之后。程序读写"文件"而无需知道底层存储是SSD、HDD还是网络驱动器。它们分配"内存"而无需管理物理RAM芯片。它们在"CPU"上运行而无需担心中断和缓存一致性。
- **资源管理**:多个程序共享CPU、内存、磁盘和网络。OS决定谁获得什么资源、何时获得、获得多久。公平高效的分配策略保持系统的响应性。
- **隔离与保护**:程序之间不得相互干扰。浏览器中的Bug不应导致内核崩溃。恶意程序不应读取另一个程序的密码。OS利用硬件支持(特权级、虚拟内存)强制实施边界。
## 进程
- **进程**是正在运行的程序。它是OS的基本工作单元。每个进程都有:
- **代码**(程序指令,只读)。
- **数据**(全局变量,堆分配)。
- **堆栈**(函数调用帧,局部变量)。
- **状态**(寄存器值、程序计数器、打开的文件等)。
- **进程控制块(PCB)**是OS用于跟踪进程的数据结构。它存储进程ID(PID)、状态、程序计数器、寄存器内容、内存映射、打开的文件描述符和调度优先级。当OS从一个进程切换到另一个进程时,它将当前进程的状态保存到其PCB中,并加载下一个进程的状态。这就是**上下文切换**。
- 上下文切换代价高昂:保存和恢复寄存器、刷新缓存、使TLB项失效需要微秒级时间。在一个运行数千个进程的系统中,开销可能很大。这就是为什么每进程每请求的服务器架构(如老式Apache)被基于线程或事件驱动的架构取代。
- Unix中的**进程创建**使用 `fork()``exec()`
- `fork()` 创建当前进程的一个**副本**。子进程获得父进程内存、文件描述符和状态的一份副本。两个进程从同一点继续执行,但 `fork()` 在子进程中返回0,在父进程中返回子进程的PID。
- `exec()` 用新程序替换当前进程的代码。在 `fork()` 之后,子进程通常调用 `exec()` 来运行一个不同的程序。
- 这种先fork后exec的模型很优雅:创建新进程(fork)和加载新程序(exec)是独立的操作,可以各自定制。在fork和exec之间,子进程可以重定向I/O、更改环境变量或降低权限。
![进程状态转换:新建→就绪→运行→阻塞/终止,包含抢占和I/O等待](../images/process_states.svg)
- **进程状态**:一个进程处于以下几种状态之一:
- **运行**:当前在CPU核心上执行。
- **就绪**:等待CPU核心(可运行但尚未被调度)。
- **阻塞**(等待):无法继续,直到某个事件发生(I/O完成、锁获取、定时器到期)。
- **终止**:执行完毕,等待父进程收集其退出状态。
## 线程
- **线程**是进程内的轻量级执行单元。进程内的所有线程共享相同的代码、数据和堆,但每个线程有自己的堆栈和寄存器状态。
- 与多个进程相比的优势:线程共享内存,因此它们之间的通信很快(只需读写共享变量)。进程需要进程间通信(管道、套接字、共享内存映射),这更慢且更复杂。
- 劣势:共享内存是危险的。两个线程同时写入同一变量会导致**竞态条件**(结果取决于哪个线程先运行)。这引导我们进入同步问题,在文件4中介绍。
- **内核线程**由OS调度器管理。每个线程独立地被调度到CPU核心上。创建和切换内核线程涉及系统调用,开销与进程上下文切换类似(但更小)。
- **用户线程**(绿色线程)由用户空间的运行时库管理,对OS不可见。创建和切换它们的成本更低(无需系统调用),但一个用户线程的阻塞操作会阻塞进程中的所有线程(因为OS只看到一个内核线程)。
- 现代系统使用**混合模型**:许多用户线程映射到较少数量的内核线程上(M:N线程)。Go的goroutine和Erlang的进程是由语言运行时调度到OS线程上的用户级线程。
- **线程池**预先创建固定数量的线程,等待任务。当任务到达时,分配给一个空闲线程。这避免了为每个任务创建和销毁线程的开销。Web服务器、数据库引擎和ML推理服务器都使用线程池。
## CPU调度
- **调度器**决定每个时刻哪个进程/线程在哪个CPU核心上运行。目标是:最大化CPU利用率、最小化响应时间(对交互式任务)、最大化吞吐量(对批处理任务)、并确保公平性。
- **先来先服务(FCFS)**:进程按到达顺序运行。简单但存在**护航效应**:一个长时间运行的进程阻塞了后面所有较短的进程。
- **最短作业优先(SJF)**:运行最短的进程优先。可证明最小化平均等待时间,但需要预先知道作业长度(通常不可能)。其抢占式版本**最短剩余时间优先(SRTF)**,如果出现更短的作业则中断正在运行的作业。
- **轮转(RR)**:每个进程获得一个固定的**时间片**(如10 ms),然后被抢占并移到队列末尾。公平且响应性好,但时间片大小很重要:太小会导致过多上下文切换,太大则会退化为FCFS。
- **优先级调度**:每个进程有一个优先级。高优先级进程先运行。危险是**饥饿**:如果高优先级进程源源不断到来,低优先级进程可能永远无法运行。**老化**解决这个问题:进程等待时间越长,其优先级就越高。
- **多级反馈队列(MLFQ)**:具有不同优先级和时间片的多个队列。新进程从最高优先级队列(短时间片)开始。如果一个进程用完其时间片(CPU密集型),它被降到较低优先级队列(较长时间片)。交互式进程自然停留在高优先级队列中(它们在使用完时间片之前就因I/O阻塞了)。这可以适应工作负载,而无需预先了解作业类型。
- **完全公平调度器(CFS)**:Linux调度器。它维护一棵红黑树(平衡二叉搜索树),进程按"虚拟运行时间"(它们已经消耗的CPU时间)排序。具有最小虚拟运行时间的进程接下来运行。这确保了随着时间的推移,每个进程获得其公平份额。CFS每次调度决策运行时间为 $O(\log n)$。
## 内存管理
- OS管理物理RAM,将其分配给进程并在不再需要时回收。
- **分页**(来自文件2)将虚拟内存划分为固定大小的页,物理内存划分为帧。页表将页映射到帧。分页消除了外部碎片(分配之间的浪费空间),因为所有页面大小相同。
- **请求分页**仅在首次访问时将页加载到RAM中(而不是在进程启动时)。这节省了内存:一个拥有1 GB代码的程序在典型运行中可能只使用50 MB。其余部分从未被加载。
- 当RAM满且需要新页时,OS必须**换出**一个现有页面。**页面置换**算法(LRU、FIFO、时钟,来自文件2)决定换出哪个页面。好的置换最小化缺页次数;坏的置换导致系统颠簸。
- **分段**将内存划分为可变大小的段(代码、数据、栈、堆),每个段有自己的基地址和长度。分段提供逻辑组织,而分页提供物理管理。现代系统最小限度地使用分段(主要用于保护),并依赖分页进行内存管理。
- **堆**是动态分配内存所在的地方(C中的`malloc`/`free`Java中的`new`,Python中隐式管理)。OS向进程提供大块内存,**内存分配器**(如 `glibc malloc``jemalloc``tcmalloc`)将这些大块细分为更小的分配。分配器设计影响性能:碎片浪费空间,线程间的争用浪费时间。
## 文件系统
- **文件系统**将持久存储(SSD、HDD)上的数据组织为命名的文件和目录层次结构。
- **inode**(索引节点)存储文件的元数据:大小、所有权、权限、时间戳以及指向磁盘上数据块的指针。文件名存储在目录中,目录将名称映射到inode编号。这种分离意味着一个文件可以有多个名称(**硬链接**)指向同一个inode。
- **FAT**(文件分配表):一种简单的文件系统,用于USB驱动器和SD卡。一个表将每个簇(块)映射到文件中的下一个簇,形成一个链表。简单但不好支持权限、日志记录或大文件。
- **ext4**:默认的Linux文件系统。使用带有直接、间接、二级间接和三级间接块指针的inode来处理任何大小的文件。支持**区段**(块的连续范围)以高效处理大文件。最大文件大小:16 TB,最大分区:1 EB。
- **日志记录**防止因崩溃而损坏。在修改文件系统结构之前,更改被写入**日志**(journal)。如果系统在操作中间崩溃,重启时会重放日志以完成或撤销该操作。没有日志记录,写入期间的崩溃可能使文件系统处于不一致状态(文件的数据块已更新但其inode未更新,反之亦然)。
- **基于B树的文件系统**(Btrfs、ZFS)使用B树(平衡搜索树)来组织数据和元数据,实现高效搜索、写时复制快照以及用于数据完整性的内置校验和。这些与数据库索引中使用的B树相同。
## 系统调用与内核模式
- **系统调用**是用户程序和OS内核之间的接口。当程序需要做一些特权操作(读取文件、分配内存、创建进程、发送网络数据包)时,它会进行系统调用。
- CPU在两种模式下运行:
- **用户模式**:受限制。程序可以执行自己的代码并访问自己的内存,但不能直接访问硬件、其他进程的内存或OS数据结构。
- **内核模式**:不受限制。OS内核可以访问所有硬件和内存。系统调用是从用户模式到内核模式的受控通道。
- 当程序调用 `read()` 时,发生以下过程:
1. 程序将参数放入寄存器并触发**陷阱**(一种软件中断)。
2. CPU切换到内核模式并跳转到系统调用处理程序。
3. 内核验证参数,执行I/O操作,将数据复制到用户的缓冲区。
4. 内核切换回用户模式并返回结果。
- 常见系统调用:`open``read``write``close`(文件),`fork``exec``wait``exit`(进程),`mmap``brk`(内存),`socket``bind``listen``accept`(网络)。
- **中断**是迫使CPU暂时停止当前操作并运行中断处理程序(在内核中)的硬件信号。一次键盘按键、一个网络数据包到达或一个定时器滴答都会产生中断。定时器中断特别重要:它使OS能够抢占正在运行的进程并切换到另一个(抢占式多任务)。
## 网络基础
- 网络栈是OS的一个子系统,实现机器之间的通信。理解它解释了分布式训练如何同步梯度、模型服务如何处理请求以及为什么延迟很重要。
![TCP/IP栈:应用层、传输层、网络层和链路层,每层添加头部](../images/tcp_ip_layers.svg)
- **TCP/IP模型**将网络组织为分层结构,每层为上层提供抽象:
- **链路层**:处理单个物理链路上的通信(以太网、Wi-Fi)。处理MAC地址和帧。
- **网络层(IP)**:将数据包跨多个网络从源路由到目标。每台机器有一个**IP地址**(例如 IPv4 的 192.168.1.1 或 128位的IPv6地址)。路由器基于目标IP逐跳转发数据包。
- **传输层(TCP/UDP)**:提供应用程序之间的端到端通信。
- **应用层**HTTP、DNS、gRPC等协议,应用程序直接使用。
- **TCP**(传输控制协议)提供可靠、有序的交付。它建立一个连接(三次握手:SYN、SYN-ACK、ACK),保证所有数据按序到达(使用序列号和确认),重传丢失的数据包,并控制发送速率以避免网络过载(**拥塞控制**)。代价是延迟:握手增加了一个往返时间,重传增加了延迟。
- **UDP**(用户数据报协议)提供不可靠、无序的交付。无需握手、无需重传、无顺序保证。延迟远低于TCP。用于速度比可靠性更重要的场景:视频流、在线游戏、DNS查询。在ML中,一些梯度同步协议使用基于UDP的RDMA以获得更低延迟。
- **套接字**是用于网络通信的OS API。一个**套接字**是由(IP地址,端口号)标识的端点。服务器创建一个套接字,将其绑定到一个端口(例如HTTP的80),监听连接,并接受它们。客户端创建一个套接字并连接到服务器的地址:端口。然后通过套接字像文件一样读写数据。
- **DNS**(域名系统)将人类可读的名称(google.com)翻译为IP地址(142.250.80.46)。它是一个分布式的、层次化的数据库:你的机器询问本地解析器,后者询问根服务器,根服务器委托给每个域的权威服务器。
- **HTTP**(超文本传输协议)是Web的请求-响应协议。客户端发送一个请求(方法 + URL + 头部 + 可选体),服务器发送一个响应(状态码 + 头部 + 体)。ML模型服务(如TensorFlow Serving、Triton)将模型暴露为HTTP或gRPC端点。
- **延迟 vs 带宽**:延迟是一个数据包从源到目标所需的时间(由物理距离和网络跳数决定)。带宽是数据传输速率(每秒字节数)。高带宽、高延迟的连接(卫星互联网)可以传输大量数据,但每个字节需要很长时间才能到达。对于分布式训练,**延迟**对同步屏障(所有GPU必须等待最慢的那个)很重要,而**带宽**对传输大的梯度张量很重要(第6章)。
## 虚拟化与容器
- **虚拟化**在单个物理机上运行多个操作系统。**虚拟机监视器**(VMware、KVM、Xen)创建**虚拟机(VM)**,每个虚拟机有自己的虚拟CPU、内存、磁盘和网络接口。每个虚拟机运行一个完整的操作系统(来宾OS),它认为自己拥有专用硬件。
- VM提供强隔离(一个VM崩溃不影响其他VM)和灵活性(在同一台机器上运行Linux和Windows,在物理主机之间迁移VM)。代价是开销:每个VM运行一个完整的OS内核,消耗内存和CPU来执行与宿主机OS冗余的OS操作。
![VM在虚拟硬件上运行独立的来宾OS;容器共享宿主机内核,轻量得多](../images/container_vs_vm.svg)
- **容器**Docker、Podman)提供了一种更轻量的替代方案。容器不是虚拟化整个硬件,而是共享宿主机OS内核,并使用内核特性来隔离进程:
- **命名空间**隔离进程可以看到的内容:每个容器拥有自己的进程树视图(PID命名空间)、网络接口(网络命名空间)、文件系统挂载点(挂载命名空间)和主机名(UTS命名空间)。容器内的进程不能看到其他容器中的进程。
- **Cgroups**(控制组)限制进程可以使用的内容:CPU时间、内存、磁盘I/O、网络带宽。容器不能消耗超过其cgroup允许的资源,防止一个容器饿死其他容器。
- 容器在毫秒内启动(无需OS启动),使用最小开销(共享内核),并通过**Dockerfile**定义,该文件指定基础镜像、依赖项和命令。这使得它们可复现:`docker build` 在任何地方产生相同的环境。
- 对于ML,容器解决了"在我机器上能运行"的问题。具有特定版本CUDA、cuDNN、PyTorch和Python的训练环境被打包为容器镜像。任何人都可以在任何机器上复现确切的环境。云训练平台(AWS SageMaker、GCP Vertex AI)在容器中运行训练任务。
- **Kubernetes**(K8s)大规模编排容器:它将容器调度到集群中的多台机器上,重启失败的容器,根据负载进行扩缩容,并管理容器之间的网络。大规模ML服务(数千个模型副本处理数百万请求)在Kubernetes上运行。
## 安全基础
- OS通过多种机制实施安全:
- **权限**:每个文件有一个所有者、一个组和权限位(拥有者、组和其他人的读/写/执行)。进程以启动它的用户的身份(UID)运行,只能访问权限位允许的文件。**root**用户(UID 0)绕过所有权限检查,这就是为什么以root身份运行是危险的。
- **权限分离**:进程以所需的最小权限运行。Web服务器不需要root访问权限;它应该以一个受限用户身份运行,该用户只能读取Web文件并绑定到端口80。如果服务器被攻破,攻击者的访问限制在该受限用户能做的范围内。
- **沙箱化**:限制进程在文件权限之外能做的事情。**seccomp**(Linux)限制进程可以进行的系统调用。**AppArmor**和**SELinux**定义强制访问控制策略。容器结合了命名空间、cgroups和seccomp进行多层隔离。
- **地址空间布局随机化(ASLR)**:每次程序运行时,随机化堆栈、堆和库的内存位置。这使得攻击者更难利用内存损坏漏洞(缓冲区溢出),因为他们无法预测代码或数据在内存中的位置。
- 安全是一个全系统层面的关注:链条的强度取决于最弱的一环。模型服务系统需要安全的网络通信(TLS/HTTPS)、经过身份验证的API访问(API密钥、OAuth)、输入验证(防止对抗性输入)和隔离执行(具有最小权限的容器)。
## 编程任务(使用CoLab或笔记本)
1. 探索进程创建。使用Python的 `os.fork()`(仅Unix)创建一个子进程,并观察父进程和子进程如何从同一点继续执行。
```python
import os
pid = os.fork()
if pid == 0:
# 子进程
print(f"Child: my PID is {os.getpid()}, parent PID is {os.getppid()}")
else:
# 父进程
print(f"Parent: my PID is {os.getpid()}, child PID is {pid}")
os.wait() # 等待子进程结束
```
2. 模拟轮转调度。给定一个带有执行时间的进程列表,模拟调度并计算平均等待时间。
```python
def round_robin(processes, quantum=3):
"""模拟轮转调度。
processes: (name, burst_time) 元组列表。
"""
queue = [(name, burst, 0) for name, burst in processes] # (name, remaining, wait)
time = 0
log = []
while queue:
name, remaining, waited = queue.pop(0)
waited += (time - waited - (processes[[p[0] for p in processes].index(name)][1] - remaining))
run_time = min(quantum, remaining)
log.append(f" t={time:3d}: {name} runs for {run_time} (remaining: {remaining - run_time})")
time += run_time
remaining -= run_time
if remaining > 0:
queue.append((name, remaining, time))
else:
log.append(f" t={time:3d}: {name} DONE (turnaround: {time})")
for line in log:
print(line)
print("轮转调度 (quantum=3)")
round_robin([("P1", 10), ("P2", 4), ("P3", 6)], quantum=3)
```
3. 模拟LRU页面置换。给定一个页面访问序列和固定数量的帧,统计缺页次数。
```python
def lru_page_replacement(pages, n_frames):
"""模拟LRU页面置换。"""
frames = []
faults = 0
for page in pages:
if page in frames:
frames.remove(page)
frames.append(page) # 移动到最近使用
status = "HIT "
else:
faults += 1
if len(frames) >= n_frames:
evicted = frames.pop(0) # 移除最近最少使用
status = f"MISS (evict {evicted})"
else:
status = "MISS (cold)"
frames.append(page)
print(f" Page {page}: {status} frames={frames}")
print(f"\nTotal faults: {faults}/{len(pages)} ({faults/len(pages):.0%})")
print("LRU with 3 frames:")
lru_page_replacement([1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5], n_frames=3)
```
@@ -0,0 +1,226 @@
# 并发与并行
*并发与并行是程序同时处理多件事情的方式。本文涵盖并发与并行的区别、同步原语、经典并发问题、死锁、无锁数据结构、并行编程模型、异步编程和扩展定律——这些概念支撑着多线程服务器、分布式训练和每一个现代应用程序。*
- 单个CPU核心一次执行一条指令。但现代系统有8个、64个甚至数千个核心(GPU)。即使在单核上,我们也希望处理多个任务:一边下载文件一边渲染界面一边处理用户输入。**并发**和**并行**是管理多个活动的两种策略。
## 并发 vs 并行
![并发在单核上交错执行任务;并行在多核上同时执行任务](../images/concurrency_vs_parallelism.svg)
- **并发**是关于*管理*多个任务。任务通过交错进行:任务A运行一会儿,然后任务B,然后回到A。在单核上,并发创造了同时执行的假象。这些任务并非真正同时执行;它们轮流进行。
- **并行**是关于*执行*多个任务同时进行。有$n$个核心,$n$个任务可以真正同时运行。并行需要多个硬件执行单元。
- 类比:并发是一个厨师交替切菜和搅拌锅。并行是两个厨师各自同时做一个任务。一个系统可以是并发但不并行的(单核,任务交错),并行但不并发的(多核运行独立程序,没有交互),或者两者兼有(多核运行互相交错交互的任务)。
- 在ML中,并发出现在数据加载中(数据预处理与GPU计算重叠),而并行出现在分布式训练中(多个GPU同时计算梯度,第6章)。
## 同步原语
- 当多个线程共享数据时,**同步**防止竞态条件。竞态条件发生在结果依赖于线程执行的不可预测顺序时。
- 考虑两个线程同时增加一个共享计数器:`counter += 1`。这实际上是三个操作:(1)读取计数器,(2)加1,(3)写入计数器。如果两个线程读取相同的值(比如5),都加1,都写入6,计数器最终为6而不是正确的7。一次增加丢失了。
- **互斥锁**(互斥排斥锁)确保一次只有一个线程访问临界区。一个线程在进入临界区前**获取**锁,之后**释放**锁。任何其他试图获取已被持有锁的线程将阻塞直到锁被释放。
```
lock.acquire()
counter += 1 # 一次只有一个线程在此
lock.release()
```
- 互斥锁是正确的,但会引入**争用**:如果许多线程竞争同一个锁,它们花费时间等待而不是计算。这限制了可扩展性。极端情况下,所有线程都想要同一个锁,会使整个程序串行化。
- **信号量**泛化了互斥锁。计数信号量维护一个计数器:`wait()` 递减计数器(如果会变负则阻塞),`signal()` 递增计数器。初始化为1的信号量行为类似互斥锁。初始化为$n$的信号量允许最多$n$个线程同时进入临界区(适用于资源池如数据库连接)。
- **条件变量**让一个线程等待直到某个特定条件满足。该线程释放一个锁,在条件变量上等待,当另一个线程发出该条件的信号时被唤醒。这避免了忙等待(在一个循环中反复检查条件,浪费CPU)。
- **监视器**将互斥锁与条件变量和共享数据捆绑为一个单一抽象。Java的 `synchronized` 关键字和Python的 `threading.Condition` 实现了类似监视器的语义。
- **读写锁**区分读线程(可以共享访问,因为读取不会修改数据)和写线程(需要独占访问)。多个读线程可以同时持有锁,但一个写线程会阻塞所有读线程和其他写线程。当读操作远多于写操作时(例如,提供预测的缓存模型),这是最优的。
## 经典并发问题
- **生产者-消费者**(有界缓冲区):生产者生成项目并将其放入固定大小的缓冲区;消费者移除项目。挑战:缓冲区满时生产者必须等待,缓冲区空时消费者必须等待,且两者必须防止损坏缓冲区。
- 解决方案使用两个信号量(一个计数空位,一个计数满位)加上一个用于缓冲区本身的互斥锁。这是大多数消息队列、日志系统和数据管道背后的模式。
- **读者-写者**:多个读者可以同时读取,但写者需要独占访问。挑战是公平性:如果读者源源不断地到来,写者可能饥饿(永远得不到访问)。解决方案要么优先考虑读者,要么优先考虑写者,要么公平地交替。
- **哲学家就餐问题**:五位哲学家围坐在一张有五个叉子的桌子旁。每人需要两把叉子才能吃饭。如果所有五位同时拿起左边的叉子,没人能拿起右边的叉子,所有人都饿死(死锁)。解决方案包括:同时拿起两把叉子(原子操作),引入不对称性(一位哲学家先拿右边的叉子),或者使用服务员(限制用餐人数为4的信号量)。
## 死锁
- **死锁**发生在一组线程各自等待集合中另一个线程持有的资源,形成一个依赖循环。没有人能继续。
![死锁:线程A持有锁1想要锁2,线程B持有锁2想要锁1——循环等待](../images/deadlock_cycle.svg)
- 死锁的四个**必要条件**(必须同时满足):
1. **互斥**:资源一次只能被一个线程持有。
2. **持有并等待**:一个线程持有一个资源的同时等待另一个资源。
3. **不可剥夺**:资源不能被强制从线程中拿走。
4. **循环等待**:等待图中存在一个循环。
- **死锁预防**打破四个条件之一:
- 消除循环等待:对资源施加全序。所有线程以相同的顺序获取资源。如果每个线程总是在获取锁A之后才获取锁B,则不可能有循环。
- 消除持有并等待:要求线程一次性(原子地)请求所有资源。
- **死锁避免**动态决定是否批准一个资源请求可能导致死锁。**银行家算法**维护每个线程的最大可能需求,仅批准使系统保持"安全状态"(所有线程最终都能完成的状态)的请求。该算法每个请求 $O(n^2 m)$($n$个线程,$m$种资源类型),对大多数实际系统来说过于昂贵。
- **死锁检测**让死锁发生,然后检测它们(通过在等待图中找到循环)并恢复(通过杀死一个线程或回滚一个事务)。
- 在实践中,大多数系统对常见情况使用预防(资源排序),对罕见情况使用检测。数据库系统是经典例子:它们检测事务之间的死锁并中止一个来打破循环。
## 无锁和免等待数据结构
- 锁引入了争用、优先级反转和死锁风险。**无锁**数据结构完全避免使用锁,使用硬件提供的**原子操作**。
- 关键的原子操作是**比较并交换(CAS)**:原子地检查一个内存位置是否具有期望的值,如果是,则将其替换为新值。伪代码:
```
CAS(address, expected, new_value):
if *address == expected:
*address = new_value
return true
else:
return false
```
- CAS实现为单个硬件指令,因此即使没有锁也是原子的。无锁算法使用重试循环中的CAS:读取当前值,计算新值,尝试CAS。如果另一个线程在此期间修改了该值,CAS失败,线程重试。
- **无锁**:至少一个线程在有限步骤内取得进展(不可能死锁,但个别线程在争用下可能无限重试)。
- **免等待**:每个线程在有限步骤内取得进展(最强保证,但最难实现)。
- 无锁的堆栈、队列和哈希映射广泛用于高性能系统。Java的 `ConcurrentHashMap` 和Go的原子操作都建立在CAS之上。
## 并行编程模型
- **共享内存**并行:所有线程访问同一内存空间。同步是程序员的责任。**OpenMP**提供编译器指令来并行化循环:
```c
#pragma omp parallel for
for (int i = 0; i < n; i++) {
result[i] = compute(data[i]);
}
```
- 编译器将循环迭代拆分到可用的核心上。OpenMP对数据并行工作负载(对许多数据点执行相同操作)很有效,广泛用于科学计算。
- **消息传递**并行:每个进程有自己的内存。通信通过发送和接收消息实现。**MPI**(消息传递接口)是跨节点分布式计算的标准:
```c
MPI_Send(data, count, MPI_FLOAT, dest, tag, MPI_COMM_WORLD);
MPI_Recv(data, count, MPI_FLOAT, src, tag, MPI_COMM_WORLD, &status);
```
- MPI可扩展到数千个节点,因为没有需要同步的共享状态。分布式深度学习(第6章)使用集合操作如 `MPI_AllReduce`(环状 all-reduce)来跨GPU同步梯度。
- **GPU并行**遵循**SIMT**(单指令多线程)模型:数千个线程在不同数据上执行相同的指令。这非常适合矩阵运算(第2章),其中相同的乘加操作应用于每个元素。我们将在后续章节中详细介绍GPU编程。
## 异步与事件驱动编程
- 并非所有并发都需要线程。**异步**编程使用**事件循环**在单个线程中处理许多I/O密集型任务。
- 事件循环维护一个任务队列。当一个任务需要等待I/O(网络响应、文件读取)时,它注册一个回调并交出控制权。事件循环选取下一个就绪的任务。当I/O完成时,回调被排队并最终执行。等待期间没有线程被阻塞。
- **协程**是可以暂停和恢复的函数。`async/await` 语法(Python、JavaScript、Rust)使协程看起来像常规的顺序代码:
```python
async def fetch_data(url):
response = await http_get(url) # 在此暂停,事件循环运行其他任务
return process(response) # 响应到达时恢复
```
- `await` 关键字暂停协程并将控制权返回给事件循环。当等待的操作完成时,协程从中断处恢复。这是协作式多任务:协程自愿放弃控制,不同于抢占式多任务中OS强制切换线程。
- 异步适用于具有许多并发连接的**I/O密集型**工作负载(处理数千个客户的Web服务器)。它不适用于**CPU密集型**工作(单线程事件循环无法利用多核)。对于CPU密集型工作,请使用线程或进程。
- Python的**全局解释器锁(GIL)**阻止线程真正的并行:一次只有一个线程可以执行Python字节码。这就是为什么Python对CPU并行使用多处理(独立的进程,每个有自己的解释器),对I/O并发使用异步。GIL正在Python 3.13+中被移除(自由线程Python),这将启用真正的多线程并行。
## 扩展定律
- **阿姆达尔定律**描述了并行化程序的理论加速。如果程序的$p$部分是可并行的,其余 $1-p$ 部分是串行的:
$$\text{加速比}(n) = \frac{1}{(1-p) + \frac{p}{n}}$$
![阿姆达尔定律:串行部分限制了最大加速比——10%串行意味着最大10x,无论多少核心](../images/amdahl_serial_bottleneck.svg)
- 其中$n$是处理器数量。当 $n \to \infty$ 时,最大加速比趋近于 $\frac{1}{1-p}$。如果95%的程序是并行的,最大加速比为 $\frac{1}{0.05} = 20\times$,无论你添加多少核心。串行部分就是瓶颈。
- 这对ML有深远影响:如果数据加载花费训练时间的10%并且是串行的,增加更多GPU最多只能将训练加速10倍。10%的串行瓶颈限制了所有东西(这就是为什么高效的数据管道和I/O与计算重叠很重要,第6章)。
- **古斯塔夫森定律**提供了更乐观的视角。它不是在固定问题规模并添加处理器,而是固定总时间并问可以做多少额外工作。如果并行部分随问题规模扩展:
$$\text{加速比}(n) = 1 - p + p \cdot n$$
- 这是关于$n$线性的。论证是:用更多处理器,我们解决更大的问题,而不是更快地解决同一问题。在ML中,这对应于用更多GPU增加批量大小(弱扩展),而不是保持批量大小固定(强扩展)。
## 编程任务(使用CoLab或笔记本)
1. 演示竞态条件。两个线程在没有同步的情况下增加一个共享计数器,观察丢失的更新。
```python
import threading
counter = 0
def increment(n):
global counter
for _ in range(n):
counter += 1 # 不是原子的:读、加、写
threads = [threading.Thread(target=increment, args=(100000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Expected: {4 * 100000}")
print(f"Actual: {counter}")
print(f"Lost updates: {4 * 100000 - counter}")
```
2. 用锁修复竞态条件并测量开销。
```python
import threading
import time
lock = threading.Lock()
counter = 0
def increment_locked(n):
global counter
for _ in range(n):
with lock:
counter += 1
start = time.time()
threads = [threading.Thread(target=increment_locked, args=(100000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
elapsed = time.time() - start
print(f"Counter: {counter} (correct: {4 * 100000})")
print(f"Time with lock: {elapsed:.3f}s")
```
3. 可视化阿姆达尔定律。绘制不同并行比例下加速比与处理器数量的关系图。
```python
import jax.numpy as jnp
import matplotlib.pyplot as plt
n_procs = jnp.arange(1, 65)
for p, color in [(0.5, "#e74c3c"), (0.9, "#f39c12"), (0.95, "#27ae60"), (0.99, "#3498db")]:
speedup = 1 / ((1 - p) + p / n_procs)
plt.plot(n_procs, speedup, color=color, linewidth=2, label=f"p={p}")
# 最大加速比线
plt.axhline(1 / (1 - p), color=color, linestyle="--", alpha=0.3)
plt.xlabel("处理器数量")
plt.ylabel("加速比")
plt.title("阿姆达尔定律:串行比例限制加速比")
plt.legend()
plt.grid(True)
plt.show()
```
@@ -0,0 +1,273 @@
# 编程语言
*编程语言是人类意图与机器执行之间的接口。本文涵盖语言范式、类型系统、内存管理策略、编译流水线、解释与JIT编译、关键语言特性、领域特定语言以及设计权衡。*
- 每一份软件、每一个ML模型、每一个操作系统都是用编程语言编写的。但存在数百种语言,每种都有不同的优势。为什么?因为语言设计涉及基本的权衡:性能 vs 安全、表现力 vs 简洁性、控制 vs 抽象。理解这些权衡有助于你为工作选择合适的工具,并理解你所处的约束。
## 语言范式
- **范式**是一种编程风格:一套指导你如何组织代码和思考问题的原则。
- **命令式**编程将计算描述为一系列改变状态的命令。"设x为5。将3加到x。如果x > 7,打印它。"C、Python和Java本质上是命令式的。心智模型是一个带有内存的机器,你逐步修改它。
- **面向对象(OOP)**编程围绕**对象**组织代码:数据(属性)和行为(方法)的捆绑。对象通过相互发送消息来交互。关键思想是**封装**(将内部状态隐藏在公共接口之后)、**继承**(通过扩展现有类创建新类)和**多态**(通过共享接口统一处理不同类型)。Java、C++和Python支持OOP。
- **函数式编程(FP)**将计算视为数学函数的求值。核心原则:**不可变性**(数据一旦创建就不改变)、**纯函数**(输出仅取决于输入,无副作用)和**一等函数**(函数是可以作为参数传递、从其他函数返回和存储在变量中的值)。Haskell是纯函数式的。Python、JavaScript和Scala支持函数式风格。
- 纯函数易于推理、测试和并行化(没有共享的可变状态意味着没有竞态条件)。这就是为什么函数式思想越来越多地用于分布式系统和数据管道。JAX(本书中一直在使用)是函数式的:`jax.grad` 之所以有效,是因为JAX函数是纯函数。
- **逻辑编程**描述*什么*应该为真,而不是*如何*计算它。你陈述事实和规则,运行时找到解。Prolog是经典例子:给定"苏格拉底是人"和"所有人都是必死的",引擎推导出"苏格拉底是必死的。"逻辑编程用于AI知识库和类型检查。
- 大多数现代语言是**多范式**的:Python支持命令式、OOP和函数式风格。Rust支持命令式和函数式。范式是一种工具,不是信仰。
## 类型系统
- **类型**对值进行分类,并确定哪些操作是有效的。整数3和字符串"3"是不同的类型:你可以对整数进行加法,但不能对字符串(好吧,你可以拼接字符串,但那是不同的操作)。
- **静态类型**:类型在**编译时**检查,在程序运行之前。类型错误及早被发现。C、Java、Rust和Go是静态类型的。你必须声明类型(或者编译器推断它们):
```rust
let x: i32 = 5; // Rustx是一个32位整数
let y: f64 = 3.14; // y是一个64位浮点数
// let z = x + y; // 编译错误:不能加 i32 和 f64
```
- **动态类型**:类型在**运行时**检查,当操作实际执行时。更灵活,但类型错误只有在代码运行时才暴露。Python、JavaScript和Ruby是动态类型的:
```python
x = 5 # x是一个int(目前)
x = "hello" # 现在x是一个字符串——没有错误
```
- **强类型**:语言阻止隐式类型转换。Python是强类型的:`"3" + 5` 引发TypeError。**弱类型**:语言静默地转换类型。JavaScript是弱类型的:`"3" + 5` 得到 `"35"`(数字被强制转换为字符串)。C是弱类型的:你可以将指针强制转换为整数。
- **类型推断**让编译器推导类型而无需显式注解:
```rust
let x = 5; // 编译器推断:i32
let y = x + 3.0; // 编译错误:混合类型,即使有推断
```
- **泛型**(参数化多态)让你编写适用于任何类型的代码:
```rust
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut max = &list[0];
for item in &list[1..] {
if item > max { max = item; }
}
max
}
// 适用于整数、浮点数、字符串——任何支持比较的类型
```
- 对于ML:Python的动态类型使实验快速,但隐藏了错误。生产ML系统越来越多地使用类型提示(`def train(model: nn.Module, lr: float) -> float`)和静态分析工具(mypy)以在部署前捕获错误。PyTorch和JAX使用Python以获得灵活性;TensorRT和ONNX Runtime使用C++以获得性能。
## 内存管理
- 每个程序分配和释放内存。如何管理这是最具影响力的语言设计决策之一。
![内存布局:堆栈从高地址向下增长,堆向上增长,代码和数据在底部](../images/stack_vs_heap.svg)
- **堆栈**存储局部变量和函数调用帧。分配很简单(移动栈指针),释放是自动的(函数返回时弹出帧)。堆栈访问很快,因为它总在缓存中。但堆栈有固定大小(通常1-8 MB),且仅支持LIFO(后进先出)分配。
- **堆**存储动态分配的数据(编译时大小未知的对象、数组、字符串)。堆分配较慢(需要找到一个空闲块),需要显式或自动释放。堆可以增长到填满可用内存。
- **手动内存管理**(C、C++):程序员显式分配(`malloc`)和释放(`free`)堆内存。最大控制和性能,但极易出错:
- **释放后使用**:访问已被释放的内存。导致崩溃或安全漏洞。
- **双重释放**:释放同一内存两次。破坏分配器的内部数据结构。
- **内存泄漏**:分配了内存但从未释放。程序慢慢消耗所有可用RAM。
- **垃圾回收(GC)**:运行时自动检测并释放不再可达的内存。程序员从不调用 `free`
- **跟踪GC**Java、Go、Python的循环收集器):定期从"根"(堆栈变量、全局变量)遍历所有可达对象,释放不可达对象。简单但导致**GC暂停**:收集器运行时程序停止。现代收集器(Go的并发GC、Java的ZGC)将暂停时间最小化到亚毫秒级。
- **引用计数**Python的主要机制、Swift、Objective-C):每个对象跟踪有多少引用指向它。当计数降到0时,对象被立即释放。无暂停,但无法处理**循环**(A引用B,B引用A,两者计数都 > 0 但都不可达)。Python使用单独的循环检测器来处理此问题。
- **所有权**(Rust):编译器在编译时强制实施内存安全规则,零运行时开销。
- 每个值有且仅有一个**所有者**。当所有者超出作用域时,该值被丢弃(释放)。
- 值可以被**借用**(引用),但编译器强制:要么一个可变引用,要么任意数量的不可变引用,永远不能同时存在。
- 这阻止了释放后使用、双重释放、数据竞争和悬垂指针,全部在编译时完成。无需GC,无运行时开销。
- **借用检查器**是Rust的杀手级特性,也是其最陡峭的学习曲线。它保证了内存安全和线程安全,且没有垃圾回收,这就是Rust越来越多地用于性能关键系统(OS内核、游戏引擎、ML推理运行时如Candle和Burn)的原因。
## 编译流水线
- **编译器**在程序运行之前将源代码转换为机器码(或其他目标语言)。该流水线有几个阶段:
![编译流水线:源代码→词法分析器→解析器→语义分析→优化器→代码生成→机器码](../images/compilation_pipeline.svg)
1. **词法分析**(分词):将源文本转换为令牌流。`x = 3 + y` 变为 `[IDENT("x"), EQUALS, INT(3), PLUS, IDENT("y")]`。词法分析器去除空白和注释。
2. **语法分析**:从令牌流构建**抽象语法树(AST)**。AST表示程序的层次结构。`3 + y * 2` 解析为 `Add(3, Mul(y, 2))`(乘法优先级更高)。解析器检查语法:括号不匹配和缺少分号在此被捕获。
3. **语义分析**:检查类型、解析变量名、验证函数调用参数是否正确。静态类型检查在此发生。输出是带类型注解的AST。
4. **优化**:在不改变行为的情况下转换程序以使其运行更快。常见优化:
- **常量折叠**:在编译时计算 `3 + 5`,替换为 `8`
- **死代码消除**:移除永远无法执行的代码。
- **循环展开**:用重复的内联代码替换循环以减少分支开销。
- **内联**:用函数体替换函数调用,消除调用开销。
5. **代码生成**:将优化后的表示转换为目标机器码(x86、ARM)或中间表示。
- **LLVM**是主流的编译器基础设施。它提供了一个通用中间表示(LLVM IR),许多语言可以编译到该表示上。LLVM的优化器在这个IR上工作,其后端为许多目标生成机器码。Clang(C/C++)、Rust、Swift、Julia和许多其他语言使用LLVM。这意味着LLVM优化器的改进同时惠及所有这些语言。
## 解释与JIT编译
- **解释器**逐行(或逐语句)执行程序而不产生机器码。这使得启动快速且开发交互式,但执行较慢(每行每次运行时都要重新分析)。
- 大多数解释型语言实际上编译为**字节码**:一种比源代码更简单但不特定于机器的中间表示。字节码在**虚拟机(VM)**上运行。
- **CPython**(标准Python实现)将Python源代码编译为字节码(`.pyc` 文件),由CPython VM执行。VM逐条指令解释字节码。这就是为什么Python在计算密集型代码上比C慢约~100倍。
- **JVM**Java虚拟机):Java编译为JVM字节码(`.class` 文件)。JVM最初解释字节码,然后**JIT编译**频繁执行的代码路径("热点")为本机机器码。这就是为什么Java启动比C慢(解释开销),但对于长时间运行的程序(JIT优化的热路径)接近C的速度。
- **JIT(即时)编译**在运行时将代码编译为机器码,使用仅在执行期间可用的信息。JIT可以根据实际运行时数据进行优化:如果一个函数总是用整数参数调用,JIT生成专门化的仅整数机器码,跳过类型检查。
- **PyPy**是另一个带有JIT编译器的Python实现。它通过将热点循环JIT编译为机器码,使大多数Python代码运行速度比CPython快5-10倍。然而,它与C扩展模块(NumPy、PyTorch)的兼容性有限,这限制了它在ML中的使用。
- 从解释到编译的范围不是二元的:
- 纯解释:Bash shell脚本。
- 字节码解释:CPython。
- 字节码 + JITJVM、.NET CLR、LuaJIT、PyPy。
- 提前(AOT)编译:C、C++、Rust、Go。
- AOT + 运行时代码生成:JAX的 `jax.jit` 在首次调用时编译Python函数为优化的XLA代码,然后缓存编译后的版本。
## 关键语言特性
- **闭包**:捕获其包围作用域中变量的函数。该函数"闭合"其定义时的环境:
```python
def make_adder(n):
def add(x):
return x + n # n 从包围作用域捕获
return add
add5 = make_adder(5)
print(add5(3)) # 8
```
- 闭包是回调、装饰器和部分应用背后的机制。它们对函数式编程至关重要。
- **模式匹配**:一种强大的控制流机制,解构数据并根据其形状进行分支:
```rust
match value {
Some(x) if x > 0 => println!("Positive: {}", x),
Some(0) => println!("Zero"),
Some(x) => println!("Negative: {}", x),
None => println!("Nothing"),
}
```
- 模式匹配比if-else链更具表现力:它检查数据的结构(是Some还是None?它包含的值是否符合某个条件?),而不仅仅是相等性。Python在3.10中增加了结构模式匹配(`match`/`case`)。
- **代数数据类型(ADT)**:可以是多个变体之一的类型,每个变体携带不同的数据。`Result` 类型要么是 `Ok(value)` 要么是 `Err(error)``Tree` 要么是 `Leaf(value)` 要么是 `Node(left, right)`。ADT结合模式匹配可以穷尽处理所有情况,消除整类bug(空指针异常、未处理的错误码)。
- **特质与接口**:定义一个类型必须实现的一组方法,而不指定如何实现。这实现了多态:一个接受"任何实现了Display特质的类型"的函数可以处理整数、字符串和自定义类型。Rust使用特质,Java使用接口,Go使用隐式接口,Python使用鸭子类型("如果它走路像鸭子……")。
## 领域特定语言
- **领域特定语言(DSL)**是为特定问题域设计的语言,在该领域内用通用性换取表现力。
- **SQL**:关系数据库的语言。`SELECT name FROM users WHERE age > 30` 比等价的命令式循环可读性强得多且更易优化。数据库引擎优化查询执行计划,自动选择连接策略和索引使用。
- **正则表达式**:用于文本模式匹配的微型语言。`\d{3}-\d{4}` 匹配像"555-1234"这样的电话号码。正则引擎将模式编译为有限自动机以实现高效匹配。
- **着色器语言**GLSL、HLSL、Metal Shading Language):在GPU核心上运行的程序,用于计算像素颜色、顶点位置或计算操作。着色器是海量并行的:每次调用独立处理一个像素或一个元素。这与CUDA用于ML计算的执行模型相同。
- 在ML中,像PyTorch和JAX这样的框架本质上是嵌入在Python中的张量计算DSL。它们提供领域特定的抽象(张量、自动微分、设备放置),同时利用Python的生态系统。
## 语言设计权衡
- 没有一种语言在所有方面都是最好的。设计是关于选择哪些权衡:
- **性能 vs 安全**:C提供了原始速度和硬件控制,但会让你破坏内存。Rust以编译时内存安全提供相当的速度。Java提供内存安全但有垃圾回收开销。Python提供最大的安全性和表现力,但执行速度慢100倍。
- **表现力 vs 简洁性**:Haskell的类型系统可以表达非常精确的约束,但有陡峭的学习曲线。Go故意省略了泛型(直到最近)、继承和异常以追求简洁性。Python的"应该有一种——最好只有一种——显而易见的做法"哲学保持了语言的可学习性。
- **控制 vs 抽象**:C/C++让你控制内存布局、缓存行为和硬件交互。Python隐藏了所有这些。对于ML训练(GPU计算占主导),Python的开销可以忽略不计。对于ML推理(每微秒都很关键),C++或Rust可能是必要的。
- **编译速度 vs 运行时速度**:Go在几秒内编译完成(简单的类型系统,最小优化)。Rust需要几分钟编译(复杂的类型系统,激进优化)。权衡的是开发者迭代速度与部署后的性能。
- ML生态系统反映了这些权衡:Python用于实验和训练(表现力取胜),C++/CUDA用于内核和推理(性能取胜),Rust用于基础设施和安全关键系统(安全取胜)。
## 编程任务(使用CoLab或笔记本)
1. 探索闭包和高阶函数。实现一个简单的函数工厂,验证闭包捕获其环境。
```python
def make_multiplier(factor):
"""返回一个将输入乘以 factor 的函数。"""
def multiply(x):
return x * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(f"double(5) = {double(5)}") # 10
print(f"triple(5) = {triple(5)}") # 15
# 闭包通过引用捕获,而不是通过值
def make_counter():
count = [0] # 可变的容器以允许修改
def increment():
count[0] += 1
return count[0]
return increment
counter = make_counter()
print(f"counter() = {counter()}") # 1
print(f"counter() = {counter()}") # 2
print(f"counter() = {counter()}") # 3
```
2. 比较动态与静态类型行为。展示Python的动态类型如何提供灵活性但可能隐藏bug。
```python
def add(a, b):
return a + b
# 适用于不同类型——灵活!
print(add(3, 5)) # 8 (int + int)
print(add("hello ", "world")) # "hello world" (str + str)
print(add([1, 2], [3, 4])) # [1, 2, 3, 4] (list + list)
# 但类型错误仅在运行时暴露:
try:
print(add("hello", 5)) # TypeErrorstr + int
except TypeError as e:
print(f"运行时错误:{e}")
print("静态类型检查器会在运行前捕获此问题")
```
3. 测量解释型Python与编译/JIT方法在计算密集型任务上的性能差异。
```python
import time
import jax
import jax.numpy as jnp
n = 1_000_000
# 纯Python循环(解释型)
start = time.time()
total = 0.0
for i in range(n):
total += i * i
python_time = time.time() - start
# JAX(通过XLA编译)
@jax.jit
def sum_squares_jax(n):
return jnp.sum(jnp.arange(n, dtype=jnp.float32) ** 2)
_ = sum_squares_jax(10) # 预热JIT
start = time.time()
result = sum_squares_jax(n)
jax_time = time.time() - start
print(f"Python loop: {python_time:.4f}s")
print(f"JAX (JIT): {jax_time:.6f}s")
print(f"Speedup: {python_time / jax_time:.0f}x")
```