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 KB(L2 驻留)。在 1M 时是 20 MB(L3 驻留)。每元素时间增加了三倍。修复:热/冷分离 + 更窄的 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,000(40 KB)时没问题。在 N = 1,000,000 时,它每滴答分配并清零 4 MB——仅 malloc 成本就相当可观。修复:在启动时分配一次缓冲区,就地填充或重用。 - 日志记录。每个事件一次
print(f"creature {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 内部时,是正确的工具。perf(Linux)。与 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(deep=True).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_x=pos_x, pos_y=pos_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_x=world.pos_x, pos_y=world.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 对抗,它就不再是限制。
