来源:https://root-11.codeberg.page/intro-book-python/
9 --- 排序会破坏索引
在第5节------身份是一个整数中,练习10给你留下了一个错误。玩家1持有索引列表 [3, 17, 21, 28, 41]。庄家按花色对牌堆列进行了排序。现在玩家1的手牌是错误的------相同的索引,相同的槽位,但却是不同的牌。
这个错误就是本节要指出的结构性事实。排序并没有损坏任何东西;玩家的引用从一开始就不可靠。索引指向一个槽位 ,而不是一个物体。 当槽位的内容发生变化时,索引就悄悄地改变了含义。
不仅仅是排序。任何重新排列都会如此:swap_remove(一种O(1)的删除操作,将最后一行移动到被释放的槽位,将在第21节中出现)、为了局部性而重新洗牌(第28节)、批量删除后的压缩。相同的索引,相同的数组,相同的代码行,现在却意味着一张不同的牌。
"但是Python对象是稳定的引用------我不能直接回到那种方式吗?"
这是许多读者想要退缩的时刻。来自第6节的Python本能------带有属性的 class Card------免费给了你对象身份。你上周持有引用的一个 Card 实例,今天仍然是同一个 Card 对象,无论它所在的列表发生了什么。id(card) 不会改变。通过Python解释器指向堆上分配的 Card 的指针在对象的生命周期内是稳定的。
所以这种诱惑是真实的:保持索引对齐的numpy列,同时保持一个并行的对象引用 list[Card],当你需要稳定性时使用这些对象。或者完全回到 list[Card]------至少引用是有效的。
这种权衡在面对第3节的占用表或第7节的访问成本表时是站不住脚的。numpy-SoA布局在单列查询上比 list[Card] 小5倍 且快75倍;携带一个并行的对象列表来"挽救"引用的稳定性,会牺牲掉大部分占用优势,并增加了保持列数据与对象数据同步的问题。你没有解决问题;你只是把它隐藏在一个额外的不变量中。
结构性的修复方法是第10节将要构建的:一个随行移动的 id 列,再加上(对于数量可变的表)一个代计数器。牌本身是一个槽位;牌的名字是我们选择让其稳定的一个整数。 成本是一个额外的 np.uint32 列。好处是,从现在开始我们需要的每一次重新排列------排序、swap_remove、由局部性驱动的重新排序、压缩------都能在不破坏外部引用的情况下工作。
本节唯一的工作是使槽位与名字的区别足够具体,以至于第10节的解决方案感觉是必然的,而不是形式化的。
!NOTE
为什么要先感受痛苦? 因为第10节中的修复很小------只增加一列------只有当学生知道它在修复什么时,小的修复才会被采纳。那些没有先感受到错误就读到"总是存储一个id"的学生会像拜神教一样添加id,然后在代码库看起来太杂乱时丢弃它们。在看着玩家1失去他们的手牌之后读到它,学生就再也不会丢弃它们了。
练习
你应该还保留着第5节中的 deck.py。这些练习将对其进行扩展。
-
重现错误。 当玩家1持有
[3, 17, 21, 28, 41]时,按花色对牌堆列本身(suits、ranks和locations保持步调一致)进行排序。模式是order = np.argsort(suits, kind="stable"); suits[:] = suits[order]; ranks[:] = ranks[order]; locations[:] = locations[order]。使用card_to_string打印玩家1的手牌。确认牌已经改变了。 -
第二次重新排列。 不进行排序,而是交换两张牌的位置:
pythonsuits[[3, 17]] = suits[[17, 3]] ranks[[3, 17]] = ranks[[17, 3]] locations[[3, 17]] = locations[[17, 3]]再次打印玩家1的手牌。相同的错误形态,不同的原因。
-
第三次重新排列。 使用
swap_remove模式移除槽位7处的牌(将最后一行移动到槽位7,然后删除最后一行):suits[7] = suits[-1]; suits = suits[:-1],对其他列同样操作。打印玩家1的手牌。注意槽位[17, 21, 28, 41]处的牌没有变化,但槽位3现在可能持有之前最后一张牌所代表的内容;与此同时,槽位51已经被静默地删除了。 -
量化破坏程度。 编写一个函数,接收原始的
[3, 17, 21, 28, 41]和一个新构建的牌堆,对牌堆列本身应用 Fisher-Yates 洗牌(order = rng.permutation(52)并对所有三列重新排序),并计算五个引用中有多少个仍然指向相同的(suit, rank)值。运行100次。经过一次随机洗牌后,大约有多少比例的引用幸存?(剧透:非常小。每个槽位存活的概率是1/52,偶然存活的期望数量是5/52 ≈ 0.1。) -
能够存活的引用。 在不编写任何新代码的情况下------在纸上------描述什么样的引用能够在洗牌后存活。(提示:你已经知道了。牌的
(suit, rank)对该牌是唯一的。能够存活的引用是不依赖于槽位的引用。) -
"对象引用"的非修复。 在numpy列旁边构建一个并行的
list[Card](如果愿意,可以使用@dataclass)。填充它们,使得cards[i]镜像(suits[i], ranks[i], locations[i])。现在,不更新对象列表 ,按花色对numpy列进行排序。如果玩家1从对象列表中读取,他们看到了什么?如果他们从numpy列中读取呢?请注意,你在没有修复旧错误的情况下引入了一个新错误------不同步的状态。 -
(挑战) 永不重新排列的成本。 假设你决定永远不对牌堆列进行排序、交换或删除,以永远避免这个错误。那么洗牌将如何工作?弃牌将如何工作?为什么这不能扩展到一万个生物?
这些练习的参考答案见 09_sort_breaks_indices_solutions.md。
接下来是什么
练习5指向了答案;练习7使得"永不重新排列"的选项看起来很糟糕。真正的修复方法是将身份与位置分开存储 ------一个随行移动的 id 列,对于数量可变的表,再加上一个代计数器。第10节------稳定的ID和代次将构建它。
10 --- 稳定的ID和代次
在第9节中,你看到了玩家的引用变得陈旧,因为他们持有的是槽位 ,而不是名字。修复方法是给每一行一个名字------一个稳定的标识符------当它移动时随行一起移动。
一个稳定的id就是多一列。对于牌堆:
python
ids = np.arange(52, dtype=np.uint32)
现在每张牌都有一个槽位 (它在各列中的当前索引)和一个id (它的名字)。当你对各列进行排序时,你使 ids 与其他所有列保持步调一致地重新排序:
python
order = np.argsort(suits, kind="stable")
suits[:] = suits[order]
ranks[:] = ranks[order]
locations[:] = locations[order]
ids[:] = ids[order]
id == 17 的牌仍然是同一张牌------它的花色、点数和位置没有改变。它只是在不同的槽位上。
要通过id找到一张牌,扫描 ids 列:
python
def slot_of(ids: np.ndarray, target: int) -> int | None:
matches = np.where(ids == target)[0]
return int(matches[0]) if matches.size else None
这是 O(N),对于一副52张牌的牌堆来说没问题,但对于一百万个生物来说就慢了。修复方法------维护一个在每个重新排列时更新的 id_to_slot 映射------是第23节------索引映射。目前,线性扫描是诚实的教学方式。
代次:当槽位被重用时
牌堆是数量恒定的。总是52张牌,不多不少。简单的 ids 列就足够了。
对于数量可变的表------出生和死亡的生物、到达和被处理的数据包、来来往往的会话------槽位会被重用 。一个新的生物在一个刚刚死去生物所在的槽位中诞生。对于这样的表,ids 列的行为就像数据库中的自增主键 :每个新行获得一个全新的、永不重复的整数;旧行永远保留它们原始的id。模拟器在结构上与数据库有一点不同------它回收槽位 以保持内存有界,而数据库表只是增长。这就是代次存在的原因。想象一下,代码持有对死去生物的引用:他们的引用指向一个槽位,这个槽位现在可能持有一个不同的生物,可能具有相同的id(如果id被重用的话),或者------更糟糕的是------一个看起来有效但不再是他们关心的那个行。
再多一列就解决了这个问题:一个 gens(代次)计数器,每次槽位被重用时递增。现在一个引用是一个对 (id, gen)。要解引用它,你需要检查该行存储的 gen 是否仍然与引用的 gen 匹配。如果匹配,则引用是活的。如果不匹配,则自引用被获取以来该槽位已被重用,解引用返回 None。
python
from typing import NamedTuple
class CreatureRef(NamedTuple):
id: int
gen: int
def get_slot(creatures, ref: CreatureRef) -> int | None:
slot = creatures.id_to_slot.get(ref.id)
if slot is None:
return None
if int(creatures.gens[slot]) != ref.gen:
return None
return slot
(这是书中少数几个 NamedTuple 物有所值的地方之一:CreatureRef 是一个通过外部代码传递的值,给它的字段命名使得API可读。根据第6节,成本是真实的------每个引用一次 NamedTuple 分配------但引用是罕见的,不是每个滴答都会发生。当相同的教训适用于热数据时,答案仍然是numpy列。)
这种模式被称为代竞技场 。它是每个ECS引擎中每个"句柄"类型背后的单一机制:Bevy的 Entity、Rust的 slotmap::SlotMap、C++的 entt::registry 以及数据库中的间接句柄模式。它们在细节上有所不同------id的宽度、打包成 u64、代次溢出处理------但结构思想是相同的:一列用于身份,一列用于代次,一个带检查的解引用。
这已经足够支撑本书的其余部分了。现在排序可以工作了,因为id列随行一起移动。现在删除可以工作了,因为代次计数器拒绝了陈旧的引用。仅追加和回收表(第24节)是在同一机制上的两种策略。
!NOTE
第5节的强形式仍然适用。 如果你的行有一个自然键------
(suit, rank)、(date, ticker)、(species, position)------你不需要一个代理id。没有id也可以玩纸牌游戏;能够存活的引用是(suit, rank)对,因为数据在构造上是唯一的。当数据没有自然的唯一元组时------一旦你开始在运行时产生行,大多数情况都是如此------代理id和代次才变得有用。
练习
这些练习再次扩展了第5节的牌堆,然后向模拟器的数量可变情况迈出了一步。
- 添加id列。 在你的牌堆中添加
ids = np.arange(52, dtype=np.uint32)。修改你的排序函数,使其ids与其他列一起重新排序。验证原始的id仍然存在,只是顺序变了。 - 通过id找到一张牌。 按照正文中的描述实现
slot_of(ids, target)。用它来在排序后查找id == 17的牌。 - 解决第9节的错误。 当玩家1持有 ids
[3, 17, 21, 28, 41](而不是槽位)时,对牌堆进行排序。使用slot_of将id转换为槽位并打印手牌。确认牌没有改变。 - 对排列友好的手牌查询。 重写
cards_held_by(locations, ids, player) -> np.ndarray,使其返回 ids ,而不是槽位。现在玩家持有名字。通过在发牌后对牌堆进行排序并确认cards_held_by仍然返回相同的五张牌来进行测试。 - 第一次使用代次计数器。 添加
gens = np.zeros(52, dtype=np.uint32)。这副52张牌的牌堆实际上并不回收槽位,但扩展一个类似于swap_remove的小操作:从牌堆(位置0)中弹出最后一张牌,在被释放的槽位处插入一张"新的"牌,并将该槽位的gens增加1。在操作之前 获取一个CreatureRef风格的(id, gen)引用。操作之后,通过id查找槽位;将gens[slot]与引用的gen进行比较。确认解引用正确地报告了陈旧。 - (挑战) 一个小型的代竞技场。 在牌堆之外,构建一个
Creatures类,包含pos: np.ndarray (float32)、gens: np.ndarray (uint32),以及一个等待重用的槽位列表free: list[int]。实现insert(pos) -> CreatureRef、remove(ref)和get(ref) -> float | None。通过例子说服自己,陈旧的引用无法读取新生物的数据。 - (挑战)
id_to_slot的形状。 目前slot_of是 O(N) 的。勾画(不要实现)id_to_slot数组------np.full(N_ids, MAX, dtype=np.uint32)------它允许你在 O(1) 时间内完成查找。注意每次重新排序时必须做什么:当槽位i是idk的新家时,id_to_slot[k] = i。这是第23节------索引映射的预兆。查找加速需要你付出另一列来保持对齐。 - (挑战) 与真正的ECS句柄比较。 阅读 bevy_ecs (Rust) 的
Entity文档,或者查看任何Python ECS库的EntityHandle文档。找出你的哪些字段和操作与之对应。生产库添加了哪些你为模拟器不需要的功能?有意识地决定是否采用它。(这是来自第41节------面向压缩的编程和第42节------你只能修复你写下的代码的"从头开始,然后评估crate成本"的举措。)
牌堆练习(1-5)的参考答案见 10_stable_ids_and_generations_solutions.md。竞技场和库练习遵循相同的形态,值得在没有参考答案的情况下完成。
接下来是什么
你现在有了稳定的引用。模拟器接下来需要的将是以 O(1) 而不是 O(N) 的方式通过id查找行------一个在每个重新排序时维护的 id_to_slot 映射。这就是第23节------索引映射。它是另一个 np.ndarray,每当列移动时更新。
第二部分结束了。身份是一个整数;行步调一致地对齐;SoA是默认选择;单例被推导出来;排序会破坏索引,而id修复了它。下一个阶段是时间与传递 ,从第11节------滴答开始。来自 code/sim/SPEC.md 的生态系统模拟器即将开始运行。
11 --- 滴答
一个程序的生命有一个形态:
- 启动------初始化。分配表,打开输入,为RNG设置种子,世界达到一个已知状态。
- 步骤------模拟中的滴答声,纸牌游戏中的回合,服务器中的请求。向前运动的基本重复单元。
- 保存和加载------将内存中的状态保存到磁盘,以便将来的运行可以从当前运行停止的地方继续。可选,但如果你想要它,它就在这里。
- 退出 ------将资源归还给内核。内存、文件句柄、套接字、锁文件。未能干净地做到这一点被称为内存泄漏(或陈旧的锁,或损坏的套接字)。
本节是关于步骤的。步骤是时间预算发挥作用的地方,系统DAG运行的地方,确定性要么保持要么被打破的地方。其他阶段是真实的------本书将在持久化于第36节被提及时回到保存和加载,退出主要是操作系统的任务------但内部步骤是成就或破坏本书所依赖的所有其他属性的关键。
每个步骤都是一个滴答。读取滴答开始时的状态;写入滴答结束时的状态;没有东西在滴答中间被部分更新。即使是一个交互式程序------等待下一步的纸牌游戏,等待击键的文本编辑器------也是一个滴答循环,只是由一个外部触发器驱动。一个对文件进行单次传递然后退出的程序是一个 N=1 的退化滴答循环。
滴答的两种形态
时间驱动的 滴答以固定速率触发。来自 code/sim/SPEC.md 的模拟器以 30 Hz 运行:每 33 毫秒一个滴答。循环唤醒,将每个系统推进一步,休眠直到下一个滴答。大多数模拟、游戏、控制循环、音频引擎和动画系统都是时间驱动的。速率是对外界的一个承诺:以这个速率,输出才会出现。
回合制的滴答在事件到达时触发。当玩家采取行动时,纸牌游戏滴答。当对手移动时,国际象棋引擎滴答。一个离散事件模拟器在下一个待处理事件的时间戳处滴答,无论那个事件在多么遥远的将来。时钟与事件一起前进,而不是在事件之下。回合制滴答没有固定速率;它们的节奏由输入流决定。
两者都是滴答。区别在于触发下一轮传递的是什么:
python
# 时间驱动
import time
TICK_S = 1.0 / 30.0 # 33.3 毫秒
while running:
start = time.perf_counter()
run_all_systems(world)
elapsed = time.perf_counter() - start
if elapsed < TICK_S:
time.sleep(TICK_S - elapsed)
python
# 回合制
while running:
event = wait_for_next_event()
apply_event(world, event)
第0节的模拟器是时间驱动的。第5节的纸牌游戏是回合制的------你发出的每一张牌都是一个滴答。两者都是有效的;两者都符合同一个框架。
不是 asyncio。不是线程。
现代Python读者会想到的两个反应,但在这里都不是正确的工具。
asyncio 反应说"控制循环应该是异步的。" asyncio 是一个用于 I/O 密集型工作的调度器------那些大部分时间在等待套接字、文件或休眠的代码。一个模拟滴答是CPU密集型 的:每个滴答,你都有计算要完成,目标是尽可能快地完成它,然后精确地休眠直到下一个截止时间。asyncio 事件循环增加了分发开销(可等待对象包装、任务步进、事件循环自身的簿记),而没有任何回报------你不是在等待外部 I/O。一个带有 time.sleep 的同步 while True: 循环是正确的形态,而且更短。
线程 反应说"使用一个 Timer 线程来触发滴答。" 这更糟糕。CPython的GIL意味着定时器线程和主线程不能同时运行Python代码;每33毫秒触发一次滴答的定时器线程与模拟器需要的锁争夺同一个锁。你增加了调度器的非确定性(操作系统在每个滴答间隔后决定谁获得GIL),增加了每次唤醒时的GIL获取成本,并且一无所获------你可以直接从主线程调用 time.sleep。
一个模拟滴答需要三样东西:精确性(精确地休眠到下一个截止时间)、确定性(相同的输入产生相同的输出)和简单性(只有一个地方可以阅读以理解循环)。一个使用 time.perf_counter 和 time.sleep 的同步循环提供了所有这三样。上述两种反应都没有提供其中任何一样。选择能给你真正需要属性的最简单工具。
滴答中能容纳什么
预算制约了设计。根据 code/measurement/tick_budget.py,在这台机器上测量的一个运动系统(pos += vel * dt):
| N | 布局 | 滴答时间 | 30 Hz 预算 | 60 Hz 预算 |
|---|---|---|---|---|
| 10,000 | numpy SoA | 0.011 毫秒 | 0.03% | 0.07% |
| 10,000 | Python dataclass | 0.280 毫秒 | 0.84% | 1.7% |
| 100,000 | numpy SoA | 0.023 毫秒 | 0.07% | 0.14% |
| 100,000 | Python dataclass | 2.858 毫秒 | 8.6% | 17.1% |
| 1,000,000 | numpy SoA | 0.613 毫秒 | 1.8% | 3.7% |
| 1,000,000 | Python dataclass | 27.947 毫秒 | 84% | 超支 |
| 10,000,000 | numpy SoA | 28.965 毫秒 | 87% | 超支 |
阅读这些行。在 100,000 个实体时,两种布局在 30 Hz 下都舒适地运行,但 dataclass 循环使用的预算已经是 numpy 版本的 125 倍 。在 1,000,000 个实体时,dataclass 版本在一个****系统上就消耗了 30 Hz 预算的 84%------模拟器的其余部分只剩下 5 毫秒用于其他所有事情。它在 60 Hz 下根本无法运行。numpy 版本仍然有 98% 的预算空闲。在 10,000,000 个实体时,即使是 numpy 版本也达到了 30 Hz 预算的 87%;模拟器已经达到了这个硬件上的规模极限,下一个举措要么是减少每个元素的工作量,要么是将工作划分到多个进程(第31节),要么接受更慢的滴答速率。
dataclass 版本在 10,000,000 时被跳过了,因为可以推断出它大约需要 280 毫秒每滴答------对于一个系统来说,这相当于 30 Hz 预算的八个滴答------这还没有算上其他任何工作。对这个差距的正确解读不是"numpy 快",而是"一个解释器受限的内部循环对你的滴答能够维持的种群数量设置了一个硬性上限,而且这个上限比大多数读者预期的要低得多"。
预算也是同一个循环中混合了回合制和时间驱动思维会产生漂移的地方:回合制子系统的节奏会渗入时间驱动子系统的预算中。修复方法是将两者清晰地分开------通常是一个外层循环,另一个作为向它提供数据的事件源。
滴答是任何具有向前运动的程序中向前运动的单位。接下来的章节将命名在一个 滴答中能容纳 什么,以什么顺序,以及什么不能容纳。
练习
你需要为这些练习创建一个新项目。mkdir tick_lab && cd tick_lab && uv init 就足够了。
- 一个30 Hz时间驱动的循环。 编写一个以30 Hz循环的
main()。每次迭代,打印程序启动以来经过的时间。在滴答之间休眠以保持速率。运行10秒钟。你真的得到了300次迭代吗?使用time.perf_counter()------time.time()可能会在时钟校正时倒退。 - 天真的休眠错误。 用
time.sleep(1/30)(不测量工作时间)替换你的休眠逻辑。运行30秒。程序会随着时间的推移而漂移吗?为什么?(提示:每次迭代的工作 + 休眠现在变成了33 毫秒 + work_ms,而不是总共33 毫秒。) - 掉帧。 在循环内部,休眠50毫秒------比预算长。循环现在以20 Hz运行;它已经错过帧 了。当这种情况发生时,打印一个警告。正确的检测方法是:
if elapsed > TICK_S: print(f"missed deadline by {elapsed - TICK_S:.3f} s")。 - 一个回合制循环。 编写一个微型的REPL:打印
>,用input()读取一行,打印you said: <line>。每一行就是一个滴答。运行它。注意这个循环没有固定速率------它的节奏是你的打字速度。 - 运行滴答预算示例。
uv run code/measurement/tick_budget.py。注意 dataclass 版本在60 Hz下不再合适的行。注意它在30 Hz下不再合适的行。注意 numpy 版本在两个 N 值下仍然良好。本书要求你在接下来的三十章中保持 numpy 这条线运行。 - asyncio 比较。 使用
asyncio.run和await asyncio.sleep重写练习1。进行测量:它是否以相同的速率滴答?程序是否使用了更多的内存?每个滴答的挂钟时间是否更多?并排比较你的两个实现。大多数读者会发现 asyncio 版本更难阅读,并且没有明显更快------这正是上面正文所预言的校准。 - (挑战) 一个离散事件滴答循环。 维护一个按时间戳排序的
(timestamp, message)事件列表。弹出最小时间戳的事件,将"模拟时钟"前进到该时间戳,打印消息,重复直到队列为空。这是一个离散事件模拟器的结构,也是第12节的一个预览。使用heapq作为优先队列。
接下来是什么
练习7暗示了下一节。时钟可以存在于事件本身,独立于循环触发的频率。第12节------事件时间与滴答时间将命名这种分离。