DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程1-2 来源https://root-11.codeberg.page/intro-book-python/1 — 机器模型大多数关于“计算机如何工作”的解释都会使用一个带有 CPU 和一个称为内存的大块的示意图。这个图是错的。内存是许多不同速度的东西你的数据位于哪个部分决定了你的程序是快还是慢。CPU 内部有L1 缓存——很小每核心有时只有 32 KB但从它读取大约需要 1 纳秒。它周围是L2——几百 KB大约 3-4 纳秒。然后是L3——以 MB 计量大约 10 纳秒。CPU 外部是主内存 (RAM)——以 GB 计量每次读取大约 100 纳秒。这些数字因芯片而异但比率是稳定的。L1 大约比 RAM 快一百倍。当你的代码读取arr[17]时CPU 不只拉取第 17 个字节。它拉取一个完整的 64 字节块——一个缓存行——并将该行保存在 L1 中。随后读取arr[18]几乎是免费的。顺序读取很快因为每个被加载的行在被驱逐之前大部分都被使用了。随机读取很慢因为每次读取都需要一次新的 RAM 访问。指针是内存中的一个地址。跟随一个指针就是一次内存读取而 CPU 无法预测这个地址。如果该地址在缓存中读取很快如果不在你需要等待完整的约 100 纳秒。一个拥有许多对象和它们之间许多指针的程序是一个充满了这些等待的程序。为什么你以前不需要考虑这些如果你上周用过 Python上述这些都不会出现。解释器运行你的代码操作系统给它分配内存并且它工作了。你在 100 KB 或 100 MB 时感觉不到任何悬崖。你写了一个for循环循环运行了每个元素的成本就是它本来的样子。这种体验是真实的并且它在向你隐藏机器。一次 Pythonfor循环迭代的成本——PyObject_Add、引用计数递增、PyLong装箱、字节码分发——在这台机器上每个元素大约 5 纳秒。这个数字高于L3 缓存未命中的成本。所以当你在 Python 列表中迭代时缓存层次结构对你来说是不可见的你在每一步的解释器上花费了如此长的时间以至于下一个字节是在 L1 中还是必须来自 RAM 都成了四舍五入的误差。这就是 Python 中机器模型缺失的部分。层次结构仍然存在只是瓶颈转移了。要看到机器你必须查看解释器分发不占主导地位的地方。两个这样的地方都可以在你的笔记本电脑上测量到1. 三种方式对一百万个 int64 求和。code/measurement/cache_cliffs.py将 N 从 1 万遍历到 1 亿并对以下操作计时对 Python 列表进行sum(lst)对连续的 numpy 数组进行arr.sum()以及对arr[idx].sum()其中idx是一个被打乱顺序的排列。在这台机器上NPython 列表numpy 顺序numpy 收集收集/顺序10,0004.85 ns0.54 ns1.47 ns2.7×100,0004.60 ns0.18 ns2.88 ns16.4×1,000,0004.60 ns0.21 ns3.51 ns17.0×10,000,0004.62 ns0.19 ns10.33 ns53.7×100,000,0004.60 ns0.16 ns11.80 ns72.2×阅读这些列。Python 列表在五个数量级上平坦地保持在约 4.6 纳秒/元素。从解释器内部看缓存层次结构不存在。numpy 顺序列快 25-30 倍揭示了带宽——内部循环是 C 语言字节是类型化的预取器工作正常。numpy 收集列是对相同数据以打乱顺序进行访问一旦工作集离开 L1在 1 万到 10 万之间每个元素的成本就会攀升到 1 亿时与顺序访问的差距达到了72 倍。这个比率就是这台机器上测量到的 L1 到 RAM 的成本差距。2. 引发一次异常 vs 一百万次异常。code/measurement/try_except.py在命中率从 0.0001% 到 99.9999% 的范围内比较了try/except ZeroDivisionError与显式的if value ! 0检查。在 50/50 的情况下try/except形式慢 4 倍在 99.9999%几乎不引发异常的情况下try/except形式比if还快。区别在于 CPU 的分支预测器高频率下的跳转分支基本上是免费的而一个预测错误的分支成本约为 10-20 个周期。教训不是“使用 try/except”或“使用 if”——而是常数因子是依赖于比率的即使是 Python 也继承了这一点。3. 常数因子会泄露出来。code/measurement/string_methods.py针对相同的输出比较了%-格式化、f-strings 和.format。在这台机器上%-格式化比 f-strings 快约 20%而 f-strings 比.format快约 5%。这些在一次性日志行中都不重要。但在紧凑循环中它们都很重要。“现代惯用”的选择并不自动是廉价的选择。本章要求你做什么关于现代 CPU 的主要事实是算术基本上是免费的成本在于将数据送到算术单元。一个尊重这一点的程序是快速的。一个忽视这一点的程序与一个做相同工作、相同加法次数、但布局符合缓存喜好的程序相比可能会慢一百倍。在 Python 中这个事实披着一层伪装解释器如此之慢以至于机器看起来没有悬崖。一旦你离开纯 Python伪装就会脱落——而本书教授的内容几乎都涉及离开纯 Python转向类型化的连续列在那里悬崖就在它一直存在的地方。这也是为什么仅凭“复杂度类”会产生误导。一个命中缓存很好的 O(N log N) 算法可以胜过将读取分散到 RAM 各处的“更快”的 O(N) 算法。大 O 描述了成本随 N 增长的方式而布局描述了被乘进去的常数因子。在本书所针对的规模下常数因子往往胜出。练习这些练习是校准。在你的机器上运行它们并记下数字——本书的其余部分会引用它们。查找你的缓存大小。在 Linux 上lscpu | grep -i cache列出每核心的 L1d、L1i、L2、L3。在 macOS 上sysctl -a | grep cache。把它们写下来。这些是 §27 中会约束你的预算。运行缓存悬崖示例。uv run code/measurement/cache_cliffs.py。读取输出。注意 numpy 收集列开始攀升的大小——那是你溢出 L1 的地方。注意它再次攀升的地方——L2、L3。确认解释器的掩蔽作用。修改示例使其在现有测量之外在每个大小步骤同时打印arr.tolist()的总和。确认 Python 列表的成本仍然是平坦的——即使数据相同悬崖也没有出现。运行 try/except 示例。uv run code/measurement/try_except.py。注意交叉点在什么命中率下try/except变得比if更快在大多数机器上它高于 99%。运行字符串格式化示例。uv run code/measurement/string_methods.py。注意在你的机器上的排名。这个顺序可能随 CPython 版本而改变——请实际测量不要死记硬背。一个指针链表。使用class Node: __slots__ (value, next)构建一个包含 1,000,000 个节点的链然后通过从头部跟随.next遍历来对value求和。将其与对相同长度的 numpyint64数组的相同求和进行比较。你看到的比率大致是在 Python 中一层间接寻址的 L1 到 RAM 的比率——请注意当对象嵌套更深时这个比率会复合增长。挑战将你的lscpu输出与你的基准测试关联起来。使用练习 1 中的缓存大小和练习 2 中的计时识别收集列中的每一步正在离开哪一级缓存。这些转换并不总是清晰的——标注出它们存在噪声的地方。[!NOTE]本章中的数字是在作者机器上测量的。Python 列表平坦、numpy 阶梯状、收集/顺序比率扩大的形态在不同的硬件上是稳健的。确切的比率会随 CPU 代次而变化较旧或较小的芯片Raspberry Pi 4、2012 时代的 Intel显示跨 L1/L2/L3 的梯度阶梯而现代桌面芯片通常在 L3 到 RAM 的边界处显示一个大悬崖。在你自己的机器上测量重现形态而不是具体数字。这些练习的参考答案注释见 01_the_machine_model_solutions.md。接下来是什么你在练习 1 中记下的缓存大小和在练习 2 中找到的悬崖是整个书背后的常数。§2 — 数字及其如何适配 将采取下一步每个数据单元有多大以及一个缓存行中能容纳多少个2 — 数字及其如何适配一个缓存行是 64 字节。这是 CPU 一次加载的内存单元。你与数据所做的一切部分程度上都是关于 64 字节中能容纳多少东西的问题。一个int的实际成本你上周写了x 1问题就结束了。内存中存放的是一个PyLong对象一个头、一个引用计数、一个长度以及一个或多个保存值的 32 位“数字”部分。即使是0最小大小也是28 字节。当值增长超过一个数字时对象每增加一个数字就增长 4 字节。在这台机器上来自code/measurement/number_footprint.py的结果int 0 28 bytes int 1 28 bytes int 256 (最后一个被驻留的) 28 bytes int 257 28 bytes int 1_000 28 bytes int 2**31 32 bytes int 2**63 36 bytes int 2**127 44 bytes float 0.0 24 bytes float 3.14 24 bytes float 1e300 24 bytes一个PyFloat是 24 字节固定大小。一个PyLong至少 28 字节并且随数值大小增长。一个bool也是一个PyLong。一个complex是 32 字节。在每种情况下仅仅是头部就比数值本身还大。这是本章问题的第一部分。选择能容纳你值范围的最窄类型——这种定义了缓存行是容纳 8 个东西还是 64 个东西的规范——在纯 Python 中不存在。没有uint8。没有int32。每个 Pythonint都是同样昂贵的对象无论它保存的是值0还是2**63。你不能用范围来换取缓存行因为你不能选择范围。[!NOTE]CPython 将[-5, 256]范围内的小整数作为单例缓存小整数缓存。一个包含零的列表并不会分配一百万个PyLong(0)对象——它分配一百万个指针全都指向同一个对象。一旦值超出这个范围每个值都是一个全新的分配。用id(0) id(0)为真与id(257) id(257)有时为真有时为假取决于解析器在同一编译单元中对字面常量的缓存——但永远不可靠来确认这一点。将小整数缓存视为你不能依赖的 CPython 实现细节。CPython 核心开发者 Terry Jan Reedy 确认3.15.0b1 及更新版本将此上限从 256 提高到了 1024——这个边界在本书编写过程中移动了。持久的论点是机制本身而不是数字。numpy 还给你什么numpy让宽度预算重新存在。np.int8是一个字节范围 -128 到 127。np.int16是两个字节np.int32是四个字节np.int64是八个字节。np.float32是四个字节约 7 位十进制数字精度np.float64是八个字节约 15 位。有符号/无符号和整数/浮点变体可以自由组合。一个np.zeros(N, dtypenp.uint8)是 N 个字节——平坦、连续、没有每个元素的头部。一个缓存行可以容纳64个它们。一个np.zeros(N, dtypenp.int64)是 8N 个字节一个缓存行可以容纳8个。如果你的循环每缓存行访问一个元素那么int64版本的内存加载次数是uint8版本的 8 倍。宽度预算回来了。同样的示例数据列讲述了 N1,000,000 时的故事布局数据大小求和 (ms)大整数的 Python 列表38.25 MB2.56浮点数的 Python 列表38.38 MB4.27numpy int80.95 MB0.28numpy int161.91 MB0.34numpy int323.81 MB0.45numpy int647.63 MB0.42numpy float323.81 MB0.22numpy float647.63 MB0.36在此规模下Python 列表与 numpy 的比率列表相比 numpy int8 多40 倍的字节相比 int16 多20 倍相比 int32 多10 倍相比 int64 多5 倍。选择能容纳你值范围的最窄 numpy 宽度在列表到 numpy 的基础上还可以给你带来高达 8 倍的额外缩减。求和时间从毫秒下降到零点几毫秒——两个数量级。选择能容纳你值范围的最窄类型并写下原因。一副 52 张牌的花色需要 4 个值点数需要 13 个位置可能需要 8 个——所有这些都可以放在np.uint8中。一个生物的pos需要大约十公里的网格分辨率到厘米级别这可以放在np.float32中。一年模拟中微秒级的时间戳需要大约 3×10¹³这装不进np.uint324×10⁹但可以舒适地放在np.uint64中。做出选择并把选择写下来。浮点数不是实数它们看起来像实数但并不是。float32只有大约 40 亿个值float64只有大约 18 千万亿个值这是有限的。操作有边缘情况1.0 / 0.0 inf0.0 / 0.0 nan并且nan ! nan—— 是的nan的相等性是被故意破坏的因为没有合理的答案。但是对于普通浮点数也不可靠0.1 0.2 0.3是False因为0.1和0.2无法在二进制中精确表示并且舍入误差刚好超过了0.3。这就是为什么math.isclose(a, b, rel_tol1e-9, abs_tol0.0)存在的原因——这是标准库承认是用于浮点数的错误工具并且比较它们需要一个你特意选择的容差。两个几乎相等的浮点数相减会失去它们的大部分精度这是灾难性抵消。将一个很小的浮点数加到一个很大的浮点数上会悄悄地丢弃那个很小的浮点数这是吸收。如果你知道这些的存在它们都不是问题如果你假设浮点数就是数学那么它们全都是问题。code/measurement/sums.py使用六种求和算法sum、math.fsum、Kahan、Neumaier、pairwise、decimal 参考在五个病态数据集随机平衡、大数加许多小数、交替符号、微小增量以及包含 NaN 的数组上演示了后果。运行它阅读其中的差异。相同的输入数据以不同的顺序求和会得到不同的答案而“天真”的答案有时会偏差几个数量级。解决方法不是“使用 float64 而不是 float32”——而是选择一种能感知数据形状的求和算法。对于无法限定输入的单次求和math.fsum和 Neumaier 通常是正确的默认选择。本书大部分内容使用np.uint8、np.uint16、np.uint32、np.float32和用于时间的np.uint64。int*和float64仅在范围或精度真正需要时才出现。选择在每个列声明时都有记录。练习每个值的成本。打印sys.getsizeof(0)、sys.getsizeof(2**31)、sys.getsizeof(2**127)、sys.getsizeof(0.0)、sys.getsizeof(True)。确认即使是一个bool也花费 28 字节bool是int的子类。然后打印np.array([0, 2**31, 0], dtypenp.int64).nbytes。三个 int64 总共 24 字节没有头部没有每个值的指针。缓存行填充。对于每个 numpy dtype——int8、int16、int32、int64、float32、float64——计算一个 64 字节缓存行中可以容纳多少个。一个包含 16 个元素的np.array(_, dtypenp.int32)恰好是一行一个包含 8 个元素的np.array(_, dtypenp.float64)恰好是一行。宽度与速度。对np.ones(100_000_000, dtypenp.int8)求和然后对np.ones(100_000_000, dtypenp.int64)求和。时间的比率应该小于字节的比率8 倍因为计算不是瓶颈——内存带宽才是。还要注意int8求和会溢出这提示了为什么本书在选择宽度时要考虑最大值。浮点怪异。计算0.0 / 0.0、1.0 / 0.0、(-1.0) ** 0.5、math.sqrt(-1.0)。打印它们。然后执行nan float(nan); assert nan ! nan—— 确认它不会引发异常。是错误的工具。打印0.1 0.2 0.3。观察到False。打印0.1 0.2来查看舍入误差0.30000000000000004。然后使用math.isclose(0.1 0.2, 0.3)并观察到True。阅读math.isclose文档 —— 注意默认的rel_tol1e-9是一个选择当问题需要更紧或更松的容差时你应该显式地进行选择。标准库提供isclose是因为语言设计者知道在这里不可靠依靠它。灾难性抵消。计算np.float32(1e10) - (np.float32(1e10) - np.float32(1.0))。结果应该是1.0在float32上通常不是。用np.float64重复观察到它更接近但并不总是精确地等于1.0。运行求和示例。uv run code/measurement/sums.py。阅读五个数据集上不同算法之间的差异。注意哪个数据集的差异最大。这个数据集决定了你在生产中应该选用哪个求和例程。选择一个宽度。对于以下每一列写下你会选择的 dtype 以及原因一年模拟中以 30 Hz 频率运行的生物滴答年龄纸牌的花色4K 屏幕的像素数拥有最多 1 亿用户的系统中的用户 ID16 位 PCM 中的音频样本值。挑战浮点数的eps。np.finfo(np.float32).eps是满足1.0 x ! 1.0的最小x。计算该值然后计算np.float32(1.0) np.float32(0.5) * np.finfo(np.float32).eps—— 结果是1.0还是1.0 eps/2这对于一次将一个小的数字加到一个大的运行总计上意味着什么接下来是什么§3 —Vec是一个表 将采取下一步既然你知道元素有多大np.array对它们做了什么以及本书的其余部分期望它们呈现什么形状