Skip to content

gh-158660: Add PyListWriter C API - #160127

Open
vstinner wants to merge 11 commits into
python:mainfrom
vstinner:list_writer
Open

vstinner wants to merge 11 commits into
python:mainfrom
vstinner:list_writer

Conversation

@vstinner

@vstinner vstinner commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

@vstinner

Copy link
Copy Markdown
Member Author

If a list writer is used by multiple threads, listwriter_resize() can call _PyObject_GC_SET_SHARED() on Free Threading.

@vstinner

Copy link
Copy Markdown
Member Author

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

@read-the-docs-community

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

Copy link
Copy Markdown

@vstinner

Copy link
Copy Markdown
Member Author

Another advantage of using this C API instead of PyList_New() is that the Python list object is only tracked by the GC when PyListWriter_Finish() is called. You cannot see the uninitialized list using the gc module.

@vstinner

Copy link
Copy Markdown
Member Author

Tests / Hypothesis tests on Ubuntu (pull_request): Failing after 10m

Oh, this failure seems to be unrelated to my change:

FAIL: test_system_site_packages (test.test_site.ImportSideEffectTests.test_system_site_packages)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/runner/work/cpython/cpython-ro-srcdir/Lib/test/test_site.py", line 645, in test_system_site_packages
    self.assertIn(system_paths['purelib'], sys_path)
    ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: '/home/runner/work/cpython/cpython-builddir/hypovenv/lib/python3.16/site-packages' not found in ['', '/usr/local/lib/python316.zip', '/home/runner/work/cpython/cpython-ro-srcdir/Lib', '/home/runner/work/cpython/cpython-builddir/build/lib.linux-x86_64-3.16', '/tmp/test_python_0lhq3tgr/test.test_site.ImportSideEffectTests.test_system_site_packages-venv-d_ynd3hp/lib/python3.16/site-packages']

@vstinner

vstinner commented Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

I ran a benchmark to compare PyListWriter_Append() to PyList_SetItem() function and to PyList_SET_ITEM() macro. On Free Threading, PyListWriter_Append() is between 2.9x and 3.9x faster than PyList_SetItem() function, and between 1.6x and 1.7x slower than the PyList_SET_ITEM() macro (which is excluded from the limited C API).

PyListWriter_Append() is the function with the highest overhead. PyListWriter_Extend() and PyListWriter_ExtendArray() should have a way lower overhead per item, since they add multiple items at once.

I ran the benchmark on Free Threaded build on Linux (gcc -O3). Benchmark script:

import pyperf, functools, _testcapi
runner = pyperf.Runner()
sizes = (0, 10, 100, 1_000)

for list_len in sizes:
    test_list = list(range(list_len))
    func = functools.partial(_testcapi.bench_setitem_macro, test_list)
    runner.bench_time_func(f'setitem macro {list_len:,}-list', func)

for list_len in sizes:
    test_list = list(range(list_len))
    func = functools.partial(_testcapi.bench_setitem_func, test_list)
    runner.bench_time_func(f'setitem func {list_len:,}-list', func)

for list_len in sizes:
    test_list = list(range(list_len))
    func = functools.partial(_testcapi.bench_writer, test_list)
    runner.bench_time_func(f'writer {list_len:,}-list', func)

Results:

setitem macro 0-list: Mean +- std dev: 12.1 ns +- 0.2 ns
setitem macro 10-list: Mean +- std dev: 33.4 ns +- 1.3 ns
setitem macro 100-list: Mean +- std dev: 219 ns +- 3 ns
setitem macro 1,000-list: Mean +- std dev: 2.21 us +- 0.02 us

setitem func 0-list: Mean +- std dev: 12.3 ns +- 0.1 ns
setitem func 10-list: Mean +- std dev: 155 ns +- 1 ns
setitem func 100-list: Mean +- std dev: 1.47 us +- 0.01 us
setitem func 1,000-list: Mean +- std dev: 14.4 us +- 0.1 us

writer 0-list: Mean +- std dev: 15.0 ns +- 0.1 ns
writer 10-list: Mean +- std dev: 52.8 ns +- 0.2 ns
writer 100-list: Mean +- std dev: 378 ns +- 18 ns
writer 1,000-list: Mean +- std dev: 3.70 us +- 0.04 us
  • "setitem macro" is the fastest way to create a list: PyList_New() + PyList_SET_ITEM() macro -- NOT AVAILABLE in abit3t
  • "setitem func" is the abi3t way, function calls: PyList_New() + PyList_SetItem() function
  • "writer" is this PR: PyListWriter_Create() + PyListWriter_Append() + PyListWriter_Finish()

PyList_SetItem() is between 4.6x and 6.7x slower than the PyList_SET_ITEM() macro: well, that's the issue #158660, PyList_SetItem() function is way slower than PyList_SET_ITEM() macro on Free Threading.

PyListWriter_Append() is between 1.6x and 1.7x slower than the PyList_SET_ITEM() macro, and it's between 2.9x and 3.9x faster than PyList_SetItem() function.

On Free Threading, PyListWriter_Append() is way faster than PyList_SetItem() function because it avoids locking.

Patch adding 3 bench functions to _testcapi:

Details
diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c
index 033ee3234cf..a3a8d9989c0 100644
--- a/Modules/_testcapimodule.c
+++ b/Modules/_testcapimodule.c
@@ -2889,6 +2889,112 @@ uptime_bsd(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(args))
 #endif
 
 
+static PyObject*
+bench_setitem_macro(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyObject *result = PyList_New(len);
+        if (result == NULL) {
+            return NULL;
+        }
+
+        for (Py_ssize_t j=0; j < len; j++) {
+            PyObject *item = Py_NewRef(PyList_GET_ITEM(list, j));
+            PyList_SET_ITEM(result, j, item);
+        }
+
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject*
+bench_setitem_func(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyObject *result = PyList_New(len);
+        if (result == NULL) {
+            return NULL;
+        }
+
+        for (Py_ssize_t j=0; j < len; j++) {
+            PyObject *item = Py_NewRef(PyList_GET_ITEM(list, j));
+            if (PyList_SetItem(result, j, item) < 0) {
+                Py_DECREF(result);
+                return NULL;
+            }
+        }
+
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject*
+bench_writer(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyListWriter *writer = PyListWriter_Create(len);
+        if (writer == NULL) {
+            return NULL;
+        }
+
+        for (Py_ssize_t j=0; j < len; j++) {
+            PyObject *item = PyList_GET_ITEM(list, j);
+            if (PyListWriter_Append(writer, item) < 0) {
+                PyListWriter_Discard(writer);
+                return NULL;
+            }
+        }
+
+        PyObject *result = PyListWriter_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 +3094,9 @@ static PyMethodDef TestMethods[] = {
 #ifdef HAVE_SYSCTLBYNAME
     {"uptime_bsd", uptime_bsd, METH_NOARGS},
 #endif
+    {"bench_setitem_macro", bench_setitem_macro, METH_VARARGS},
+    {"bench_setitem_func", bench_setitem_func, METH_VARARGS},
+    {"bench_writer", bench_writer, METH_VARARGS},
     {NULL, NULL} /* sentinel */
 };
 

@vstinner

vstinner commented Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

I also ran benchmarks on PyListWriter_Extend() and PyListWriter_ExtendArray() functions.

setitem macro 0-list: Mean +- std dev: 15.4 ns +- 0.2 ns
setitem macro 10-list: Mean +- std dev: 43.5 ns +- 2.2 ns
setitem macro 100-list: Mean +- std dev: 243 ns +- 4 ns
setitem macro 1,000-list: Mean +- std dev: 2.86 us +- 0.15 us

writer append 0-list: Mean +- std dev: 15.9 ns +- 1.2 ns
writer append 10-list: Mean +- std dev: 53.0 ns +- 0.7 ns
writer append 100-list: Mean +- std dev: 394 ns +- 18 ns
writer append 1,000-list: Mean +- std dev: 3.52 us +- 0.26 us

writer extend 0-list: Mean +- std dev: 29.5 ns +- 1.1 ns
writer extend 10-list: Mean +- std dev: 55.1 ns +- 0.8 ns
writer extend 100-list: Mean +- std dev: 174 ns +- 18 ns
writer extend 1,000-list: Mean +- std dev: 1.50 us +- 0.15 us

writer extend array 0-list: Mean +- std dev: 21.0 ns +- 1.0 ns
writer extend array 10-list: Mean +- std dev: 34.0 ns +- 0.7 ns
writer extend array 100-list: Mean +- std dev: 165 ns +- 11 ns
writer extend array 1,000-list: Mean +- std dev: 1.43 us +- 0.10 us

PyListWriter_Extend() is between 1.3x slower and... 1.9x faster than PyList_SET_ITEM() macro! It's between 1.04x and 2.3x faster than PyListWriter_Append().

PyListWriter_ExtendArray() is between 1.3x and 2.0x faster than PyList_SET_ITEM() macro! It's between 1.6x and 2.6x faster than PyListWriter_Append().

Well, it's not surprising that PyListWriter_Extend() and PyListWriter_ExtendArray() are faster than the PyList_SET_ITEM() macro, since they process many items in a single function call.

Benchmark script:

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

for list_len in sizes:
    test_list = list(range(list_len))
    func = functools.partial(_testcapi.bench_setitem_macro, test_list)
    runner.bench_time_func(f'setitem macro {list_len:,}-list', func)

for list_len in sizes:
    test_list = list(range(list_len))
    func = functools.partial(_testcapi.bench_writer_append, test_list)
    runner.bench_time_func(f'writer append {list_len:,}-list', func)

for list_len in sizes:
    test_list = list(range(list_len))
    func = functools.partial(_testcapi.bench_writer_extend, test_list)
    runner.bench_time_func(f'writer extend {list_len:,}-list', func)

for list_len in sizes:
    test_list = list(range(list_len))
    func = functools.partial(_testcapi.bench_writer_extend_array, test_list)
    runner.bench_time_func(f'writer extend array {list_len:,}-list', func)

Patch adding C benchmarks:

Details
diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c
index 033ee3234cf..c191d12c862 100644
--- a/Modules/_testcapimodule.c
+++ b/Modules/_testcapimodule.c
@@ -2889,6 +2889,185 @@ uptime_bsd(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(args))
 #endif
 
 
+static PyObject*
+bench_setitem_macro(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyObject *result = PyList_New(len);
+        if (result == NULL) {
+            return NULL;
+        }
+
+        for (Py_ssize_t j=0; j < len; j++) {
+            PyObject *item = Py_NewRef(PyList_GET_ITEM(list, j));
+            PyList_SET_ITEM(result, j, item);
+        }
+
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject*
+bench_setitem_func(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyObject *result = PyList_New(len);
+        if (result == NULL) {
+            return NULL;
+        }
+
+        for (Py_ssize_t j=0; j < len; j++) {
+            PyObject *item = Py_NewRef(PyList_GET_ITEM(list, j));
+            if (PyList_SetItem(result, j, item) < 0) {
+                Py_DECREF(result);
+                return NULL;
+            }
+        }
+
+        Py_DECREF(result);
+    }
+    (void)PyTime_PerfCounterRaw(&t2);
+
+    return PyFloat_FromDouble(PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject*
+bench_writer_append(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyListWriter *writer = PyListWriter_Create(len);
+        if (writer == NULL) {
+            return NULL;
+        }
+
+        for (Py_ssize_t j=0; j < len; j++) {
+            PyObject *item = PyList_GET_ITEM(list, j);
+            if (PyListWriter_Append(writer, item) < 0) {
+                PyListWriter_Discard(writer);
+                return NULL;
+            }
+        }
+
+        PyObject *result = PyListWriter_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_extend(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyListWriter *writer = PyListWriter_Create(len);
+        if (writer == NULL) {
+            return NULL;
+        }
+
+        if (PyListWriter_Extend(writer, list) < 0) {
+            PyListWriter_Discard(writer);
+            return NULL;
+        }
+
+        PyObject *result = PyListWriter_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_extend_array(PyObject *Py_UNUSED(self), PyObject *args)
+{
+    PyObject *list;
+    Py_ssize_t loops;
+    if (!PyArg_ParseTuple(args, "On", &list, &loops)) {
+        return NULL;
+    }
+
+    PyObject **list_items = _PyList_CAST(list)->ob_item;
+    Py_ssize_t len = PyList_GET_SIZE(list);
+
+    PyTime_t t1, t2;
+    (void)PyTime_PerfCounterRaw(&t1);
+    for (Py_ssize_t i=0; i < loops; i++) {
+        PyListWriter *writer = PyListWriter_Create(len);
+        if (writer == NULL) {
+            return NULL;
+        }
+
+        if (PyListWriter_ExtendArray(writer, list_items, len) < 0) {
+            PyListWriter_Discard(writer);
+            return NULL;
+        }
+
+        PyObject *result = PyListWriter_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 +3167,11 @@ static PyMethodDef TestMethods[] = {
 #ifdef HAVE_SYSCTLBYNAME
     {"uptime_bsd", uptime_bsd, METH_NOARGS},
 #endif
+    {"bench_setitem_macro", bench_setitem_macro, METH_VARARGS},
+    {"bench_setitem_func", bench_setitem_func, METH_VARARGS},
+    {"bench_writer_append", bench_writer_append, METH_VARARGS},
+    {"bench_writer_extend", bench_writer_extend, METH_VARARGS},
+    {"bench_writer_extend_array", bench_writer_extend_array, METH_VARARGS},
     {NULL, NULL} /* sentinel */
 };
 

Comment thread Doc/c-api/list.rst
Comment thread Doc/c-api/list.rst Outdated
@pitrou

pitrou commented Oct 22, 2026

Copy link
Copy Markdown
Member

+1 on the principle, this looks like a very useful addition.

@ngoldbaum

ngoldbaum commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

I implemented wrappers in PyO3 for Victor's API and ran a new round of benchmarks with an implementation using PyListWriter_AppendSteal. It is an improvement over the iterator approach but I'm not able to approach the performance of PyList_SET_ITEM because of per-element overhead.

I have two ideas to improve Victor's initial proposal.

  • We can help performance for small lists by avoiding zero-initialization of the initial allocation. The list stays untracked while building, and its length counts the initialized items, so cleanup does not read the uninitialized capacity.
  • We can improve performance for large lists in the free-threaded and limited-API builds by adding an ExtendArraySteal function. ExtendArray expects borrowed references and must incref, so that's why I want a stealing API. AppendSteal has per-item overhead which ExtendArraySteal would amortize.

Here is a patch that removes the zero-initialization from this PR:

Details
diff --git a/Objects/listobject.c b/Objects/listobject.c
--- a/Objects/listobject.c
+++ b/Objects/listobject.c
@@ -4415,12 +4415,29 @@
         return NULL;
     }
 
-    PyObject *list = PyList_New(size);
+    PyObject *list = PyList_New(0);
     if (list == NULL) {
         return NULL;
     }
-    Py_SET_SIZE(list, 0);
     _PyObject_GC_UNTRACK(list);
+
+    // Keep the length at zero until items have been initialized.
+    if (size > 0) {
+#ifdef Py_GIL_DISABLED
+        _PyListArray *array = list_allocate_array(size);
+        PyObject **items = array != NULL ? array->ob_item : NULL;
+#else
+        PyObject **items = PyMem_New(PyObject *, size);
+#endif
+        if (items == NULL) {
+            Py_DECREF(list);
+            PyErr_NoMemory();
+            return NULL;
+        }
+        PyListObject *list_obj = _PyList_CAST(list);
+        list_obj->ob_item = items;
+        list_obj->allocated = size;
+    }
 
     PyListWriter *writer = (PyListWriter*)list;
     assert(listwriter_check_consistency(writer));

Here is a plot that illustrates the timings for all three approaches, along with the iterator approach, PyList_SetItem, and PyList_SET_ITEM on a version-specific build:

image

Note that this is a somewhat different workload than Victor's test above. PyO3 streams input items with a size hint, so a batched approach is most natural. Victor passes an existing array directly, and his range(1000) inputs are entirely cached, immortal integers. Using integers inside the range where they are immortal impacts the timing, because the INCREF to take ownership is free.

I lean towards leaving in AppendSteal with the zero-initialization hack above since it seems to be either faster or a wash on all builds compared with PyList_SetItem and there's no need for making for generating different code on abi3 and abi3t, assuming the APIs make their way into the stable ABI.

In release mode, no longer initialize items to NULL for better
performance. The caller is responsible to initialize all items before
calling PyListWriter_Finish().
@vstinner

Copy link
Copy Markdown
Member Author

I have two ideas to improve Victor's initial proposal.
We can help performance for small lists by avoiding zero-initialization of the initial allocation. (...)

Oh sure, there is no need to initialize items to NULL since the list is not tracked by the GC. I implemented this optimization.

We can improve performance for large lists in the free-threaded and limited-API builds by adding an ExtendArraySteal function.

I'm not sure how other C API Working Group members feel about functions stealing references. But ok, I added the function!

@vstinner
vstinner marked this pull request as ready for review October 22, 2026 22:04
@vstinner

Copy link
Copy Markdown
Member Author

I mark the PR as ready for review.

@vstinner

Copy link
Copy Markdown
Member Author

@encukou: So what do you think of this APi?

@encukou

encukou commented Oct 23, 2026

Copy link
Copy Markdown
Member

PyListWriter_Append is unnecesary: you can call PyListWriter_ExtendArray with a size of 1.

the Python list object is only tracked by the GC when PyListWriter_Finish() is called. You cannot see the uninitialized list using the gc module.

How important is this?

  • If this is a valid list, we don't really need new writer type AFAICS -- we could use PyList_NewEmpty(Py_ssize_t n_preallocated) and PyListWriter_ExtendArray[Steal].
  • If this is not a valid list object, I'd prefer if ob_type was also filled in at PyListWriter_Finish time.

I'm not sure how other C API Working Group members feel about functions stealing references.

If there's a function that doesn't steal, it's fine to add an additional one with a Steal suffix appended.

Personally I'd prefer separating suffixes like this with an underscore (PyListWriter_Append_Steal).

@vstinner

Copy link
Copy Markdown
Member Author

How important is this?

It's quite important. PyListWriter implementation relies on the fact that the internal list objects cannot be seen by Python or other C code. The incomplete list object cannot be tracked by the GC since its items are uninitialized memory. If we remove this optimization, so initialize list items to NULL again, well, you might just use PyList_New() in this case (but suffer from the abi3t performance tax, which is the issue there).

If this is not a valid list object, I'd prefer if ob_type was also filled in at PyListWriter_Finish time.

We don't do that in other places (like in PyLongWriter_Create() which has similar design). Doing it would make the code more complicated. For example, setting ob_type to NULL in Create() triggers a crash in Append(): _PyList_CAST(writer) creates a segfault since the type is NULL. What would be the benefits of setting ob_type to NULL?

PyListWriter_Append is unnecesary: you can call PyListWriter_ExtendArray with a size of 1.

I would prefer to add it to have an API easier and more convenient to use. For example, that's why we have PyObject_CallNoArgs() and PyObject_CallOneArg() even if we have PyObject_Vectorcall().

By the way, I vaguely recall that PyObject_CallOneArg() has a lower memory usage of the stack memory. See issue #73044 (comment) and commit 7bfb42d which removed the macro:

#define _PyObject_CallArg1(func, arg) \
    _PyObject_FastCall((func), (PyObject **)&(arg), 1)

The commit message says:

Using the _PyObject_CallArg1() macro increases the usage of the C stack, which
was unexpected and unwanted. PyObject_CallFunctionObjArgs() doesn't have this
issue.

@vstinner

Copy link
Copy Markdown
Member Author

I created capi-workgroup/decisions#118 for the C API Working Group.

@vstinner

vstinner commented Oct 27, 2026 •

Copy link
Copy Markdown
Member Author

I re-ran benchmarks differently to produce comparison tables. I ran benchmarks on Free Threading with CPU isolation.

In short, PyListWriter_ExtendArraySteal() is the fastest function to build a Python list object in the abi3t. Well, it's not surprising since this function is basically implemented as memcpy(list_items, items, nitem * sizeof(PyObject*)) call (if we ignore the code to resize the list if needed).

Also, in average, PyListWriter_ExtendArraySteal() is 1.71x faster than PyList_New() + PyList_SET_ITEM() macro (which is not available in the limited C API). And in average, PyListWriter_ExtendArray() is 1.33x faster than PyList_New() + PyList_SET_ITEM() macro

Comparison to the PyList_SET_ITEM() macro:

Benchmark setitem_macro setitem_func writer_append writer_append_steal writer_extend writer_extend_array writer_extend_array_steal
size=0 22.3 ns 23.0 ns: 1.03x slower 24.2 ns: 1.09x slower 24.9 ns: 1.12x slower 48.9 ns: 2.20x slower 26.6 ns: 1.19x slower 29.6 ns: 1.33x slower
size=10 59.8 ns 288 ns: 4.81x slower 75.5 ns: 1.26x slower 77.9 ns: 1.30x slower 73.1 ns: 1.22x slower 49.2 ns: 1.21x faster 46.3 ns: 1.29x faster
size=100 430 ns 2.82 us: 6.56x slower 530 ns: 1.23x slower 535 ns: 1.24x slower 291 ns: 1.48x faster 267 ns: 1.61x faster 184 ns: 2.33x faster
size=1,000 4.13 us 27.3 us: 6.61x slower 4.69 us: 1.14x slower 4.80 us: 1.16x slower 2.11 us: 1.95x faster 2.08 us: 1.98x faster 1.48 us: 2.79x faster
size=100,000 764 us 2.79 ms: 3.65x slower 776 us: 1.02x slower 656 us: 1.16x faster 600 us: 1.27x faster 588 us: 1.30x faster 334 us: 2.29x faster
Geometric mean (ref) 3.79x slower 1.14x slower 1.13x slower 1.06x faster 1.33x faster 1.71x faster

Comparison to the PyList_SetItem() function (abi3t):

Benchmark setitem_func writer_append writer_append_steal writer_extend writer_extend_array writer_extend_array_steal
size=0 23.0 ns 24.2 ns: 1.05x slower 24.9 ns: 1.08x slower 48.9 ns: 2.13x slower 26.6 ns: 1.16x slower 29.6 ns: 1.29x slower
size=10 288 ns 75.5 ns: 3.81x faster 77.9 ns: 3.69x faster 73.1 ns: 3.93x faster 49.2 ns: 5.84x faster 46.3 ns: 6.21x faster
size=100 2.82 us 530 ns: 5.33x faster 535 ns: 5.27x faster 291 ns: 9.68x faster 267 ns: 10.55x faster 184 ns: 15.30x faster
size=1,000 27.3 us 4.69 us: 5.82x faster 4.80 us: 5.68x faster 2.11 us: 12.90x faster 2.08 us: 13.08x faster 1.48 us: 18.46x faster
size=100,000 2.79 ms 776 us: 3.59x faster 656 us: 4.25x faster 600 us: 4.64x faster 588 us: 4.74x faster 334 us: 8.34x faster
Geometric mean (ref) 3.32x faster 3.37x faster 4.03x faster 5.06x faster 6.47x faster

PyListWriter_AppendSteal() compared to PyListWriter_Append():

Benchmark writer_append writer_append_steal
size=0 24.2 ns 24.9 ns: 1.03x slower
size=10 75.5 ns 77.9 ns: 1.03x slower
size=1,000 4.69 us 4.80 us: 1.02x slower
size=100,000 776 us 656 us: 1.18x faster
Geometric mean (ref) 1.01x faster

Benchmark hidden because not significant (1): size=100

It seems like PyListWriter_AppendSteal() is mostly slower than PyListWriter_Append() which is surprising since PyListWriter_Append() calls PyListWriter_AppendSteal()! Well, I would say that the performance is basically the same, and the difference is just noise in the benchmark.

PyListWriter_ExtendArraySteal() compared to PyListWriter_ExtendArray():

Benchmark writer_extend_array writer_extend_array_steal
size=0 26.6 ns 29.6 ns: 1.11x slower
size=10 49.2 ns 46.3 ns: 1.06x faster
size=100 267 ns 184 ns: 1.45x faster
size=1,000 2.08 us 1.48 us: 1.41x faster
size=100,000 588 us 334 us: 1.76x faster
Geometric mean (ref) 1.28x faster

PyListWriter_ExtendArray() and PyListWriter_ExtendArraySteal() compared to the PyList_SET_ITEM() macro:

Benchmark setitem_macro writer_extend_array writer_extend_array_steal
size=0 22.3 ns 26.6 ns: 1.19x slower 29.6 ns: 1.33x slower
size=10 59.8 ns 49.2 ns: 1.21x faster 46.3 ns: 1.29x faster
size=100 430 ns 267 ns: 1.61x faster 184 ns: 2.33x faster
size=1,000 4.13 us 2.08 us: 1.98x faster 1.48 us: 2.79x faster
size=100,000 764 us 588 us: 1.30x faster 334 us: 2.29x faster
Geometric mean (ref) 1.33x faster 1.71x faster

Files:

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants