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

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

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

7 — 数组结构 (SoA)

你的牌堆有三个 numpy 列:suitsrankslocations。每个字段都存在于自己的数组中,由实体索引。这种布局被称为数组结构——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列——它根本不需要suitrank

“只读一列”的实际成本

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

held_by_p1=int(np.sum(locations==1))

该调用遍历locationsN 字节,生成一个 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。一个不需要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 — 变更缓冲区 的预兆。)
  8. (挑战)一个从头开始的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。目的是在第三部分转变为本书其余部分之前,让你的指尖感受到数组优先的模式。

  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 — 滴答 开始。来自code/sim/SPEC.md的生态系统模拟器即将开始运行。

http://www.jsqmd.com/news/900011/

相关文章:

  • 2026年上海/贵阳门窗厂家推荐榜单:系统门窗、平开/推拉门窗品质与工艺深度解析 - 品牌企业推荐师(官方)
  • 2026年工业气体/特种气体厂家实力榜单:液氮液氩液氧高纯气体及稀有气体供应商深度推荐 - 品牌企业推荐师(官方)
  • 03、单线通讯—SIF协议在资源受限MCU中的定时器驱动实现与优化
  • 告别PyTorch卡顿:树莓派5从YOLOv5迁移到YOLOv8+ncnn的完整踩坑实录
  • 2026年5月更新江苏无尘室净化空调系统:一体化服务商的深度选择指南 - 2026年企业资讯
  • 【小白零基础】 OpenClaw2.7.5 Windows 快速部署方法(包含安装包)
  • 学术创作提速新思路:okbiye 智能论文撰写模块,适配高校全品类论文创作需求
  • 2026年5月长春数字科技职业大专选校指南:深度解析长春数字科技职业学院 - 2026年企业资讯
  • YOLO 数据集构建与效果验证实战指南
  • 用STM32F103C8T6做个可调电源:从原理图到代码的保姆级教程(含LCD1602显示与过流保护)
  • 实战复盘:我用Python+Appium给公司老旧的Win32客户端做自动化回归测试,踩了这些坑
  • 基于树莓派Ubuntu Mate与PX4的UDP通信:搭建QGC地面站远程监控系统
  • 从单体AI代理到协调者模式:架构演进提升任务完成率与可维护性
  • 避坑指南:Unity中用C# DateTime处理时间,别忘了时区和性能这两件事
  • 具身智能(Embodied AI)
  • 钉钉消息防撤回补丁PC版:终极解决方案,让你不再错过任何重要信息
  • 手把手教你用Python免费调用阿里云通义千问1.8B模型API(附完整代码)
  • 谷歌seo主页优化做什么?图片Alt标签加这3个词最管用
  • RAG系统静默失败:诊断、防御与全链路质量保障实战
  • 2026年广告物料制作厂家推荐榜:写真/KT板/PVC板/雕刻/条幅/车贴/喷绘加工优质品牌深度解析 - 品牌企业推荐师(官方)
  • Qt ItemDataRole深度解析:从核心角色到界面定制
  • 别再死磕单级PID了!PX4固定翼姿态控制器里的串级PID,为什么是双回路的?
  • 瑞芯微RK3588 开发板USB线刷eMMC系统教程
  • 2025-2026年尚百年全铝家居联系电话:电话查询前请核实产品特性与订购流程 - 品牌推荐
  • C++ 高性能编程:如何用 AVX2 手写达到硬件理论极限的向量点积算子
  • 别再为OpenMV串口传图卡顿发愁了!实测对比STM32调试器与TTL模块,教你选对硬件(附921600波特率避坑指南)
  • 易语言资源表实战:从数据封装到动态资源调用的完整指南
  • 弱人工智能、强人工智能、超人工智能 概念解析
  • 使用Nodejs与Taotoken构建一个轻量级AI助手后端服务
  • 不只是安装:用LabelImg标注完数据后,如何高效管理你的VOC格式XML文件?