29 — 10K → 1M 的墙一个在 10,000 个生物时运行顺畅的模拟器在 1,000,000 个生物时往往会卡住。不是因为算法变了——而是因为在小规模下不可见的常数因子现在成了约束。这一节是关于找到墙。修复方法是你已经掌握的技术热/冷分离第 26 节、工作集规范第 27 节、为局部性排序第 28 节、预分配缓冲区、批量清理。这一节的工作是教会读者测量——找出哪些常数因子爆炸了。Python 遇到的墙跳过了预分配。一个惰性增长的to_insert: list[CreatureRow]在每滴答 100 次追加1 万生物 × 1% 繁殖率时没问题。在每滴答 1 万次追加100 万 × 1%时Python 列表的append是摊销 O(1)但每次容量翻倍都是一次 N 字节的复制在此规模下翻倍占主导地位。修复使用[None] * estimated_max加上一个n_inserts计数器进行预分配这与第 22 节已经使用的模式相同。纯 Python 中的线性扫描。列表推导式[c for c in creatures if c.id target_id]在 10K 时是 0.1 毫秒但在 1M 时是几十毫秒。修复id_to_slot映射第 23 节加上并行的存在性标志。在 Python 中线性扫描成本比 Rust 更尖锐——你在每次迭代中支付解释器分发成本根据第 1 节每步约 5 纳秒。缓存溢出。10K 时的生物工作集是 200 KBL2 驻留。在 1M 时是 20 MBL3 驻留。每元素时间增加了三倍。修复热/冷分离 更窄的 numpy 数据类型。pandas 墙。一个默认数据类型的 1000 万行 × 20 列的pandas.DataFrame在任何操作之前就占用 1.6 GB 以上。DataFrame.merge会分配中间副本groupby.apply会为每行物化 Python 对象这两者都可能早于数据本身导致内存不足。修复放弃 pandas。要么转向 numpy SoA当工作集仍然适合 RAM 且列是显式的时候要么转向sqlite当不适合或长期不适合时。code/measurement/sqlite_performance_test.py显示 sqlite 在磁盘上提供约 83万-90 万次随机查找/秒——对于许多 pandas 难以处理的工作负载来说速度足够快可以作为生产级答案。迁移通常是一个一天的项目却能回报每季度数天的内存不足OOM调试时间。每滴答分配。一个每滴答调用np.zeros(N)的系统在 N 10,00040 KB时没问题。在 N 1,000,000 时它每滴答分配并清零 4 MB——仅 malloc 成本就相当可观。修复在启动时分配一次缓冲区就地填充或重用。日志记录。每个事件一次print(fcreature {i} ate)在 10K 时是可容忍的。在 1M 事件时它成为模拟器的瓶颈——print会刷新、格式化、分发 GIL。修复根据第 37 节写入 numpy 事件日志批量刷新或者直接关闭它。模式任何每个生物 O(1) 的成本乘以 100 万就不再免费。任何在 10K 时每滴答 O(N) 的东西现在在挂钟时间上等价于 O(N²)。修复是局部的——每个成本都是一行更改——但找到它们需要测量。测量工具正确的工具是性能分析器。在 Python 中三个不错的选择cProfile标准库。python -m cProfile -o profile.out my_sim.py记录每个 Python 级别的函数调用。使用python -m pstats profile.out或snakeviz阅读。适用于找到热门的 Python 函数对 numpy 内部不透明numpy 操作显示为一个 C 调用。py-spy第三方。py-spy record -o flame.svg -- python my_sim.py生成火焰图类似于perf。可以看到 numpy 操作内部的 C 堆栈这是cProfile做不到的。当瓶颈在numpy 内部时是正确的工具。perfLinux。与 Rust 版本使用的工具相同。perf record -- python my_sim.py; perf report在操作系统级别读取可以看到一切但不解释任何东西——你读取原始符号。同一个模拟器在 10K 和 1M 时产生不同的火焰图墙就是差异所在。校准一个有用的练习在 10K 下运行你的模拟器 1,000 次滴答计时。在 1M 下运行 100 次滴答相同的总实体-滴答数计时。1M 版本应该花费大约 10 倍的时间而不是 100 倍。如果花费 100 倍说明某个东西已经越过了一个常数因子墙性能分析器会告诉你是什么。修复是结构性的。应用这些技术热/冷分离、工作集、为局部性排序、预分配缓冲区、批量清理、确定性结构。每一章你都已经读过了。墙是所有这些技术都变得非可选的那一刻。练习校准。在 N 10,000 下运行你的模拟器 1,000 次滴答。计时。记下挂钟总时间。扩大规模。在 N 1,000,000 下运行 100 次滴答相同的总实体-滴答数。计时。计算比率。使用cProfile进行性能分析。python -m cProfile -s cumulative my_sim.py | head -30。识别前三个最热的函数。使用py-spy进行性能分析。py-spy record -o flame.svg -- python my_sim.py。在浏览器中打开火焰图。识别cProfile未暴露的 numpy 内部的热点区域。预分配清理缓冲区。将to_insert []加上to_insert.append(...)替换为预分配的数组加上一个n_inserts计数器第 22 节的模式。重新运行重新进行性能分析。列表调整大小的调用应该从热点列表中消失。热/冷分离。在组织上应用第 26 节的分离。重新运行重新进行性能分析。在 numpy SoA 中你可能看不到性能分析有任何变化根据第 26 节的框架在 numpy 结构化数组形式中你应该会看到明显的改进。使用索引映射。将任何线性的np.where(arr target)[0]查找替换为第 23 节的id_to_slot形式。重新运行重新进行性能分析。亲身体验 pandas 墙。构建一个 500 万行 × 10 个 float64 列的 pandas DataFrame。注意其内存使用df.memory_usage(deepTrue).sum() / 1e6MB。现在将相同的数据移动到 10 个 numpyfloat32列中注意内存比率。现在将其移动到一个 sqlite 表中注意磁盘大小和使用sqlite_performance_test.py作为模板的示例查找时间。有意识地决定哪种形式适合你的工作负载。挑战找到一个新的墙。挑选你模拟器中的任何一个系统找到一个比预期扩展得更差的常数因子。修复方法通常是上述技术之一识别哪一个就是这一课的收获。接下来是什么第 30 节——超越墙 采取下一步当你最快、最紧凑、经过热/冷分离、为局部性排序的模拟器也不再适合 RAM 时架构本身就会发生转变。30 — 超越墙在 1 亿个生物每个生物 24 字节的热数据的情况下工作集是 2.4 GB。在 10 亿个时是 24 GB。大多数台式机有 16-64 GB 的 RAM。模拟器不能再同时容纳它的世界、它的历史、操作系统以及其他所有东西并以高速运行。解决办法是流式处理任何时候只有世界的相关切片在内存中其余部分存在于磁盘上并按需读取。其形态dataclassclassStreamingWorld:in_memory:Window# 最近状态的一个小的连续范围archive:Archive# 其余部分磁盘上仅追加一个窗口的最近状态存在于内存中为廉价查询而索引。较旧的状态以仅追加的块形式存在于磁盘上当查询需要时它被读入窗口。这种模式在需要大规模处理的任何地方都会出现时间序列数据库Prometheus、InfluxDB近期指标在 RAM 中较旧的序列被压缩并驻留在磁盘上。游戏回放系统最近 30 秒可从内存环回放完整的比赛从服务器流式传输。事件溯源系统近期状态被缓存完整的事件日志在磁盘上重放可以重建。数据库预写日志追加到日志刷新到数据文件数据文件成为磁盘驻留最近的日志加上内存保存活动集。用于流式处理的 Python 工具包Python 为你提供了一套小巧且非常适合这种机制的工具。命名正确的和错误的工具是本章在 Python 版本中的贡献。np.savez和np.savez_compressed。将一组命名的 numpy 列保存到一个.npz文件中。该格式是未压缩的或 zip 压缩的类型化字节——与内存中已有的字节相同。通过np.load(path)[column_name]加载。这是“对世界进行快照”和“加载一个块”的规范 Python 答案。它快速、模式可见且语言可移植。sqlite。当数据按 ID、范围或连接查询时——这些访问模式是关系型数据库的构建目标——sqlite 是正确的后端。从第 29 节和code/measurement/sqlite_performance_test.py在磁盘上每秒约 83万-90 万次随机查找在滴答预算层面与内存无区别。模拟器的归档可以是一个 sqlite 数据库每个列族一个表查询是SELECT * FROM events WHERE tick BETWEEN ? AND ?。作为参考实现的 simlog。.archive/simlog/logger.py中的日志记录器正是这种架构预先分配的 numpyContainer作为内存窗口双缓冲带有一个后台线程在模拟继续写入交换进来的容器时将已满的容器转储到磁盘。700 行经过充分测试作为一个 vendored 的参考存在。当你理解本章时阅读它它是流式处理模式的生产版本。对磁盘驻留数据的分块操作。一些 numpy 原语通过分块迭代接受任意大的输入。.archive/numpy_unique_args_permutations.py探索了np.unique的参数同样的形态扩展到np.histogram、np.argsort当与np.lexsort和跨块稳定合并配对时以及任何归约式操作——一次读取 N 行更新累加器在读取下一块之前丢弃当前块。一个刻意不推荐的 Python 选项。np.memmap让 numpy 将磁盘文件视为 RAM操作系统仅将访问到的页面调入内存。它看起来像一个免费的胜利——实际上吞吐量很少能超过明确使用np.fromfile读取你实际想要的块因为操作系统的预取启发式算法与模拟器的访问模式不匹配。如果你今天正在使用它并且数字看起来没问题那也行本书不推荐将其作为默认做法。流式处理带来的架构转变日志是规范状态。世界的表可以从日志派生。如果日志是完整且持久的那么每个其他内存中的表示都是可重建的。这是第 37 节——日志就是世界的结构框架日志不是状态的记录它就是状态。持久性是表的序列化。一个快照是世界当前的 SoA写为这些列已经持有的字节——np.savez(path, pos_xpos_x, pos_ypos_y, ...)。恢复是np.load(path)。没有单独的领域模型序列化是转置而不是翻译。这是第 36 节。存储是一个和其他任何成本一样的成本。从磁盘读取消耗带宽和 IOPS就像从 RAM 读取消耗缓存行加载一样。具有带宽每秒字节数和 IOPS每秒操作数限制的存储系统必须计入滴答预算。SQLite、网络套接字、分布式文件系统——都是具有各自成本曲线的存储系统。这是第 38 节。清理摊销了写入成本。来自第 22 节的清理系统已经批处理了内存中的变更以避免滴答中的竞争。在流式处理规模下同样的模式再次发挥作用出于第二个原因它批处理了磁盘写入。如果没有批处理每滴答 10,000 次单独变更将意味着 10,000 次磁盘写入——每次写入 100 微秒每滴答 I/O 整整一秒远超预算。有了清理这 10,000 次变更变成每滴答一个持久的批次少量磁盘页顺序刷新到日志。一次系统调用一次通过块层的行程一次 DMA 传输——而不是每种操作各 10,000 次。成本在整个批次中摊销而不是每行支付。你在第 22 节中组装的架构已经是小型化的流式处理架构本节只是让你在规模上将其明确表述出来。流式处理规模下的模拟器不再是一个在内存中运行的进程它是一个在内存窗口和持久日志之间的管道系统在世界的任何当前挂载的切片上运行。每次读取都可能缺页到磁盘每次写入都被缓冲到下一个清理的批次中。从内存到流式处理的转变是本书中最大的架构转变。在这堵墙之下模拟器是一个工作状态在 RAM 中的单进程程序。在这堵墙之上模拟器更接近于一个数据库其工作状态在磁盘上只有一个小的内存热路径。技术不同规范相同——布局、工作集、所有权、确定性——只是在不同的规模上应用。这堵墙是大多数项目要么重新架构要么默默接受低于目标的性能的地方。本书指出这堵墙并为技术命名它并不假装这些技术是免费的。练习计算你的流式处理阈值。估计你的模拟器在完整 SoA 下每个生物的内存占用。用你的机器 RAM你可以为模拟器腾出一半除以该占用。结果大致是模拟器遇到流式处理墙的 N。预测成本。一次磁盘读取约为 100 微秒NVMe SSD、200-500 微秒SATA SSD或 10 毫秒机械硬盘。在 33 毫秒的滴答预算下一个滴答能负担多少次磁盘读取一个系统可能想要进行多少次对一个小世界进行快照。编写一个函数snapshot(world, path)调用np.savez_compressed(path, pos_xworld.pos_x, pos_yworld.pos_y, ...)。使用np.load读回。确认模拟器继续运行无法区分。一个窗口化日志。实现一个仅追加的日志其中最近的条目存在于固定大小的 numpy 环形缓冲区中溢出被转储到一个 sqlite 表或.npz文件中。验证窗口内的查询很快窗口外的查询需要支付磁盘成本。日志即世界。使用练习 4 中的窗口化日志通过在滴答 ≤ 请求滴答的最近快照上重放日志来重建更早滴答时的生物状态。将查询速度与内存中的情况进行比较。认真阅读 simlog。.archive/simlog/logger.py是端到端的窗口化日志架构。跟踪一次log(time, value, ...)调用的路径它落在哪个容器中交换何时发生磁盘写入何时发生。你阅读的 700 行是你不需要编写的 700 行。分块 numpy。通过np.save在磁盘上构建一个 2 GB 的 numpy 数组。通过顺序读取 100 MB 的块来计算其均值与先加载整个数组再计算进行比较。注意在 I/O 受限的极限下分块版本在系统调用开销上略高但限制了内存。挑战记录你的瓶颈。为你的模拟器写下你在 33 毫秒滴答预算内能运行的最大 N。包括内存占用、缓存模式以及任何磁盘瓶颈成本。在此 N 之上模拟器需要流式处理架构。接下来是什么你已经完成了“规模”。下一个阶段是“并发”从第 31 节——不相交的写集合可自由并行化开始。模拟器即将开始在多个进程上运行——而一旦你停止与 GIL 对抗它就不再是限制。
DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程29-30
29 — 10K → 1M 的墙一个在 10,000 个生物时运行顺畅的模拟器在 1,000,000 个生物时往往会卡住。不是因为算法变了——而是因为在小规模下不可见的常数因子现在成了约束。这一节是关于找到墙。修复方法是你已经掌握的技术热/冷分离第 26 节、工作集规范第 27 节、为局部性排序第 28 节、预分配缓冲区、批量清理。这一节的工作是教会读者测量——找出哪些常数因子爆炸了。Python 遇到的墙跳过了预分配。一个惰性增长的to_insert: list[CreatureRow]在每滴答 100 次追加1 万生物 × 1% 繁殖率时没问题。在每滴答 1 万次追加100 万 × 1%时Python 列表的append是摊销 O(1)但每次容量翻倍都是一次 N 字节的复制在此规模下翻倍占主导地位。修复使用[None] * estimated_max加上一个n_inserts计数器进行预分配这与第 22 节已经使用的模式相同。纯 Python 中的线性扫描。列表推导式[c for c in creatures if c.id target_id]在 10K 时是 0.1 毫秒但在 1M 时是几十毫秒。修复id_to_slot映射第 23 节加上并行的存在性标志。在 Python 中线性扫描成本比 Rust 更尖锐——你在每次迭代中支付解释器分发成本根据第 1 节每步约 5 纳秒。缓存溢出。10K 时的生物工作集是 200 KBL2 驻留。在 1M 时是 20 MBL3 驻留。每元素时间增加了三倍。修复热/冷分离 更窄的 numpy 数据类型。pandas 墙。一个默认数据类型的 1000 万行 × 20 列的pandas.DataFrame在任何操作之前就占用 1.6 GB 以上。DataFrame.merge会分配中间副本groupby.apply会为每行物化 Python 对象这两者都可能早于数据本身导致内存不足。修复放弃 pandas。要么转向 numpy SoA当工作集仍然适合 RAM 且列是显式的时候要么转向sqlite当不适合或长期不适合时。code/measurement/sqlite_performance_test.py显示 sqlite 在磁盘上提供约 83万-90 万次随机查找/秒——对于许多 pandas 难以处理的工作负载来说速度足够快可以作为生产级答案。迁移通常是一个一天的项目却能回报每季度数天的内存不足OOM调试时间。每滴答分配。一个每滴答调用np.zeros(N)的系统在 N 10,00040 KB时没问题。在 N 1,000,000 时它每滴答分配并清零 4 MB——仅 malloc 成本就相当可观。修复在启动时分配一次缓冲区就地填充或重用。日志记录。每个事件一次print(fcreature {i} ate)在 10K 时是可容忍的。在 1M 事件时它成为模拟器的瓶颈——print会刷新、格式化、分发 GIL。修复根据第 37 节写入 numpy 事件日志批量刷新或者直接关闭它。模式任何每个生物 O(1) 的成本乘以 100 万就不再免费。任何在 10K 时每滴答 O(N) 的东西现在在挂钟时间上等价于 O(N²)。修复是局部的——每个成本都是一行更改——但找到它们需要测量。测量工具正确的工具是性能分析器。在 Python 中三个不错的选择cProfile标准库。python -m cProfile -o profile.out my_sim.py记录每个 Python 级别的函数调用。使用python -m pstats profile.out或snakeviz阅读。适用于找到热门的 Python 函数对 numpy 内部不透明numpy 操作显示为一个 C 调用。py-spy第三方。py-spy record -o flame.svg -- python my_sim.py生成火焰图类似于perf。可以看到 numpy 操作内部的 C 堆栈这是cProfile做不到的。当瓶颈在numpy 内部时是正确的工具。perfLinux。与 Rust 版本使用的工具相同。perf record -- python my_sim.py; perf report在操作系统级别读取可以看到一切但不解释任何东西——你读取原始符号。同一个模拟器在 10K 和 1M 时产生不同的火焰图墙就是差异所在。校准一个有用的练习在 10K 下运行你的模拟器 1,000 次滴答计时。在 1M 下运行 100 次滴答相同的总实体-滴答数计时。1M 版本应该花费大约 10 倍的时间而不是 100 倍。如果花费 100 倍说明某个东西已经越过了一个常数因子墙性能分析器会告诉你是什么。修复是结构性的。应用这些技术热/冷分离、工作集、为局部性排序、预分配缓冲区、批量清理、确定性结构。每一章你都已经读过了。墙是所有这些技术都变得非可选的那一刻。练习校准。在 N 10,000 下运行你的模拟器 1,000 次滴答。计时。记下挂钟总时间。扩大规模。在 N 1,000,000 下运行 100 次滴答相同的总实体-滴答数。计时。计算比率。使用cProfile进行性能分析。python -m cProfile -s cumulative my_sim.py | head -30。识别前三个最热的函数。使用py-spy进行性能分析。py-spy record -o flame.svg -- python my_sim.py。在浏览器中打开火焰图。识别cProfile未暴露的 numpy 内部的热点区域。预分配清理缓冲区。将to_insert []加上to_insert.append(...)替换为预分配的数组加上一个n_inserts计数器第 22 节的模式。重新运行重新进行性能分析。列表调整大小的调用应该从热点列表中消失。热/冷分离。在组织上应用第 26 节的分离。重新运行重新进行性能分析。在 numpy SoA 中你可能看不到性能分析有任何变化根据第 26 节的框架在 numpy 结构化数组形式中你应该会看到明显的改进。使用索引映射。将任何线性的np.where(arr target)[0]查找替换为第 23 节的id_to_slot形式。重新运行重新进行性能分析。亲身体验 pandas 墙。构建一个 500 万行 × 10 个 float64 列的 pandas DataFrame。注意其内存使用df.memory_usage(deepTrue).sum() / 1e6MB。现在将相同的数据移动到 10 个 numpyfloat32列中注意内存比率。现在将其移动到一个 sqlite 表中注意磁盘大小和使用sqlite_performance_test.py作为模板的示例查找时间。有意识地决定哪种形式适合你的工作负载。挑战找到一个新的墙。挑选你模拟器中的任何一个系统找到一个比预期扩展得更差的常数因子。修复方法通常是上述技术之一识别哪一个就是这一课的收获。接下来是什么第 30 节——超越墙 采取下一步当你最快、最紧凑、经过热/冷分离、为局部性排序的模拟器也不再适合 RAM 时架构本身就会发生转变。30 — 超越墙在 1 亿个生物每个生物 24 字节的热数据的情况下工作集是 2.4 GB。在 10 亿个时是 24 GB。大多数台式机有 16-64 GB 的 RAM。模拟器不能再同时容纳它的世界、它的历史、操作系统以及其他所有东西并以高速运行。解决办法是流式处理任何时候只有世界的相关切片在内存中其余部分存在于磁盘上并按需读取。其形态dataclassclassStreamingWorld:in_memory:Window# 最近状态的一个小的连续范围archive:Archive# 其余部分磁盘上仅追加一个窗口的最近状态存在于内存中为廉价查询而索引。较旧的状态以仅追加的块形式存在于磁盘上当查询需要时它被读入窗口。这种模式在需要大规模处理的任何地方都会出现时间序列数据库Prometheus、InfluxDB近期指标在 RAM 中较旧的序列被压缩并驻留在磁盘上。游戏回放系统最近 30 秒可从内存环回放完整的比赛从服务器流式传输。事件溯源系统近期状态被缓存完整的事件日志在磁盘上重放可以重建。数据库预写日志追加到日志刷新到数据文件数据文件成为磁盘驻留最近的日志加上内存保存活动集。用于流式处理的 Python 工具包Python 为你提供了一套小巧且非常适合这种机制的工具。命名正确的和错误的工具是本章在 Python 版本中的贡献。np.savez和np.savez_compressed。将一组命名的 numpy 列保存到一个.npz文件中。该格式是未压缩的或 zip 压缩的类型化字节——与内存中已有的字节相同。通过np.load(path)[column_name]加载。这是“对世界进行快照”和“加载一个块”的规范 Python 答案。它快速、模式可见且语言可移植。sqlite。当数据按 ID、范围或连接查询时——这些访问模式是关系型数据库的构建目标——sqlite 是正确的后端。从第 29 节和code/measurement/sqlite_performance_test.py在磁盘上每秒约 83万-90 万次随机查找在滴答预算层面与内存无区别。模拟器的归档可以是一个 sqlite 数据库每个列族一个表查询是SELECT * FROM events WHERE tick BETWEEN ? AND ?。作为参考实现的 simlog。.archive/simlog/logger.py中的日志记录器正是这种架构预先分配的 numpyContainer作为内存窗口双缓冲带有一个后台线程在模拟继续写入交换进来的容器时将已满的容器转储到磁盘。700 行经过充分测试作为一个 vendored 的参考存在。当你理解本章时阅读它它是流式处理模式的生产版本。对磁盘驻留数据的分块操作。一些 numpy 原语通过分块迭代接受任意大的输入。.archive/numpy_unique_args_permutations.py探索了np.unique的参数同样的形态扩展到np.histogram、np.argsort当与np.lexsort和跨块稳定合并配对时以及任何归约式操作——一次读取 N 行更新累加器在读取下一块之前丢弃当前块。一个刻意不推荐的 Python 选项。np.memmap让 numpy 将磁盘文件视为 RAM操作系统仅将访问到的页面调入内存。它看起来像一个免费的胜利——实际上吞吐量很少能超过明确使用np.fromfile读取你实际想要的块因为操作系统的预取启发式算法与模拟器的访问模式不匹配。如果你今天正在使用它并且数字看起来没问题那也行本书不推荐将其作为默认做法。流式处理带来的架构转变日志是规范状态。世界的表可以从日志派生。如果日志是完整且持久的那么每个其他内存中的表示都是可重建的。这是第 37 节——日志就是世界的结构框架日志不是状态的记录它就是状态。持久性是表的序列化。一个快照是世界当前的 SoA写为这些列已经持有的字节——np.savez(path, pos_xpos_x, pos_ypos_y, ...)。恢复是np.load(path)。没有单独的领域模型序列化是转置而不是翻译。这是第 36 节。存储是一个和其他任何成本一样的成本。从磁盘读取消耗带宽和 IOPS就像从 RAM 读取消耗缓存行加载一样。具有带宽每秒字节数和 IOPS每秒操作数限制的存储系统必须计入滴答预算。SQLite、网络套接字、分布式文件系统——都是具有各自成本曲线的存储系统。这是第 38 节。清理摊销了写入成本。来自第 22 节的清理系统已经批处理了内存中的变更以避免滴答中的竞争。在流式处理规模下同样的模式再次发挥作用出于第二个原因它批处理了磁盘写入。如果没有批处理每滴答 10,000 次单独变更将意味着 10,000 次磁盘写入——每次写入 100 微秒每滴答 I/O 整整一秒远超预算。有了清理这 10,000 次变更变成每滴答一个持久的批次少量磁盘页顺序刷新到日志。一次系统调用一次通过块层的行程一次 DMA 传输——而不是每种操作各 10,000 次。成本在整个批次中摊销而不是每行支付。你在第 22 节中组装的架构已经是小型化的流式处理架构本节只是让你在规模上将其明确表述出来。流式处理规模下的模拟器不再是一个在内存中运行的进程它是一个在内存窗口和持久日志之间的管道系统在世界的任何当前挂载的切片上运行。每次读取都可能缺页到磁盘每次写入都被缓冲到下一个清理的批次中。从内存到流式处理的转变是本书中最大的架构转变。在这堵墙之下模拟器是一个工作状态在 RAM 中的单进程程序。在这堵墙之上模拟器更接近于一个数据库其工作状态在磁盘上只有一个小的内存热路径。技术不同规范相同——布局、工作集、所有权、确定性——只是在不同的规模上应用。这堵墙是大多数项目要么重新架构要么默默接受低于目标的性能的地方。本书指出这堵墙并为技术命名它并不假装这些技术是免费的。练习计算你的流式处理阈值。估计你的模拟器在完整 SoA 下每个生物的内存占用。用你的机器 RAM你可以为模拟器腾出一半除以该占用。结果大致是模拟器遇到流式处理墙的 N。预测成本。一次磁盘读取约为 100 微秒NVMe SSD、200-500 微秒SATA SSD或 10 毫秒机械硬盘。在 33 毫秒的滴答预算下一个滴答能负担多少次磁盘读取一个系统可能想要进行多少次对一个小世界进行快照。编写一个函数snapshot(world, path)调用np.savez_compressed(path, pos_xworld.pos_x, pos_yworld.pos_y, ...)。使用np.load读回。确认模拟器继续运行无法区分。一个窗口化日志。实现一个仅追加的日志其中最近的条目存在于固定大小的 numpy 环形缓冲区中溢出被转储到一个 sqlite 表或.npz文件中。验证窗口内的查询很快窗口外的查询需要支付磁盘成本。日志即世界。使用练习 4 中的窗口化日志通过在滴答 ≤ 请求滴答的最近快照上重放日志来重建更早滴答时的生物状态。将查询速度与内存中的情况进行比较。认真阅读 simlog。.archive/simlog/logger.py是端到端的窗口化日志架构。跟踪一次log(time, value, ...)调用的路径它落在哪个容器中交换何时发生磁盘写入何时发生。你阅读的 700 行是你不需要编写的 700 行。分块 numpy。通过np.save在磁盘上构建一个 2 GB 的 numpy 数组。通过顺序读取 100 MB 的块来计算其均值与先加载整个数组再计算进行比较。注意在 I/O 受限的极限下分块版本在系统调用开销上略高但限制了内存。挑战记录你的瓶颈。为你的模拟器写下你在 33 毫秒滴答预算内能运行的最大 N。包括内存占用、缓存模式以及任何磁盘瓶颈成本。在此 N 之上模拟器需要流式处理架构。接下来是什么你已经完成了“规模”。下一个阶段是“并发”从第 31 节——不相交的写集合可自由并行化开始。模拟器即将开始在多个进程上运行——而一旦你停止与 GIL 对抗它就不再是限制。