当前位置: 首页 > news >正文

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.outsnakeviz阅读。适用于找到热门的 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 倍,说明某个东西已经越过了一个常数因子墙,性能分析器会告诉你是什么。

修复是结构性的。应用这些技术:热/冷分离、工作集、为局部性排序、预分配缓冲区、批量清理、确定性结构。每一章你都已经读过了。墙是所有这些技术都变得非可选的那一刻。

练习

  1. 校准。在 N = 10,000 下运行你的模拟器 1,000 次滴答。计时。记下挂钟总时间。
  2. 扩大规模。在 N = 1,000,000 下运行 100 次滴答(相同的总实体-滴答数)。计时。计算比率。
  3. 使用cProfile进行性能分析。python -m cProfile -s cumulative my_sim.py | head -30。识别前三个最热的函数。
  4. 使用py-spy进行性能分析。py-spy record -o flame.svg -- python my_sim.py。在浏览器中打开火焰图。识别cProfile未暴露的 numpy 内部的热点区域。
  5. 预分配清理缓冲区。to_insert = []加上to_insert.append(...)替换为预分配的数组加上一个n_inserts计数器(第 22 节的模式)。重新运行;重新进行性能分析。列表调整大小的调用应该从热点列表中消失。
  6. 热/冷分离。在组织上应用第 26 节的分离。重新运行;重新进行性能分析。在 numpy SoA 中,你可能看不到性能分析有任何变化(根据第 26 节的框架);在 numpy 结构化数组形式中,你应该会看到明显的改进。
  7. 使用索引映射。将任何线性的np.where(arr == target)[0]查找替换为第 23 节的id_to_slot形式。重新运行;重新进行性能分析。
  8. 亲身体验 pandas 墙。构建一个 500 万行 × 10 个 float64 列的 pandas DataFrame。注意其内存使用(df.memory_usage(deep=True).sum() / 1e6MB)。现在将相同的数据移动到 10 个 numpyfloat32列中;注意内存比率。现在将其移动到一个 sqlite 表中;注意磁盘大小和使用sqlite_performance_test.py作为模板的示例查找时间。有意识地决定哪种形式适合你的工作负载。
  9. (挑战)找到一个新的墙。挑选你模拟器中的任何一个系统,找到一个比预期扩展得更差的常数因子。修复方法通常是上述技术之一;识别哪一个就是这一课的收获。

接下来是什么

第 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.saveznp.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.histogramnp.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 中的单进程程序。在这堵墙之上,模拟器更接近于一个数据库,其工作状态在磁盘上,只有一个小的内存热路径。技术不同;规范相同——布局、工作集、所有权、确定性——只是在不同的规模上应用。

这堵墙是大多数项目要么重新架构,要么默默接受低于目标的性能的地方。本书指出这堵墙并为技术命名;它并不假装这些技术是免费的。

练习

  1. 计算你的流式处理阈值。估计你的模拟器在完整 SoA 下每个生物的内存占用。用你的机器 RAM(你可以为模拟器腾出一半)除以该占用。结果大致是模拟器遇到流式处理墙的 N。
  2. 预测成本。一次磁盘读取约为 100 微秒(NVMe SSD)、200-500 微秒(SATA SSD)或 10 毫秒(机械硬盘)。在 33 毫秒的滴答预算下,一个滴答能负担多少次磁盘读取?一个系统可能想要进行多少次?
  3. 对一个小世界进行快照。编写一个函数snapshot(world, path),调用np.savez_compressed(path, pos_x=world.pos_x, pos_y=world.pos_y, ...)。使用np.load读回。确认模拟器继续运行,无法区分。
  4. 一个窗口化日志。实现一个仅追加的日志,其中最近的条目存在于固定大小的 numpy 环形缓冲区中,溢出被转储到一个 sqlite 表或.npz文件中。验证窗口内的查询很快;窗口外的查询需要支付磁盘成本。
  5. 日志即世界。使用练习 4 中的窗口化日志,通过在滴答 ≤ 请求滴答的最近快照上重放日志来重建更早滴答时的生物状态。将查询速度与内存中的情况进行比较。
  6. 认真阅读 simlog。.archive/simlog/logger.py是端到端的窗口化日志架构。跟踪一次log(time, value, ...)调用的路径:它落在哪个容器中,交换何时发生,磁盘写入何时发生。你阅读的 700 行是你不需要编写的 700 行。
  7. 分块 numpy。通过np.save在磁盘上构建一个 2 GB 的 numpy 数组。通过顺序读取 100 MB 的块来计算其均值;与先加载整个数组再计算进行比较。注意:在 I/O 受限的极限下,分块版本在系统调用开销上略高,但限制了内存。
  8. (挑战)记录你的瓶颈。为你的模拟器写下你在 33 毫秒滴答预算内能运行的最大 N。包括内存占用、缓存模式以及任何磁盘瓶颈成本。在此 N 之上,模拟器需要流式处理架构。

接下来是什么

你已经完成了“规模”。下一个阶段是“并发”,从第 31 节——不相交的写集合可自由并行化开始。模拟器即将开始在多个进程上运行——而一旦你停止与 GIL 对抗,它就不再是限制。

http://www.rkmt.cn/news/1435763.html

相关文章:

  • Keil RTOS迁移中NVIC优先级配置的关键问题与解决方案
  • Github趋势榜 2026年第16周
  • 数字孪生在虚拟电厂领域的应用
  • 5分钟掌握iOS降级:LeetDown让你的A6/A7设备重返经典系统
  • 2026年5月知名的牡丹江实木办公家具哪家好怎么选择厂家推荐榜:牡丹江市爱民区鑫万顺木器厂、双叶家具、华鹤、光明、柏氏家具厂家选择指南 - 海棠依旧大
  • 3大社交软件防撤回终极方案:RevokeMsgPatcher深度使用指南
  • 如何一键打造你的专属怀旧桌面:经典任务栏终极指南
  • 2026学生降AI率网站盘点: 学术打磨+逻辑优化哪家强? - 降AI小能手
  • 2026年5月比较好的AI视觉分选哪家好有哪些厂家推荐榜,AI视觉分选设备与定制化检测方案厂家选择指南 - 海棠依旧大
  • 首发:推荐一家梅州专业的粘贴钢板加固公司 - 品牌推广大师
  • 深圳全屋定制找源头工厂避坑 - 产品测评官
  • Kali Linux 2023下,手把手教你搞定Ubertooth One驱动与libbtbb编译(避坑指南)
  • 用 AI 这件事,90% 的人卡在第一步,深度长文,耐心看完
  • GitHub功能全解析:AI代码创作、开发者工作流等应有尽有,komi-learn助力编码代理持续学习
  • 科研党必备:用EndNote 20建立你的第一个文献库,告别参考文献混乱
  • 24寸重型挖泥船多少钱 - 舒雯文化
  • Claude Code 100个真实案例 - 用AI搭建数据可视化大屏(领导看了直拍大腿)
  • 2026年8月重庆武隆旅游多少钱|导游服务、费用参考与避坑指南 - 随峰国旅
  • Boss-Key:上班族的智能隐身助手,一键隐藏窗口的办公神器
  • Zotero Style插件高能进度条终极解决方案:3步修复显示问题
  • 木点点整装深圳全屋定制评价 - 产品测评官
  • 终极戴森球计划工厂蓝图库:5000+免费设计快速构建星际帝国
  • 从仿真到实测:在LTspice中手把手验证MOSFET小信号模型与增益计算
  • 基于ESP8266与Home Assistant的智能水泵控制系统设计与实现
  • 2026 宁波黄金回收避坑 添价收全国连锁正规经营实力雄厚 - 薛定谔的梨花猫
  • 技术深度解析:开源项目douyin-downloader无水印视频批量下载架构设计与性能突破
  • 深圳全屋定制价格透明良心商家 - 产品测评官
  • 极连AI高性价比AI平台解读,对比高性价比AI
  • 避坑指南:高云GW5A开发板连接OV7725/OV5640摄像头的I2C配置与视频采集时序调试实录
  • Gemini舆情分析能力全透视(官方未公开的8项隐藏参数与阈值调优手册)