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

来源:https://root-11.codeberg.page/intro-book-python/

7 --- 数组结构 (SoA)

你的牌堆有三个 numpy 列:suitsrankslocations。每个字段都存在于自己的数组中,由实体索引。这种布局被称为数组结构 ------SoA。相反的布局------一个单一的 list[Card],其中每个元素是一个包含所有三个字段的 dataclass------被称为结构数组 ------AoS。它们是关于相同数据存放在何处的不同选择。

python 复制代码
# SoA: 三列,索引步调一致
suits     = np.zeros(52, dtype=np.uint8)
ranks     = np.zeros(52, dtype=np.uint8)
locations = np.zeros(52, dtype=np.uint8)

# AoS: 一个对象列表
@dataclass
class Card:
    suit: int
    rank: int
    location: int

cards: list[Card] = [...]  # 52 个实例

大多数 Python 程序员默认会选择 AoS。这是每个入门教程都会教的:为实体定义一个类,将实例放入列表。问题在于,在实际的循环中,"实体"是内部循环读取的任何东西,而不是数据模型认为应该放在一起的东西。一个计算玩家 1 手牌中牌数的系统只读取 location 列------它根本不需要 suitrank

"只读一列"的实际成本

使用 SoA,这个计数是一个 numpy 原语:

python 复制代码
held_by_p1 = int(np.sum(locations == 1))

该调用遍历 locationsN 字节,生成一个 N 字节的布尔掩码,并对其进行求和------所有操作都在 C 语言内部进行,没有 Python 级别的迭代。在这台机器上,当 N = 1,000,000 张牌时,调用大约需要 0.5 毫秒。

使用 AoS,同样的计数是一个 Python for 循环:

python 复制代码
held_by_p1 = sum(1 for c in cards if c.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。一个不需要 suitrank 的读取器无论如何都要为它们付费,因为它们在同一条缓存行中进入。添加一个 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()

  1. 构建两种布局。 从 §5 获取你的 deck.py 并添加一个 AoS 双胞胎:一个包含 52 个条目的 list[Card],其中 Card 是一个包含三个整数字段的 @dataclass。构建两者并验证它们编码了相同的逻辑内容。
  2. 用两种方法计算玩家手中的牌数。 使用 np.sum(locations == player) 编写 count_held_soa(locations, player),并使用 Python 生成器表达式编写 count_held_aos(cards, player)。确认它们在相同的牌堆上返回相同的数字。
  3. 在 10,000 个条目时计时。 将你的牌堆复制到长度为 10,000。使用 timeit 对两个函数计时(例如,numpy 版本使用 number=1000,AoS 版本使用 number=100)。注意每纳秒每元素的比率。
  4. 扩展到 1,000,000 个条目。 在长度 1,000,000 时重复。SoA 版本读取 1 MB 的字节;AoS 版本通过 Python 的属性机制遍历一百万个指针追逐。注意其比率。在大多数机器上,它在 50-200 倍范围内。
  5. Python 版的热/冷情况。nickname: str = "" 字段和 dealt_at: int = -1 字段扩展 Card------总共五个字段而不是三个。重新构建两者。再次对计数计时。注意 SoA 时间不变 (计数仍然只遍历 locations),而 AoS 时间也大致不变 (解释器分发无论如何都占主导地位)。与本章的 Rust 版本进行比较,在 Rust 版本中 AoS 时间会随着行大小而增长------Python 的惩罚是以不同的方式固定的。
  6. AoS 不会输的情况。 编写一个更新一张特定卡片的所有字段的函数。SoA 写入三个(或五个)不同的列;AoS 写入一个 Python 对象。对于"更新一张卡片的所有字段"的情况------单个实体,没有循环------AoS 具有竞争力或更好。对它计时。注意这种情况没有内部循环,这就是为什么 §4 中的模式区分不适用。
  7. 先构建,后读取。 从 §6 中你知道构建 dataclass 实例很慢。计时构建 一次百万条目的 AoS 列表,然后对位置查询求和 1000 次。与构建一次百万条目的 SoA,然后求和 1000 次进行比较。构建成本会在多次读取中摊销;对于短寿命的数据,即使是 SoA 的构建时间也会成为一个因素。(提示:这是 [§22 --- 变更缓冲区](#§22 — 变更缓冲区) 的预兆。)
  8. (挑战) 一个从头开始的 SoaDeck 类。 将列(suits, ranks, locations, dealt_at)包装到一个拥有它们所有的一个 Python 类中。提供 reorder(self, order) 作为唯一的公共修改器。你在正确性方面获得了什么?在灵活性方面失去了什么?(提示:你刚刚提前四章重建了 [§25 --- 表的所有权](#§25 — 表的所有权) 中的契约。)

接下来是什么

[§8 --- 有了一,就有了多](#§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。目的是在第三部分转变为本书其余部分之前,让你的指尖感受到数组优先的模式。

  1. 函数加切片。 编写 def highest_rank_in_hand(hand, ranks),其中 hand 是一个包含牌索引的 numpy 数组,ranks 是牌堆的点数列。函数体应该是一行:int(ranks[hand].max())。在 5 张牌的手牌上使用它。然后在 1 张牌的手牌上使用它。然后在空手牌上使用它。同一个函数,三个 N 值。
  2. 逆转冲动。 给定一个存在于假设的 Card 类上的 OOP 风格的 def is_face_card(self) -> bool,将其重写为 def face_cards(ranks),返回一个形状为 (N,) 的 numpy 布尔掩码。在一次调用中将其应用于所有 52 张牌:mask = face_cards(ranks); face_count = int(mask.sum())
  3. N = 0 的情况。hand 为空时,highest_rank_in_hand 会做什么?在空数组上调用 arr.max() 会引发异常。选择一种行为------返回 None,返回一个哨兵值,引发异常------并证明选择的合理性。(提示:大多数用法可以通过 if hand.size == 0: return None 进行短路。)
  4. 对单个值的谓词。 假设你想判断一张牌是否是红色(花色 0 和 1 是红心/方块)。首先编写数组版本 def red_mask(suits)------一行:(suits < 2)。然后说服自己单例情况是 red_mask(np.array([suit]))[0]------数组版本覆盖了它。
  5. 计数开销。 计时 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 时重复。)
  6. 重新审视 dataclass 双胞胎。 从 §7 练习 1 中获取你的 list[Card]。将 face_count_aos(cards) 编写为生成器表达式求和,将 face_count_soa(ranks) 编写为 numpy 版本。在 1,000,000 个实体上对两者计时。你在这里测量的比率与 §7 中为 count_held 测量的比率相同------它不是特定于一个查询,而是你在纯 Python 中编写的任何内部循环的每元素分发成本。
  7. (挑战) 来自教程。 找到任何使用带有方法(__init__is_face__repr__ 等)的 class Card 的 Python 教程。将他们的完整纸牌游戏重写为三个(或四个)numpy 数组加上自由函数。比较代码行数。比较清晰度。比较当你想查询"桌面上所有的花牌"时会发生什么------一次 numpy 调用与对每个卡牌方法调用的循环。

接下来是什么

你已经完成了"身份与结构"。卡片按照规则行事;行对齐;布局是 SoA;单例被推导出来。下一个阶段是"时间与传递",从 [§11 --- 滴答](#§11 — 滴答) 开始。来自 code/sim/SPEC.md 的生态系统模拟器即将开始运行。

相关推荐
chase_my_dream10 分钟前
C++ + SLAM 高频面试问题整理
开发语言·c++·面试
KaMeidebaby31 分钟前
卡梅德生物技术快报|纯化重组蛋白实操详解
人工智能·python·tcp/ip·算法·机器学习
Cloud_Shy61833 分钟前
解读《Effective Python 3rd Edition》:从练气到老魔(第五章 Item 30 - 32)
开发语言·人工智能·笔记·python·学习方法
天佑木枫1 小时前
15天Python入门系列 · 序
开发语言·python
happylifetree1 小时前
Python017-第二章15.数据容器-dict常用操作
python
装不满的克莱因瓶1 小时前
了解 LangChain 中的 LLM 与 ChatModel 的差异
人工智能·python·ai·langchain·llm·agent·chatmodel
宋拾壹2 小时前
同时添加多个类目
android·开发语言·javascript
IT知识分享2 小时前
从零开发在线简繁转换工具:OpenCC 实战、避坑经验与方案选型
javascript·python
lunzi_08262 小时前
【学习笔记】《Python编程 从入门到实践》第8章:函数定义、参数传递与模块导入
笔记·python·学习
凡人叶枫2 小时前
Effective C++ 条款04:确定对象被使用前已先被初始化
java·linux·开发语言·c++·嵌入式开发