From 98e6e5dc44a817a7385e036bc2de094be9fada08 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:31:46 +0900 Subject: [PATCH] Feat: Free-threaded-safe TypedPython modules declare Py_MOD_GIL_NOT_USED (#159) --- .github/workflows/typedpython.yml | 6 +- .../src/main/python/typedpython/cgen.py | 61 +- .../src/main/python/typedpython/frontend.py | 7 + .../main/python/typedpython/runtime/API.md | 93 ++- .../python/typedpython/runtime/tp_runtime.h | 245 ++++-- .../src/main/python/typedpython/verify.py | 8 +- .../src/test/python/runtime_shim/tp_shim.c | 14 +- .../src/test/python/test_cgen_classes.py | 10 +- .../src/test/python/test_eval_breaker.py | 5 +- .../src/test/python/test_free_threaded.py | 697 ++++++++++++++++++ .../src/test/python/test_frontend.py | 7 +- 11 files changed, 1065 insertions(+), 88 deletions(-) create mode 100644 python-multiplatform-ksp/src/test/python/test_free_threaded.py diff --git a/.github/workflows/typedpython.yml b/.github/workflows/typedpython.yml index c03f0ea4d..0a73e631f 100644 --- a/.github/workflows/typedpython.yml +++ b/.github/workflows/typedpython.yml @@ -34,7 +34,7 @@ jobs: - name: Create the environment # pyrefly is pinned exactly: the gate reads its versioned type-report format. run: | - uv venv .venv --python 3.13 + uv venv .venv --python 3.15t uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest - name: Behaviour tests @@ -62,7 +62,7 @@ jobs: - name: Create the environment run: | - uv venv .venv --python 3.13 + uv venv .venv --python 3.15t uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest - name: Relax ASLR entropy (ASan shadow mapping on newer kernels) @@ -103,7 +103,7 @@ jobs: - name: Create the TypedPython host environment working-directory: python-multiplatform-ksp run: | - uv venv .venv --python 3.13 + uv venv .venv --python 3.15t uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest - name: App fixture tests diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py index cb2c3d673..72ba16e10 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -22,6 +22,20 @@ guard, or the impl's 1 in a pure function) increments `__typedpython_deopts__` and calls `__typedpython_interpreted__[f]` with the original arguments. +Free-threaded CPython 3.15t (issue #159; runtime/API.md "Free-threaded CPython" holds the table of +every piece of shared state). The module slots declare `{Py_mod_gil, Py_MOD_GIL_NOT_USED}` (under +`#ifdef Py_GIL_DISABLED`), so importing it keeps the GIL off. In `tp_wrap_f`, a function with list +parameters holds the lists' critical section (`tp_cs_begin`, one list or two) from before the first +copy-in to after the last write-back: `tp_cs_end` is part of the same `exits` that run the write-back +on every path (ok, error, deopt, the last before the interpreted redo), and is a no-op on a path +that left before locking. More than two list parameters are refused here (`CGenError`), as in the +front end and the verifier: CPython has no three-object critical section. A function that holds an +array does not poll the eval breaker (#141, open decision #152), so a long compiled array loop +delays other threads' stop-the-world requests until it returns: a liveness delay, results +unaffected. The `__typedpython_deopts__` read-add-write of `tp_cg_redo` runs in the module dict's +critical section. Module state is written only by the exec slot; the class guards are atomic +(tp_runtime.h). + Depth (issue #57): every impl function calls `tp_enter_call()` before it owns anything (a refusal returns -1 directly: RecursionError set, nothing counted) and `tp_leave_call()` in the one exit block, so ok, error and deopt exits all leave exactly once. @@ -502,6 +516,9 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No w(_state_functions(mg)) w("static PyModuleDef_Slot tp_slots[] = {") w(" {Py_mod_exec, (void *)tp_exec},") + w("#ifdef Py_GIL_DISABLED") + w(" {Py_mod_gil, Py_MOD_GIL_NOT_USED}, /* free-threaded safe: runtime/API.md \"Free-threaded CPython\" */") + w("#endif") w(" {0, NULL}") w("};") w("") @@ -556,6 +573,10 @@ def _check_function(f: ir.Function, functions: dict[str, ir.Function]) -> None: "without redo") if f.entry_globals: _check_entry_globals(f, functions) + n_arrays = sum(isinstance(p, ir.ArrayParam) for p in f.params) + if n_arrays > 2: + raise CGenError(f"{f.name}: {n_arrays} array parameters; at most two (CPython offers one- and " + "two-object critical sections only, and a third lock could suspend the others)") for p in f.params: if isinstance(p, ir.ArrayParam): if p.type not in ir.ARRAYS: @@ -1027,7 +1048,7 @@ def _global_ok(self, cls: str) -> str: def _slow(self, cls: str) -> None: """Count a failed per-access check; in a pure function it is a deopt (module docstring).""" - self.emit(f"tp_st->cls_rt[{self._class(cls)}].slow++;") + self.emit(f"tp_class_note_slow(&tp_st->cls_rt[{self._class(cls)}]);") if self.f.pure: self.emit("tp_rc = 1; goto tp_exit;") @@ -1112,10 +1133,10 @@ def ev_New(self, e: ir.New) -> str: self.emit(" } else {") self.depth += 1 if self.f.pure: - self.emit(f"if ({gk}) {{ tp_st->cls_rt[{k}].slow++; }}") + self.emit(f"if ({gk}) {{ tp_class_note_slow(&tp_st->cls_rt[{k}]); }}") self.emit("tp_rc = 1; goto tp_exit;") # (g is NULL here: pure never loads it) else: - self.emit(f"if ({gk}) tp_st->cls_rt[{k}].slow++;") + self.emit(f"if ({gk}) tp_class_note_slow(&tp_st->cls_rt[{k}]);") # call what the global held when it was loaded: the class itself, or the rebinding self.emit(f" {t} = tp_call({gk} ? tp_st->classes[{k}] : {g}, tp_av, {len(items)}, NULL);") self.depth -= 1 @@ -1497,6 +1518,8 @@ def wrapper(self) -> str: L.append(f" {_CT[f.returns]} tp_r = {init};") args = [] arrays = [] + if sum(isinstance(p, ir.ArrayParam) for p in f.params) > 0: + L.append(" tp_cs tp_lock = {0};") for i, p in enumerate(f.params): if isinstance(p, ir.ArrayParam): L.append(f" {_ARRAY_CT[p.type]} tp_a{i} = {{0}};") @@ -1510,6 +1533,8 @@ def wrapper(self) -> str: if f.returns is not Type.NONE: args.append("&tp_r") exits = [f" if ({_ARRAY_PFX[p.type]}_exit(&tp_a{i}) < 0) tp_x = -1;" for i, p in arrays] + if arrays: + exits.append(" tp_cs_end(&tp_lock); /* after the write-back; a no-op if never taken */") L.append(" (void)tp_x;") # arity / keywords -> the interpreted function raises CPython's own TypeError L.append(f" if (tp_nargs != {n} || (tp_kwnames != NULL && PyTuple_GET_SIZE(tp_kwnames) != 0)) goto tp_deopt;") @@ -1546,6 +1571,14 @@ def wrapper(self) -> str: objs = ", ".join(f"tp_args[{i}]" for i, _ in arrays) L.append(f" {{ PyObject *const tp_objs[{len(arrays)}] = {{{objs}}};") L.append(f" if (tp_any_same(tp_objs, {len(arrays)})) goto tp_deopt; }}") + if arrays: + # Free-threaded (#159): the lists' critical section(s) from before the first copy-in to + # after the last write-back (every exit runs `exits`). A non-list argument deopts here, + # as tp_*_array_enter would, without being locked. + L.append(" if (" + " || ".join(f"!PyList_CheckExact(tp_args[{i}])" for i, _ in arrays) + + ") goto tp_deopt;") + second = f"tp_args[{arrays[1][0]}]" if len(arrays) == 2 else "NULL" + L.append(f" tp_cs_begin(&tp_lock, tp_args[{arrays[0][0]}], {second});") for i, p in arrays: L.append(f" tp_s = {_ARRAY_PFX[p.type]}_enter(tp_args[{i}], &tp_a{i});") L.append(" if (tp_s < 0) goto tp_error;") @@ -1620,13 +1653,20 @@ def wrapper(self) -> str: PyObject *tp_count = NULL, *tp_one = NULL, *tp_next = NULL, *tp_table = NULL, *tp_f = NULL; PyObject *tp_result = NULL; int tp_k; + tp_one = PyLong_FromLong(1); + if (tp_one == NULL) goto tp_done; + /* The counter is a read-modify-write of a module global: under the module dict's critical + * section, or concurrent deopts lose counts (#159). */ + tp_k = 0; + Py_BEGIN_CRITICAL_SECTION(tp_dict); tp_k = PyDict_GetItemStringRef(tp_dict, "__typedpython_deopts__", &tp_count); if (tp_k == 0) PyErr_SetString(PyExc_RuntimeError, "typedpython: __typedpython_deopts__ is missing"); + if (tp_k > 0) { + tp_next = PyNumber_Add(tp_count, tp_one); + if (tp_next == NULL || PyDict_SetItemString(tp_dict, "__typedpython_deopts__", tp_next) < 0) tp_k = -1; + } + Py_END_CRITICAL_SECTION(); if (tp_k <= 0) goto tp_done; - tp_one = PyLong_FromLong(1); - if (tp_one == NULL) goto tp_done; - tp_next = PyNumber_Add(tp_count, tp_one); - if (tp_next == NULL || PyDict_SetItemString(tp_dict, "__typedpython_deopts__", tp_next) < 0) goto tp_done; tp_k = PyDict_GetItemStringRef(tp_dict, "__typedpython_interpreted__", &tp_table); if (tp_k == 0) PyErr_SetString(PyExc_RuntimeError, "typedpython: __typedpython_interpreted__ is missing"); if (tp_k <= 0) goto tp_done; @@ -1704,10 +1744,11 @@ def _class_info_function(mg: _ModGen) -> str: f" tp_c = tp_st->classes[{k}];", " tp_d = Py_BuildValue(\"{s:O,s:I,s:I,s:O,s:n,s:n,s:O}\",", f" \"compiled\", tp_st->cls_ok[{k}] ? Py_True : Py_False,", - f" \"captured_tag\", tp_st->cls_rt[{k}].tag,", + f" \"captured_tag\", tp_class_rt_tag(&tp_st->cls_rt[{k}]),", " \"tag\", tp_class_tag(tp_c),", - f" \"current\", (tp_c != NULL && tp_class_tag(tp_c) == tp_st->cls_rt[{k}].tag) ? Py_True : Py_False,", - f" \"slow\", tp_st->cls_rt[{k}].slow, \"refreshed\", tp_st->cls_rt[{k}].refreshed,", + f" \"current\", (tp_c != NULL && tp_class_tag(tp_c) == tp_class_rt_tag(&tp_st->cls_rt[{k}])) ? Py_True : Py_False,", + f" \"slow\", tp_class_rt_slow(&tp_st->cls_rt[{k}]), \"refreshed\", " + f"tp_class_rt_refreshed(&tp_st->cls_rt[{k}]),", " \"watched\", tp_st->watch_id1 > 0 ? Py_True : Py_False);", f" if (tp_d == NULL || PyDict_SetItemString(tp_out, {c_string(cd.name)}, tp_d) < 0) {{", " Py_XDECREF(tp_d); Py_DECREF(tp_out); return NULL; }", diff --git a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py index 10b5712c5..83e707fd2 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py @@ -509,6 +509,13 @@ def _signature(fn: ast.FunctionDef, classes: dict[str, ir.ClassDecl] | None = No params.append(ir.Param(a.arg, Type.OBJ, c[0], c[1])) else: params.append(ir.Param(a.arg, SCALAR_KIND.get(kind, Type.OBJ))) + n_lists = sum(isinstance(p, ir.ArrayParam) for p in params) + if n_lists > 2: + # Free-threaded CPython (#159): the lists are locked from copy-in to write-back, and CPython + # offers one- and two-object critical sections only (a nested third could suspend the others). + raise Skip(line, f"more than two list parameters ({n_lists}) are not supported: compiled code " + "holds each list's lock from copy-in to write-back, and free-threaded CPython " + "has no lock for more than two objects at once") if fn.returns is None: raise Skip(line, "no return annotation") if isinstance(fn.returns, ast.Constant) and fn.returns.value is None: diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md index 82057cd29..d5587a224 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -61,6 +61,23 @@ Python error set, nothing to undo), `-1` Python error set (CPython's exception t /* aliasing guard: 1 if any two of the n objects are the same object */ int tp_any_same(PyObject *const *objs, Py_ssize_t n); + /* Free-threaded CPython (#159): the wrapper holds the lists' critical section from before the + first enter to after the last exit, on every path (ok, error, deopt). tp_cs is zero-initialised + (`tp_cs c = {0};`). b == NULL locks one list, else both (PyCriticalSection2: ordered by + address, so two threads locking (a, b) and (b, a) cannot deadlock). More than two list + parameters are not compiled (frontend.Skip, verify/structure, CGenError): CPython has no + three-object section and a nested third would suspend the others. With a GIL both are no-ops. */ + typedef struct { int n; /* + a PyCriticalSection2 on free-threaded builds */ } tp_cs; + void tp_cs_begin(tp_cs *c, PyObject *a, PyObject *b); + void tp_cs_end(tp_cs *c); /* idempotent: a no-op when nothing is held */ +`tp_*_array_enter` / `_exit` read and write the list through borrowed references (`PyList_GET_ITEM`, +`PyList_SetItem`) and REQUIRE the caller to hold that critical section. Between enter and exit no +other thread can change the list, so the whole call is one atomic step as far as lists go (an +interleaving CPython can produce: each list operation takes the same lock). A long compiled array +loop does not poll (below), so it delays other threads' stop-the-world requests (the free-threaded +GC) until it returns: a liveness delay, results unaffected. Polls inside array-holding functions +need the re-validation decided in issue #152 and are not added. + ## Opaque objects (Kotlin interop and any Python object; ir.Global/GetAttr/CallObject/Truth/CompareObj/ObjToFloat) All return new references or NULL with CPython's exception set; none deopts. PyObject *tp_global(PyObject *module_dict, PyObject *name); /* globals, then builtins; NameError("name 'x' is not defined") */ @@ -95,16 +112,18 @@ object, so a pure compiled recursion pays O(1) per call) and compares the total `Py_GetRecursionLimit()`. Over it: `RecursionError("maximum recursion depth exceeded")`, the text CPython's own Python-frame check uses. It then calls `Py_EnterRecursiveCall(" in compiled code")`, the C-stack guard: a raised recursion limit can never let compiled C overflow the stack. Counters are -per module (the header is `static`): frames of a *different* compiled module that sit between two -frames of this one are not counted. +per module (the header is `static`) and per thread (`TP_THREAD_LOCAL`): frames of a *different* +compiled module that sit between two frames of this one are not counted, and another thread's +frames never are. ## Eval breaker (top of every loop iteration and impl entry; issue #141) int tp_poll(PyObject *breaker); /* 0 continue; -1 exception set (a signal handler raised) */ int tp_poll_slow(PyObject *breaker); /* every TP_POLL_INTERVAL polls: call the breaker */ - Py_ssize_t tp_snapshot_depth; /* ++ at entry, -- at tp_exit of a snapshot-holding impl */ + Py_ssize_t tp_snapshot_depth; /* THREAD-LOCAL; ++ at entry, -- at tp_exit of a snapshot-holding impl */ + int tp_poll_countdown; /* THREAD-LOCAL */ `breaker` is the module state's `lambda: None`. Calling it runs CPython's own eval-breaker handling -in its RESUME: signals, pending calls, forced GIL switching to a waiting thread, async exceptions and -scheduled collections, exactly as between two bytecodes. A function that holds an array copy-in +in its RESUME: signals, pending calls, stop-the-world requests of other threads (the free-threaded +GC), async exceptions and scheduled collections, exactly as between two bytecodes. A function that holds an array copy-in (ir.ArrayParam) or an entry-globals snapshot never polls, and while one is active the slow path does nothing: the event stays pending and is handled after it returns. @@ -112,14 +131,20 @@ nothing: the event stays pending and is handled after it returns. Guard state, one per ClassDecl in the module state (zeroed = not compiled): typedef struct { - unsigned int tag; /* tp_version_tag at which the slots were proved; never 0 while compiled */ - unsigned int bad_tag; /* a tag at which tp_class_refresh failed (0: none); not retried */ - uint64_t gepoch; /* tp_globals_epoch at which module_dict[name] was last seen to be the class */ - PyObject *init; /* strong: the class's own __init__ at capture, or NULL */ - Py_ssize_t slow; /* failed per-access checks (introspection) */ - Py_ssize_t refreshed; /* new tags adopted by tp_class_refresh (introspection) */ + unsigned int tag; /* ATOMIC. tp_version_tag at which the slots were proved; never 0 while compiled */ + unsigned int bad_tag; /* ATOMIC. a tag at which tp_class_refresh failed (0: none); not retried */ + uint64_t gepoch; /* ATOMIC. tp_globals_epoch at which module_dict[name] was last seen to be the class */ + PyObject *init; /* IMMUTABLE after capture. strong: the class's own __init__ at capture, or NULL */ + Py_ssize_t slow; /* ATOMIC. failed per-access checks (introspection) */ + Py_ssize_t refreshed; /* ATOMIC. new tags adopted by tp_class_refresh (introspection) */ } tp_class_rt; - static uint64_t tp_globals_epoch; /* moves when a watched module dict changes a class-name key */ + static uint64_t tp_globals_epoch; /* ATOMIC. moves when a watched module dict changes a class-name key */ + /* atomic accessors (relaxed loads, acq_rel adds); every read of cls->tp_version_tag goes through one */ + unsigned int tp_type_tag(const PyTypeObject *cls); + void tp_class_note_slow(tp_class_rt *rt); + unsigned int tp_class_rt_tag(const tp_class_rt *rt); + Py_ssize_t tp_class_rt_slow(const tp_class_rt *rt); + Py_ssize_t tp_class_rt_refreshed(const tp_class_rt *rt); At module init (none runs user code): @@ -152,7 +177,10 @@ At every access (the class may have changed since init; cgen.py "Classes changed int tp_class_refresh(PyTypeObject *cls, tp_class_rt *rt, const char *const *fields, Py_ssize_t n, PyObject *const *descrs); /* LOAD_GLOBAL of the class name as an identity test: 1 module_dict[name] is cls, 0 not (or not a - module global), -1 error. watched: a 1 is cached in rt->gepoch until tp_globals_epoch moves */ + module global), -1 error. watched: a 1 is cached in rt->gepoch until tp_globals_epoch moves. + The slow path reads the epoch and looks the name up inside the module dict's critical section + (the dict watcher fires before a change is stored and under that same lock), so a binding + changed by another thread is never remembered as current. */ int tp_class_global_ok(PyObject *module_dict, PyObject *name, PyObject *cls, tp_class_rt *rt, int watched); unsigned int tp_class_tag(PyObject *cls); /* cls->tp_version_tag; 0 for NULL */ @@ -187,3 +215,42 @@ per-interpreter counter that only grows and, once it wraps to 0, assigns nothing gets at most MAX_VERSIONS_PER_CLASS (1000) tags. So a tag never repeats within an interpreter and a class whose tags are exhausted keeps 0 (never current). Py_TPFLAGS_VALID_VERSION_TAG is "Unused. Legacy flag" in 3.14 (never set) and is not consulted. + +## Free-threaded CPython (3.15t; issue #159) +Generated modules declare `{Py_mod_gil, Py_MOD_GIL_NOT_USED}` (under `#ifdef Py_GIL_DISABLED`), so +importing one keeps the GIL off. Every piece of shared mutable state, classified (the audit; a new +static or module-state field must be added here): + +| State | Where | Class | How | +|---|---|---|---| +| `tp_depth` (compiled frames in flight, cached frame) | tp_runtime.h static | thread-local | `TP_THREAD_LOCAL` | +| `tp_poll_countdown` | tp_runtime.h static | thread-local | `TP_THREAD_LOCAL` | +| `tp_snapshot_depth` | tp_runtime.h static | thread-local | `TP_THREAD_LOCAL`: one thread's array function never silences another thread's polls | +| `tp_pc` (loop countdown) | local of each impl | per call | a C local | +| `tp_globals_epoch` | tp_runtime.h static | atomic | relaxed loads, acq_rel add in the watcher callback (any thread); the epoch + dict lookup that remember a binding run in the module dict's critical section | +| `cls_rt[k].tag`, `.bad_tag`, `.gepoch` | module state | atomic | relaxed `tp_atomic_*`; a stale tag is never reused (tags only grow), so a lost or reordered refresh costs one more refresh | +| `cls_rt[k].slow`, `.refreshed` | module state | atomic | `tp_atomic_add_ssize` (introspection only) | +| `cls->tp_version_tag` | CPython type | atomic read | `tp_type_tag` (CPython clears and reassigns it from any thread) | +| `cls_rt[k].init`, `names`, `kwnames`, `classes`, `cls_ok`, `slots`, `descrs`, `breaker`, `watch_id1` | module state | immutable after init | written by `tp_exec` (and `tp_clear` at teardown, when no call can run); the `PyMemberDef`s live in the classes the state holds | +| `tp_name_strings`, `tp_fields_*`, `tp_class_names`, `tp_source*`, `tp_methods`, `tp_slots`, `tp_moduledef` | generated statics | immutable | never written after import | +| `__typedpython_interpreted__` | module dict entry | immutable after init | the dict is filled by `tp_exec` only; read with `PyDict_GetItemRef` | +| `__typedpython_deopts__` | module dict entry | critical section | the read-add-write of `tp_cg_redo` runs in `Py_BEGIN_CRITICAL_SECTION(module dict)`: concurrent deopts lose no count | +| list arguments of array parameters | caller's lists | critical section | `tp_cs_begin` before the copy-in, `tp_cs_end` after the write-back, on every path | +| module globals (class names, entry globals) | module dict | strong reads | `tp_global` and `tp_class_global_ok` use `PyDict_GetItemRef` | +| a compiled class's instances | other threads' objects | CPython | `PyMember_GetOne` / `SetOne` are what the interpreter's member descriptors call (they take the object's lock on free-threaded builds); `tp_new_fixed` writes only its own fresh object | +| `errno` (math helpers) | C library | thread-local | per thread by POSIX | + +Borrowed references are never held across anything another thread can free. Replaced in this change: +`tp_global`: `PyDict_GetItemWithError` (module globals, builtins) and `PyDict_GetItemString` +(`__builtins__`) became `PyDict_GetItemRef` / `PyDict_GetItemStringRef`; the builtins dict taken from +a module is kept alive by an own reference while it is read; `PyEval_GetBuiltins` became +`PyEval_GetFrameBuiltins` (a new reference). `tp_class_global_ok`: `PyDict_GetItemWithError` became +`PyDict_GetItemRef` inside the dict's critical section. `tp_cg_redo`: the counter update is under the +module dict's critical section. `PyList_GET_ITEM` in `tp_*_array_enter` stays a borrow: it is read +under the list's critical section the caller holds. Borrows that cannot race: `PyModule_GetDict` (the +dict lives as long as the module, which the running function keeps alive), `PyTuple_GET_SIZE` of a +kwnames tuple, `PyTuple` items. + +Memory orders: relaxed loads and stores for the tags and the epoch (the synchronisation that +publishes a class or global change to another thread, a lock or an event, orders them), acquire-release +for the epoch's increment. `tp_runtime.h` needs the GCC/Clang `__atomic` builtins. diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h index 2c50fa814..6ec54714d 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h @@ -22,6 +22,92 @@ #error "tp_runtime.h needs CPython 3.12 or newer (PyErr_GetRaisedException)" #endif +/* ------------------------------------------------------------------------------------------ + * Free-threaded CPython (3.15t is the only supported build; issue #159, runtime/API.md + * "Free-threaded CPython"). Without a GIL every static, every module-state field and every + * object another thread can reach is shared. Each piece of shared mutable state is one of: + * thread-local TP_THREAD_LOCAL (tp_depth, tp_poll_countdown, tp_snapshot_depth) + * atomic tp_atomic_* below (tp_globals_epoch, tp_class_rt tag / bad_tag / gepoch / + * counters, tp_version_tag reads) + * immutable written only by the module's exec slot (or by tp_clear, when no call can run) + * locked a critical section on the object (array parameters, the module dict, the + * deopt counter) + * and a borrowed reference is never held across anything another thread could free: containers + * are read through the strong-reference API (PyDict_GetItemRef ...) or under a critical section. + * ------------------------------------------------------------------------------------------ */ +#if defined(__GNUC__) || defined(__clang__) +static inline unsigned int tp_atomic_load_u32(const unsigned int *p) +{ return __atomic_load_n(p, __ATOMIC_RELAXED); } +static inline void tp_atomic_store_u32(unsigned int *p, unsigned int v) +{ __atomic_store_n(p, v, __ATOMIC_RELAXED); } +static inline uint64_t tp_atomic_load_u64(const uint64_t *p) +{ return __atomic_load_n(p, __ATOMIC_RELAXED); } +static inline void tp_atomic_store_u64(uint64_t *p, uint64_t v) +{ __atomic_store_n(p, v, __ATOMIC_RELAXED); } +static inline void tp_atomic_add_u64(uint64_t *p, uint64_t v) +{ (void)__atomic_fetch_add(p, v, __ATOMIC_ACQ_REL); } +static inline Py_ssize_t tp_atomic_load_ssize(const Py_ssize_t *p) +{ return __atomic_load_n(p, __ATOMIC_RELAXED); } +static inline void tp_atomic_add_ssize(Py_ssize_t *p, Py_ssize_t v) +{ (void)__atomic_fetch_add(p, v, __ATOMIC_RELAXED); } +#else +#error "tp_runtime.h needs the GCC/Clang __atomic builtins (the build uses cc, see cbuild.py)" +#endif + +/* cls->tp_version_tag: CPython clears and reassigns it from any thread (PyType_Modified, under the + * type's lock); every read here is an atomic load, as CPython's own (FT_ATOMIC_LOAD_UINT_RELAXED). */ +static inline unsigned int tp_type_tag(const PyTypeObject *cls) +{ + return tp_atomic_load_u32(&cls->tp_version_tag); +} + +#if defined(_MSC_VER) +#define TP_THREAD_LOCAL __declspec(thread) +#else +#define TP_THREAD_LOCAL __thread +#endif + +/* Critical sections for array parameters (tp_cs_*): from before the copy-in to after the + * write-back, on every exit path. One list or two (CPython offers one- and two-object sections + * only; a function with more list parameters stays interpreted). With a GIL they are no-ops. */ +typedef struct { + int n; /* 0 = not held, 1 or 2 objects locked */ +#ifdef Py_GIL_DISABLED + PyCriticalSection2 cs; +#endif +} tp_cs; + +static inline void tp_cs_begin(tp_cs *c, PyObject *a, PyObject *b) +{ +#ifdef Py_GIL_DISABLED + if (b == NULL) { + PyCriticalSection_Begin((PyCriticalSection *)&c->cs, a); + c->n = 1; + } + else { + PyCriticalSection2_Begin(&c->cs, a, b); + c->n = 2; + } +#else + (void)a; + c->n = b == NULL ? 1 : 2; +#endif +} + +/* idempotent: a no-op when nothing is held */ +static inline void tp_cs_end(tp_cs *c) +{ +#ifdef Py_GIL_DISABLED + if (c->n == 1) { + PyCriticalSection_End((PyCriticalSection *)&c->cs); + } + else if (c->n == 2) { + PyCriticalSection2_End(&c->cs); + } +#endif + c->n = 0; +} + /* ------------------------------------------------------------------------------------------ * Exception messages that CPython changed between 3.13 and 3.14. * 3.13: "integer division or modulo by zero" (int //), "integer modulo by zero" (int %), @@ -581,6 +667,16 @@ static inline int tp_unbox_bool(PyObject *o, int *out) * * enter: rc 0 -> the struct holds the copy; rc 1 or -1 -> the struct is zeroed (so exit is a safe * no-op) and nothing was changed. `list` is borrowed: the caller keeps the argument alive. + * + * Free-threaded (#159): the CALLER holds the list's critical section (tp_cs_begin) from before + * enter to after exit, on every exit path. enter and exit read and write the list's items through + * borrowed references (PyList_GET_ITEM) and rely on that lock: no other thread can change the list + * between the copy-in and the write-back, so the whole call is one atomic step as far as lists are + * concerned (CPython can produce that interleaving: every list operation takes the same lock). + * Liveness note (issue #152): a function holding an array does not poll the eval breaker, so a long + * compiled array loop delays other threads' stop-the-world requests (the free-threaded GC) until it + * returns; results are unaffected. Polls inside such functions need the re-validation decided in + * #152 (after a poll the critical section is re-taken and the list may have changed). * exit: writes the dirty slots back as new objects (PyList_SetItem steals the new reference and, on * failure, drops it), always frees, and zeroes the struct. On a write-back failure it stops, * frees, and returns -1 with that error set (MemoryError, or IndexError if the list shrank). @@ -903,29 +999,42 @@ static inline int tp_any_same(PyObject *const *objs, Py_ssize_t n) * its "__builtins__" (a module or a dict) is searched next, the interpreter's builtins if absent. */ static inline PyObject *tp_global(PyObject *module_dict, PyObject *name) { - PyObject *v, *builtins; - v = PyDict_GetItemWithError(module_dict, name); - if (v != NULL) { - Py_INCREF(v); + PyObject *v = NULL, *builtins = NULL; + int rc; + /* strong references only (PyDict_GetItemRef): another thread may rebind or delete the name, + * or replace __builtins__, at any moment */ + rc = PyDict_GetItemRef(module_dict, name, &v); + if (rc > 0) { return v; } - if (PyErr_Occurred()) { + if (rc < 0) { return NULL; } - builtins = PyDict_GetItemString(module_dict, "__builtins__"); /* borrowed, errors ignored */ + rc = PyDict_GetItemStringRef(module_dict, "__builtins__", &builtins); /* errors ignored */ + if (rc < 0) { + PyErr_Clear(); + builtins = NULL; + } if (builtins != NULL && PyModule_Check(builtins)) { - builtins = PyModule_GetDict(builtins); + PyObject *d = PyModule_GetDict(builtins); /* borrowed: owned by `builtins`, which we hold */ + Py_XINCREF(d); + Py_DECREF(builtins); + builtins = d; } if (builtins == NULL || !PyDict_Check(builtins)) { - builtins = PyEval_GetBuiltins(); + Py_XDECREF(builtins); + builtins = PyEval_GetFrameBuiltins(); /* new reference */ + if (builtins == NULL) { + PyErr_Clear(); + } } if (builtins != NULL) { - v = PyDict_GetItemWithError(builtins, name); - if (v != NULL) { - Py_INCREF(v); + rc = PyDict_GetItemRef(builtins, name, &v); + Py_DECREF(builtins); + if (rc > 0) { return v; } - if (PyErr_Occurred()) { + if (rc < 0) { return NULL; } } @@ -1032,20 +1141,24 @@ static inline void tp_release(PyObject **slot) /* Per-class guard state, one per ClassDecl in the module state (zeroed = not compiled). */ typedef struct { - unsigned int tag; /* tp_version_tag at which the slots were proved; never 0 while compiled */ - unsigned int bad_tag; /* a tag at which tp_class_refresh failed (0: none); not retried */ - uint64_t gepoch; /* tp_globals_epoch at which module_dict[name] was last seen to be cls */ - PyObject *init; /* strong: the class's own __init__ at capture, or NULL if it had none */ - Py_ssize_t slow; /* per-access checks that failed (introspection only) */ - Py_ssize_t refreshed; /* new tags adopted by tp_class_refresh (introspection only) */ + unsigned int tag; /* tp_version_tag at which the slots were proved; never 0 while compiled. + ATOMIC (tp_atomic_*): any thread reads and refreshes it */ + unsigned int bad_tag; /* a tag at which tp_class_refresh failed (0: none); not retried. ATOMIC */ + uint64_t gepoch; /* tp_globals_epoch at which module_dict[name] was last seen to be cls. + ATOMIC */ + PyObject *init; /* strong: the class's own __init__ at capture, or NULL if it had none. + IMMUTABLE after tp_class_capture */ + Py_ssize_t slow; /* per-access checks that failed (introspection only). ATOMIC */ + Py_ssize_t refreshed; /* new tags adopted by tp_class_refresh (introspection only). ATOMIC */ } tp_class_rt; /* Moves whenever a watched module dict changes a key that is (or may be) a compiled class name, is * cleared, cloned or deallocated (tp_globals_watch_event). Static: one counter per extension, shared * by every instance of the module in the process; an instance re-proves its own globals after any - * move. The module declares no Py_mod_multiple_interpreters / Py_mod_gil slot, so CPython loads it - * only into interpreters sharing the main GIL and keeps the GIL on in a free-threaded build: plain - * reads and writes are serialised. */ + * move. ATOMIC (tp_atomic_*): the watcher callback runs on whichever thread changes the dict, and + * the module declares Py_mod_gil = Py_MOD_GIL_NOT_USED. The module declares no + * Py_mod_multiple_interpreters slot, so CPython loads it only into interpreters sharing the main + * one. */ static uint64_t tp_globals_epoch = 1; /* `name` in cls's OWN dict (not through the MRO). 1 found (*out a new reference), 0 absent, -1 error. */ @@ -1159,11 +1272,13 @@ static inline int tp_class_capture(PyObject *cls_obj, const char *const *fields, tp_class_rt_clear(rt, descrs, n); return -1; } - if (!PyUnstable_Type_AssignVersionTag(cls) || cls->tp_version_tag == 0) { + if (!PyUnstable_Type_AssignVersionTag(cls) || tp_type_tag(cls) == 0) { tp_class_rt_clear(rt, descrs, n); return 0; } - rt->tag = cls->tp_version_tag; + /* init time: the class has not been handed to compiled code yet, plain stores are enough (the + * import completing publishes them) */ + rt->tag = tp_type_tag(cls); rt->bad_tag = 0; rt->gepoch = 0; rt->slow = 0; @@ -1184,14 +1299,17 @@ static inline int tp_class_refresh(PyTypeObject *cls, tp_class_rt *rt, const cha PyObject *d; int found; - if (!PyUnstable_Type_AssignVersionTag(cls) || cls->tp_version_tag == 0) { + if (!PyUnstable_Type_AssignVersionTag(cls) || tp_type_tag(cls) == 0) { return 0; /* tags exhausted for this class */ } - tag = cls->tp_version_tag; - if (tag == rt->tag) { + /* The tag is read BEFORE the dict is examined: a change after the read gives the class a new + * tag, so a tag adopted below is stale at once and the next access refreshes again. Two + * threads refreshing at the same time adopt the same or an older tag; both are safe. */ + tag = tp_type_tag(cls); + if (tag == tp_atomic_load_u32(&rt->tag)) { return 1; } - if (tag == rt->bad_tag) { + if (tag == tp_atomic_load_u32(&rt->bad_tag)) { return 0; } if (!tp_class_shape_ok(cls)) { @@ -1215,18 +1333,32 @@ static inline int tp_class_refresh(PyTypeObject *cls, tp_class_rt *rt, const cha if (d != rt->init) { goto bad; } - rt->tag = tag; - rt->refreshed++; + tp_atomic_store_u32(&rt->tag, tag); + tp_atomic_add_ssize(&rt->refreshed, 1); return 1; bad: - rt->bad_tag = tag; + tp_atomic_store_u32(&rt->bad_tag, tag); return 0; } +/* A failed per-access check was taken (introspection counter). */ +static inline void tp_class_note_slow(tp_class_rt *rt) +{ + tp_atomic_add_ssize(&rt->slow, 1); +} + +/* introspection reads (__typedpython_class_info__) */ +static inline unsigned int tp_class_rt_tag(const tp_class_rt *rt) { return tp_atomic_load_u32(&rt->tag); } +static inline Py_ssize_t tp_class_rt_slow(const tp_class_rt *rt) { return tp_atomic_load_ssize(&rt->slow); } +static inline Py_ssize_t tp_class_rt_refreshed(const tp_class_rt *rt) +{ + return tp_atomic_load_ssize(&rt->refreshed); +} + /* cls's current tp_version_tag, 0 for NULL (introspection: __typedpython_class_info__). */ static inline unsigned int tp_class_tag(PyObject *cls) { - return cls != NULL ? ((PyTypeObject *)cls)->tp_version_tag : 0u; + return cls != NULL ? tp_type_tag((PyTypeObject *)cls) : 0u; } /* The per-access check of FieldGet / FieldSet (obj != NULL) and New (obj == NULL): 1 = the captured @@ -1240,7 +1372,7 @@ static inline int tp_class_current(PyObject *obj, PyObject *cls_obj, tp_class_rt if (cls == NULL || (obj != NULL && !Py_IS_TYPE(obj, cls))) { return 0; } - if (cls->tp_version_tag == rt->tag) { /* rt->tag != 0 while cls != NULL */ + if (tp_type_tag(cls) == tp_atomic_load_u32(&rt->tag)) { /* rt->tag != 0 while cls != NULL */ return 1; } return tp_class_refresh(cls, rt, fields, n, descrs); @@ -1253,24 +1385,33 @@ static inline int tp_class_current(PyObject *obj, PyObject *cls_obj, tp_class_rt static inline int tp_class_global_ok(PyObject *module_dict, PyObject *name, PyObject *cls, tp_class_rt *rt, int watched) { - PyObject *v; + PyObject *v = NULL; + uint64_t epoch; + int rc; if (cls == NULL) { return 0; } - if (watched && rt->gepoch == tp_globals_epoch) { + if (watched && tp_atomic_load_u64(&rt->gepoch) == tp_atomic_load_u64(&tp_globals_epoch)) { return 1; } - v = PyDict_GetItemWithError(module_dict, name); /* borrowed */ - if (v == NULL) { - return PyErr_Occurred() ? -1 : 0; - } - if (v != cls) { - return 0; + /* The epoch is read and the dict looked up inside the dict's critical section. The watcher + * runs BEFORE a change is stored and under the same lock, so either the change finished (the + * lookup sees it) or it has not begun (its epoch move comes later and invalidates what is + * remembered below). Without the lock a thread could read the epoch after the move but look up + * before the store, and remember a stale binding as current for good. */ + Py_BEGIN_CRITICAL_SECTION(module_dict); + epoch = tp_atomic_load_u64(&tp_globals_epoch); + rc = PyDict_GetItemRef(module_dict, name, &v); /* strong */ + Py_END_CRITICAL_SECTION(); + if (rc <= 0) { + return rc; /* 0 absent, -1 error */ + } + rc = (v == cls); + Py_DECREF(v); + if (rc && watched) { + tp_atomic_store_u64(&rt->gepoch, epoch); } - if (watched) { - rt->gepoch = tp_globals_epoch; - } - return 1; + return rc; } /* The body of a module's PyDict_WatchCallback: move tp_globals_epoch when `key` is one of the @@ -1285,7 +1426,7 @@ static inline int tp_globals_watch_event(PyDict_WatchEvent event, PyObject *key, #if PY_VERSION_HEX >= 0x030D0000 for (i = 0; names[i] != NULL; i++) { if (PyUnicode_EqualToUTF8(key, names[i])) { - tp_globals_epoch++; + tp_atomic_add_u64(&tp_globals_epoch, 1); return 0; } } @@ -1295,7 +1436,7 @@ static inline int tp_globals_watch_event(PyDict_WatchEvent event, PyObject *key, (void)names; #endif } - tp_globals_epoch++; + tp_atomic_add_u64(&tp_globals_epoch, 1); return 0; } @@ -1392,12 +1533,6 @@ static inline int tp_is_exact(PyObject *obj, PyTypeObject *cls) * neither raise RecursionError nor stop before the C stack ends. tp_enter_call / tp_leave_call * bracket every compiled impl function. See API.md "Call depth". * ------------------------------------------------------------------------------------------ */ -#if defined(_MSC_VER) -#define TP_THREAD_LOCAL __declspec(thread) -#else -#define TP_THREAD_LOCAL __thread -#endif - typedef struct { Py_ssize_t count; /* compiled frames in flight on this thread (this module) */ PyObject *frame; /* strong ref: the top Python frame when `pydepth` was measured */ @@ -1475,8 +1610,10 @@ static inline void tp_leave_call(void) #define TP_POLL_INTERVAL 4096 #endif -static int tp_poll_countdown = TP_POLL_INTERVAL; -static Py_ssize_t tp_snapshot_depth = 0; +/* Per thread (#159): a shared counter would make threads poll for each other and, worse, let one + * thread's snapshot-holding function suppress another thread's polls. */ +static TP_THREAD_LOCAL int tp_poll_countdown = TP_POLL_INTERVAL; +static TP_THREAD_LOCAL Py_ssize_t tp_snapshot_depth = 0; static inline int tp_poll_slow(PyObject *breaker) { diff --git a/python-multiplatform-ksp/src/main/python/typedpython/verify.py b/python-multiplatform-ksp/src/main/python/typedpython/verify.py index 9ad89afc3..90fde0577 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/verify.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/verify.py @@ -37,7 +37,9 @@ verify/structure break/continue outside a loop, the ForRange var assigned in its body, a zero or non-constant step, duplicate names, bad declarations, an entry global that collides with a parameter/local/other entry global or - is assigned in the body + is assigned in the body, or more than two array parameters (the lists + are locked from copy-in to write-back: one- and two-object critical + sections only, #159) verify/proven-overflow `proven` is set on an op the verifier cannot prove stays inside i64 (or on a node `proven` does not apply to, or it is not a bool) verify/redo `Call.redo` where it is not legal (see below) @@ -310,6 +312,10 @@ def declarations(self) -> frozenset[str]: if p.name in params: self.error(STRUCTURE, f"parameter '{p.name}' appears twice") params.add(p.name) + if sum(type(p) is ArrayParam for p in fn.params) > 2: + self.error(STRUCTURE, "more than two array parameters: compiled code holds each list's " + "critical section from copy-in to write-back, and free-threaded " + "CPython has no section for more than two objects (#159)") eg = fn.entry_globals if not isinstance(eg, tuple): self.error(STRUCTURE, "entry_globals is not a tuple") diff --git a/python-multiplatform-ksp/src/test/python/runtime_shim/tp_shim.c b/python-multiplatform-ksp/src/test/python/runtime_shim/tp_shim.c index 2b6b07221..0f3030f35 100644 --- a/python-multiplatform-ksp/src/test/python/runtime_shim/tp_shim.c +++ b/python-multiplatform-ksp/src/test/python/runtime_shim/tp_shim.c @@ -608,4 +608,16 @@ static PyMethodDef methods[] = { static struct PyModuleDef moddef = {PyModuleDef_HEAD_INIT, TP_STR(TP_SHIM_NAME), NULL, -1, methods, NULL, NULL, NULL, NULL}; -PyMODINIT_FUNC TP_CAT(PyInit_, TP_SHIM_NAME)(void) { return PyModule_Create(&moddef); } +PyMODINIT_FUNC TP_CAT(PyInit_, TP_SHIM_NAME)(void) +{ + PyObject *m = PyModule_Create(&moddef); +#ifdef Py_GIL_DISABLED + /* #159: the helpers are thread-safe; do not let importing the shim switch the GIL back on for + the rest of the test process */ + if (m != NULL && PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED) < 0) { + Py_DECREF(m); + return NULL; + } +#endif + return m; +} diff --git a/python-multiplatform-ksp/src/test/python/test_cgen_classes.py b/python-multiplatform-ksp/src/test/python/test_cgen_classes.py index b3355c5d0..dfbeb0058 100644 --- a/python-multiplatform-ksp/src/test/python/test_cgen_classes.py +++ b/python-multiplatform-ksp/src/test/python/test_cgen_classes.py @@ -454,8 +454,14 @@ def churn(rounds): pass churn(20) # warm up caches, then measure - counts = lambda: (sys.getrefcount(shared), sys.getrefcount(node), sys.getrefcount(m.Node), - sys.getrefcount(m.Sub), sys.getrefcount(bare)) + def counts(): + # Free-threaded CPython (#159): a heap type is deferred-refcounted, and sys.getrefcount of it + # returns a flagged value (bit 60) whose count moves as thread-local counts are merged, with + # no change in what holds the type. Only a value without the flag is a real count. + deferred = 1 << 60 + types = tuple(None if (n := sys.getrefcount(t)) >= deferred else n for t in (m.Node, m.Sub)) + return (sys.getrefcount(shared), sys.getrefcount(node), sys.getrefcount(bare)) + types + base = counts() churn(3000) assert counts() == base, (base, counts()) diff --git a/python-multiplatform-ksp/src/test/python/test_eval_breaker.py b/python-multiplatform-ksp/src/test/python/test_eval_breaker.py index bd08f6bb4..45f40bb67 100644 --- a/python-multiplatform-ksp/src/test/python/test_eval_breaker.py +++ b/python-multiplatform-ksp/src/test/python/test_eval_breaker.py @@ -153,4 +153,7 @@ def handler(signum, frame): assert r["handler"] is not None, "the handler never ran" assert r["t1"] - r["t0"] > 0.3, f"call too short to judge: {r}" if r["s"] == r["plain"]: - assert r["handler"] >= r["t1"], f"handler ran inside the call but its write is not in the result: {r}" + # The deferred handler runs at the first eval-breaker check after the call returns, which can + # come a few microseconds BEFORE the child reads t1 (3.15t checks right after a call): the + # tolerance only has to separate that from "ran inside a call of 0.3 s or more". + assert r["handler"] >= r["t1"] - 0.005, f"handler ran inside the call but its write is not in the result: {r}" diff --git a/python-multiplatform-ksp/src/test/python/test_free_threaded.py b/python-multiplatform-ksp/src/test/python/test_free_threaded.py new file mode 100644 index 000000000..c9257b281 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_free_threaded.py @@ -0,0 +1,697 @@ +"""Compiled modules are safe on free-threaded CPython 3.15t (python-multiplatform #159, #141 phase 2). + +Free-threaded 3.15t is the only build (maintainer decision 2026-10-04). A generated extension must + * declare `Py_mod_gil = Py_MOD_GIL_NOT_USED`, so importing it does not switch the GIL back on; + * keep its shared mutable state thread-safe (runtime/API.md "Free-threaded CPython"); + * read nothing through a borrowed reference that another thread can free; + * hold the list's critical section from before the copy-in to after the write-back (array params). + +Every concurrency scenario runs in a CHILD interpreter with a timeout (a crash or a deadlock fails +one test, not the run). Iteration counts scale with TP_FT_STRESS (default 1). + +Red record (before the change, on the 3.15t venv): the GIL test and the three module-level checks +failed (`Py_mod_gil` absent, the GIL came back at import), the concurrent counters lost updates, and +a three-list function was compiled. +""" +from __future__ import annotations + +import dataclasses +import json +import os +import re +import subprocess +import sys +import sysconfig +import textwrap +from pathlib import Path + +import pytest + +from typedpython import cbuild, cgen, frontend, pipeline, verify + +STRESS = max(1, int(os.environ.get("TP_FT_STRESS", "1"))) +FREE_THREADED = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + +SOURCE = '''\ +# typedpython: compiled + + +class Cell: + __slots__ = ("a", "b") + a: object + b: object + + def __init__(self, a: object, b: object) -> None: + self.a = a + self.b = b + + +class Twin: + __slots__ = ("a", "b") + a: object + b: object + + def __init__(self, a: object, b: object) -> None: + self.a = a + self.b = b + + +def geta(c: Cell) -> object: + return c.a + + +def seta(c: Cell, v: object) -> None: + c.a = v + + +def mk(a: object, b: object) -> Cell: + return Cell(a, b) + + +def total(xs: list[float], rounds: int) -> float: + s: float = 0.0 + for r in range(rounds): + for i in range(len(xs)): + s += xs[i] + return s + + +def fill(xs: list[float], v: float) -> None: + for i in range(len(xs)): + xs[i] = v + + +def edge(xs: list[float]) -> float: + return xs[0] - xs[len(xs) - 1] + + +def fill_int(xs: list[int], v: int) -> None: + for i in range(len(xs)): + xs[i] = v + + +def edge_int(xs: list[int]) -> int: + return xs[0] - xs[len(xs) - 1] + + +def rotate(xs: list[float]) -> None: + if len(xs) > 1: + first: float = xs[0] + for i in range(1, len(xs)): + xs[i - 1] = xs[i] + xs[len(xs) - 1] = first + + +def zero2(xs: list[int], ys: list[int]) -> int: + for i in range(len(xs)): + xs[i] = 0 + for i in range(len(ys)): + ys[i] = 0 + return 0 + + +def pair_edge(xs: list[int], ys: list[int]) -> int: + return xs[0] - ys[len(ys) - 1] + + +def three(a: list[int], b: list[int], c: list[int]) -> int: + return len(a) + len(b) + len(c) + + +K: int = 3 + + +def usek(x: int) -> int: + return x + K +''' + +LOADER = """\ +import faulthandler, importlib.machinery, importlib.util, json, sys, threading, time +faulthandler.enable() +path = sys.argv[1] +loader = importlib.machinery.ExtensionFileLoader("ftmod", path) +spec = importlib.util.spec_from_file_location("ftmod", path, loader=loader) +m = importlib.util.module_from_spec(spec) +loader.exec_module(m) +STRESS = int(sys.argv[2]) + + +def run_threads(targets): + errors = [] + + def wrap(fn): + def go(): + try: + fn() + except BaseException as e: # a failed assertion in a worker must reach the report + errors.append(repr(e)) + return go + + ts = [threading.Thread(target=wrap(t)) for t in targets] + for t in ts: + t.start() + for t in ts: + t.join() + return errors +""" + + +@pytest.fixture(scope="module") +def built(tmp_path_factory): + d = tmp_path_factory.mktemp("ft") + path = d / "ftmod.py" + path.write_text(SOURCE) + result = pipeline.compile_module(path, d / "out") + assert result.extension is not None, result.skipped + compiled = {f.name for f in result.module.functions} + wanted = {"geta", "seta", "mk", "total", "fill", "edge", "fill_int", "edge_int", "rotate", "zero2", + "pair_edge", "usek"} + assert wanted <= compiled, {n: result.skipped.get(n) for n in wanted - compiled} + return result, path, d + + +@pytest.fixture(scope="module") +def extension(built): + return built[0].extension + + +def run_child(extension: Path, body: str, timeout: int = 180, env: dict | None = None) -> dict: + script = LOADER + textwrap.dedent(body) + e = dict(os.environ) + e.pop("PYTHON_GIL", None) + if os.environ.get("TP_FT_FORCE_GIL_OFF") == "1": # red record: what the code does with no GIL + e["PYTHON_GIL"] = "0" + e.update(env or {}) + proc = subprocess.run([sys.executable, "-c", script, str(extension), str(STRESS)], + capture_output=True, text=True, timeout=timeout, env=e) + assert proc.returncode == 0, f"child failed ({proc.returncode}):\n{proc.stdout}\n{proc.stderr}" + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +pytestmark = pytest.mark.skipif(not FREE_THREADED, reason="needs a free-threaded CPython (3.15t)") + + +# --- the module declares it does not need the GIL ---------------------------------------------------- + +def test_generated_module_declares_gil_not_used(built): + result, path, _ = built + c_text = cgen.generate(result.module, path) + assert re.search(r"\{\s*Py_mod_gil\s*,\s*Py_MOD_GIL_NOT_USED\s*\}", c_text) + assert "Py_GIL_DISABLED" in c_text # guarded: a GIL build still compiles + + +def test_importing_a_compiled_module_keeps_the_gil_off(extension): + r = run_child(extension, """ + print(json.dumps({"before": True, "gil": sys._is_gil_enabled(), + "f": m.total([1.0, 2.0], 1)})) + """) + assert r["gil"] is False and r["f"] == 3.0 + + +def test_no_runtime_warning_about_the_gil(extension): + script = LOADER.replace("import faulthandler", "import warnings\nwarnings.simplefilter('error')\n" + "import faulthandler") + proc = subprocess.run([sys.executable, "-c", script + "print('ok')", str(extension), "1"], + capture_output=True, text=True, timeout=120) + assert proc.returncode == 0, proc.stderr + assert "global interpreter lock" not in proc.stderr + + +# --- more than two list parameters stay interpreted -------------------------------------------------- + +def test_three_list_parameters_stay_interpreted(built): + result, _, _ = built + assert "three" not in {f.name for f in result.module.functions} + reason = result.skipped["three"] + assert "list" in reason and ("three" in reason or "more than two" in reason), reason + + +def test_front_end_gives_a_clear_reason(tmp_path): + p = tmp_path / "m.py" + p.write_text("# typedpython: compiled\n\n\ndef f(a: list[int], b: list[float], c: list[int]) -> int:\n" + " return len(a)\n") + m = frontend.lower(p) + assert not m.functions + assert "more than two list parameters" in m.skipped["f"], m.skipped["f"] + + +def test_two_list_parameters_still_compile(built): + result, _, _ = built + assert {"zero2", "pair_edge"} <= {f.name for f in result.module.functions} + + +def test_verifier_rejects_three_array_params(built): + from typedpython import ir + result, _, _ = built + f = next(f for f in result.module.functions if f.name == "zero2") + third = ir.ArrayParam("zs", ir.Type.I64_ARRAY, False) + bad = dataclasses.replace(f, params=f.params + (third,)) + mod = dataclasses.replace(result.module, functions=type(result.module.functions)([bad]), skipped={}) + proved, diags = verify.verify(mod) + assert not proved.functions + assert any("array" in d.message and "two" in d.message for d in diags), diags + + +def test_cgen_refuses_three_array_params(built): + from typedpython import ir + result, path, _ = built + f = next(f for f in result.module.functions if f.name == "zero2") + third = ir.ArrayParam("zs", ir.Type.I64_ARRAY, False) + bad = dataclasses.replace(f, params=f.params + (third,)) + mod = dataclasses.replace(result.module, functions=type(result.module.functions)([bad]), skipped={}) + with pytest.raises(cgen.CGenError, match="two"): + cgen.generate(mod, path) + + +# --- concurrent calls -------------------------------------------------------------------------------- + +def test_private_lists_in_many_threads(extension): + r = run_child(extension, """ + N, ROUNDS = 8, 40 * STRESS + bad = [] + def work(k): + def go(): + xs = [float(k * 1000 + i) for i in range(500)] + want = sum(xs) + for _ in range(ROUNDS): + if m.total(xs, 3) != want * 3: + bad.append(("total", k)) + m.fill(xs, float(k)) + if xs != [float(k)] * 500: + bad.append(("fill", k)) + xs[:] = [float(k * 1000 + i) for i in range(500)] + return go + errs = run_threads([work(k) for k in range(N)]) + print(json.dumps({"bad": bad, "errs": errs})) + """) + assert r == {"bad": [], "errs": []} + + +def test_shared_list_only_read(extension): + r = run_child(extension, """ + shared = [float(i) * 0.5 for i in range(2000)] + want = sum(shared) * 5 + bad = [] + def go(): + for _ in range(60 * STRESS): + v = m.total(shared, 5) + if v != want: + bad.append(v) + errs = run_threads([go for _ in range(8)]) + print(json.dumps({"bad": bad[:3], "errs": errs, "gil": sys._is_gil_enabled()})) + """) + assert r == {"bad": [], "errs": [], "gil": False} + + +def test_shared_list_writers_and_readers_see_whole_calls(extension): + """`fill` writes every element in one call, `edge` reads the first and last: with the list's + critical section held from copy-in to write-back, a reader sees a state between two whole calls, + so first == last, always.""" + r = run_child(extension, """ + shared = [0.0] * 20000 + torn = [] + stop = False + def writer(v): + def go(): + for i in range(40 * STRESS): + m.fill(shared, v + i) + return go + def reader(): + while not stop: + d = m.edge(shared) + if d != 0.0: + torn.append(d) + def driver(): + global stop + errs = run_threads([writer(1000.0 * k) for k in range(1, 4)]) + stop = True + return errs + rs = [threading.Thread(target=lambda: errs_r.extend(run_threads([reader] * 3)))] + errs_r = [] + rs[0].start() + errs = driver() + rs[0].join() + print(json.dumps({"torn": len(torn), "errs": errs + errs_r})) + """) + assert r == {"torn": 0, "errs": []} + + +def test_shared_list_int_writers_and_readers(extension): + r = run_child(extension, """ + shared = [0] * 20000 + torn = [] + stop = False + def writer(v): + def go(): + for i in range(40 * STRESS): + m.fill_int(shared, v + i) + return go + def reader(): + while not stop: + d = m.edge_int(shared) + if d != 0: + torn.append(d) + errs_r = [] + rt = threading.Thread(target=lambda: errs_r.extend(run_threads([reader] * 3))) + rt.start() + errs = run_threads([writer(1000 * k) for k in range(1, 4)]) + stop = True + rt.join() + print(json.dumps({"torn": len(torn), "errs": errs + errs_r})) + """) + assert r == {"torn": 0, "errs": []} + + +def test_shared_list_read_modify_write_equals_a_serialisation(extension): + """`rotate` is a read-modify-write of the whole list: T calls from any threads must leave the + list rotated by exactly T (a lost update or a torn copy would not).""" + r = run_child(extension, """ + n = 64 + shared = [float(i) for i in range(n)] + T_PER, THREADS = 150 * STRESS, 6 + errs = run_threads([(lambda: [m.rotate(shared) for _ in range(T_PER)]) + for _ in range(THREADS)]) + k = (T_PER * THREADS) % n + want = [float(i) for i in list(range(k, n)) + list(range(k))] + print(json.dumps({"ok": shared == want, "errs": errs})) + """) + assert r == {"ok": True, "errs": []} + + +def test_two_shared_lists(extension): + r = run_child(extension, """ + a = [7] * 3000 + b = [9] * 3000 + bad = [] + def zero(): + for _ in range(30 * STRESS): + m.zero2(a, b) + m.zero2(b, a) # the opposite order: the two-list section is ordered + a[:] = [7] * 3000 + b[:] = [9] * 3000 + def look(): + for _ in range(200 * STRESS): + d = m.pair_edge(a, b) + if d not in (7 - 9, 0, 7, -9): + bad.append(d) + errs = run_threads([zero, zero, look, look]) + print(json.dumps({"bad": bad, "errs": errs})) + """) + assert r == {"bad": [], "errs": []} + + +def test_shared_objects_concurrent_fieldset_and_fieldget(extension): + r = run_child(extension, """ + cell = m.Cell((0,), None) + written = {(k, i) for k in range(1, 7) for i in range(200 * STRESS)} | {(0,)} + bad = [] + def writer(k): + def go(): + for i in range(200 * STRESS): + m.seta(cell, (k, i)) + return go + def reader(): + for _ in range(2000 * STRESS): + v = m.geta(cell) + if v not in written: + bad.append(v) + def maker(): + for i in range(500 * STRESS): + c = m.mk((i,), [i]) + if c.a != (i,) or c.b != [i]: + bad.append(("mk", i)) + errs = run_threads([writer(k) for k in range(1, 4)] + [reader] * 3 + [maker] * 2) + print(json.dumps({"bad": [repr(x) for x in bad[:3]], "errs": errs})) + """) + assert r == {"bad": [], "errs": []} + + +def test_class_modified_while_threads_read_fields(extension): + r = run_child(extension, """ + cell = m.Cell(11, 22) + stop = False + bad = [] + def reader(): + while not stop: + if m.geta(cell) != 11: + bad.append("get") + c = m.mk(1, 2) + if (c.a, c.b) != (1, 2): + bad.append("mk") + m.seta(cell, 11) + def modifier(): + global stop + for i in range(300 * STRESS): + m.Cell.counter = i # every assignment moves the class's version tag + if i % 7 == 0: + m.Cell.other = lambda self: i + stop = True + errs_r = [] + rt = threading.Thread(target=lambda: errs_r.extend(run_threads([reader] * 4))) + rt.start() + errs = run_threads([modifier]) + rt.join() + info = m.__typedpython_class_info__()["Cell"] + print(json.dumps({"bad": bad[:3], "errs": errs + errs_r, "compiled": info["compiled"], + "refreshed": info["refreshed"] > 0})) + """) + assert r == {"bad": [], "errs": [], "compiled": True, "refreshed": True} + + +def test_global_rebinding_with_the_dict_watcher(extension): + """The thread that rebinds `Cell` calls `mk` right after: it must see its own rebinding, however + many other threads are refreshing the global's cache at the same moment.""" + r = run_child(extension, """ + stop = False + bad = [] + def hammer(): + while not stop: + c = m.mk(1, 2) + if type(c).__name__ not in ("Cell", "Twin") or (c.a, c.b) != (1, 2): + bad.append("hammer") + errs_r = [] + ht = threading.Thread(target=lambda: errs_r.extend(run_threads([hammer] * 4))) + ht.start() + orig = m.Cell + for i in range(10000 * STRESS): + m.Cell = m.Twin if i % 2 == 0 else orig + want = "Twin" if i % 2 == 0 else "Cell" + got = type(m.mk(1, 2)).__name__ + if got != want: + bad.append((i, got, want)) + stop = True + ht.join() + print(json.dumps({"bad": bad[:3], "errs": errs_r})) + """) + assert r == {"bad": [], "errs": []} + + +def test_deopt_counter_does_not_lose_updates(extension): + r = run_child(extension, """ + N, M = 8, 300 * STRESS + before = m.__typedpython_deopts__ + res = [] + def go(): + for _ in range(M): + res.append(m.usek(1.5)) # a float for an int parameter: deopt, interpreted + errs = run_threads([go for _ in range(N)]) + print(json.dumps({"deopts": m.__typedpython_deopts__ - before, "want": N * M, + "same": set(res) == {4.5}, "errs": errs})) + """) + assert r["errs"] == [] and r["same"] + assert r["deopts"] == r["want"] + + +def test_entry_global_rebinding_in_another_thread(extension): + r = run_child(extension, """ + stop = False + bad = [] + def reader(): + while not stop: + v = m.usek(10) + if v not in (13, 110, 1010): + bad.append(v) + errs_r = [] + rt = threading.Thread(target=lambda: errs_r.extend(run_threads([reader] * 4))) + rt.start() + for i in range(3000 * STRESS): + m.K = (3, 100, 1000)[i % 3] + stop = True + rt.join() + print(json.dumps({"bad": bad[:3], "errs": errs_r})) + """) + assert r == {"bad": [], "errs": []} + + +# --- the mutation: without the list's critical section the shared-list test must fail ---------------- + +def _mutant(built, tmp: Path, drop: str) -> Path: + result, path, _ = built + c_text = cgen.generate(result.module, path) + kept = [ln for ln in c_text.split("\n") if drop not in ln] + assert len(kept) < len(c_text.split("\n")), f"{drop} not found in the generated C" + return cbuild.build("\n".join(kept), "ftmod", tmp / drop, ext_suffix=None) + + +def test_mutant_without_the_array_critical_section_is_caught(built, tmp_path): + so = _mutant(built, tmp_path, "tp_cs_begin") + detected = False + for _ in range(3): + try: + r = run_child(so, """ + shared = [0.0] * 20000 + torn = [] + stop = False + def writer(v): + def go(): + for i in range(60): + m.fill(shared, v + i) + return go + def reader(): + while not stop: + d = m.edge(shared) + if d != 0.0: + torn.append(d) + errs_r = [] + rt = threading.Thread(target=lambda: errs_r.extend(run_threads([reader] * 3))) + rt.start() + errs = run_threads([writer(1000.0 * k) for k in range(1, 4)]) + stop = True + rt.join() + print(json.dumps({"torn": len(torn)})) + """) + detected = r["torn"] > 0 + except AssertionError: + detected = True # a crash is a detection too + if detected: + break + assert detected, "the mutant (no critical section around copy-in) was not caught" + + +def _racy_runtime(tmp: Path, *, drop_dict_lock: bool, plain_epoch: bool) -> Path: + """A copy of tp_runtime.h with one #159 protection removed (the mutation of that protection).""" + h = (cbuild.RUNTIME_DIR / "tp_runtime.h").read_text() + g = h + if drop_dict_lock: + g = g.replace(" Py_BEGIN_CRITICAL_SECTION(module_dict);\n", "") + g = g.replace(" Py_END_CRITICAL_SECTION();\n if (rc <= 0) {\n return rc;", + " if (rc <= 0) {\n return rc;") + if plain_epoch: + g = g.replace("tp_atomic_add_u64(&tp_globals_epoch, 1);", "tp_globals_epoch++;") + g = g.replace("tp_atomic_load_u64(&tp_globals_epoch)", "tp_globals_epoch") + g = g.replace("tp_atomic_load_u64(&rt->gepoch)", "rt->gepoch") + g = g.replace("tp_atomic_store_u64(&rt->gepoch, epoch)", "rt->gepoch = epoch") + assert g != h + d = tmp / "rt" + d.mkdir(parents=True, exist_ok=True) + (d / "tp_runtime.h").write_text(g) + return d + + +def test_mutant_without_the_dict_critical_section_is_caught(built, tmp_path): + """Without the critical section around the epoch read and the global lookup, a thread can + remember a binding the rebinding thread has already replaced (the watcher fires before the store).""" + result, path, _ = built + rt = _racy_runtime(tmp_path, drop_dict_lock=True, plain_epoch=False) + so = cbuild.build(cgen.generate(result.module, path), "ftmod", tmp_path / "mut", runtime_dir=rt) + body = """ + stop = False + bad = [] + def hammer(): + while not stop: + m.mk(1, 2) + ht = threading.Thread(target=lambda: run_threads([hammer] * 6)) + ht.start() + orig = m.Cell + for i in range(20000): + m.Cell = m.Twin if i % 2 == 0 else orig + if type(m.mk(1, 2)).__name__ != ("Twin" if i % 2 == 0 else "Cell"): + bad.append(i) + stop = True + ht.join() + print(json.dumps({"stale": len(bad)})) + """ + stale = 0 + for _ in range(3): + stale = run_child(so, body)["stale"] + if stale: + break + assert stale > 0, "the mutant (no dict critical section) was not caught" + + +# --- ThreadSanitizer (best effort) -------------------------------------------------------------------- +# The interpreter is not instrumented, so TSan sees races between two accesses made by generated C or +# the header (the epoch, the class state) but NOT a missing list lock (the list is written by CPython). + +TSAN_SCRIPT = """ +cell = m.Cell(1, 2) +stop = False +def hammer(): + while not stop: + c = m.mk(1, 2); m.geta(cell); m.seta(cell, 5) +ht = threading.Thread(target=lambda: run_threads([hammer] * 3)); ht.start() +orig = m.Cell +for i in range(300): + m.Cell = m.Twin if i % 2 == 0 else orig + type(m.mk(1, 2)) + m.Cell.counter = i +stop = True; ht.join() +shared = [float(i) for i in range(500)] +run_threads([(lambda: [m.rotate(shared) for _ in range(50)]) for _ in range(4)]) +run_threads([(lambda: [m.fill(shared, 1.0) or m.total(shared, 2) for _ in range(50)]) for _ in range(4)]) +run_threads([(lambda: [m.usek(1.5) for _ in range(100)]) for _ in range(4)]) +print("TSAN-RUN-DONE") +""" + + +def _tsan_launcher(tmp: Path) -> Path | None: + if sys.platform != "darwin" and not sys.platform.startswith("linux"): + return None + cc = (sysconfig.get_config_var("CC") or "cc").split() + lc, launcher = tmp / "tsan_launcher.c", tmp / "tsan_python" + lc.write_text("#include \nint main(int argc, char **argv) { return Py_BytesMain(argc, argv); }\n") + libdir = sysconfig.get_config_var("LIBDIR") + link = subprocess.run(cc + ["-fsanitize=thread", "-g", f"-I{sysconfig.get_paths()['include']}", str(lc), + f"-L{libdir}", f"-lpython{sysconfig.get_config_var('LDVERSION')}", + f"-Wl,-rpath,{libdir}", "-o", str(launcher)], + capture_output=True, text=True) + return launcher if link.returncode == 0 else None + + +def _tsan_reports(built, tmp: Path, launcher: Path, rt: Path | None) -> tuple[int, bool]: + result, path, _ = built + flags = list(cbuild.DEFAULT_FLAGS) + ["-O1", "-g", "-fno-omit-frame-pointer", "-fsanitize=thread"] + so = cbuild.build(cgen.generate(result.module, path), "ftmod", tmp / ("so-" + (rt.parent.name if rt else "real")), + runtime_dir=rt, flags=flags) + env = dict(os.environ, PYTHONHOME=sys.base_prefix, + TSAN_OPTIONS="halt_on_error=0:report_signal_unsafe=0:history_size=4") + env.pop("PYTHON_GIL", None) + env.pop("DYLD_INSERT_LIBRARIES", None) + run = subprocess.run([str(launcher), "-c", LOADER + textwrap.dedent(TSAN_SCRIPT), str(so), "1"], + capture_output=True, text=True, env=env, timeout=600) + out = run.stdout + run.stderr + return out.count("WARNING: ThreadSanitizer"), "TSAN-RUN-DONE" in out + + +@pytest.fixture(scope="module") +def tsan_launcher(tmp_path_factory): + d = tmp_path_factory.mktemp("tsan") + launcher = _tsan_launcher(d) + if launcher is None: + pytest.skip("cannot link a ThreadSanitizer launcher on this host") + return d, launcher + + +def test_threadsanitizer_reports_nothing_on_the_extension(built, tsan_launcher): + d, launcher = tsan_launcher + reports, done = _tsan_reports(built, d, launcher, None) + assert done and reports == 0, f"{reports} ThreadSanitizer report(s) (done={done})" + + +def test_threadsanitizer_sees_a_plain_epoch(built, tsan_launcher): + """The check can fail: with the epoch back to plain loads and increments TSan reports the race + between the watcher callback and the readers.""" + d, launcher = tsan_launcher + rt = _racy_runtime(d / "racy", drop_dict_lock=True, plain_epoch=True) + reports, done = _tsan_reports(built, d, launcher, rt) + assert done and reports > 0 diff --git a/python-multiplatform-ksp/src/test/python/test_frontend.py b/python-multiplatform-ksp/src/test/python/test_frontend.py index f82cd9379..dc06deaec 100644 --- a/python-multiplatform-ksp/src/test/python/test_frontend.py +++ b/python-multiplatform-ksp/src/test/python/test_frontend.py @@ -320,14 +320,15 @@ def bump_last(a: list[float], e: float) -> None: def test_list_params_become_array_params_with_stored(tmp_path): m = lower(tmp_path, """ @compiled - def scale(a: list[float], b: list[float], k: float, c: list[int]) -> None: + def scale(a: list[float], b: list[float], k: float) -> None: for i in range(len(a)): a[i] = b[i] * k """) f = function(m, "scale") + # two list parameters at most (#159: the lists are locked from copy-in to write-back; CPython + # has no three-object critical section). A third one is test_free_threaded.py's. assert f.params == ( - ArrayParam("a", Type.F64_ARRAY, True), ArrayParam("b", Type.F64_ARRAY, False), - Param("k", F64), ArrayParam("c", Type.I64_ARRAY, False), + ArrayParam("a", Type.F64_ARRAY, True), ArrayParam("b", Type.F64_ARRAY, False), Param("k", F64), ) assert f.body[0].stop == Len(I64, "a")