37 — 日志就是世界第 36 节提到持久化就是转置内存中的表以其字节形式写入再以其字节形式读回。本节提出了更深层次的结构性主张。日志就是世界而世界是被解码后的日志。在事件源模拟器中每个状态变化都是一个事件(tick42, kindbecome_hungry, creature_id17) (tick42, kindeat, creature_id23, food_id8, energy_delta5.0) (tick43, kindreproduce, parent_id14, offspring_id400, offspring_energy2.5) (tick43, kinddie, creature_id89)日志就是这样一个事件序列。世界的表可以从日志中重建从一个空的世界或一个快照开始按顺序重放事件得到的表与实时模拟器产生的世界在比特上是完全相同的。一个结构性的事实日志和世界具有相同的形态。一个存在性表hungry: np.ndarray是一个生物 ID 的列表。become_hungry和stop_being_hungry事件的日志是一个(tick, creature_id)对的列表当重放时它会生成相同的数组。一个列energy: np.ndarray是从一个空数组加上写入每个条目的事件开始的结果。日志保存了这些写入列是重放它们的累积效果。在最明确的形式——三元存储形态中日志是三个并行的 numpy 列rids:np.ndarray# uint32 — 哪个实体行 IDkeys:np.ndarray# uint8 — 哪一列数字代码vals:np.ndarray# float64 — 要写入的值这些三元组构成日志转置后它们构成列。转置是唯一的转换。因为没有模型鸿沟所以也不存在阻抗不匹配。不是logging模块当 Python 程序员听到“记录每个状态变化”时第一反应是使用标准库的logging模块。logging模块不是这项工作的正确工具。它用于人类可读的诊断输出——格式化的字符串、时间戳、严重级别、日志轮转。本章讨论的状态变化日志是结构化的、可查询的、可重放的。不同的工作用不同的工具。# 反模式错误的importlogging loggerlogging.getLogger(simulator)logger.info(fcreature{cid}ate food{fid}, energy_delta{delta})这行代码写入磁盘的是一个字符串。要重放下游工具必须将该字符串解析回结构化的字段——这正是第 36 节所说的在此架构中不存在的转换。你一次一个打印调用地重新引入了 ORM 陷阱。规范的 Python 形式将结构化事件追加到 numpy 列将列以字节形式写入。磁盘上的格式就是内存中的格式。无需解析没有解析错误没有成本。simlog一个可工作的标本.archive/simlog/logger.py库直接用 Python、以 700 行代码实现了这种三元存储形态。它的设计值得仔细研究因为它解决了每当模拟器想要记录所有内容时会出现的三个问题并且它得出的结论不特定于任何语言或领域。IOPS 问题 → 批处理。一个天真的事件记录器每次事件调用一次f.write。在每分钟一百万次事件的情况下这就是每分钟一百万次磁盘操作——受限于 IOPS而不是带宽第 38 节。磁盘在排队操作时其带宽大部分处于空闲状态。解决方法将事件收集到内存缓冲区中当缓冲区填满时将其作为一次大的写入刷新。IOPS 随“每秒缓冲区刷新次数”而变化带宽吸收实际的字节量。日志记录成本从磁盘延迟受限变为带宽受限——通常快 100-1000 倍。这与第 22 节的清理摊销模式相同只是应用在磁盘边界上。冗余问题 → 码本和类型推断。模拟器事件记录中的大多数字段都会重复相同的类型代码出现数千次相同的一组活动字符串相同的少数实体类型。存储每个事件的完整有效载荷会浪费字节。解决方法一个码本为每个唯一的字符串分配一个小整数代码日志存储代码而不是字符串。在读取时码本反转映射。simlog 更进一步采用类型推断——每个值都存储为一个f648 字节无论它最初是整数、浮点数还是字符串代码。高达 2⁵³ 的整数可以无损往返这种联合格式消除了每字段的类型标签。节省是复合的在典型的 5% 字段密度下该格式使用的内存比密集列数组大约少 6 倍。写入阻塞问题 → 双缓冲指针交换。如果模拟器在磁盘刷新时阻塞那么每次刷新时模拟都会暂停。解决方法两个Container实例每个保存可调整数量的行默认 200,000。当一个填满时前台线程将其交给后台线程进行刷新新事件继续进入另一个。当刷新完成时容器的角色交换——通过一个单一的指针交换通常称为转轮。从模拟器的角度来看写入一个事件就是一次推送到 numpy 列永远不会等待磁盘。这与第 15 节的“滴答期间世界是冻结的”模式相同只是应用在生产者/消费者边界而不是系统/系统边界。综合结果在作者机器上simlog 的log()调用每次事件大约花费0.9-1.9 微秒每行字段越少越快越多越慢——已发布的基准测试显示5 个字段为 934 纳秒11 个字段为 1906 纳秒。热路径输出是由后台线程顺序写入的一系列.npz块_write_chunk模拟器的log()从不等待磁盘。辅助方法to_csv、to_sqlite在模拟之后读回.npz块并将其转换以供下游消费者使用——这是后处理不是实时日志记录路径的一部分。结构性恒等式——日志 世界——适用于所有这些格式变化的是边界处的存储系统第 38 节。该库不需要知道什么是“事件”。它存储三元组消费者解释它们。这种分离使得相同的代码既能用作模拟日志记录器也能用作审计跟踪还能用作重放源——三种用途一种结构模式。为什么这在实践中很重要重放是结构性的。快照 日志 暂停/恢复。要恢复任何滴答 T 处的世界加载滴答 S ≤ T 的最近快照然后重放从 S 到 T 的日志。成本受限于T − S个事件如果定期拍摄快照这个值很小。可审计性是免费的。世界中的每个变化都在日志中。要回答“为什么生物 17 死了”扫描涉及 17 的事件的日志。日志是系统完整的历史按顺序排列。测试就是重放。一个测试夹具是一个初始世界加上一个日志。一个测试就是“重放这个日志对结果断言此属性”。没有unittest.mock没有设置夹具没有模拟时间和随机的pytest.fixture构建器。分布是结构性的。从同一日志运行相同代码的两个节点会产生比特相同的世界。发送日志世界汇聚。日志是记录系统。快照是日志状态的缓存它们的存在是为了性能而不是为了正确性。如果快照丢失日志可以重建它们。如果日志丢失没有快照可以恢复尚未记录的事件。规范使这一切起作用的规范是结构性的而不是风格性的。模拟器中的每个状态变化在被应用之前都会被记录。清理传递第 22 节是自然的位置——它看到每个变更并可以在提交时记录每个变更。第 38 节的存储系统是自然的接收器——日志写入是顺序的、批量的并在滴答中摊销。一个尊重此规范的模拟器其历史就是日志其状态是日志的投影其持久性就是日志加上最近的快照。第 35 节和第 37 节结合将最后两章作为一个架构来阅读。第 35 节说模拟器的外部接口是一个结构化的队列输入在一个地方到达输出在一个地方离开没有系统直接读取环境。第 37 节说模拟器的历史记录是一个结构化的日志状态变化被批处理、通过码本去重、并通过双缓冲转轮写入。它们共同描述了一个以模拟器为确定性归约器的事件源架构。这种结合带来了大多数 Python 系统因为难以手动维护而放弃的四个属性免费重放。重新运行日志得到相同的世界。免费测试。一个夹具是(initial_world, input_log)一个测试对结果进行断言。没有模拟没有夹具构建器没有依赖注入。免费分布。在节点之间发送日志世界通过构造收敛。免费审计。日志就是审计。“生物 17 发生了什么”这个问题只需一次np.where就能回答。高性能属性从相同的形态中产生队列摊销系统调用——没有每事件的内核转换。日志摊销磁盘写入——没有每次变更的刷新。清理批量处理两者——每滴答一次传递产生一次队列排空和一个日志批次。工作池在所有操作中保持温暖第 31 节。第 1 至 7 部分中的每个架构选择都是为了使这最终的架构能够组合。Numpy SoA 使队列和日志与世界共享形态。单写入者所有权使清理可以无竞争地批处理。确定性使重放可以往返。EBP 使become_hungry事件的日志就是任何后续滴答中的hungry表。索引映射使基于 ID 的引用能够在清理应用的swap_remove传递中幸存。没有一项是孤立的准备所有这些都是为了这个连接处而构建的。剩下的章节——以第 38 节结束的第 8 部分、第 9 部分、第 10 部分——是关于操作问题和元规范。高性能 Python 模拟器的结构性答案现在已经就位。练习记录模拟器。将三个并行的 numpy 列rids: uint32、keys: uint8、vals: float64加上一个n_events计数器添加到你的世界中。修改清理传递为每个应用的变更推送一个三元组。在 100 次滴答后日志大约有active × ticks个三元组。从日志重建。编写def replay(initial: World, events: TripleStore) - World按顺序应用每个三元组。验证从一个初始世界开始并应用日志产生一个与实时模拟器在同一滴答的输出相同的世界。使用第 16 节的hash_world函数对两者进行哈希。保存和加载日志。通过第 36 节的np.savez持久化三元存储。重新加载。重放。确认比特相同的状态。快照 日志。在滴答 S 保存一个快照从滴答 S 开始保存日志。通过加载快照并重放从 S 到 T 的日志来重建任何 T S 的滴答。与实时模拟器进行验证。运行 simlog。打开.archive/simlog/logger.py并跟踪log()调用它在内存中接触了什么在磁盘上没有接触什么交换何时发生磁盘写入何时发生。在纸上画出调用图。你阅读的 700 行是你不需要编写的 700 行。码本节省。使用 1,000,000 个事件所有事件的kind都是eat比较两种存储形式每事件存储字面字符串eat与存储带有一行码本的uint8代码。码本形式小约 24 倍1 字节 vs 短字符串的 24 字节加上 Python 对象开销并且无损往返。logging模块陷阱。配置 Python 的标准logging模块将事件写入文件每个eat事件一行。生成 100,000 个事件。然后将相同的事件写入一个 numpy 三元存储。比较文件大小、写入时间、查询“有多少eat事件涉及生物 42”的时间。三元存储形式在每个维度上都更快并且查询是一个简单的np.where。挑战simlog API三种视图。以三种形式勾勒一个假设的 simlog-v2 的 API作为一个类。class Simlog: def log(self, **fields): ...; def to_arrays(self): ...。可跨模拟器重用可通过 pip 安装。作为你模拟器内部的一个模块。相同的形态但直接访问模拟器的现有类型无需跨越包边界。可重用性较低效率更高——没有需要保持稳定的公共 API。作为一个 ECS 系统。一个日志系统其读集合是to_remove、to_insert和任何其他提交时的表写集合是日志列。它与cleanup在同一个 DAG 中运行也许会合并。清理的两个部分——提交变更和记录变更——成为一个系统。不实现任何一个只勾勒所有三个。比较每种形式获得的收益和损失可重用性、性能、测试的容易程度、与模拟器其他关注点的距离。接下来是什么第 38 节——存储系统带宽和 IOPS 具体说明了跨越 I/O 边界的成本。日志在那里快照也在那里每个外部连接也在那里。38 — 存储系统带宽和 IOPS一个存储系统是程序中将字节保存得比 RAM 更久的那部分。磁盘、网络、分布式文件系统、消息队列、消息代理——都是存储系统。它们技术不同但共享一个成本模型。成本有两个维度。带宽——每秒字节数。字节通过存储系统的速度。NVMe SSD约 3-7 GB/s 读取2-5 GB/s 写入。SATA SSD约 500 MB/s。机械硬盘100-200 MB/s 顺序。千兆网络100 MB/s。万兆网络1 GB/s。本地 NVMe 上的 SQLite对于批量插入200-500 MB/s。IOPS——每秒操作数。存储系统每秒能完成的独立读/写操作的数量。NVMe10万-100 万随机 IOPS顺序 IOPS 数更高底层闪存可以流式传输。SATA SSD5万-10 万 IOPS。机械硬盘100-200 IOPS受限于寻道时间。网络连接受限于延迟 × 并发度。工作负载的成本受两者的约束。在 NVMe 上一次 1 MB 的顺序读取是一次 IOP 和约 250 微秒的带宽时间。一百万次 1 字节的随机读取是一百万次 IOP 和约 10 秒的延迟时间。相同的总字节数成本相差三个数量级。第 22 节的批量清理模式在第 30 节的流式处理规模下将许多小的变更收集成一次大的写入。这将一个高 IOPS、低带宽的工作负载每滴答 1000 次单独写入转换为一个低 IOPS、带宽友好的工作负载每滴答一次批处理写入。该模式天然适合 IOPS 是主要约束的存储系统。SQL 的适用之处——以及不适用之处在第 36 节和第 37 节之后一个合理的问题是如果快照是np.savez状态变化是 simlog 的三元存储那为什么这一章要讲 SQLite模拟器的热路径不经过 SQL。快照是通过np.savez写入的类型化字节日志是通过 simlog 写入的类型化列。SQL 从未参与这些决策。单写入者、批量清理、队列边界的架构在没有 SQL 的情况下是完整的。SQL 适用于边界在三个特定角色中日志的可查询归档。simlog 写入一个三元存储。想要问“在滴答 1000-2000 中有多少生物进食”的分析师需要带有索引的关系查询。simlog 的to_sqlite()方法是一个后处理导出——而不是热路径写入。三元存储是真实来源SQLite 是它的一个可查询视图。第 35 节队列上的外部输入和输出。配置表、场景定义、先前运行的结果——这些通常存在于 SQL 数据库中。读取它们是队列的一个方向将摘要写回是另一个方向。pandas OOM 迁移第 29 节。不是为模拟器本身——而是为与模拟器并行的分析工作流。当 pandas 遇到内存墙时SQLite 是分析师针对模拟输出进行查询的答案。本章是关于边界上任何存储系统的成本以 SQLite 作为实例。下面的数字可以推广到 PostgreSQL、DuckDB、Parquet 文件、S3 等任何系统带宽、IOPS、批处理。SQLite 在本章中占有一席之地因为它随 Python 一起提供无需服务器即可运行并且当边界需要持久化查询时它是大多数读者会使用的格式。已测量的 Python“磁盘慢”神话大多数 Python 程序员都有一个直觉“内存快磁盘慢”。对于冷访问这是真的第一次从冷存储读取数据库文件是一次真正的磁盘寻道。对于热访问——一旦操作系统页面缓存拥有了相关块——差距比直觉认为的要小得多。根据code/measurement/sqlite_performance_test.py对填充了相同数据的 SQLite 表进行 100,000 次随机点查找在作者机器上测量后端查找次数/秒:memory:(RAM)906,488本地 NVMe SSD 上的文件热826,628磁盘上的版本比内存中的版本慢 9%而不是 10 倍或 100 倍。一旦文件在操作系统页面缓存中变热每次“磁盘”读取实际上都是一次内存读取SSD 仅在内核认为某个页面已过期时才被访问。开销主要由 SQLite 的分发和结果编组主导而不是由存储介质主导。两个实际后果对于适合 RAM 的工作负载默认使用:memory:很少是正确的选择。磁盘上的版本以大约 90% 的吞吐量提供持久性这几乎总是一个好交易。来自第 36 节的np.savez快照继承了相同的形态。一旦文件变热加载 100 MB 快照就是在memcpy带宽下的内存复制而不是磁盘寻道。值得记住的三个具体例子SQLite。在本地 NVMe 上SQLite 使用逐条INSERT语句处理约 5 万行/秒的插入使用带批量事务的预编译语句处理约 50万-100 万行/秒使用对内存表执行INSERT INTO ... SELECT FROM ...处理约 500 万行/秒。.archive/simlog/logger.py中的 simlog 导出器使用最后一种形式。同一个数据库吞吐量相差三个数量级取决于工作负载是推 IOPS 还是带宽。# 反模式错误的—— 每行一次 INSERT约 5 万/秒forrowinrows:cursor.execute(INSERT INTO t VALUES (?, ?, ?),row)conn.commit()# 规范的 —— 在单个事务中批处理约 50万-100 万/秒withconn:cursor.executemany(INSERT INTO t VALUES (?, ?, ?),rows)# 批量导出最快的方法 —— INSERT-FROM-SELECT约 500 万/秒conn.execute(INSERT INTO t SELECT * FROM source_view)网络套接字。到服务器的往返受延迟限制~0.1 毫秒局域网~10-100 毫秒互联网~1 毫秒数据中心。从工作负载的角度来看每次往返是一次 IOP。在响应达到许多 KB 之前带宽不是主要的限制因素。在此规模下的第 22 节模式将许多请求批处理成一次往返。Python 的requests.Session在多次调用中保持 TCP 连接节省 TCP 握手每次约 1-3 毫秒httpx.AsyncClient允许你在一个连接上并发发送多个请求。分布式文件系统。S3、EFS、CephFS、NFS——带宽随并发性扩展许多对象上的许多并行读取 高聚合带宽但每对象 IOPS 较低每个请求一次操作。想要顺序带宽的工作负载会分散到许多对象上想要小读取低延迟的工作负载不适合这种存储系统。每行调用一次s3.get_object(...)的循环在任何规模下都是一种反模式。用数字表示的教训当向模拟器添加存储系统时测量你的工作负载的带宽和IOPS——而不仅仅是系统的规格表。一个限制在 10 万 IOPS 的 7 GB/s NVMe 驱动器对于随机工作负载其瓶颈约为每 IOP 30 KB。低于该块大小IOPS 成为约束。第 4 节的预算框架也适用于此。一个 30 Hz 的滴答有 33 毫秒的预算。一次 100 微秒的磁盘读取消耗预算的 0.3%。十次消耗 3%。一百次消耗 30%——已经是滴答的三分之一。限制每滴答的 I/O尽可能批处理并将每次跨边界操作视为与缓存未命中和算术运算同一账本中的真实成本。边界内部的模拟器是一个纯函数。边界的存储系统是该函数与持久化现实的连接。该连接的成本是带宽 × IOPS 预算规范是批处理模式架构是队列。练习测量你的带宽。在 Linux 上dd if/dev/zero of/tmp/test bs1M count1024 oflagdirect测量顺序写入。记下你的数字。测量你的 IOPS。对 10,000 次独立的f.write()os.fsync()调用计时每次 4 KB。计算 IOPS 为10_000 / time_in_seconds。与你的驱动器规格表进行比较。批处理与非批处理。将一个包含 1,000,000 行、每行 32 字节的文件写入磁盘首先进行 1,000,000 次单独写入然后进行一次连接字节的批量写入。比较时间。批处理版本应该快 50-1000 倍具体取决于你的文件系统。SQLite 吞吐量三种形式。将 1,000,000 行插入到 SQLite 表中首先作为单独的INSERT语句for r in rows: cur.execute(...)然后在单个事务中使用executemany然后通过对内存源执行INSERT INTO ... SELECT FROM ...进行插入。注意三个数量级的差异。运行 SQLite 热磁盘示例。uv run code/measurement/sqlite_performance_test.py。在你的机器上注意内存与磁盘的差距。在echo 3 | sudo tee /proc/sys/vm/drop_caches清除页面缓存后重新运行差距应该会显著扩大。缓存清除后的第一次读取是冷磁盘读取后续读取恢复到热读取速率。计算你的滴答预算。在 30 Hz、每滴答 1,000 次变更的情况下可接受的每变更 I/O 成本最大是多少低于 NVMe 延迟没问题高于延迟你必须批处理。pandas-OOM 到 sqlite 的迁移。取一个 5,000,000 行 × 10 个 float64 列的pandas.DataFrame。注意其内存df.memory_usage(deepTrue).sum()。然后将相同的数据移动到一个具有相同列的 SQLite 表中并为你的查询适当地建立索引。对两者运行一个代表性查询。比较挂钟时间。pandas 版本可能会内存不足SQLite 版本在任何现代机器的内存下都能舒适地运行。挑战第二个存储系统。如果你手头有网络文件系统NFS、SSHFS、使用s3fs-fuse的 S3针对一个远程文件重复练习 3。注意延迟与带宽的权衡。IOPS 限制是你的带宽延迟积除以 I/O 大小。接下来是什么你已经完成了“I/O 与持久性”。模拟器现在可以与持久化存储和外部系统通信而不会牺牲确定性或布局规范。下一个阶段是“系统的系统”从第 39 节——系统的系统开始适用于不符合标准滴答模型的工作的模式——长时间运行的优化、时间片搜索、循环外计算。之后规范第 40-43 节以设计规则结束本书这些规则使模拟器能够随着时间推移持续工作。
DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程37-38
发布时间:2026/6/5 3:22:18
37 — 日志就是世界第 36 节提到持久化就是转置内存中的表以其字节形式写入再以其字节形式读回。本节提出了更深层次的结构性主张。日志就是世界而世界是被解码后的日志。在事件源模拟器中每个状态变化都是一个事件(tick42, kindbecome_hungry, creature_id17) (tick42, kindeat, creature_id23, food_id8, energy_delta5.0) (tick43, kindreproduce, parent_id14, offspring_id400, offspring_energy2.5) (tick43, kinddie, creature_id89)日志就是这样一个事件序列。世界的表可以从日志中重建从一个空的世界或一个快照开始按顺序重放事件得到的表与实时模拟器产生的世界在比特上是完全相同的。一个结构性的事实日志和世界具有相同的形态。一个存在性表hungry: np.ndarray是一个生物 ID 的列表。become_hungry和stop_being_hungry事件的日志是一个(tick, creature_id)对的列表当重放时它会生成相同的数组。一个列energy: np.ndarray是从一个空数组加上写入每个条目的事件开始的结果。日志保存了这些写入列是重放它们的累积效果。在最明确的形式——三元存储形态中日志是三个并行的 numpy 列rids:np.ndarray# uint32 — 哪个实体行 IDkeys:np.ndarray# uint8 — 哪一列数字代码vals:np.ndarray# float64 — 要写入的值这些三元组构成日志转置后它们构成列。转置是唯一的转换。因为没有模型鸿沟所以也不存在阻抗不匹配。不是logging模块当 Python 程序员听到“记录每个状态变化”时第一反应是使用标准库的logging模块。logging模块不是这项工作的正确工具。它用于人类可读的诊断输出——格式化的字符串、时间戳、严重级别、日志轮转。本章讨论的状态变化日志是结构化的、可查询的、可重放的。不同的工作用不同的工具。# 反模式错误的importlogging loggerlogging.getLogger(simulator)logger.info(fcreature{cid}ate food{fid}, energy_delta{delta})这行代码写入磁盘的是一个字符串。要重放下游工具必须将该字符串解析回结构化的字段——这正是第 36 节所说的在此架构中不存在的转换。你一次一个打印调用地重新引入了 ORM 陷阱。规范的 Python 形式将结构化事件追加到 numpy 列将列以字节形式写入。磁盘上的格式就是内存中的格式。无需解析没有解析错误没有成本。simlog一个可工作的标本.archive/simlog/logger.py库直接用 Python、以 700 行代码实现了这种三元存储形态。它的设计值得仔细研究因为它解决了每当模拟器想要记录所有内容时会出现的三个问题并且它得出的结论不特定于任何语言或领域。IOPS 问题 → 批处理。一个天真的事件记录器每次事件调用一次f.write。在每分钟一百万次事件的情况下这就是每分钟一百万次磁盘操作——受限于 IOPS而不是带宽第 38 节。磁盘在排队操作时其带宽大部分处于空闲状态。解决方法将事件收集到内存缓冲区中当缓冲区填满时将其作为一次大的写入刷新。IOPS 随“每秒缓冲区刷新次数”而变化带宽吸收实际的字节量。日志记录成本从磁盘延迟受限变为带宽受限——通常快 100-1000 倍。这与第 22 节的清理摊销模式相同只是应用在磁盘边界上。冗余问题 → 码本和类型推断。模拟器事件记录中的大多数字段都会重复相同的类型代码出现数千次相同的一组活动字符串相同的少数实体类型。存储每个事件的完整有效载荷会浪费字节。解决方法一个码本为每个唯一的字符串分配一个小整数代码日志存储代码而不是字符串。在读取时码本反转映射。simlog 更进一步采用类型推断——每个值都存储为一个f648 字节无论它最初是整数、浮点数还是字符串代码。高达 2⁵³ 的整数可以无损往返这种联合格式消除了每字段的类型标签。节省是复合的在典型的 5% 字段密度下该格式使用的内存比密集列数组大约少 6 倍。写入阻塞问题 → 双缓冲指针交换。如果模拟器在磁盘刷新时阻塞那么每次刷新时模拟都会暂停。解决方法两个Container实例每个保存可调整数量的行默认 200,000。当一个填满时前台线程将其交给后台线程进行刷新新事件继续进入另一个。当刷新完成时容器的角色交换——通过一个单一的指针交换通常称为转轮。从模拟器的角度来看写入一个事件就是一次推送到 numpy 列永远不会等待磁盘。这与第 15 节的“滴答期间世界是冻结的”模式相同只是应用在生产者/消费者边界而不是系统/系统边界。综合结果在作者机器上simlog 的log()调用每次事件大约花费0.9-1.9 微秒每行字段越少越快越多越慢——已发布的基准测试显示5 个字段为 934 纳秒11 个字段为 1906 纳秒。热路径输出是由后台线程顺序写入的一系列.npz块_write_chunk模拟器的log()从不等待磁盘。辅助方法to_csv、to_sqlite在模拟之后读回.npz块并将其转换以供下游消费者使用——这是后处理不是实时日志记录路径的一部分。结构性恒等式——日志 世界——适用于所有这些格式变化的是边界处的存储系统第 38 节。该库不需要知道什么是“事件”。它存储三元组消费者解释它们。这种分离使得相同的代码既能用作模拟日志记录器也能用作审计跟踪还能用作重放源——三种用途一种结构模式。为什么这在实践中很重要重放是结构性的。快照 日志 暂停/恢复。要恢复任何滴答 T 处的世界加载滴答 S ≤ T 的最近快照然后重放从 S 到 T 的日志。成本受限于T − S个事件如果定期拍摄快照这个值很小。可审计性是免费的。世界中的每个变化都在日志中。要回答“为什么生物 17 死了”扫描涉及 17 的事件的日志。日志是系统完整的历史按顺序排列。测试就是重放。一个测试夹具是一个初始世界加上一个日志。一个测试就是“重放这个日志对结果断言此属性”。没有unittest.mock没有设置夹具没有模拟时间和随机的pytest.fixture构建器。分布是结构性的。从同一日志运行相同代码的两个节点会产生比特相同的世界。发送日志世界汇聚。日志是记录系统。快照是日志状态的缓存它们的存在是为了性能而不是为了正确性。如果快照丢失日志可以重建它们。如果日志丢失没有快照可以恢复尚未记录的事件。规范使这一切起作用的规范是结构性的而不是风格性的。模拟器中的每个状态变化在被应用之前都会被记录。清理传递第 22 节是自然的位置——它看到每个变更并可以在提交时记录每个变更。第 38 节的存储系统是自然的接收器——日志写入是顺序的、批量的并在滴答中摊销。一个尊重此规范的模拟器其历史就是日志其状态是日志的投影其持久性就是日志加上最近的快照。第 35 节和第 37 节结合将最后两章作为一个架构来阅读。第 35 节说模拟器的外部接口是一个结构化的队列输入在一个地方到达输出在一个地方离开没有系统直接读取环境。第 37 节说模拟器的历史记录是一个结构化的日志状态变化被批处理、通过码本去重、并通过双缓冲转轮写入。它们共同描述了一个以模拟器为确定性归约器的事件源架构。这种结合带来了大多数 Python 系统因为难以手动维护而放弃的四个属性免费重放。重新运行日志得到相同的世界。免费测试。一个夹具是(initial_world, input_log)一个测试对结果进行断言。没有模拟没有夹具构建器没有依赖注入。免费分布。在节点之间发送日志世界通过构造收敛。免费审计。日志就是审计。“生物 17 发生了什么”这个问题只需一次np.where就能回答。高性能属性从相同的形态中产生队列摊销系统调用——没有每事件的内核转换。日志摊销磁盘写入——没有每次变更的刷新。清理批量处理两者——每滴答一次传递产生一次队列排空和一个日志批次。工作池在所有操作中保持温暖第 31 节。第 1 至 7 部分中的每个架构选择都是为了使这最终的架构能够组合。Numpy SoA 使队列和日志与世界共享形态。单写入者所有权使清理可以无竞争地批处理。确定性使重放可以往返。EBP 使become_hungry事件的日志就是任何后续滴答中的hungry表。索引映射使基于 ID 的引用能够在清理应用的swap_remove传递中幸存。没有一项是孤立的准备所有这些都是为了这个连接处而构建的。剩下的章节——以第 38 节结束的第 8 部分、第 9 部分、第 10 部分——是关于操作问题和元规范。高性能 Python 模拟器的结构性答案现在已经就位。练习记录模拟器。将三个并行的 numpy 列rids: uint32、keys: uint8、vals: float64加上一个n_events计数器添加到你的世界中。修改清理传递为每个应用的变更推送一个三元组。在 100 次滴答后日志大约有active × ticks个三元组。从日志重建。编写def replay(initial: World, events: TripleStore) - World按顺序应用每个三元组。验证从一个初始世界开始并应用日志产生一个与实时模拟器在同一滴答的输出相同的世界。使用第 16 节的hash_world函数对两者进行哈希。保存和加载日志。通过第 36 节的np.savez持久化三元存储。重新加载。重放。确认比特相同的状态。快照 日志。在滴答 S 保存一个快照从滴答 S 开始保存日志。通过加载快照并重放从 S 到 T 的日志来重建任何 T S 的滴答。与实时模拟器进行验证。运行 simlog。打开.archive/simlog/logger.py并跟踪log()调用它在内存中接触了什么在磁盘上没有接触什么交换何时发生磁盘写入何时发生。在纸上画出调用图。你阅读的 700 行是你不需要编写的 700 行。码本节省。使用 1,000,000 个事件所有事件的kind都是eat比较两种存储形式每事件存储字面字符串eat与存储带有一行码本的uint8代码。码本形式小约 24 倍1 字节 vs 短字符串的 24 字节加上 Python 对象开销并且无损往返。logging模块陷阱。配置 Python 的标准logging模块将事件写入文件每个eat事件一行。生成 100,000 个事件。然后将相同的事件写入一个 numpy 三元存储。比较文件大小、写入时间、查询“有多少eat事件涉及生物 42”的时间。三元存储形式在每个维度上都更快并且查询是一个简单的np.where。挑战simlog API三种视图。以三种形式勾勒一个假设的 simlog-v2 的 API作为一个类。class Simlog: def log(self, **fields): ...; def to_arrays(self): ...。可跨模拟器重用可通过 pip 安装。作为你模拟器内部的一个模块。相同的形态但直接访问模拟器的现有类型无需跨越包边界。可重用性较低效率更高——没有需要保持稳定的公共 API。作为一个 ECS 系统。一个日志系统其读集合是to_remove、to_insert和任何其他提交时的表写集合是日志列。它与cleanup在同一个 DAG 中运行也许会合并。清理的两个部分——提交变更和记录变更——成为一个系统。不实现任何一个只勾勒所有三个。比较每种形式获得的收益和损失可重用性、性能、测试的容易程度、与模拟器其他关注点的距离。接下来是什么第 38 节——存储系统带宽和 IOPS 具体说明了跨越 I/O 边界的成本。日志在那里快照也在那里每个外部连接也在那里。38 — 存储系统带宽和 IOPS一个存储系统是程序中将字节保存得比 RAM 更久的那部分。磁盘、网络、分布式文件系统、消息队列、消息代理——都是存储系统。它们技术不同但共享一个成本模型。成本有两个维度。带宽——每秒字节数。字节通过存储系统的速度。NVMe SSD约 3-7 GB/s 读取2-5 GB/s 写入。SATA SSD约 500 MB/s。机械硬盘100-200 MB/s 顺序。千兆网络100 MB/s。万兆网络1 GB/s。本地 NVMe 上的 SQLite对于批量插入200-500 MB/s。IOPS——每秒操作数。存储系统每秒能完成的独立读/写操作的数量。NVMe10万-100 万随机 IOPS顺序 IOPS 数更高底层闪存可以流式传输。SATA SSD5万-10 万 IOPS。机械硬盘100-200 IOPS受限于寻道时间。网络连接受限于延迟 × 并发度。工作负载的成本受两者的约束。在 NVMe 上一次 1 MB 的顺序读取是一次 IOP 和约 250 微秒的带宽时间。一百万次 1 字节的随机读取是一百万次 IOP 和约 10 秒的延迟时间。相同的总字节数成本相差三个数量级。第 22 节的批量清理模式在第 30 节的流式处理规模下将许多小的变更收集成一次大的写入。这将一个高 IOPS、低带宽的工作负载每滴答 1000 次单独写入转换为一个低 IOPS、带宽友好的工作负载每滴答一次批处理写入。该模式天然适合 IOPS 是主要约束的存储系统。SQL 的适用之处——以及不适用之处在第 36 节和第 37 节之后一个合理的问题是如果快照是np.savez状态变化是 simlog 的三元存储那为什么这一章要讲 SQLite模拟器的热路径不经过 SQL。快照是通过np.savez写入的类型化字节日志是通过 simlog 写入的类型化列。SQL 从未参与这些决策。单写入者、批量清理、队列边界的架构在没有 SQL 的情况下是完整的。SQL 适用于边界在三个特定角色中日志的可查询归档。simlog 写入一个三元存储。想要问“在滴答 1000-2000 中有多少生物进食”的分析师需要带有索引的关系查询。simlog 的to_sqlite()方法是一个后处理导出——而不是热路径写入。三元存储是真实来源SQLite 是它的一个可查询视图。第 35 节队列上的外部输入和输出。配置表、场景定义、先前运行的结果——这些通常存在于 SQL 数据库中。读取它们是队列的一个方向将摘要写回是另一个方向。pandas OOM 迁移第 29 节。不是为模拟器本身——而是为与模拟器并行的分析工作流。当 pandas 遇到内存墙时SQLite 是分析师针对模拟输出进行查询的答案。本章是关于边界上任何存储系统的成本以 SQLite 作为实例。下面的数字可以推广到 PostgreSQL、DuckDB、Parquet 文件、S3 等任何系统带宽、IOPS、批处理。SQLite 在本章中占有一席之地因为它随 Python 一起提供无需服务器即可运行并且当边界需要持久化查询时它是大多数读者会使用的格式。已测量的 Python“磁盘慢”神话大多数 Python 程序员都有一个直觉“内存快磁盘慢”。对于冷访问这是真的第一次从冷存储读取数据库文件是一次真正的磁盘寻道。对于热访问——一旦操作系统页面缓存拥有了相关块——差距比直觉认为的要小得多。根据code/measurement/sqlite_performance_test.py对填充了相同数据的 SQLite 表进行 100,000 次随机点查找在作者机器上测量后端查找次数/秒:memory:(RAM)906,488本地 NVMe SSD 上的文件热826,628磁盘上的版本比内存中的版本慢 9%而不是 10 倍或 100 倍。一旦文件在操作系统页面缓存中变热每次“磁盘”读取实际上都是一次内存读取SSD 仅在内核认为某个页面已过期时才被访问。开销主要由 SQLite 的分发和结果编组主导而不是由存储介质主导。两个实际后果对于适合 RAM 的工作负载默认使用:memory:很少是正确的选择。磁盘上的版本以大约 90% 的吞吐量提供持久性这几乎总是一个好交易。来自第 36 节的np.savez快照继承了相同的形态。一旦文件变热加载 100 MB 快照就是在memcpy带宽下的内存复制而不是磁盘寻道。值得记住的三个具体例子SQLite。在本地 NVMe 上SQLite 使用逐条INSERT语句处理约 5 万行/秒的插入使用带批量事务的预编译语句处理约 50万-100 万行/秒使用对内存表执行INSERT INTO ... SELECT FROM ...处理约 500 万行/秒。.archive/simlog/logger.py中的 simlog 导出器使用最后一种形式。同一个数据库吞吐量相差三个数量级取决于工作负载是推 IOPS 还是带宽。# 反模式错误的—— 每行一次 INSERT约 5 万/秒forrowinrows:cursor.execute(INSERT INTO t VALUES (?, ?, ?),row)conn.commit()# 规范的 —— 在单个事务中批处理约 50万-100 万/秒withconn:cursor.executemany(INSERT INTO t VALUES (?, ?, ?),rows)# 批量导出最快的方法 —— INSERT-FROM-SELECT约 500 万/秒conn.execute(INSERT INTO t SELECT * FROM source_view)网络套接字。到服务器的往返受延迟限制~0.1 毫秒局域网~10-100 毫秒互联网~1 毫秒数据中心。从工作负载的角度来看每次往返是一次 IOP。在响应达到许多 KB 之前带宽不是主要的限制因素。在此规模下的第 22 节模式将许多请求批处理成一次往返。Python 的requests.Session在多次调用中保持 TCP 连接节省 TCP 握手每次约 1-3 毫秒httpx.AsyncClient允许你在一个连接上并发发送多个请求。分布式文件系统。S3、EFS、CephFS、NFS——带宽随并发性扩展许多对象上的许多并行读取 高聚合带宽但每对象 IOPS 较低每个请求一次操作。想要顺序带宽的工作负载会分散到许多对象上想要小读取低延迟的工作负载不适合这种存储系统。每行调用一次s3.get_object(...)的循环在任何规模下都是一种反模式。用数字表示的教训当向模拟器添加存储系统时测量你的工作负载的带宽和IOPS——而不仅仅是系统的规格表。一个限制在 10 万 IOPS 的 7 GB/s NVMe 驱动器对于随机工作负载其瓶颈约为每 IOP 30 KB。低于该块大小IOPS 成为约束。第 4 节的预算框架也适用于此。一个 30 Hz 的滴答有 33 毫秒的预算。一次 100 微秒的磁盘读取消耗预算的 0.3%。十次消耗 3%。一百次消耗 30%——已经是滴答的三分之一。限制每滴答的 I/O尽可能批处理并将每次跨边界操作视为与缓存未命中和算术运算同一账本中的真实成本。边界内部的模拟器是一个纯函数。边界的存储系统是该函数与持久化现实的连接。该连接的成本是带宽 × IOPS 预算规范是批处理模式架构是队列。练习测量你的带宽。在 Linux 上dd if/dev/zero of/tmp/test bs1M count1024 oflagdirect测量顺序写入。记下你的数字。测量你的 IOPS。对 10,000 次独立的f.write()os.fsync()调用计时每次 4 KB。计算 IOPS 为10_000 / time_in_seconds。与你的驱动器规格表进行比较。批处理与非批处理。将一个包含 1,000,000 行、每行 32 字节的文件写入磁盘首先进行 1,000,000 次单独写入然后进行一次连接字节的批量写入。比较时间。批处理版本应该快 50-1000 倍具体取决于你的文件系统。SQLite 吞吐量三种形式。将 1,000,000 行插入到 SQLite 表中首先作为单独的INSERT语句for r in rows: cur.execute(...)然后在单个事务中使用executemany然后通过对内存源执行INSERT INTO ... SELECT FROM ...进行插入。注意三个数量级的差异。运行 SQLite 热磁盘示例。uv run code/measurement/sqlite_performance_test.py。在你的机器上注意内存与磁盘的差距。在echo 3 | sudo tee /proc/sys/vm/drop_caches清除页面缓存后重新运行差距应该会显著扩大。缓存清除后的第一次读取是冷磁盘读取后续读取恢复到热读取速率。计算你的滴答预算。在 30 Hz、每滴答 1,000 次变更的情况下可接受的每变更 I/O 成本最大是多少低于 NVMe 延迟没问题高于延迟你必须批处理。pandas-OOM 到 sqlite 的迁移。取一个 5,000,000 行 × 10 个 float64 列的pandas.DataFrame。注意其内存df.memory_usage(deepTrue).sum()。然后将相同的数据移动到一个具有相同列的 SQLite 表中并为你的查询适当地建立索引。对两者运行一个代表性查询。比较挂钟时间。pandas 版本可能会内存不足SQLite 版本在任何现代机器的内存下都能舒适地运行。挑战第二个存储系统。如果你手头有网络文件系统NFS、SSHFS、使用s3fs-fuse的 S3针对一个远程文件重复练习 3。注意延迟与带宽的权衡。IOPS 限制是你的带宽延迟积除以 I/O 大小。接下来是什么你已经完成了“I/O 与持久性”。模拟器现在可以与持久化存储和外部系统通信而不会牺牲确定性或布局规范。下一个阶段是“系统的系统”从第 39 节——系统的系统开始适用于不符合标准滴答模型的工作的模式——长时间运行的优化、时间片搜索、循环外计算。之后规范第 40-43 节以设计规则结束本书这些规则使模拟器能够随着时间推移持续工作。