news 2026/5/26 18:00:06

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程3-4

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

3 —Vec是一个表

Python 中的list是堆上的一个头对象,它存储三样东西:长度、容量(超额分配一小部分),以及一个指向连续PyObject*指针区域的指针。最后那个词就是教训所在。list不包含你的整数;它包含指向整数对象的指针,每个整数对象都单独分配在堆上。lst[i]从连续区域读取一个指针,然后解引用它,在内存的其他地方找到实际的PyLong(每个 int 28 字节,每个 float 24 字节)。

如果你上周用过 Python,这就是你使用的容器,对于某些问题来说,它是正确的形状。但对于本书主干所教授的几乎所有内容,即“处理一个表的所有行”,它也是错误的形状。一个包含 N 行元组的list是一个大的跳转表,位于 N+10N 个分散在堆上的小对象前面。遍历它是追逐指针,而不是顺序读取。

一个numpy数组——np.array(..., dtype=...)——具有相同的“三样东西在堆上”的形状,但其连续区域保存的是,而不是指针。一个包含一千万个 int64 的 numpy 数组是 80 MB 的连续字节;一个包含一千万个 int 的列表是 280 MB 的PyLong对象加上 80 MB 的指针,而且是分散的。arr[i]计算base + i * 8并读取——一次。没有对象解引用。没有每个元素的分配。

本书的主干使用两种容器:list用于小型簿记(你的表名、你的系统调度),以及numpy.ndarray用于行。没有对象的dict,没有类层次结构,没有为需要扩展的东西加上__slots__dataclass。不是因为它们不存在,而是因为每个包装每行一个PyObject的容器都会在每次读取时支付指针追逐的代价,而本书的其余部分就是关于不支付那个代价。

翻转,测量

取相同的数据——N 行,每行 K 个整数——并以五种方式布局。前两种是官方教程所教的。中间两种是仅使用标准库的翻转。第五种是规范化的终点。

布局说明
1.[(i, i+1, …) for i in range(N)]元组列表 — 结构数组,默认
2.[[i, i+1, …] for i in range(N)]列表的列表 — 结构数组,内部可变
3.tuple([i+k for i in range(N)] for k …)元组包含列表 — 数组结构,标准库
4.tuple(array.array('q', …) for k …)元组包含array.array— 数组结构,标准库类型化
5.tuple(np.arange(...) for k in range(K))元组包含 numpy 列 — 数组结构,类型化 + C

code/measurement/aos_vs_soa_footprint.py在 N=1,000,000 且 K=10 时,在一个新的子进程中构建每个布局,这样 RSS 读数不会相互影响。值超出了小整数缓存,因此PyLong对象不是跨行共享的单例。每种布局三个数字:峰值 RSS、构建时间、对第 0 列求和的时间。

布局RSS构建时间求和列 0
元组列表(结构数组)437 MB0.74 秒24.9 毫秒
列表的列表(结构数组)498 MB0.61 秒26.9 毫秒
元组包含列表(数组结构)383 MB0.46 秒2.5 毫秒
元组包含array.array(数组结构类型化)77 MB0.66 秒11.6 毫秒
元组包含 numpy int64 列(数组结构 numpy)94 MB0.09 秒0.4 毫秒

[!NOTE]
在作者机器上测量;请用uv run code/measurement/aos_vs_soa_footprint.py在你的机器上复现。数量级是持久的论断。数字会随 K、N、值范围和 CPython 版本而变化,但其形态——即结构数组到数组结构的翻转、装箱到类型化的翻转以及 Python 循环到 C 循环的翻转是三个独立的胜利——在不同机器上是稳定的。

这五行分离了三个独立的决策,而四行的版本将它们混为一谈了。

可变的结构数组比不可变的结构数组更差。在此规模下,将内部的元组替换为列表需要额外约 60 MB 的列表头开销。“列表的列表”模式是 Python 入门教程中最常教授的布局,也是此比较中最昂贵的布局。

第一步 — 结构数组 → 数组结构 — 是速度的翻转。元组包含列表是中级 Python 程序员在不接触 numpy 的情况下可能编写的相同代码。它在内存上仅节省了约 12%,但第 0 列的求和速度比结构数组形式快约10 倍。胜利在于访问模式:遍历一个包含 100 万个PyLong指针的连续列表,而不是遍历 100 万个元组对象并通过每个元组解引用以访问row[0]。存储几乎没有改善;循环则 dramatically 改善了。

第二步 — 装箱列表 → 类型化字节 — 是内存的翻转。list[int]array.array('q', …)将每一列从约 38 MB 的指针和PyLong对象缩小到约 8 MB 的连续 int64 字节。整个结构下降到约 77 MB 总计,在此次运行中小于 numpy(numpy 带有约 20 MB 的一次性导入开销)。但是列求和变慢了——2.5 毫秒 → 11.6 毫秒——因为 Python 必须在将每个int64相加之前将其拆箱为一个临时的PyLong。拆箱的代价收回了大约三分之一的数组结构速度优势。类型化存储节省了字节;它并不节省内部循环。

第三步 — Python 循环 → C 循环 — 是数量级的提升。np.sum遍历与array.array存储的相同的类型化字节,但循环在 C 语言中,解释器被移出了关键路径。11.6 毫秒 → 0.4 毫秒;在相同的字节上实现了约30 倍加速,没有进一步的内存节省(并且有一点点导入开销成本)。这是模拟器(§11 及以后)及其之后的每个系统所依赖的布局。

一起阅读这三个步骤:数组结构的翻转是速度的提升,类型化存储的翻转是内存的提升,C 向量化的翻转是更大规模上的又一次速度提升。每个都是独立的决策;每个都可以在不采用其他决策的情况下进行。numpy 恰好将第二个和第三个捆绑在一个库中,这就是为什么大多数教学将它们简化为“使用 numpy”。这个示例表明它们是独立的胜利。

Python 默认的陷阱,被命名了

官方教程并没有错。它针对教授语言进行了优化,而不是教授布局。它教授的道路看起来像这样:

  1. 为行创建一个类。
  2. 将实例放入列表中。
  3. 当类变得嘈杂时,使用dataclass
  4. 当内存压力出现时,使用__slots__

每一步都是一个局部改进和一个全局陷阱。第 1 步将你锁定在结构数组。第 2 步在行之间插入指针。第 3 步使结构数组更符合人体工程学。第 4 步节省了每个实例的__dict__,但对基本形状没有作用——每一行仍然是通过指针访问的自己的堆对象。__slots__的胜利是真实而微小的;数组结构的胜利是相同的数据花费的内存减少了 4-5 倍,而且你根本不需要一个类。

不存在无成本的抽象。每个指针都有代价,在行的list中,那个代价与行数线性增长。四步路径堆叠了指针:一个包含 N 个行指针的外部列表,每个行指向 K 个字段对象,每个字段是堆上其他地方单独分配的值。__slots__移除了一层(每个实例的__dict__);数组结构的翻转移除了其余部分。本书接下来的几个阶段将教授替代方案。

练习

  1. 指针追逐或值读取。打印sys.getsizeof(0)sys.getsizeof(1000)sys.getsizeof(10**100)。注意即使是一个小的 Python int 也花费 28 字节。然后打印np.array([0, 1000, 10**18], dtype=np.int64).nbytes。三个 int64 = 24 字节,并且没有每个元素的头部。
  2. 驻留陷阱。使用值 0 和 1 重复练习 1,然后使用值 257 和 1000 再次重复。使用id()确认[0] * 1_000_000在所有位置上共享一个PyLong对象,但[1000 + i for i in range(1_000_000)]则不共享。“小整数列表很便宜”的直觉仅在 CPython 的小整数缓存[-5, 256]内成立。
  3. 容量与长度。构建lst = []。在一个循环中,追加 0…1000,并在每一步之后打印len(lst)sys.getsizeof(lst)。观察超额分配模式——list以块的形式增长,类似于Vec::push,但这些块是 CPython 的实现细节(目前增长因子约为~1.125 ×)。
  4. 运行 §3 的示例。uv run code/measurement/aos_vs_soa_footprint.py。读取输出。列求和(sum-c0)很重要:即使你忽略内存行,布局 1 和布局 5 之间列求和成本的差距在相同数据上也有两个数量级。
  5. dict陷阱。构建d = {i: i*i for i in range(1_000_000)}并对查找 100,000 个随机键计时。构建arr = np.arange(1_000_000) ** 2并通过arr[idx]对相同的访问模式计时。注意你已经将“通过整数查找”替换为“通过整数索引”,并且这两种结构的成本不同。
  6. swap-remove 与 remove。构建lst = list(range(1_000_000))。计时通过lst.pop(500_000)从中间移除 100 个元素(慢——每次 pop 会移动大约半个列表)。通过lst[i] = lst[-1]; lst.pop()计时等效操作。注意数量级的差异。这个技巧在 §21 中将发挥重要作用。
  7. (挑战)读取你自己的数组。使用np.frombuffer(arr.tobytes(), dtype=np.int64)并确认arr.data.tobytes()的长度正好是arr.size * 8字节。你会写入磁盘的字节就是已经在内存中的字节。这就是 §36 — 持久性是表的序列化 所说的“表自我序列化”。

应用参考

如果你想看到这个规范在一个真实的代码片段中得到贯彻,请阅读.archive/simlog/logger.py。这是一个 700 行的列式日志记录器,它将字典负载放入预先分配的 numpy 列中,采用双缓冲设计,允许模拟器写入一个缓冲区,同时后台线程将另一个缓冲区转储到磁盘。本书现在不要求你阅读它。这是本章及接下来几章指向的目的地。

接下来是什么

§4 — 成本是布局,而你有预算 将布局推理带入每滴答的领域:在你的机器上,每滴答实际上可以移动多少字节,这能为你购买多少实体?在那之后,§5 — 身份是一个整数 是贯穿全文的模拟器获得其第一个具体形状的地方。

4 — 成本是布局 — 而你有预算

一个程序以某个目标速率运行。一个游戏以 30 Hz 或 60 Hz 运行;一个音频循环以 48 kHz 运行;一个控制循环以 1 kHz 运行;一个 Web 请求处理程序以“和人愿意等待一样快”的速度运行。目标速率设置了一个预算——一个滴答工作可用的时间。

目标速率每滴答预算
30 Hz33 毫秒
60 Hz17 毫秒
1000 Hz1 毫秒
1 000 0001 微秒

程序在一个滴答中执行的每个操作都会从这个预算中支出。操作的成本差异很大。根据你在 §1 中测量的数字:

操作典型成本
浮点乘法< 1 纳秒
L1 读取~1 纳秒
L3 读取~10 纳秒
Python 解释器分发~5 纳秒 / 元素
RAM 读取~100 纳秒
磁盘读取~100 微秒
网络往返~100 毫秒

加粗的行是大多数解释遗漏的。在一个 Pythonfor循环内部,每一步都要支付PYTHON_NEXT_INSTR、引用计数工作、PyObject装箱的成本——即使你什么都不做,也大约是 5 纳秒。这个成本高于 L1 读取,与 L3 读取相当。它是关于纯 Python 性能的主要事实,并且它没有出现在任何 C 风格的成本表中。

三种模式——以及第四种

当一个循环的成本主要由算术运算主导时,它是计算受限的——通常当数据适合 L1 且内部工作繁重时(点积、超越函数、整数除法)。当它的成本主要由内存子系统传送字节的速度主导时,它是带宽受限的——通常当工作集大于 L3访问模式是顺序的,因此预取器可以提前填充缓存行。当它的成本主要由单独的内存往返主导时,它是延迟受限的——通常当访问模式是随机的,因此预取器无法提供帮助。

Python 添加了第四种:解释器受限。从 §1 的缓存悬崖示例中,对一个 Python 列表中的 1 亿个int64值求和每个元素花费 4.59 纳秒,而在 numpy 数组中每个元素花费 0.15 纳秒。Python 列表的运行既不是带宽受限,也不是延迟受限——字节是相同的字节。它是解释器受限的。CPU 将其大部分周期花在字节码分发器和PyLong算术路径上,而不是数据上。解决方法不是“购买更快的 RAM”;解决方法是离开纯 Python 进行内部循环

这四种模式有非常不同的时间预算:

模式每元素成本30 Hz 下的预算
计算受限~1 纳秒 (L1 + ALU)3300 万次操作 / 滴答
带宽受限~0.2 纳秒 (numpy 顺序)1.65 亿次操作 / 滴答
延迟受限~12 纳秒 (numpy 收集)270 万次操作 / 滴答
解释器受限~5 纳秒 (Python 循环)660 万次操作 / 滴答

在 30 Hz 滴答中处理 1,000,000 个实体的循环,如果是带宽受限则花费预算的 0.6%,如果是延迟受限则花费 36%,如果是解释器受限则花费 14%。相同的算法,相同的数据,四种运行方式,成本相差四个数量级。复杂性类推理无法区分这些模式。

成本是布局,而不仅仅是复杂性

相同的算法在一个顺序的 numpy 列上花费 0.2 毫秒,而在一个包含相同数据的元组列表上可能花费 27 毫秒,因为每一行的读取都是对一个单独分配的元组的指针追逐,而行内的每一列读取又是对一个PyLong的另一次指针追逐。从 §3 的示例中,对一百万个十整数行中的第 0 列求和,作为元组列表花费了 30 毫秒,而作为 numpy 数组结构花费了 0.4 毫秒——在相同的有效载荷上相差75 倍。两个具有相同大 O 复杂度、相同输入数据和相同机器的程序,在内部循环上相差近两个数量级,仅仅因为它们的数据所在的位置。

这给了你一个设计规则。在决定其他任何事情之前,先决定你的目标速率。这设定了预算。然后当你选择数据结构时,问问自己结果工作集是否适合缓存;问问你的内部循环每行执行多少次内存加载;问问循环中的任何单个操作是否占用了大部分预算;问问你是在解释器内部运行还是在解释器外部运行。一旦预算被命名,大多数决定就变得强制了。

反向方向也很有用。如果你发现自己想向内部循环添加一些东西——一个字典查找、一个针对类的getattr、一个 Python 级别的回调、一个异常处理程序——根据预算计算它以微秒为单位的成本。通常答案会是“这一项添加就用掉了我 80% 的滴答时间”,而正确的做法不是优化它,而是将它完全移出内部循环。

工程类比

这种思维形态对于其他领域的工程师来说是熟悉的。电气工程师通过根据电流预算计费毫安来设计电路。结构工程师根据载荷预算计费千牛顿。数据导向的程序员根据滴答预算计费微秒。好的设计是以毫伏和微安来衡量的——以及以纳秒和微秒来衡量。选择单位,写下预算,并根据它进行核算。编程没有特殊的会计豁免。

[!NOTE]
时间是一个预算。功率是另一个预算。缓存命中在能量上几乎是免费的——数据已经在算术单元旁边。缓存未命中会启动内存控制器、总线驱动器,有时还会启动 DRAM 刷新;这就是瓦特消耗的地方。一个适合 L2 的循环将其大部分时间花在廉价算术上;一个通过 RAM 追逐指针的循环将其大部分时间花在等待上,而在等待期间,CPU 会降低时钟频率,芯片保持冷却。符合时间预算的相同数组结构和顺序访问规范也符合功率预算。对于嵌入式、移动、控制和电池供电的工作,功率是主要预算;时间是其下游。上面的“毫伏和微安”是字面意思,而不是比喻。

一个 Python 特定的补充:一个解释器受限的循环在每个有用操作上也是相对耗电的,因为 CPU 在满负荷运行进行分发工作,而不是算术运算。转向 numpy 同时改善了时间能源。这里没有权衡——规范化的选择也是廉价的选择。

练习

  1. 选择你的速率。对于以下每个系统,说出一个合理的目标速率以及由此产生的每滴答预算:一个纸牌游戏;一个实时策略游戏;一个市场数据馈送;一个嵌入式传感器控制器;一个用户正在等待的 Web API 端点;一个处理数十亿行的离线批处理作业。
  2. 计算一个操作。在一个包含 1,000,000 个条目的字典上,对一次dict[k]查找计时(对一百万次重复使用timeit然后除以)。注意其以微秒为单位的成本。在 30 Hz 滴答(33 毫秒)中你能容纳多少次?在 1 kHz 滴答(1 毫秒)中呢?
  3. 布局差异。对 numpy 数组中的 1,000,000 个int64值求和。对 Pythondict中具有整数键的 1,000,000 个 int 求和(使用sum(d.values()))。每元素的时间差异(以纳秒为单位)是多少?这些时间去了哪里?将答案映射到上面的模式表。
  4. 悬崖。使用你在 §1 练习 2 中的数字,选择一个刚好适合 L2 的 numpy 数组大小和一个刚好不适合的大小。在每个大小上对arr.sum()计时。悬崖是真实的。
  5. 从预算反向工作。你的目标是 60 Hz;你的内部循环处理 100,000 个实体;每个实体接触一个缓存行的状态。在这四种模式(计算、带宽、延迟、解释器)中,估算循环的成本(以微秒为单位)。与你的 60 Hz 预算(16,666 微秒)进行比较。注意哪种模式给你留有余地,哪种模式超出了预算。
  6. 一个糟糕的设计。通过大 O 推理构建一个“显然很快”的 Python 设计,但它在处理一百万个实体时超出了 30 Hz 的预算。(提示:dataclass实例的列表,每滴答执行for entity in entities: entity.update()是典型的例子。从模式表的解释器受限行估算其成本。)
  7. 找到你 CPU 的 TDP。在制造商的规格表上查找你的 CPU 的额定热设计功耗,或者在 Linux 上使用sudo dmidecode -t processor | grep -i 'power\|TDP'本地读取它。记下该值。TDP 是芯片可以在不发生热节流的情况下持续散发的热量——突发可以持续几十秒达到 1.5-2 倍高;持续运行则会回落到 TDP。
  8. 电池预算。一个典型的笔记本电脑电池大约有 50 Wh。你的模拟器以 30 Hz 运行,平均功耗为 8 W(主要是内部循环的内存带宽)。一次充满电可以购买多少小时的模拟时间?如果一个布局更改将更多的负载推到 RAM 并将平均功耗提高到 14 W,那么能运行多少小时?将布局更改的成本表示为电池寿命的百分比。
  9. 测量增量功率。在一个终端中,运行一个持续的顺序 numpy 求和循环:
importnumpyasnp arr=np.arange(10_000_000,dtype=np.int64)whileTrue:_=int(arr.sum())

在另一个终端中:sudo perf stat -a -e power/energy-pkg/ -- sleep 30读取 30 秒内的封装能量计数器。使用随机收集版本(带有打乱的idxarr[idx].sum())和空闲基线运行相同的测量。将每个转换为平均瓦特。随机访问运行应该比顺序运行消耗更多瓦特,顺序运行应该比空闲消耗更多。它们之间的差距是破坏预取器的能量成本。
10.(挑战)每访问焦耳。估算每次内存读取的能量:L1 命中 ≈ 0.1 nJ,L2 ≈ 1 nJ,RAM ≈ 30 nJ(粗略值;已发布的数字因芯片和工艺而异)。估算顺序(主要是预取,接近 L1 成本)与随机索引(主要是 RAM 未命中)对 1000 万个int64求和的总能量。将两者转换为毫瓦时,并表示为 50 Wh 电池的分数。绝对数字很小;比率才是你的电池寿命和数据中心电费所关心的。

接下来是什么

你现在有了机器模型(§1)、数据宽度(§2)、表原语(§3)和预算计算(§4)。下一节是本书的概念核心:§5 — 身份是一个整数。纸牌游戏在等着你。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/26 17:54:01

Unity-MCP协议:重新定义游戏AI开发的工作流底层协议

1. 这不是又一个“AI插件”&#xff0c;而是Unity开发工作流的底层重定义我第一次在内部测试环境里把MCP协议接入我们正在做的开放世界RPG项目时&#xff0c;没敢直接告诉主程——怕他以为我又在折腾什么花哨但不落地的玩具。结果三天后&#xff0c;他主动把我叫到工位前&#…

作者头像 李华
网站建设 2026/5/26 17:54:01

FPG财盛国际:服务体系完善度与使用感受分析

在外汇服务行业持续规范化的背景下&#xff0c;用户选择平台时已不再只关注单一功能&#xff0c;而是更看重稳定运行、服务响应、风险提示和信息透明度等综合体验。FPG财盛国际作为被不少用户关注的品牌&#xff0c;在整体评估中可以从多个维度展开观察。相较于单纯强调速度或规…

作者头像 李华
网站建设 2026/5/26 17:51:01

基于huBERT的匈牙利政治文本情感与情绪分析模型构建实践

1. 项目概述&#xff1a;当BERT遇上匈牙利政治话语在自然语言处理&#xff08;NLP&#xff09;的广阔天地里&#xff0c;情感分析&#xff08;Sentiment Analysis&#xff09;和情绪分析&#xff08;Emotion Analysis&#xff09;一直是两个既紧密相关又有所区别的核心任务。简…

作者头像 李华
网站建设 2026/5/26 17:49:05

告别命令行焦虑!5分钟学会iOS应用签名的终极懒人方案

告别命令行焦虑&#xff01;5分钟学会iOS应用签名的终极懒人方案 【免费下载链接】ios-app-signer This is an app for OS X that can (re)sign apps and bundle them into ipa files that are ready to be installed on an iOS device. 项目地址: https://gitcode.com/gh_mi…

作者头像 李华
网站建设 2026/5/26 17:48:42

如何通过 TaoToken CLI 快速安装配置多模型 API 环境

&#x1f680; 告别海外账号与网络限制&#xff01;稳定直连全球优质大模型&#xff0c;限时半价接入中。 &#x1f449; 点击领取海量免费额度 如何通过 TaoToken CLI 快速安装配置多模型 API 环境 对于需要同时接入多个大模型的开发者来说&#xff0c;逐一配置不同厂商的 AP…

作者头像 李华