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

DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程37-38

37 — 日志就是世界

第 36 节提到持久化就是转置:内存中的表以其字节形式写入,再以其字节形式读回。本节提出了更深层次的结构性主张。日志就是世界,而世界是被解码后的日志。

在事件源模拟器中,每个状态变化都是一个事件:

(tick=42, kind=become_hungry, creature_id=17) (tick=42, kind=eat, creature_id=23, food_id=8, energy_delta=+5.0) (tick=43, kind=reproduce, parent_id=14, offspring_id=400, offspring_energy=2.5) (tick=43, kind=die, creature_id=89)

日志就是这样一个事件序列。世界的表可以从日志中重建:从一个空的世界(或一个快照)开始,按顺序重放事件,得到的表与实时模拟器产生的世界在比特上是完全相同的。

一个结构性的事实:日志和世界具有相同的形态

一个存在性表hungry: np.ndarray是一个生物 ID 的列表。become_hungrystop_being_hungry事件的日志是一个(tick, creature_id)对的列表,当重放时,它会生成相同的数组。一个列energy: np.ndarray是从一个空数组加上写入每个条目的事件开始的结果。日志保存了这些写入;列是重放它们的累积效果。

在最明确的形式——三元存储形态中,日志是三个并行的 numpy 列:

rids:np.ndarray# uint32 — 哪个实体(行 ID)keys:np.ndarray# uint8 — 哪一列(数字代码)vals:np.ndarray# float64 — 要写入的值

这些三元组构成日志;转置后,它们构成列。转置是唯一的转换。因为没有模型鸿沟,所以也不存在阻抗不匹配。

不是logging模块

当 Python 程序员听到“记录每个状态变化”时,第一反应是使用标准库的logging模块。logging模块不是这项工作的正确工具。它用于人类可读的诊断输出——格式化的字符串、时间戳、严重级别、日志轮转。本章讨论的状态变化日志是结构化的、可查询的、可重放的。不同的工作用不同的工具。

# 反模式:错误的!importlogging logger=logging.getLogger("simulator")logger.info(f"creature{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 更进一步,采用类型推断——每个值都存储为一个f64(8 字节),无论它最初是整数、浮点数还是字符串代码。高达 2⁵³ 的整数可以无损往返;这种联合格式消除了每字段的类型标签。节省是复合的:在典型的 5% 字段密度下,该格式使用的内存比密集列数组大约少 6 倍。

写入阻塞问题 → 双缓冲指针交换。如果模拟器在磁盘刷新时阻塞,那么每次刷新时模拟都会暂停。解决方法:两个Container实例,每个保存可调整数量的行(默认 200,000)。当一个填满时,前台线程将其交给后台线程进行刷新;新事件继续进入另一个。当刷新完成时,容器的角色交换——通过一个单一的指针交换,通常称为转轮。从模拟器的角度来看,写入一个事件就是一次推送到 numpy 列,永远不会等待磁盘。这与第 15 节的“滴答期间世界是冻结的”模式相同,只是应用在生产者/消费者边界,而不是系统/系统边界。

综合结果:在作者机器上,simlog 的log()调用每次事件大约花费0.9-1.9 微秒(每行字段越少越快,越多越慢——已发布的基准测试显示,5 个字段为 934 纳秒,11 个字段为 1906 纳秒)。热路径输出是由后台线程顺序写入的一系列.npz块(_write_chunk);模拟器的log()从不等待磁盘。辅助方法(to_csvto_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 模拟器的结构性答案现在已经就位。

练习

  1. 记录模拟器。将三个并行的 numpy 列(rids: uint32keys: uint8vals: float64)加上一个n_events计数器添加到你的世界中。修改清理传递,为每个应用的变更推送一个三元组。在 100 次滴答后,日志大约有active × ticks个三元组。

  2. 从日志重建。编写def replay(initial: World, events: TripleStore) -> World,按顺序应用每个三元组。验证:从一个初始世界开始并应用日志,产生一个与实时模拟器在同一滴答的输出相同的世界。使用第 16 节的hash_world函数对两者进行哈希。

  3. 保存和加载日志。通过第 36 节的np.savez持久化三元存储。重新加载。重放。确认比特相同的状态。

  4. 快照 + 日志。在滴答 S 保存一个快照;从滴答 S 开始保存日志。通过加载快照并重放从 S 到 T 的日志来重建任何 T > S 的滴答。与实时模拟器进行验证。

  5. 运行 simlog。打开.archive/simlog/logger.py并跟踪log()调用:它在内存中接触了什么,在磁盘上没有接触什么,交换何时发生,磁盘写入何时发生。在纸上画出调用图。你阅读的 700 行是你不需要编写的 700 行。

  6. 码本节省。使用 1,000,000 个事件,所有事件的kind都是"eat",比较两种存储形式:每事件存储字面字符串"eat"与存储带有一行码本的uint8代码。码本形式小约 24 倍(1 字节 vs 短字符串的 24 字节加上 Python 对象开销),并且无损往返。

  7. logging模块陷阱。配置 Python 的标准logging模块将事件写入文件,每个eat事件一行。生成 100,000 个事件。然后将相同的事件写入一个 numpy 三元存储。比较:文件大小、写入时间、查询“有多少eat事件涉及生物 42?”的时间。三元存储形式在每个维度上都更快,并且查询是一个简单的np.where

  8. (挑战)simlog API,三种视图。以三种形式勾勒一个假设的 simlog-v2 的 API:

    • 作为一个类。class Simlog: def log(self, **fields): ...; def to_arrays(self): ...。可跨模拟器重用;可通过 pip 安装。
    • 作为你模拟器内部的一个模块。相同的形态,但直接访问模拟器的现有类型,无需跨越包边界。可重用性较低,效率更高——没有需要保持稳定的公共 API。
    • 作为一个 ECS 系统。一个日志系统,其读集合是to_removeto_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——每秒操作数。存储系统每秒能完成的独立读/写操作的数量。NVMe:10万-100 万随机 IOPS;顺序 IOPS 数更高(底层闪存可以流式传输)。SATA SSD:5万-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 预算;规范是批处理模式;架构是队列。

练习

  1. 测量你的带宽。在 Linux 上:dd if=/dev/zero of=/tmp/test bs=1M count=1024 oflag=direct测量顺序写入。记下你的数字。
  2. 测量你的 IOPS。对 10,000 次独立的f.write()+os.fsync()调用计时,每次 4 KB。计算 IOPS 为10_000 / time_in_seconds。与你的驱动器规格表进行比较。
  3. 批处理与非批处理。将一个包含 1,000,000 行、每行 32 字节的文件写入磁盘:首先进行 1,000,000 次单独写入;然后进行一次连接字节的批量写入。比较时间。批处理版本应该快 50-1000 倍,具体取决于你的文件系统。
  4. SQLite 吞吐量,三种形式。将 1,000,000 行插入到 SQLite 表中:首先作为单独的INSERT语句(for r in rows: cur.execute(...));然后在单个事务中使用executemany;然后通过对内存源执行INSERT INTO ... SELECT FROM ...进行插入。注意三个数量级的差异。
  5. 运行 SQLite 热磁盘示例。uv run code/measurement/sqlite_performance_test.py。在你的机器上注意内存与磁盘的差距。在echo 3 | sudo tee /proc/sys/vm/drop_caches清除页面缓存后重新运行;差距应该会显著扩大。缓存清除后的第一次读取是磁盘读取;后续读取恢复到热读取速率。
  6. 计算你的滴答预算。在 30 Hz、每滴答 1,000 次变更的情况下,可接受的每变更 I/O 成本最大是多少?低于 NVMe 延迟,没问题;高于延迟,你必须批处理。
  7. pandas-OOM 到 sqlite 的迁移。取一个 5,000,000 行 × 10 个 float64 列的pandas.DataFrame。注意其内存(df.memory_usage(deep=True).sum())。然后将相同的数据移动到一个具有相同列的 SQLite 表中,并为你的查询适当地建立索引。对两者运行一个代表性查询。比较挂钟时间。pandas 版本可能会内存不足;SQLite 版本在任何现代机器的内存下都能舒适地运行。
  8. (挑战)第二个存储系统。如果你手头有网络文件系统(NFS、SSHFS、使用s3fs-fuse的 S3),针对一个远程文件重复练习 3。注意延迟与带宽的权衡。IOPS 限制是你的带宽延迟积除以 I/O 大小。

接下来是什么

你已经完成了“I/O 与持久性”。模拟器现在可以与持久化存储和外部系统通信,而不会牺牲确定性或布局规范。下一个阶段是“系统的系统”,从第 39 节——系统的系统开始:适用于不符合标准滴答模型的工作的模式——长时间运行的优化、时间片搜索、循环外计算。之后,规范(第 40-43 节)以设计规则结束本书,这些规则使模拟器能够随着时间推移持续工作。

http://www.zskr.cn/news/1464276.html

相关文章:

  • 手把手教你用Python+OpenCV处理AIR-SARShip-1.0遥感大图:从数据解压到批量裁剪的完整流程
  • APK安装变慢?可能是so库压缩惹的祸!手把手教你权衡android:extractNativeLibs的利弊
  • 手写 Prefix Caching:从零构建 LLM 提示词缓存引擎
  • 2026年比较好的临沂注册公司/临沂工商注册公司优选推荐 - 行业平台推荐
  • 别再死记硬背了!用这3个PADS无模命令和快捷键组合,让你的PCB设计效率翻倍
  • 小程序用户体验排错指南:细节优化杜绝差评与流失
  • 告别调参玄学:用Matlab手把手实现L1 Ball投影,轻松拿捏高维数据稀疏解
  • 期货量化实盘连不上怎么办:天勤 TqAccount 权限与渐进开通
  • 别再手动算Q值了!用Lumerical FDTD分析组搞定高/低Q谐振腔(附2D/3D案例)
  • 别再死记硬背了!用这5个真实监控场景,彻底搞懂Prometheus聚合查询
  • NIPPON KINZOKU开始供应适用于高性能分析仪器的“内表面抛光毛细管”样品
  • 面试(4)| 3.5 小时群面复盘第四弹:求职动机 + 未转正避坑全解析
  • BLE蓝牙开发避坑指南:从0x08到0x3E,手把手教你排查20+种连接断开原因
  • 别再只懂format了!Moment.js/ Day.js 时间处理的7个高级场景与易错点复盘
  • SWaRL框架:基于强化学习的代码水印技术解析
  • 避开Simulink仿真雷区:直流电机调速系统中算法选择与PI参数整定的那些坑
  • 在Ubuntu 22.04上跑通你的第一个SDR LTE基站:基于srsRAN与USRP B210的完整配置流程
  • 中关村科金 AICC 智能联络中心:170 + 分院 2000 坐席无感切换,破解体检呼叫中心运维难题
  • PyBullet仿真进阶:如何为你的UR5机器人模型自定义关节限位与颜色材质
  • 避坑指南:Xilinx SelectIO IP核仿真中的异步复位与bitslip机制详解
  • 从《哈利·波特》到代码:用Java词频统计带你发现文本中的秘密(附完整源码)
  • 保姆级教程:不root不越狱,用华为电脑助手和MMRecovery完整导出微信聊天记录(含备份文件解析)
  • LendNova:AI驱动的信用风险评估创新实践
  • 不逐产业风口,坚守关键赛道:中国电子云以专属AI云,重新定义关键行业智能新底座
  • BilibiliDown终极指南:3步完成B站音频无损下载的完整教程
  • 2026苏州管道疏通公司实测榜单|首选老牌靠谱店,避坑指南收好 - 极速版本
  • 告别ORA-28547:深入理解Oracle Net与OCI驱动,从根源上解决连接问题
  • 【AI测试智能体10】实测打脸:5轮对话后,顶级大模型qwen-plus秒变“失忆症患者”
  • 硅胶异形件口碑如何?汇科橡胶告诉你 - mypinpai
  • UniApp微信分享卡壳?手把手教你搞定iOS Universal Links配置(HBuilderX + 苹果开发者后台)