CPython frozenset 构造性能优化:避免复制,让 frozenset(frozenset) 直接复用原对象
2026/9/10 7:19:17 网站建设 项目流程

CPython frozenset 构造性能优化:避免复制,让 frozenset(frozenset) 直接复用原对象

【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython

本篇技术指南围绕 CPython 的一条核心运行时改进展开:frozenset对象在构造阶段避免不必要的元素复制。该改动落在 CPython 内置类型层(Core and Builtins),读者读完后将理解frozenset的完整构造链路(参数校验 → vectorcall → 幂等复用 → 哈希表填充 → GC 跟踪策略),并能基于 Objects/setobject.c 中的源码证据判断什么场景下frozenset()是零拷贝的、什么场景下仍会发生全量插入。

一、这次优化改了什么

对应的变更记录位于 Misc/NEWS.d/next/Core_and_Builtins/2026-05-18-17-16-51.gh-issue-150027.sJgLvd.rst,全文只有两行:

Improve performance offrozensetobjects by avoiding copies during construction.

(提升frozenset对象性能,避免在构造期间产生复制。)

一句话概括:frozenset()的参数本身就是一个精确的frozenset实例时,CPython 不再创建新对象并逐项复制元素,而是直接返回原对象的引用。这在 Objects/setobject.c 的make_new_frozenset中实现:

static PyObject * make_new_frozenset(PyTypeObject *type, PyObject *iterable) { if (type != &PyFrozenSet_Type) { return make_new_set(type, iterable); } if (iterable != NULL && PyFrozenSet_CheckExact(iterable)) { /* frozenset(f) is idempotent */ return Py_NewRef(iterable); } PyObject *obj = make_new_set(type, iterable); if (obj != NULL) { _PyFrozenSet_MaybeUntrack(obj); } return obj; }

见 Objects/setobject.c。三个关键细节:

  1. 只对精确类型生效PyFrozenSet_CheckExact(iterable)要求参数必须是frozenset本身,而不是它的子类。若参数是frozenset子类实例,走的是通用构造路径,保证子类的__init__/初始化语义不被短路。
  2. Py_NewRef而非返回原指针:直接Py_INCREF原对象并返回,把引用计数交给调用方,符合 CPython 对象所有权约定——调用方拿到返回值后负责释放,原对象的存活期因此自然延长。
  3. 幂等性(idempotent)是安全前提frozenset(f)的结果在语义上与原对象完全一致——相同的元素、相同的类型,且frozenset不可变,复用不会引入任何可观察的行为差异。这正是这条优化可以成立的理论依据。

二、完整的 frozenset 构造链路

从 Python 层的frozenset(x)调用到对象返回,实际调用链如下:

  1. 参数入口(两种形态)

    • 经典tp_new路径:frozenset_new(Objects/setobject.c)。它先拒绝关键字参数(_PyArg_NoKeywords("frozenset", kwds),报错文本固定为"frozenset"),再用PyArg_UnpackTuple解出 0 到 1 个位置参数。
    • 更快的 vectorcall 路径:frozenset_vectorcall(Objects/setobject.c),由类型对象的.tp_vectorcall槽注册(见 Objects/setobject.c 附近的PyFrozenSet_Type定义)。它跳过tp_new的元对象分发,直接校验位置参数个数后进入make_new_frozenset
  2. 幂等短路:即上文第一节的PyFrozenSet_CheckExact分支。

  3. 通用构造路径make_new_frozenset落到make_new_set(type, iterable),后者由make_new_set_untracked(Objects/setobject.c)完成实体分配:

so = (PySetObject *)_PyType_AllocNoTrack(type, 0); ... so->fill = 0; so->used = 0; so->mask = PySet_MINSIZE - 1; so->table = so->smalltable; // 先用对象内嵌的小表 so->hash = -1; so->finger = 0; so->weakreflist = NULL; if (iterable != NULL) { if (set_update_local(so, iterable)) { ... } }

注意注释里写明的设计约束(Objects/setobject.c):"Build a set/frozenset left GC-untracked; the caller must_PyObject_GC_TRACK()it once fully built, so a half-built set is never exposed during filling."——对象在填充完成前不进入 GC 跟踪,避免其他线程看到只填了一半的 set;填充成功后才由make_new_set调用_PyObject_GC_TRACK

  1. GC 卸载优化:对象建成后make_new_frozenset还会调用_PyFrozenSet_MaybeUntrack(Objects/setobject.c)。该函数来自 gh-140232 的相关改进:遍历 frozenset 的全部元素,若没有任何元素是被 GC 跟踪的(如全部是 int、str 等不可跟踪对象),就把整个 frozenset 从 GC 跟踪列表中移除,减少后续 GC 周期的扫描开销。子类实例不参与卸载,因为子类可能引入引用环。

这条链路说明:本次优化是在"最坏情况路径"之前加了一道最廉价的短路判断——先做类型精确检查(O(1)),命中即一次Py_INCREF返回;未命中才付出分配 + 逐元素哈希插入 + 哈希表扩容的成本。

三、复制路径到底贵在哪:set_update_local 的分发逻辑

理解短路收益,需要看清"被短路掉"的那条路径有多重。填充入口是set_update_local(Objects/setobject.c),它对源对象做了四档分发:

源对象类型走的路径说明
set/frozenset(任意集合)set_merge_lock_held直接遍历源集合的哈希表,逐个set_add_entry,可批量预扩容
精确dictset_update_dict_lock_held_PyDict_Next迭代键值对(见 Objects/setobject.c),可预知PyDict_GET_SIZE并据此一次性扩容(Objects/setobject.c)
frozendict(内部类型)同上,无需锁frozendict 不可变,直接迭代
其他可迭代对象set_update_iterable_lock_heldPyObject_GetIter+ 逐项PyIter_Next+set_add_key,最慢

对集合源走的是set_merge_lock_held:需要Py_BEGIN_CRITICAL_SECTION获取源集合临界区、逐项复制键并处理哈希表扩容;set.update这类对已公开对象的更新走set_update_internal(Objects/setobject.c),还多了一层对soother的双重临界区保护(Py_BEGIN_CRITICAL_SECTION2),并对自更新(so is other)做了直接返回的短路。

也就是说,优化前frozenset(f)要经历:分配新对象 → 复制内嵌表或扩容 → 逐项set_add_entry(含哈希查找、冲突处理)→ 全程持有源集合临界区 → GC 跟踪。优化后只剩一次引用计数递增。对"把 frozenset 当缓存键/常量反复包装"的场景(例如每层递归都frozenset(prev_keys)),这是数量级的差异。

四、set为何没有同样待遇

对照代码可以明确这次优化的边界:set_new(Objects/setobject.c)只做make_new_set(type, NULL)set(x)若传入集合会完整复制元素——这是刻意的,set可变,复用同一对象会让两个"独立"的 set 共享同一份可改数据,直接破坏语义。而frozenset不可变 + 值语义,frozenset(f) is f在精确类型下成为可能(注意是"可能":返回的是Py_NewRef,引用同一对象,因此is恒成立;但 frozenset 子类、dict 源、list 源等仍会新建对象)。

五、如何验证这一行为

在任意使用当前仓库构建的 CPython 中可直接观察:

f = frozenset({1, 2, 3}) f2 = frozenset(f) print(f2 is f) # True —— 精确类型下直接复用原对象 print(frozenset(frozenset()) is frozenset()) # False —— 两次独立构造 class Sub(frozenset): pass s = Sub([1, 2]) print(frozenset(s) is s) # False —— 子类不走幂等短路,仍会新建

预期结果:精确frozenset的二次包装与源对象同一身份;子类实例则仍触发完整构造。相关测试入口在 Lib/test/test_set.py,其中包含对 frozenset 构造、比较、哈希等行为的成组覆盖,回归验证时可优先运行该文件。

六、对使用方的实际含义

  • 写代码无需改变:该优化完全透明,frozenset(x)的调用方式、异常行为均不变,只是"参数恰好是 frozenset"这一常见路径变快。
  • 性能敏感场景受益:以 frozenset 做字典键的嵌套结构(如递归解析、DAG 去重、类型参数组合缓存)中,频繁的frozenset(existing)包装调用现在接近零成本。
  • 不要依赖is做逻辑判断:虽然精确类型下frozenset(f) is f成立,但这属于实现细节层面的幂等性保证,仅用于性能,不应把它当作 API 契约来写业务逻辑;类型上仍是"返回一个引用计数递增后的 frozenset"。
  • free-threading 环境同样适用:填充路径全程使用临界区(critical section)保护源集合(Objects/setobject.c),而幂等短路路径不持有任何锁,在无 GIL 构建下收益更为直接。

七、小结

这条两行的 NEWS 背后是 CPython 内置类型层一次典型的"识别并消除冗余拷贝"优化:在make_new_frozenset中利用frozenset的不可变性与精确类型检查,将frozenset(f)从"分配 + 全量元素复制 + GC 登记"降级为一次Py_NewRef。配合既有的 GC 卸载机制(_PyFrozenSet_MaybeUntrack)与 vectorcall 直达入口,frozenset 构造在小集合高频调用的场景下开销被压到最低。阅读 Objects/setobject.c 中make_new_frozensetset_update_localset_merge_lock_held三个函数的对照关系,是理解 CPython 集合构造快慢分层的最佳切口。

【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询