来源:https://root-11.codeberg.page/intro-book-python/
7 — 数组结构 (SoA)
你的牌堆有三个 numpy 列:suits、ranks、locations。每个字段都存在于自己的数组中,由实体索引。这种布局被称为数组结构——SoA。相反的布局——一个单一的list[Card],其中每个元素是一个包含所有三个字段的dataclass——被称为结构数组——AoS。它们是关于相同数据存放在何处的不同选择。
# SoA: 三列,索引步调一致suits=np.zeros(52,dtype=np.uint8)ranks=np.zeros(52,dtype=np.uint8)locations=np.zeros(52,dtype=np.uint8)# AoS: 一个对象列表@dataclassclassCard:suit:intrank:intlocation:intcards:list[Card]=[...]# 52 个实例大多数 Python 程序员默认会选择 AoS。这是每个入门教程都会教的:为实体定义一个类,将实例放入列表。问题在于,在实际的循环中,“实体”是内部循环读取的任何东西,而不是数据模型认为应该放在一起的东西。一个计算玩家 1 手牌中牌数的系统只读取location列——它根本不需要suit或rank。
“只读一列”的实际成本
使用 SoA,这个计数是一个 numpy 原语:
held_by_p1=int(np.sum(locations==1))该调用遍历locations的N 字节,生成一个 N 字节的布尔掩码,并对其进行求和——所有操作都在 C 语言内部进行,没有 Python 级别的迭代。在这台机器上,当 N = 1,000,000 张牌时,调用大约需要 0.5 毫秒。
使用 AoS,同样的计数是一个 Pythonfor循环:
held_by_p1=sum(1forcincardsifc.location==1)该循环为每张牌支付一次字节码分发、一次getattr、一次比较和一次增量。根据 §1,解释器分发约为 5 纳秒/元素,而getattr还会增加更多。当 N = 1,000,000 时,同样的计数需要 30-50 毫秒——对于相同数据上的相同答案,慢了两个数量级。
这就是来自 §4 的带宽受限与解释器受限模式的区别。SoA 将内部循环推入 C 语言,并遍历连续的字节;AoS 将内部循环保留在解释器中。SoA 调用可以在 30 Hz 滴答(33 毫秒预算)内处理 100 万个实体,并使用不到 2% 的预算。AoS 调用在 100 万个实体时就消耗了整个滴答预算,没有为模拟的其余部分留下空间。
Python AoS 的惩罚不会随宽度缩小
在 Rust 的 AoS 布局中,成本随结构体的大小增长:一个 19 字节的Card用一个缓存行容纳三张牌,而不是六十四字节的locations。一个不需要suit和rank的读取器无论如何都要为它们付费,因为它们在同一条缓存行中进入。添加一个 16 字节的nickname字段会使差距扩大。
在 Python 中情况不同。dataclass的每个字段都是一个PyObject*指针,因此一个“更宽”的Card并不会在同一缓存行中放入更多的字节——它放入更多的指针。c.location的成本不是“额外的缓存流量”;而是 Python 属性查找的固定开销。添加你不读取的字段会使每个Card在绝对值上更重(更多的分配,更多的引用计数),但不会减慢每属性访问的速度。惩罚是固定的,由解释器分发和getattr决定。
这使得 SoA 在 Python 中的优势是绝对的,而不仅仅是量化的。numpy 原语完全脱离了解释器;而 AoS 循环则不能。任何数量的@dataclass(slots=True)规范都无法消除每属性的分发成本。根据 §6,槽减少了构建成本和每实例内存,但每次读取c.location仍然要通过 Python 的属性机制。
SoA 是默认选择
因此,SoA 是本书中的默认选择。AoS 有时是正确的选择——例如,当每个系统在每个滴答中读取每个实体的每个字段时(很少见),或者当 N 非常小,以至于无论布局如何,循环开销都占主导地位时(想想几十个项目,而不是几百万)。但这是一个需要通过测量来赢得的权衡,而不是通过习惯来假设。首先编写 SoA;只有在基准测试迫使你时才切换到 AoS。
§3 的示例(code/measurement/aos_vs_soa_footprint.py)是本章的参考测量值。重新阅读其对第 0 列求和的这一行:元组列表(AoS 的双胞胎)对一百万个十字段行的第 0 列求和需要 30 毫秒;numpy SoA 只需 0.4 毫秒就能完成相同的操作。对于规范的“系统读取一列”操作,速度快 75 倍。这就是本书其余部分中你的内部循环将处于的模式。
[!NOTE]
numpy 存储行;pandas 存储列。numpy 数组默认是行主序的,可以通过order="F"使用列主序。pandas 则相反——底层是面向列的(每列连续存储),这就是为什么 DataFrame 沿列操作很快,而沿行操作很慢。两种布局都不是对所有用途都最优的:当循环读取许多行的少数几个字段时,SoA 胜出;当循环一次处理整个记录时,行存储胜出。
练习
其中一些练习需要使用time.perf_counter()。
- 构建两种布局。从 §5 获取你的
deck.py并添加一个 AoS 双胞胎:一个包含 52 个条目的list[Card],其中Card是一个包含三个整数字段的@dataclass。构建两者并验证它们编码了相同的逻辑内容。 - 用两种方法计算玩家手中的牌数。使用
np.sum(locations == player)编写count_held_soa(locations, player),并使用 Python 生成器表达式编写count_held_aos(cards, player)。确认它们在相同的牌堆上返回相同的数字。 - 在 10,000 个条目时计时。将你的牌堆复制到长度为 10,000。使用
timeit对两个函数计时(例如,numpy 版本使用number=1000,AoS 版本使用number=100)。注意每纳秒每元素的比率。 - 扩展到 1,000,000 个条目。在长度 1,000,000 时重复。SoA 版本读取 1 MB 的字节;AoS 版本通过 Python 的属性机制遍历一百万个指针追逐。注意其比率。在大多数机器上,它在 50-200 倍范围内。
- Python 版的热/冷情况。用
nickname: str = ""字段和dealt_at: int = -1字段扩展Card——总共五个字段而不是三个。重新构建两者。再次对计数计时。注意SoA 时间不变(计数仍然只遍历locations),而AoS 时间也大致不变(解释器分发无论如何都占主导地位)。与本章的 Rust 版本进行比较,在 Rust 版本中 AoS 时间会随着行大小而增长——Python 的惩罚是以不同的方式固定的。 - AoS 不会输的情况。编写一个更新一张特定卡片的所有字段的函数。SoA 写入三个(或五个)不同的列;AoS 写入一个 Python 对象。对于“更新一张卡片的所有字段”的情况——单个实体,没有循环——AoS 具有竞争力或更好。对它计时。注意这种情况没有内部循环,这就是为什么 §4 中的模式区分不适用。
- 先构建,后读取。从 §6 中你知道构建
dataclass实例很慢。计时构建一次百万条目的 AoS 列表,然后对位置查询求和 1000 次。与构建一次百万条目的 SoA,然后求和 1000 次进行比较。构建成本会在多次读取中摊销;对于短寿命的数据,即使是 SoA 的构建时间也会成为一个因素。(提示:这是 §22 — 变更缓冲区 的预兆。) - (挑战)一个从头开始的
SoaDeck类。将列(suits, ranks, locations, dealt_at)包装到一个拥有它们所有的一个 Python 类中。提供reorder(self, order)作为唯一的公共修改器。你在正确性方面获得了什么?在灵活性方面失去了什么?(提示:你刚刚提前四章重建了 §25 — 表的所有权 中的契约。)
接下来是什么
§8 — 有了一,就有了多 是普适性原则。牌堆隐含地教会了它;下一节将为其命名。
8 — 有了一,就有了多
代码是为数组编写的。对单个实体操作的函数只是 N = 1 的特例;它不需要自己的抽象。一个有 52 张牌的纸牌游戏是三个数组——花色、点数、位置——而不是 52 个对象。一个有 100 个生物的模拟是六个长度为 100 的数组,而不是 100 个Creature实例。复数形式是基本单位;单数形式是平凡的情况。
模式很简单。首先编写数组版本。单例作为一个元素的切片出现。要洗一张牌,你在order数组中交换两个索引——就像洗整副牌一样。要找到玩家 1 手中点数最高的牌,你扫描(小的)手牌数组——与扫描所有 52 张牌的形状相同。要发一张牌,你写入locations中的一个单元格——与发很多牌的形状相同。
命名的 OOP 本能
这与大多数 Python 程序员第一天就习得的本能背道而驰:编写card.shuffle()或creature.update()的冲动,然后思考如何对许多对象执行此操作。几乎每个 Python 教程都将行为建模为对象上的方法,然后介绍对象列表作为拥有许多的自然方式,然后介绍for c in creatures: c.update()作为对每个对象做某事的自然方式。三个步骤,每一步在局部都是合理的,但它们一起构建了本章要求你放弃的模式。
当你从一开始就为数组编写代码时,这个难题就不存在了。shuffle(deck)是一个适用于任何牌堆的函数,包括只有一张牌的牌堆。update(creatures)——将列作为 numpy 数组接受——是一个适用于任何种群的函数,包括种群数量为 1 的情况。对象上的方法形式严格来说比函数加切片形式有更多的代码:它需要一个类、一个__init__、一个在数组级别什么都不做的self参数,以及一个阻止内部循环离开解释器的调用约定。
一个有用的测试:当你发现自己在为一个类编写方法时,问一问这在数组上看起来像什么?如果数组版本更短,则放弃该方法。如果数组版本长度相同,则将其保留为一个对 numpy 数组的自由函数——def shuffle(suits, ranks, locations, order),而不是class Deck: def shuffle(self): ...。无论哪种方式,单例从来都不是正确的代码单元。
性能论据
还有一个性能原因——在 Python 中比在任何编译语言中都更尖锐。每次操作一个实体的方法会强制使用它的系统调用 N 次该方法。根据code/measurement/cache_cliffs.py,无论数据大小如何,Python 每元素工作成本约为 5 纳秒;numpy 批量工作成本约为 0.2 纳秒/元素。在任何大小下,比率都约为25 倍,而这仅仅是分发成本——在你添加每次调用getattr(creature, 'energy')的成本、每次返回时的引用计数工作,以及 numpy 在连续字节上使用 SIMD 指令的损失机会之前。
在编译语言中,对creatures.iter().for_each(|c| c.update())的“显而易见”的内部循环是优化器通常可以挽救的——内联该方法,将函数体融合到循环中,对结果进行自动向量化。在 Python 中,优化器是字节码分发器,它无法做到这些。每个方法调用的形式本质上是该语言提供的最坏情况。首先为数组编写代码是解释器可以满足的请求——它可以将工作交给 numpy,并完全退出循环。为单例和迭代编写代码是一个将工作固定在内核中的请求,每个元素都要在解释器内部处理。
因此,“有了一,就有了多”不是一个架构口号,而是一种日常实践。第一次这样做没有任何成本。第一次忘记它时会付出一切代价。
练习
这些练习再次扩展了deck.py。目的是在第三部分转变为本书其余部分之前,让你的指尖感受到数组优先的模式。
- 函数加切片。编写
def highest_rank_in_hand(hand, ranks),其中hand是一个包含牌索引的 numpy 数组,ranks是牌堆的点数列。函数体应该是一行:int(ranks[hand].max())。在 5 张牌的手牌上使用它。然后在 1 张牌的手牌上使用它。然后在空手牌上使用它。同一个函数,三个 N 值。 - 逆转冲动。给定一个存在于假设的
Card类上的 OOP 风格的def is_face_card(self) -> bool,将其重写为def face_cards(ranks),返回一个形状为(N,)的 numpy 布尔掩码。在一次调用中将其应用于所有 52 张牌:mask = face_cards(ranks); face_count = int(mask.sum())。 - N = 0 的情况。当
hand为空时,highest_rank_in_hand会做什么?在空数组上调用arr.max()会引发异常。选择一种行为——返回None,返回一个哨兵值,引发异常——并证明选择的合理性。(提示:大多数用法可以通过if hand.size == 0: return None进行短路。) - 对单个值的谓词。假设你想判断一张牌是否是红色(花色 0 和 1 是红心/方块)。首先编写数组版本
def red_mask(suits)——一行:(suits < 2)。然后说服自己单例情况是red_mask(np.array([suit]))[0]——数组版本覆盖了它。 - 计数开销。计时
sum(is_face_card_per_row(suits[i], ranks[i]) for i in range(52))与int(face_cards(ranks).sum())对比。在 52 时,数组版本应该明显更快,在 100,000 时快得多。记录比率。(通过复制牌堆在 N = 100,000 时重复。) - 重新审视 dataclass 双胞胎。从 §7 练习 1 中获取你的
list[Card]。将face_count_aos(cards)编写为生成器表达式求和,将face_count_soa(ranks)编写为 numpy 版本。在 1,000,000 个实体上对两者计时。你在这里测量的比率与 §7 中为count_held测量的比率相同——它不是特定于一个查询,而是你在纯 Python 中编写的任何内部循环的每元素分发成本。 - (挑战)来自教程。找到任何使用带有方法(
__init__、is_face、__repr__等)的class Card的 Python 教程。将他们的完整纸牌游戏重写为三个(或四个)numpy 数组加上自由函数。比较代码行数。比较清晰度。比较当你想查询“桌面上所有的花牌”时会发生什么——一次 numpy 调用与对每个卡牌方法调用的循环。
接下来是什么
你已经完成了“身份与结构”。卡片按照规则行事;行对齐;布局是 SoA;单例被推导出来。下一个阶段是“时间与传递”,从 §11 — 滴答 开始。来自code/sim/SPEC.md的生态系统模拟器即将开始运行。