Skip to content

gh-127716: make memoryview thread-safe in free-threaded build - #156248

Open
nascheme wants to merge 7 commits into
python:mainfrom
nascheme:gh-127716-memoryview-thread-safe
Open

gh-127716: make memoryview thread-safe in free-threaded build#156248
nascheme wants to merge 7 commits into
python:mainfrom
nascheme:gh-127716-memoryview-thread-safe

Conversation

@nascheme

@nascheme nascheme commented Aug 23, 2026

Copy link
Copy Markdown
Member

Note: this PR was developed with assistance from LLMs, Claude Opus/Fable and GPT 5.6 Sol.

This incorporates changes from gh-155882 and gh-154770. It goes further and ensures the underlying buffer remains "pinned" if a thread releases the memory view while other threads have in-flight operations on it.

Performance testing results are below. The goal is negligible slowdown for the GIL-enabled build (achieved). Also, we want negligible slowdown in the un-contended free-threaded case (also achieved). Unfortunately, multi-threaded scaling with a shared memoryview object is terrible.

I explored various ways of improving the scaling (QSBR, RCU, hazard pointers) but I think other options are too complex in terms of implementation or have other critical problems. If you want multiple threads operating on the same underlying "mbuf", a good way to do it is to create a thread-local memoryview copy. You need to do this from the orginal "exporter" object, not the existing memoryview object. I have an additional change that adds a .reexport() method that does this, which is probably handly.

Single-thread performance

Times are nanoseconds per operation (lower is better), minus loop overhead; iteration is
nanoseconds per element. "base" is before this PR and "patched" is after.

Results from Intel i7-14700K

Benchmark base FT patched FT ratio base GIL patched GIL ratio
getitem 14.65 20.54 1.40x 16.00 15.45 0.97x
setitem 19.33 22.33 1.16x 19.17 19.52 1.02x
format 17.42 20.19 1.16x 17.88 17.76 0.99x
slice 33.36 53.38 1.60x 34.10 33.59 0.99x
cast 33.31 53.39 1.60x 35.02 34.01 0.97x
tobytes_16 30.87 28.99 0.94x 29.91 27.04 0.90x
memoryview_of_view 42.64 59.11 1.39x 41.88 41.42 0.99x
struct.unpack_from 48.83 55.51 1.14x 51.15 50.20 0.98x
BytesIO.readinto 59.57 76.09 1.28x 40.55 39.95 0.99x
BytesIO.write 63.15 76.51 1.21x 22.64 21.85 0.97x

Results from Macbook M3 Pro.

Benchmark base FT patched FT ratio
getitem 8.36 9.35 1.12x
setitem 10.71 11.00 1.03x
format 10.11 11.62 1.15x
slice 23.11 25.37 1.10x
cast 23.88 24.21 1.01x
tobytes_16 18.71 21.10 1.13x
memoryview_of_view 30.60 30.84 1.01x
unpack_from 27.16 30.20 1.11x
bytesio_write 22.12 24.90 1.13x
bytesio_readinto 22.19 24.37 1.10x

Free-threaded scaling

Throughput is millions of operations per second (higher is better). For iteration, one yielded
element is one operation. Base has no reexport(), so that workload has a
patched row only; compare it against the separate views of one exporter rows.

Results from Intel i7-14700K

Workload Build 1 thread 2 threads 4 threads 8 threads 8-thread patched/base
shared view, mv[0] base 40.4 79.5 157.5 308.1
patched 32.9 11.5 10.1 10.7 0.03x
slices of one shared view, mv[0] base 40.1 79.8 157.4 313.3
patched 32.9 16.7 13.7 10.1 0.03x
separate views of one exporter, mv[0] base 40.3 79.9 157.0 313.4
patched 33.0 65.9 130.1 259.7 0.83x
per-thread reexport() views, mv[0] patched 33.0 66.1 130.6 253.4 n/a
private views and exporters, mv[0] base 40.5 80.2 158.1 313.5
patched 32.9 66.0 130.2 250.0 0.80x
shared view, iterate 16 bytes base 82.4 55.7 61.7 60.1
patched 30.0 5.2 5.4 4.9 0.08x
shared view, tobytes() 4 KiB base 10.6 21.7 42.7 79.6
patched 10.4 9.2 12.5 9.4 0.12x
shared view, struct.unpack_from base 14.9 3.9 4.6 5.0
patched 12.5 3.2 3.2 2.1 0.42x

Results from Macbook M3 Pro. Note that this CPU has 6 P-cores and 6 E-cores.

Workload Build 1 thread 2 threads 4 threads 8 threads 4-thread patched/base
shared view, mv[0] base 73.1 144.8 282.3 321.9
patched 68.7 28.7 22.6 10.0 0.080x
slices of one shared view, mv[0] base 73.2 144.0 279.9 320.6
patched 68.4 29.0 23.0 10.0 0.082x
separate views of one exporter, mv[0] base 73.7 143.8 278.4 298.3
patched 68.2 133.8 260.8 295.1 0.94x
per-thread reexport() views, mv[0] patched 68.4 133.8 261.5 276.4 n/a
private views and exporters, mv[0] base 70.1 139.2 275.5 314.8
patched 68.4 134.2 261.6 287.4 0.95x
shared view, iterate 16 bytes base 146.3 177.0 99.1 34.1
patched 106.7 16.4 13.5 4.3 0.14x
shared view, tobytes() 4 KiB base 11.4 22.4 42.0 51.5
patched 11.0 12.8 15.8 9.7 0.38x
shared view, struct.unpack_from base 28.4 9.7 9.2 3.8
patched 27.2 8.0 7.3 5.6 0.79x

Anything that shares one managed buffer contends (shared mbuf->exports cache line).

@read-the-docs-community

read-the-docs-community Bot commented Aug 23, 2026

Copy link
Copy Markdown

@weixlu

weixlu commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Hi, thanks for the thorough fix! I read the code and it looks like all operations of the counters are well handled, so I'll close my PR.

Also, I checkout your branch and run the reproducers from #127716 and #155606. Both issues appear to be resolved.

Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
Co-authored-by: Lu Xiaowei <weixlu420302@gmail.com>
@nascheme
nascheme force-pushed the gh-127716-memoryview-thread-safe branch from 4269e2d to 8fa2daf Compare August 25, 2026 16:24
* The CHECK_* macros can return.  Restructure code so that unpin is
  always done for toreadonly().

* Use atomic load for asserts on `exports`.
Speed it up (less iterations, rounds and threads).  Rename confusing
assert_exporter_free() method to ensure_exporter_free().
@nascheme
nascheme marked this pull request as ready for review August 27, 2026 20:22
Comment thread Objects/memoryobject.c Outdated
Comment thread Objects/memoryobject.c Outdated
* Add comment to CAS while loop, yielding is unnecessary.
* Use "relaxed" memory ordering, seq-cst is overkill.
We only need the "while" loop if we are setting multiple flag bits
concurrently.  That's not needed, we only set RELEASED.  So, restructure
code, no while loop needed and someone can't accidently call the
function in the wrong way.
@maurycy

maurycy commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

@nascheme What do you think about making the iterator hold exports for its whole lifetime, instead of pinning in memoryiter_next()? I believe this should remove the performance penalty, at the expense of granularity.

@nascheme

Copy link
Copy Markdown
Member Author

Increasing performance would be nice but that would change the behaviour of memoryview.release(). I.e. you couldn't call it as long as an iterator is alive. So, I think we should not make that change.

@maurycy

maurycy commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Increasing performance would be nice but that would change the behaviour of memoryview.release(). I.e. you couldn't call it as long as an iterator is alive. So, I think we should not make that change.

I mean mbuf->exports, not self->exports. Sorry for not being clear.

memoryview.release() checks self->exports:

cpython/Objects/memoryobject.c

Lines 1253 to 1263 in e3c9012

Py_BEGIN_CRITICAL_SECTION(self);
Py_ssize_t exports = get_exports(self);
if (exports == 0) {
_memory_release(self);
result = Py_NewRef(Py_None);
}
else if (exports > 0) {
PyErr_Format(PyExc_BufferError,
"memoryview has %zd exported buffer%s", exports,
exports == 1 ? "" : "s");
}

What I'm thinking (quick Sol 5.6-assisted proof of concept patch to demonstrate the idea):

diff --git a/Objects/memoryobject.c b/Objects/memoryobject.c
index 2d8537ee57..b66f3b9153 100644
--- a/Objects/memoryobject.c
+++ b/Objects/memoryobject.c
@@ -3946,6 +3946,11 @@ memoryiter_dealloc(PyObject *self)
 {
     memoryiterobject *it = (memoryiterobject *)self;
     _PyObject_GC_UNTRACK(it);
+#ifdef Py_GIL_DISABLED
+    if (it->it_seq != NULL) {
+        mbuf_drop_export(it->it_seq->mbuf);
+    }
+#endif
     Py_XDECREF(it->it_seq);
     PyObject_GC_Del(it);
 }
@@ -3969,33 +3974,28 @@ memoryiter_next(PyObject *self)

     if (it->it_index >= it->it_length) {
         /* Preserve StopIteration for exhausted iterators over relea */
+#ifndef Py_GIL_DISABLED
         it->it_seq = NULL;
         Py_DECREF(seq);
+#endif
         return NULL;
     }

-#ifdef Py_GIL_DISABLED
-    /* Keep seq alive if another thread exhausts the iterator. */
-    Py_INCREF(seq);
-#endif
+    CHECK_RELEASED(seq);

-    PyObject *result = NULL;
-    if (memoryview_pin(seq) == 0) {
-        Py_buffer *view = &seq->view;
-        char *ptr = (char *)view->buf;
+    Py_buffer *view = &seq->view;
+    char *ptr = (char *)view->buf;

-        ptr += view->strides[0] * it->it_index++;
-        ptr = ADJUST_PTR(ptr, view->suboffsets, 0);
-        if (ptr != NULL) {
-            result = unpack_single(seq, ptr, it->it_fmt);
-        }
-        memoryview_unpin(seq);
+    ptr += view->strides[0] * it->it_index++;
+    ptr = ADJUST_PTR(ptr, view->suboffsets, 0);
+    if (ptr == NULL) {
+        return NULL;
     }
-
-#ifdef Py_GIL_DISABLED
-    Py_DECREF(seq);
-#endif
-    return result;
+    return unpack_single(seq, ptr, it->it_fmt);
 }

 static PyObject *
@@ -4032,6 +4032,11 @@ memory_iter_pinned(PyObject *seq)
     it->it_length = memory_length((PyObject *)obj);
     it->it_index = 0;
     it->it_seq = (PyMemoryViewObject*)Py_NewRef(obj);
+#ifdef Py_GIL_DISABLED
+    FT_ATOMIC_ADD_SSIZE(obj->mbuf->exports, 1);
+#endif
     _PyObject_GC_TRACK(it);
     return (PyObject *)it;
 }

The downside is that one won't be able to resize a bytearray while iter still exists:

ba = bytearray(128); m = memoryview(ba); it = iter(m)
m.release()
ba.append(1)  # would raise

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