来源https://root-11.codeberg.page/intro-book-python/12 — 事件时间与滴答时间是分离的大多数初学者认为循环的频率设定了模型的时间分辨率。如果循环以 30 Hz 运行那么模型当然只能以 1/30 秒 33 毫秒的分辨率处理事件这是错误的这种混淆使许多模拟失去了精度。滴答率是循环运行的频率。它并没有说明循环在一个滴答内做了什么。在一个滴答内循环可以处理任意时间戳的事件——微秒、皮秒无论数据携带什么。时钟存在于事件上而不是循环上。具体来说一个 30 Hz 的循环每个滴答接收 1000 个事件每个事件都带有微秒精度的时间戳它们会按时间戳顺序处理——以时间戳暗示的精度应用每个事件的效果。对外部世界渲染、日志记录、网络的输出以 30 Hz 发生但内部物理以微秒分辨率运行。滴答是一个采样率事件才是实际现象。这是以下项目使用的模型离散事件模拟器排队网络、交通、供应链事件在精确时间触发。游戏回放系统回滚网络代码、多人游戏事件延迟到达但带有原始时间戳。交易执行引擎订单带有纳秒时间戳循环按顺序处理它们。芯片设计中的逻辑模拟器门级转换具有皮秒分辨率模拟器一次推进一个转换。在每种情况下主机循环的滴答率与模拟的分辨率无关。数据携带时间。时间希望如何被存储当一章提到“时间戳”时Python 的本能反应是使用datetime。这是显而易见的选择——标准库提供了它每个教程都使用它比较操作可以使用和减法返回一个可读的timedelta。它也是大规模存储时间最昂贵的方式之一。根据code/measurement/event_time_storage.py在这台机器上一百万条覆盖一小时、微秒分辨率的事件布局数据大小构建排序计数 Tlist[datetime]53.6 MB406 毫秒8.5 毫秒22.1 毫秒np.array(dtypedatetime64[us])7.6 MB209 毫秒6.1 毫秒1.3 毫秒np.array(dtypenp.float64)(秒)7.6 MB86 毫秒36.7 毫秒1.3 毫秒两种方式的主要数字从datetime列表迁移到任一类型化的 numpy 列体积缩小 7 倍。每个datetime实例约 56 字节头部、引用计数、八个整数字段、指针每个 numpy 元素是 8 字节在datetime64[us]下是自纪元起的微秒int64或者对于f8表示是自基准秒起的float64。计算“在时间 T 之前发生了多少事件”的速度快 17 倍——这是决定本滴答处理哪些内容的每滴答查询。numpy 版本将比较作为一个带宽受限的批量操作进行评估datetime 版本为每个元素支付解释器分派和方法调用的成本。排序时间是混合的并且依赖于 dtype——测量你的具体情况。在此次运行中numpy 的 float64 排序比其 datetime64 排序慢后者比 Python 的 Timsort 在已经排序的 datetime 列表上稍快。排序成本对摄取很重要计数成本对每个滴答很重要。滴答是约束预算。simlog 参考实现vendored 于.archive/simlog/logger.py将时间存储为f8——float64 秒。对于事件日志来说这是一个规范化的选择体积小、可排序、适合批量 numpy 操作并且与列存储中的其他所有内容宽度相同。当你需要将时间戳作为挂钟日期读取而无需转换时datetime64[us]是一个合理的替代方案。仅在边界处使用datetime对象——为日志行格式化字符串与来自请求的用户提供的时间戳进行比较——绝不要在模拟规模下作为你的内存存储。代码中的解耦陷阱在于将滴答间隔硬编码为模拟的时钟粒度。如下所示的代码# 反模式错误的creature.energy-1.0/30.0# “一个滴答的燃料量”混淆了两个时钟。正确的形态是energy[mask]-elapsed_event_seconds*burn_rate[mask]使用实际经过的事件时间而不是滴答间隔。numpy 形式也是面向列的——mask是一个布尔过滤器选择受影响的生物burn_rate是每个生物的。相同的计算适用于影响一个生物的一个事件和影响一千个生物的一千个事件因为事件时间和滴答时间是解耦的。相同的模型可以以应用程序需要的任何滴答率进行采样——30 Hz 的可视化、60 Hz 的记录、1 kHz 的快进回放——而无需改变模型的意义。这种分离使得模拟器的pending_event表成为可能。每个滴答循环构建一个应触发的事件列表——碰撞、进食、繁殖——每个事件都带有其预测的时间戳作为f8。事件按其时间戳顺序触发无论它们是在哪个滴答中被预测的。一个“本应在滴答开始后 2 微秒进食”的生物其进食事件会在那个精确时刻被应用而不是在滴答开始或结束时。练习这些练习扩展了第 11 节练习 7 中的离散事件循环。一个微小的事件队列。使用numpy数组时间戳的times np.array([...], dtypenp.float64)和字符串消息的messages np.array([...], dtypeobject)。推送 10 个时间戳在[0, 10]秒内的随机事件。使用order np.argsort(times)按时间顺序弹出它们。打印每个事件格式为[tsec] message。验证输出是按时间戳排序的。错误的方式滴答率时钟。运行一个 30 Hz 的循环。在每个滴答中将计数器增加1.0 / 30.0。使用此计数器作为你的“模拟时间”。尝试在t 0.005 秒5 毫秒触发一个事件。会发生什么事件何时触发提示5 毫秒 33 毫秒事件等待下一个滴答边界损失了 28 毫秒的分辨率。正确的方式事件上的时间戳。运行相同的 30 Hz 循环但每个滴答弹出所有时间戳 ≤ 当前实时时间的事件并按时间戳顺序应用。在t 0.005 秒触发一个事件。证明事件恰好在该时间应用而不是在下一个滴答边界。以不同速率采样。在 30 Hz 循环、60 Hz 循环和 1 Hz 循环下运行相同的模型。在所有三次运行中事件应该在相同的模拟时间触发达到循环允许的精度。浮点数与时间。对于t ≈ 1 小时的事件np.float32能表示的最小时间步长是多少对于t ≈ 1 天对于t ≈ 1 年你什么时候需要np.float64参见第 2 节。提示np.spacing(np.float32(3600))是找到一小时答案的快速方法。运行存储示例。uv run code/measurement/event_time_storage.py。注意 count-time 行——这是三种布局中每滴答查询的成本。注意datetime列表所在的位置和 numpy 列所在的位置。挑战一个预算感知循环。修改你的 30 Hz 循环在每个滴答开始时弹出事件直到 (a) 队列为空或 (b) 你已使用 33 毫秒预算中的 25 毫秒。将剩余事件推迟到下一个滴答。这是交互式模拟器中使用的软实时模式。接下来是什么第 13 节——系统是表上的函数 介绍了每个滴答的构建块系统。读入集合写出集合没有隐藏状态没有意外。13 — 系统是表上的函数一个系统是一个从一个或多个表读取并向一个或多个表写入的函数。它声明其输入读集合和输出写集合。它没有隐藏状态没有全局副作用在一个滴答内不与外部世界交互。签名就是契约。defmotion(pos_x:np.ndarray,pos_y:np.ndarray,vel_x:np.ndarray,vel_y:np.ndarray,dt:float)-None:pos_xvel_x*dt pos_yvel_y*dt读集合vel_x、vel_y、dt。写集合pos_x、pos_y。这就是整个契约。只要这四个列和dt可用并且没有其他东西在写pos_x或pos_y这个系统就可以在任何时候运行。它每个滴答在整个种群上运行一次——函数体中没有每个生物的循环。for 循环消失在 numpy 中。三种形态每个系统都采取三种形态之一。一个操作是 1→1每个输入行恰好产生一个输出行。motion是一个操作——每个生物的位置更新到它的新位置。大多数更新函数都是操作。一个过滤器是 1→{0, 1}每个输入行产生零个或一个输出行。apply_starve来自code/sim/SPEC.md是一个过滤器——每个energy ≤ 0的生物在to_remove中产生一个条目energy 0的生物不产生任何东西。numpy 形式是一行defstarving(energy:np.ndarray)-np.ndarray:returnnp.where(energy0)[0]# 返回要移除的索引一个发射是 1→N每个输入行产生零个或多个输出行。apply_reproduce是一个发射——能量高于阈值的父本产生两个后代一个 1→2 的发射。这三种形态与数据库查询采取的形态相同。SELECT * FROM t WHERE p是一个过滤器SELECT a b FROM t是一个操作SELECT explode(arr) FROM t是一个发射。一个系统是用 Python 针对 numpy 列而不是用 SQL 针对表编写的数据库操作。如果你写过 SQL你已经知道这些词汇了工作是用这些术语来认识你的模拟。返回类型是契约的一半这三种形态也固定了返回类型。一个操作就地修改其写集合并返回None——当调用返回时工作已经完成。一个过滤器返回一个新的索引数组。一个发射返回一个或多个新数组。模式是修改器返回None生产者返回它们生产的东西。这种不对称的原因是另一种方式——让修改器返回它自己的写集合——是一个悄悄出现的别名错误。world step(world)读起来像是它产生了一个新世界但如果step修改并返回同一个对象那么两个名字指向同一个状态调用者无法从调用点判断哪个是真的。Python 的标准库准确地编码了这条规则list.sort()返回None这样xs xs.sort()会大声失败sorted()返回一个新列表。系统的约定是应用于列的相同规则。有一个命名的例外一个从无到有构建世界的函数——从一个种子、一个文件或一个日志——返回新世界。它的签名表明了这一点它不接受一个现有的world来修改。build_world(seed)、load(path)、replay(initial, log)是构造函数而不是系统。返回值是新状态唯一可以去的地方。OOP 方法是反形态现在是时候指出大多数 Python 教程所教的内容了。对象上的方法形态——class Creature: def tick(self, dt): self.pos self.vel * dt——是相同的原理只是通过self进行了旋转而这种旋转让你付出了一切重要的代价。签名def tick(self, dt)没有告诉你该方法读取或写入什么。方法体告诉了但只有在阅读之后。契约不再能在调用点表达它隐含在方法体中这意味着你不能在不内联每个方法的情况下推理组合。它还让你付出了循环的代价。Creature.tick的自然调用者是for c in creatures: c.tick(dt)——一个 Python 级别的循环每个元素一次方法分派解释器受限于第 1 节中每个元素约 5 ns 的下限再加上每个属性约 50-100 ns 的getattr和方法调用开销。根据code/measurement/tick_budget.py对于 1,000,000 个生物的一个运动系统成本是每滴答 27.9 毫秒而函数加列的形式是0.6 毫秒。系统的形态不仅更清晰——而且是在规模上唯一能适应 30 Hz 预算的形态。更广泛的规则是一个接受self的函数没有声明的读集合或写集合。一个接受列的函数则有。这是“OOP 与数据导向”不是风格选择的少数几个地方之一——它关乎你的系统是否有你可读的契约。日志记录是一个单独的系统Python 鼓励的另一个本能是从循环内部写入 stdout。print(fcreature {i} starved)、logger.info(...)、traceback.print_exc()——所有这些副作用都违反了系统无隐藏输出的契约。解决方法和本书中其他所有内容一样有一个log_events表一个日志记录系统写入它一个单独的刷新系统将表写入磁盘或 stdout。本书将在第 37 节——日志就是世界中构建这一规范。目前规则是如果一个系统需要与外部通信它通过其写集合中声明的一列来实现。没有意外的打印。可观测性和测试也是系统一个调试检查器是一个系统其读集合是“所有相关列”写集合是“没有可观测的东西”——它收集数据供检查对世界不产生副作用。在生产环境中它是不存在的不受标志门控——程序根本不包含它。一个测试也是一个系统。assert pos.shape vel.shape and not np.any(np.isnan(pos))是一个系统其读集合是pos和vel写集合是空其效果是如果前一个系统的契约被违反则大声失败。测试即系统是第 43 节的主题但自从第 5 节练习 1 以来你一直在编写它们。一个系统声明其输入声明其输出并且不多做。这就是使本书中所有其他规范都能工作的形态。需要注意的几个模式一个在同一个调用中读取一列、写入它并再次读取它的函数不是一个系统——它在函数体内有隐式排序。要么将其拆分为具有显式排序的两个系统要么将写入缓冲到函数退出。一个接受一个world对象并随意修改它的函数不是一个系统——它没有声明的写集合你不能从它的签名推理它。系统没有隐藏状态的契约是使系统可组合的原因。具有不相交写集合的两个系统可以无需协调并行运行第 31 节。其读集合和写集合形成一个链的两个系统必须按顺序运行第 14 节。契约是所有这一切的基础。练习使用第 5 节的牌堆、第 11 节的tick_lab或第 0 节的模拟器框架任何一个都提供足够的表。识别形态。将每个分类为操作、过滤器或发射对一个float32的np.ndarray中的每个条目进行平方。从一个int32的np.ndarray中过滤出偶数整数。将一个list[str]中的每个字符串拆分为单词返回所有单词。计算一个int32的np.ndarray的总和。将运动编写为一个系统。使用长度为 100 的 numpyfloat32列pos_x, pos_y, vel_x, vel_y按照正文中的定义编写motion(pos_x, pos_y, vel_x, vel_y, dt)。将其应用于 100 个具有随机初始位置和速度的生物。在 10 个滴答中打印一个生物的位置。函数体有两行。声明契约。为motion添加一个文档字符串明确列出其读集合和写集合。签名加上文档字符串就是系统的契约。编写一个过滤器。使用energy: np.ndarray编写starving(energy)返回一个 numpy 数组其中包含energy[i] 0的索引。这是apply_starve的只读前半部分。编写一个发射。使用parent_energy: np.ndarray、阈值threshold: float编写reproduce(parent_energy, threshold)返回两个并行的数组——parent_indices和offspring_energies——对于每个高于阈值的父本每个对应两个条目。这是一个 1→2 的发射。提示mask parent_energy threshold; idx np.where(mask)[0]; np.repeat(idx, 2)。观察非系统。在你之前的工作或任何 Python 教程中找到一个函数它接受self并随意修改或者写入全局变量或者从函数体内部调用print。注意是什么使它不是一个系统。尝试仅从签名表达其读集合和写集合——确认你不能。亲身体验 OOP 成本。运行uv run code/measurement/tick_budget.py。阅读表格。注意在 1,000,000 个生物时你已经看到了当循环在一个方法内部而不是在 numpy 中时会发生什么。对于 Python dataclass 版本30 Hz 那一行是超支的。系统形态的版本使用了预算的 1.8%。挑战作为系统的测试。编写def no_creature_moved_too_far(prev_pos_x, prev_pos_y, cur_pos_x, cur_pos_y, max_step)返回任何在两个滴答之间移动超过max_step的生物的索引。“测试”只是一个读取世界的检查系统。提示dx cur_pos_x - prev_pos_x; dy cur_pos_y - prev_pos_y; np.where(dx*dx dy*dy max_step*max_step)[0]。接下来是什么第 14 节——系统组合成一个 DAG 采取下一步当许多系统一起运行时它们如何适应14 — 系统组合成一个 DAG只有一个系统的程序是无趣的有许多系统的程序必须说明什么以什么顺序运行。顺序由数据依赖关系给出一个读取表的系统必须在同一滴答内写入该表的每个之后运行。没有凭直觉固定的顺序一切都由第 13 节刚刚让你声明的读集合和写集合给出。画出依赖图。每个系统是一个节点。对于每个读取表T的系统和每个写入T的系统画一条边writer → reader。结果是一个有向无环图——DAG。一个拓扑排序给出了一个有效的执行顺序任何尊重边的排序都是正确的。程序执行其中一个排序。来自code/sim/SPEC.md的模拟器滴答food_spawnmotionnext_eventapply_eatapply_reproduceapply_starvecleanupinspectfood_spawn首先运行因为它的输出是food而motion和next_event读取它。next_event产生pending_event三个应用程序并行消费它它们的写集合是不相交的。cleanup在它们所有之后运行因为它的读集合包括它们的写入。inspect最后运行因为它读取所有内容并且不写入任何内容。这与数据库中的查询计划形状相同。查询优化器接收一个 SQL 语句构建一个关系操作图每个操作都是一个系统并将它们拓扑排序成一个执行计划。一个模拟器是每个滴答运行的查询计划。遵循这条线索的学生最终会不知不觉地编写他们自己的最小查询引擎。不是回调。不是信号。不是发布/订阅。这就是 Python 式的“松耦合”惯用语出现的地方正确的答案是拒绝它们。需要命名和排除的三种模式观察者 / 事件总线。一个系统订阅一个事件“一个生物诞生了”并运行某个处理程序。处理程序触发的顺序是谁先订阅或者框架选择什么或者——最常见的是——设计上未指定。这与本章所要求的相反。DAG 固定顺序事件总线故意不固定。Django/Flask 风格的信号。框架教signal.connect(handler)以便任何模块可以将其自身连接到任何生命周期点。结果是一个滴答其执行顺序取决于哪些模块被导入、以什么顺序导入以及哪些connect调用运行。DAG 依赖于声明的数据依赖关系信号依赖于导入顺序。回调。一个系统在其函数体的某个点“回调”用户代码。现在用户代码是滴答的一部分但它没有声明的读集合没有声明的写集合并且在由调用系统的实现决定的时刻运行。第 13 节的契约消失了。在所有三种情况下问题都是相同的顺序没有被声明它是由运行时意外事件涌现出来的。一个在其写入器之前运行的读取器读取陈旧数据——本应已更新的表的昨天快照。一个在其消费者之后运行的读取器读取垃圾——一个更新了一半的表。DAG 是防止两者的契约。上述三种模式中的每一种都用一个希望取代了契约。一个模拟器的滴答是一个拓扑排序的调用列表deftick(world:World,dt:float)-None:food_spawn(world.food,dt)motion(world.pos_x,world.pos_y,world.vel_x,world.vel_y,dt)next_event(world.pending_event,world.pos_x,world.pos_y,world.food,...)apply_eat(world.energy,world.food,world.pending_event)apply_reproduce(world.to_insert,world.energy,world.pending_event)apply_starve(world.to_remove,world.energy,world.pending_event)cleanup(world.to_remove,world.to_insert,...)inspect(world)# 只读写集合为空八个函数调用按拓扑顺序。添加一个系统意味着添加一行并根据新系统的读集合和写集合重新推导顺序。没有register()没有subscribe()没有signal.connect()。序列就是程序程序就是序列。为什么需要无环一个循环是一个矛盾。假设系统 A 写入表 T系统 B 读取 T 并写入 U系统 A 读取 U。现在 A 既产生 TB 读取它又消费 UB 写入它。A 和 B 不能在同一个滴答内都在彼此之前运行。系统图中的一个循环是一个设计错误它必须被打破——通常是通过缓冲一个系统的写入使其在下一个滴答而不是本个滴答被消费。这种缓冲正是第 15 节——滴答之间的状态变化命名的。当你编写模拟时循环不会消失它们得到一个名称和一个规范。免费获得并行性一旦 DAG 是显式的并行性就变得微不足道。在同一 DAG 级别的任意两个系统——没有一个系统是另一个的传递依赖——可以在不同的进程上运行。在上面的模拟器中apply_eat、apply_reproduce和apply_starve都消费pending_event并产生不相交的输出表energy/food、to_insert、to_remove它们可以无需协调并行运行。调度由图表隐含。第 31 节将在 GIL 下讨论这一点。观察者模式的替代方案无法提供这一点。没有显式的 DAG框架无法判断哪些处理程序是独立的哪些不是——因此它要么串行运行所有内容要么依赖用户添加手动同步。DAG 优先的设计在读集合和写集合准确的那一刻免费获得并行性观察者优先的设计必须事后发明它。练习画出 DAG。取八个模拟器系统motion, food_spawn, next_event, apply_eat, apply_reproduce, apply_starve, cleanup, inspect并根据code/sim/SPEC.md中每个系统的读集合和写集合自己画出依赖图。与上面的图进行比较。发现循环。假设apply_starve写入food当生物死亡时将燃料返回给世界。现在apply_starve写入food而food_spawn读取它。food_spawn写入food而next_event读取它。next_event写入pending_event而apply_starve读取它。循环在哪里你将如何打破它提示第 15 节。手工进行拓扑排序。给定A 写入 XB 读取 X写入 YC 读取 X写入 ZD 读取 Y 和 Z写入 W哪些系统可以并行运行什么是有效的执行顺序是否存在多个有效顺序在 Python 中进行拓扑排序。实现def topo_sort(systems: list[tuple[str, set[str], set[str]]]) - list[str]接受(name, read_set, write_set)三元组返回一个有效的执行顺序。使用 Kahn 算法。将其应用于你对练习 1 的答案——它应该产生相同的顺序或其中一个有效的替代顺序。组合两个系统。编写motion操作写入pos_x, pos_y和next_event操作写入pending_event。将它们连接成一个按顺序调用它们的tick(world, dt)函数。在滴答后检查pending_event。添加cleanup。添加一个cleanup系统处理to_remove和to_insert两者最初都是空数组。将其连接在next_event之后。确认调用列表从上到下按依赖顺序读取。错误的方式一个观察者。使用事件总线模式实现相同的三系统滴答bus.subscribe(tick, motion); bus.subscribe(tick, next_event); bus.subscribe(tick, cleanup); bus.fire(tick, world)。运行它。注意现在顺序隐含在注册顺序中并且在运行时插入的任何新订阅者都可能静默地改变顺序。比较阅读结果代码与阅读函数调用形式。哪一个告诉你什么在何时运行挑战一个查询规划器。取五个手写的 SQL 查询每个都是一个系统形态并为每个画出关系代数计划。与motion → next_event → apply_*分解模拟器的方式进行比较。形状是相同的。接下来是什么第 15 节——滴答之间的状态变化 是使 DAG 真正起作用的规则变更被缓冲世界原子地转换。