Skip to content

gh-160147: Add PyFrozenSetWriter C API - #160154

Open
vstinner wants to merge 12 commits into
python:mainfrom
vstinner:frozenset_writer
Open

vstinner wants to merge 12 commits into
python:mainfrom
vstinner:frozenset_writer

Conversation

@vstinner

@vstinner vstinner commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Use the new API in:

  • Modules/_abc.c
  • Modules/_asynciomodule.c
  • Modules/_hashopenssl.c
  • Modules/_ssl.c

Use the new API in:

* Modules/_abc.c
* Modules/_asynciomodule.c
* Modules/_hashopenssl.c
* Modules/_ssl.c
@read-the-docs-community

read-the-docs-community Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 cpython-previews | 🛠️ Build #35420177 | 📁 Comparing 3730c02 against main (c72b435)

  🔍 Preview build  

44 files changed · ± 44 modified

± Modified

@vstinner vstinner changed the title gh-158660: Add PyFrozenSetWriter C API gh-160147: Add PyFrozenSetWriter C API Oct 22, 2026
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 22, 2026
@vstinner

Copy link
Copy Markdown
Member Author

Updating Modules/marshal.c to use PyFrozenSetWriter is more complicated, I prefer to do it in a follow-up PR.

Comment thread Objects/setobject.c
@vstinner

Copy link
Copy Markdown
Member Author

cc @corona10

@vstinner

Copy link
Copy Markdown
Member Author

This C API is just an abstraction on top of existing PySet functions. PyFrozenSetWriter_Create() doesn't allocate a PyFrozenSetWriter structure. In fact, it's just a PySetObject* pointer! It is the same design than the PyLongWriter API.

@corona10 corona10 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall lgtm.

@rhettinger

Copy link
Copy Markdown
Contributor

The edits to Modules/_abc.c, Modules/_asynciomodule.c, Modules/_hashopenssl.c, and Modules/_ssl.c all add more code than they replace. I don't see how the new API adds value.

@vstinner

Copy link
Copy Markdown
Member Author

@rhettinger:

I don't see how the new API adds value.

There multiple advantages of this new API.

The current PySet_Add() API requires that there is exactly a single reference to the frozenset, otherwise it fails with SystemError. The frozenset can leak to other C functions or even to Python before it's fully initialized. The proposed Writer API doesn't have this issue.

The internal frozenset is not tracked by the GC until Finish() is called (when the frozenset is fully initialized). So it's not possible to observe a frozenset being modified in Python (which must not be possible, frozenset are immutable, right?). See for example #159730 for recent examples of fixed bugs.

Also, I think that's a bad design to have a PySet_Add() function to modify an immutable (frozenset) object. We should avoid such API. So I propose deprecating calling PySet_Add() on a frozenset (replaced with this new Writer API). See issues:

Comment thread Doc/c-api/set.rst Outdated
@bedevere-app

bedevere-app Bot commented Oct 24, 2026

Copy link
Copy Markdown

When you're done making the requested changes, leave the comment: I have made the requested changes; please review again.

Add PyFrozenSetWriter_UpdateArray() and
PyFrozenSetWriter_UpdateArraySteal() functions.

Add tests on PyFrozenSetWriter_AddSteal().

Typoe: rename frozsetwriter_check_consistency() to
frozensetwriter_check_consistency().
@vstinner

Copy link
Copy Markdown
Member Author

Updates:

  • Add size parameter to PyFrozenSetWriter_Create()
  • Add PyFrozenSetWriter_AddSteal() function
  • Add PyFrozenSetWriter_UpdateArray() and PyFrozenSetWriter_UpdateArraySteal() functions
  • PyFrozenSetWriter_Finish() now always tracks the frozenset in the GC, instead of only tracking them if at least one item is tracked by the GC -- same behavior than PyFrozenSet_New(iterable)

Comment thread Objects/setobject.c


void
PyFrozenSetWriter_Discard(PyFrozenSetWriter *writer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you name this something else? Sets already have a discard method with a completely different meaning. No need to create confusion by giving this the same name.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, it's unfortunate that PyFrozenSetWriter_Discard() name is close set.discard(), but has a different meaning (discard the temporary frozenset and destroy the writer) :-(

If possible, I would prefer to reuse Create/Finish/Discard names, since they are already used by 3 other "Writer" C API:

For now, I only propose functions to add items to the writer. If someone needs "more complex" use cases, I would suggest creating a mutable set, and then convert it to a frozenset (call PyFrozenNew_New(temp_set)).

If later a discard method is added, it can be called PyFrozenSetWriter_DiscardItem(writer, item).

Some cleanups.
@vstinner

Copy link
Copy Markdown
Member Author

I ran a benchmark on Linux (gcc -O3) to look at performance:

PyFrozenSet_New 4-tuple: Mean +- std dev: 105 ns +- 2 ns
PyFrozenSet_New 100-tuple: Mean +- std dev: 1.94 us +- 0.03 us
PyFrozenSet_New 1,000-tuple: Mean +- std dev: 14.8 us +- 0.2 us
PyFrozenSet_New 100,000-tuple: Mean +- std dev: 1.76 ms +- 0.03 ms

PyFrozenSetWriter_Update 4-tuple: Mean +- std dev: 104 ns +- 1 ns
PyFrozenSetWriter_Update 100-tuple: Mean +- std dev: 1.61 us +- 0.02 us
PyFrozenSetWriter_Update 1,000-tuple: Mean +- std dev: 13.3 us +- 0.2 us
PyFrozenSetWriter_Update 100,000-tuple: Mean +- std dev: 1.53 ms +- 0.03 ms

PyFrozenSetWriter_Add 4: Mean +- std dev: 68.9 ns +- 5.4 ns
PyFrozenSetWriter_Add 100: Mean +- std dev: 1.46 us +- 0.02 us
PyFrozenSetWriter_Add 1,000: Mean +- std dev: 11.9 us +- 0.5 us
PyFrozenSetWriter_Add 100,000: Mean +- std dev: 1.57 ms +- 0.02 ms

PyFrozenSetWriter_UpdateArray 4: Mean +- std dev: 72.3 ns +- 0.8 ns
PyFrozenSetWriter_UpdateArray 100: Mean +- std dev: 1.08 us +- 0.01 us
PyFrozenSetWriter_UpdateArray 1,000: Mean +- std dev: 10.0 us +- 0.3 us
PyFrozenSetWriter_UpdateArray 100,000: Mean +- std dev: 991 us +- 60 us

=> PyFrozenSetWriter_UpdateArray() is the fastest method to create a frozenset.

  • The size=4 case uses the frozenset "small table" (doesn't allocate a separated table on the heap).
  • PyFrozenSetWriter_Create(0) + PyFrozenSetWriter_Update(iterable) + PyFrozenSetWriter_Finish() has the same performance than PyFrozenSet_New(iterable). Well, if you look closely, PyFrozenSetWriter_Update() is 1.11x to 1.20x faster than PyFrozenSet_New(). I'm not sure how it's possible that it's faster.
  • PyFrozenSetWriter_Create(0) + PyFrozenSetWriter_Add(item) x size + PyFrozenSetWriter_Finish() is between 1.12x and 1.52x faster than PyFrozenSet_New(). This comparison is not fair: PyFrozenSetWriter_Add() uses PyTuple_GET_SIZE() + loop on PyTuple_GET_ITEM() to iterate on the tuple, whereas PyFrozenSet_New() uses PyObject_GetIter() + loop on PyIter_Next().
  • PyFrozenSetWriter_Create(0) + PyFrozenSetWriter_UpdateArray() + PyFrozenSetWriter_Finish() is between 1.04x slower and 1.58x faster than PyFrozenSetWriter_Add(). The difference is that PyFrozenSetWriter_UpdateArray() only allocates the frozenset table at once, whereas PyFrozenSetWriter_Add() has to resize the table multiple times.

I suppose that PyFrozenSetWriter_Update() is faster than PyFrozenSet_New() because PyFrozenSet_New() iterates on items to try to untrack the frozenset in the GC, whereas PyFrozenSetWriter_Finish() calls _PyObject_GC_TRACK() without iterating on items. We should decide what's the correct behavior for PyFrozenSetWriter_Finish(): always track or only track if all items are tracked.

Note: I didn't test Free Threading since PySet_Add() and PyFrozenSetWriter avoid locking (critical section) anyway.

Benchmark script:

Details
import pyperf, functools, _testcapi
runner = pyperf.Runner()
sizes = (4, 100, 1_000, 100_000)

# Call PyFrozenSet_New() once:
# call PyObject_GetIter() and then loop on PyIter_Next()
for size in sizes:
    test_tuple = tuple(range(size))
    func = functools.partial(_testcapi.bench_frozenset_new, test_tuple)
    runner.bench_time_func(f'PyFrozenSet_New {size:,}-tuple', func)

# Call PyFrozenSetWriter_Update() once:
# call PyObject_GetIter() and then loop on PyIter_Next()
for size in sizes:
    test_tuple = tuple(range(size))
    func = functools.partial(_testcapi.bench_writer_update, test_tuple)
    runner.bench_time_func(f'PyFrozenSetWriter_Update {size:,}-tuple', func)

# Call PyFrozenSetWriter_Create(0) + PyFrozenSetWriter_Add() once per item
for size in sizes:
    test_tuple = tuple(range(size))
    func = functools.partial(_testcapi.bench_writer_add, test_tuple)
    runner.bench_time_func(f'PyFrozenSetWriter_Add {size:,}', func)

# Call PyFrozenSetWriter_Create(0) + PyFrozenSetWriter_UpdateArray() once
for size in sizes:
    test_tuple = tuple(range(size))
    func = functools.partial(_testcapi.bench_writer_update_array, test_tuple)
    runner.bench_time_func(f'PyFrozenSetWriter_UpdateArray {size:,}', func)

Patch adding _testcapi functions:

Details
diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c
index 033ee3234cf..b2a881b4b4a 100644
--- a/Modules/_testcapimodule.c
+++ b/Modules/_testcapimodule.c
@@ -2889,6 +2889,139 @@ uptime_bsd(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(args))
 #endif
 
 
+static PyObject*
+bench_frozenset_new(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *iterable;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &iterable, &loops)) {
+        return NULL;
+    }
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyObject *result = PyFrozenSet_New(iterable);
+        if (result == NULL) {
+            return NULL;
+        }
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject*
+bench_writer_add(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *tuple;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &tuple, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyTuple_GET_SIZE(tuple);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyFrozenSetWriter *writer = PyFrozenSetWriter_Create(0);
+        if (writer == NULL) {
+            return NULL;
+        }
+
+        for (Py_ssize_t j=0; j < len; j++) {
+            PyObject *item = PyTuple_GET_ITEM(tuple, j);
+            if (PyFrozenSetWriter_Add(writer, item) < 0) {
+                PyFrozenSetWriter_Discard(writer);
+                return NULL;
+            }
+        }
+
+        PyObject *result = PyFrozenSetWriter_Finish(writer);
+        if (result == NULL) {
+            return NULL;
+        }
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject*
+bench_writer_update(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *iterable;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &iterable, &loops)) {
+        return NULL;
+    }
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyFrozenSetWriter *writer = PyFrozenSetWriter_Create(0);
+        if (writer == NULL) {
+            return NULL;
+        }
+
+        if (PyFrozenSetWriter_Update(writer, iterable) < 0) {
+            PyFrozenSetWriter_Discard(writer);
+            return NULL;
+        }
+
+        PyObject *result = PyFrozenSetWriter_Finish(writer);
+        if (result == NULL) {
+            return NULL;
+        }
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject*
+bench_writer_update_array(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *tuple;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &tuple, &loops)) {
+        return NULL;
+    }
+    PyObject **items = &PyTuple_GET_ITEM(tuple, 0);
+    Py_ssize_t len = PyTuple_GET_SIZE(tuple);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyFrozenSetWriter *writer = PyFrozenSetWriter_Create(0);
+        if (writer == NULL) {
+            return NULL;
+        }
+
+        if (PyFrozenSetWriter_UpdateArray(writer, items, len) < 0) {
+            PyFrozenSetWriter_Discard(writer);
+            return NULL;
+        }
+
+        PyObject *result = PyFrozenSetWriter_Finish(writer);
+        if (result == NULL) {
+            return NULL;
+        }
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
 static PyMethodDef TestMethods[] = {
     {"set_errno",               set_errno,                       METH_VARARGS},
     {"test_config",             test_config,                     METH_NOARGS},
@@ -2988,6 +3121,10 @@ static PyMethodDef TestMethods[] = {
 #ifdef HAVE_SYSCTLBYNAME
     {"uptime_bsd", uptime_bsd, METH_NOARGS},
 #endif
+    {"bench_frozenset_new", bench_frozenset_new, METH_VARARGS},
+    {"bench_writer_add", bench_writer_add, METH_VARARGS},
+    {"bench_writer_update", bench_writer_update, METH_VARARGS},
+    {"bench_writer_update_array", bench_writer_update_array, METH_VARARGS},
     {NULL, NULL} /* sentinel */
 };
 

@vstinner

Copy link
Copy Markdown
Member Author

PyFrozenSetWriter_Finish() now always tracks the frozenset in the GC, instead of only tracking them if at least one item is tracked by the GC -- same behavior than PyFrozenSet_New(iterable)

I was wrong: PyFrozenSet_New(iterable) iterates on items to untrack the frozenset if an item is not tracked by the GC.

@vstinner

Copy link
Copy Markdown
Member Author

I updated the PR. PyFrozenSetWriter_Finish() no longer always track the frozenset in the GC.


PyFrozenSet_New() only tracks the frozenset if at least one item is tracked by the GC: PyFrozenSetWriter_Finish() should do the same. PyFrozenSetWriter_Finish() should not always track the frozenset.

PyFrozenSet_New(NULL) + PySet_Add(frozenset, item) always track the GC, but that should maybe be fixed in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting changes type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants