From 21101841da6041fa3cbd46f0375eb633e5ca82a7 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:19:39 +0900 Subject: [PATCH 1/4] Feat: Zero-copy buffer array parameters (array.array, numpy) with atomic element access (#149, WIP) --- .../src/main/python/typedpython/cgen.py | 95 ++- .../src/main/python/typedpython/frontend.py | 80 +- .../src/main/python/typedpython/ir.py | 12 + .../main/python/typedpython/runtime/API.md | 50 ++ .../python/typedpython/runtime/tp_runtime.h | 163 ++++ .../src/main/python/typedpython/verify.py | 27 +- .../src/test/python/test_buffer_arrays.py | 776 ++++++++++++++++++ 7 files changed, 1183 insertions(+), 20 deletions(-) create mode 100644 python-multiplatform-ksp/src/test/python/test_buffer_arrays.py diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py index 0f9b3976..86302b88 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -36,6 +36,22 @@ critical section. Module state is written only by the exec slot; the class guards are atomic (tp_runtime.h). +Buffer arrays (issue #149, identity-safe part). A parameter annotated `array.array[float]`, +`array.array[int]`, `numpy.typing.NDArray[numpy.float64]` or `[numpy.int64]` is an `ArrayParam` with +`buffer` "array" or "numpy". `tp_wrap_f` does not copy it: after the scalar guards, `tp_buf_enter` +checks the exact type (captured in `tp_state.buf_types` by the exec slot), takes one `Py_buffer` +export (C-contiguous, 1-D, 8-byte items, format `d` / `q` / `l`, aligned, writable when the function +stores) and any mismatch deopts before an effect. The export is released by `tp_buf_exit`, part of the +same `exits` as the list write-back, so every path (ok, error, deopt before the interpreted redo) +releases it. Index/StoreIndex are `tp_buf_ld_*` / `tp_buf_st_*`: one relaxed atomic 8-byte access on +the buffer memory (`-DTP_BUFFER_PLAIN` makes them plain, for measuring the atomics only), so there is +no copy-in, no write-back, no critical section, no aliasing guard and no limit on their number; two +buffers that share memory see each other's writes in program order. Closedness (#203) is required as +for lists. OPEN POINT (decision pending in #149): holding the export makes another thread's `append` +/ `resize` of the same array.array raise BufferError during the call; what a concurrent resize +should observe is not decided here and nothing relies on it. A function holding a buffer does not poll +the eval breaker (it counts as a snapshot holder, conservatively). + 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. @@ -148,6 +164,11 @@ class CGenError(Exception): _CT = {Type.I64: "int64_t", Type.F64: "double", Type.BOOL: "int", Type.OBJ: "PyObject *"} _ARRAY_CT = {Type.F64_ARRAY: "tp_f64_array", Type.I64_ARRAY: "tp_i64_array"} +# Buffer arrays (#149): `ArrayParam.buffer` -> the exact type the guard compares against (the index +# into the module state's `buf_types`), and the format strings the export must carry. +_BUFFER_KINDS = {None: None, "array": 0, "numpy": 1} +_BUFFER_FMT = {Type.F64_ARRAY: ('"d"', "NULL"), Type.I64_ARRAY: ('"q"', '"l"')} +_BUFFER_SFX = {Type.F64_ARRAY: "f64", Type.I64_ARRAY: "i64"} _CT_ARRAY_REV = {"tp_f64_array": Type.F64_ARRAY, "tp_i64_array": Type.I64_ARRAY} _ARRAY_ELEM = {Type.F64_ARRAY: Type.F64, Type.I64_ARRAY: Type.I64} _ARRAY_PFX = {Type.F64_ARRAY: "tp_f64_array", Type.I64_ARRAY: "tp_i64_array"} @@ -564,6 +585,7 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No w(f" tp_class_rt cls_rt[{len(module.classes) + 1}]; /* version-tag / global guard state */") w(" int watch_id1; /* dict watcher id + 1 on the module dict; 0 = none */") w(" PyObject *breaker; /* `lambda: None`; calling it runs the eval breaker (#141) */") + w(" PyObject *buf_types[2]; /* strong: array.array, numpy.ndarray (buffer arrays, #149); NULL: not importable */") w("} tp_state;") w("") w("static const char *const tp_name_strings[] = {") @@ -672,7 +694,7 @@ 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) + n_arrays = sum(isinstance(p, ir.ArrayParam) and p.buffer is None 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)") @@ -680,6 +702,8 @@ def _check_function(f: ir.Function, functions: dict[str, ir.Function]) -> None: if isinstance(p, ir.ArrayParam): if p.type not in ir.ARRAYS: raise CGenError(f"{f.name}: array param {p.name} has type {p.type}") + if p.buffer not in _BUFFER_KINDS: + raise CGenError(f"{f.name}: array param {p.name} has buffer kind {p.buffer!r}") if p.stored and f.pure: raise CGenError(f"{f.name}: array param {p.name} is stored into in a pure function") elif p.type not in _CT: @@ -733,7 +757,7 @@ def _impl_signature(f: ir.Function, fast: bool = False) -> str: params.append("int tp_fd") # compiled frames of this fast tree, itself included for p in f.params: if isinstance(p, ir.ArrayParam): - params.append(f"{_ARRAY_CT[p.type]} *{_ident('l', p.name)}") + params.append(f"{'tp_buf' if p.buffer else _ARRAY_CT[p.type]} *{_ident('l', p.name)}") elif p.type is Type.OBJ: params.append(f"PyObject *{_ident('p', p.name)}") else: @@ -1329,23 +1353,36 @@ def ev_Len(self, e: ir.Len) -> str: self.emit(f"{t} = (int64_t){ptr}->len;") return t - def _slot(self, at: Type, ptr: str, index: ir.Expr, proven: bool, store: int) -> str: + def _buffer(self, name: str) -> str | None: + """`ArrayParam.buffer` of an array parameter ("array" / "numpy"), None for a list or local.""" + a = self.arrays.get(name) + return a.buffer if a is not None else None + + def _slot(self, at: Type, ptr: str, index: ir.Expr, proven: bool, store: int, + buffer: str | None = None) -> str: if index.type is not Type.I64: raise CGenError(f"{self.f.name}: index of type {index.type}") i = self.ev(index) if proven: return f"(Py_ssize_t)({i})" slot = self.tmp("Py_ssize_t") - self.check(f"{_ARRAY_PFX[at]}_slot({ptr}, {i}, {store}, &{slot})") + if buffer: + self.check(f"tp_buf_slot_{buffer}({ptr}, {i}, {store}, &{slot})") + else: + self.check(f"{_ARRAY_PFX[at]}_slot({ptr}, {i}, {store}, &{slot})") return slot def ev_Index(self, e: ir.Index) -> str: at, ptr = self._array(e.array) if e.type is not _ARRAY_ELEM[at]: raise CGenError(f"{self.f.name}: Index of {at} typed {e.type}") - slot = self._slot(at, ptr, e.index, e.proven, 0) + buffer = self._buffer(e.array) + slot = self._slot(at, ptr, e.index, e.proven, 0, buffer) t = self.tmp(_CT[e.type]) - self.emit(f"{t} = {ptr}->data[{slot}];") + if buffer: + self.emit(f"{t} = tp_buf_ld_{_BUFFER_SFX[at]}({ptr}, {slot});") + else: + self.emit(f"{t} = {ptr}->data[{slot}];") return t def ev_NewArray(self, e: ir.NewArray) -> str: @@ -1395,6 +1432,8 @@ def _assign_array(self, s: ir.Assign, at: Type) -> None: n = self.ev(v.length) self.check(f"{pfx}_new(&{new}, {n}, {fill})") elif isinstance(v, ir.CopyArray): + if self._buffer(v.src): + raise CGenError(f"{self.f.name}: CopyArray of buffer array {v.src}") st, sptr = self._array(v.src) if st is not at: raise CGenError(f"{self.f.name}: CopyArray of {st} into {at}") @@ -1437,7 +1476,11 @@ def st_StoreIndex(self, s: ir.StoreIndex) -> None: if s.value.type is not _ARRAY_ELEM[at]: raise CGenError(f"{self.f.name}: store of {s.value.type} into {at}") v = self.ev(s.value) # CPython: value, then container, then index - slot = self._slot(at, ptr, s.index, s.proven, 1) + buffer = self._buffer(s.array) + slot = self._slot(at, ptr, s.index, s.proven, 1, buffer) + if buffer: + self.emit(f"tp_buf_st_{_BUFFER_SFX[at]}({ptr}, {slot}, {v});") + return self.emit(f"{ptr}->data[{slot}] = {v};") if not local: # a local array has no list to write back to self.emit(f"{ptr}->dirty[{slot}] = 1;") @@ -1684,14 +1727,15 @@ def wrapper(self) -> str: init = "NULL" if f.returns is Type.OBJ else "0" L.append(f" {_CT[f.returns]} tp_r = {init};") args = [] - arrays = [] - if sum(isinstance(p, ir.ArrayParam) for p in f.params) > 0: + arrays = [] # list parameters: copy-in / write-back under a critical section + buffers = [] # buffer parameters: a Py_buffer export held for the whole call (#149) + if any(isinstance(p, ir.ArrayParam) and p.buffer is None for p in f.params): 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}};") + L.append(f" {'tp_buf' if p.buffer else _ARRAY_CT[p.type]} tp_a{i} = {{0}};") args.append(f"&tp_a{i}") - arrays.append((i, p)) + (buffers if p.buffer else arrays).append((i, p)) elif p.type is Type.OBJ: args.append(f"tp_args[{i}]") # borrowed for the call's duration else: @@ -1699,9 +1743,12 @@ def wrapper(self) -> str: args.append(f"tp_a{i}") 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] + # PyBuffer_Release first (cannot fail), then the lists' write-back and the critical section + exits = [f" tp_buf_exit(&tp_a{i});" for i, p in buffers] + 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 */") + has_exits = bool(arrays or buffers) 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;") @@ -1733,6 +1780,16 @@ def wrapper(self) -> str: L.append(f" tp_s = {_UNBOX[p.type]}(tp_args[{i}], &tp_a{i});") L.append(" if (tp_s < 0) goto tp_error;") L.append(" if (tp_s != 0) goto tp_deopt;") + # buffer arrays: exact type, then one Py_buffer export each, held until every exit runs + # `exits` (a failed guard leaves the struct zeroed, so its exit is a no-op) + for i, p in buffers: + fmt1, fmt2 = _BUFFER_FMT[p.type] + kind = _BUFFER_KINDS[p.buffer] + L.append(f" tp_s = tp_buf_enter(tp_args[{i}], " + f"((tp_state *)PyModule_GetState(tp_module))->buf_types[{kind}], " + f"{fmt1}, {fmt2}, {int(p.stored)}, &tp_a{i});") + L.append(" if (tp_s < 0) goto tp_error;") + L.append(" if (tp_s != 0) goto tp_deopt;") # aliasing guard, then copy-in if len(arrays) > 1: objs = ", ".join(f"tp_args[{i}]" for i, _ in arrays) @@ -1790,7 +1847,7 @@ def wrapper(self) -> str: else: L.append(f" return {_BOX[f.returns]}(tp_r);") L.append("tp_error:") - if arrays: + if has_exits: # keep CPython's exception; write-back still runs (partial updates stay visible) L.append(" { PyObject *tp_exc = PyErr_GetRaisedException();") L += [" " + e for e in exits] @@ -1863,6 +1920,7 @@ def _state_functions(mg: _ModGen) -> str: " size_t i;", " if (tp_st == NULL) return 0;", " Py_VISIT(tp_st->breaker);", + " for (i = 0; i < 2; i++) Py_VISIT(tp_st->buf_types[i]);", " for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) Py_VISIT(tp_st->names[i]);", " for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) Py_VISIT(tp_st->kwnames[i]);", " for (i = 0; i < sizeof(tp_st->classes) / sizeof(tp_st->classes[0]); i++) Py_VISIT(tp_st->classes[i]);", @@ -1878,6 +1936,7 @@ def _state_functions(mg: _ModGen) -> str: " if (tp_st == NULL) return 0;", " if (tp_st->watch_id1 > 0) tp_globals_unwatch(PyModule_GetDict(tp_module), &tp_st->watch_id1);", " tp_release(&tp_st->breaker);", + " for (i = 0; i < 2; i++) tp_release(&tp_st->buf_types[i]);", " for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) tp_release(&tp_st->names[i]);", " for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) tp_release(&tp_st->kwnames[i]);", " for (i = 0; i < sizeof(tp_st->cls_ok) / sizeof(tp_st->cls_ok[0]); i++) tp_st->cls_ok[i] = 0;", @@ -1970,6 +2029,16 @@ def _exec_function(mg: _ModGen) -> str: " if (tp_code == NULL) goto tp_done;", " tp_res = PyEval_EvalCode(tp_code, tp_dict, tp_dict);", " if (tp_res == NULL) goto tp_done;", + ] + kinds = {p.buffer for f in mg.module.functions for p in f.params + if isinstance(p, ir.ArrayParam) and p.buffer} + # buffer arrays (#149): the exact types the entry guards compare against, taken after the + # module source ran (so its own imports are done). Not importable: NULL, and every guard deopts. + for kind, (mod, attr) in (("array", ("array", "array")), ("numpy", ("numpy", "ndarray"))): + if kind in kinds: + L.append(f" if (tp_import_attr({c_string(mod)}, {c_string(attr)}, " + f"&tp_st->buf_types[{_BUFFER_KINDS[kind]}]) < 0) goto tp_done;") + L += [ " tp_table = PyDict_New();", " if (tp_table == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_interpreted__\", tp_table) < 0) goto tp_done;", ] diff --git a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py index 8dfbc209..cf320f27 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py @@ -133,6 +133,9 @@ class _ModuleInfo: stdlib_math: bool scalar_globals: dict[str, Type] = field(default_factory=dict) # entry-global candidates classes: dict[str, ir.ClassDecl] = field(default_factory=dict) # compiled fixed-layout classes + # Top-level `import x [as y]` / `from m import n [as k]` names bound exactly once in the module: + # local name -> dotted qualified name (`np` -> `numpy`, `NDArray` -> `numpy.typing.NDArray`). + imports: dict[str, str] = field(default_factory=dict) def bound(self, name: str) -> bool: return name in self.bindings @@ -166,7 +169,7 @@ def lower(path: Path) -> ir.Module: continue assert isinstance(node, ast.FunctionDef) try: - signatures[node.name] = _signature(node, info.classes) + signatures[node.name] = _signature(node, info.classes, info) except Skip as e: skipped[node.name] = e.reason @@ -229,7 +232,60 @@ def add(name: str, line: int, how: str) -> None: math = bindings.get("math", []) return _ModuleInfo(bindings, len(math) == 1 and math[0][1] == "import math", - _scalar_globals(tree, bindings)) + _scalar_globals(tree, bindings), imports=_import_names(tree, bindings)) + + +def _import_names(tree: ast.Module, bindings: dict[str, list[tuple[int, str]]]) -> dict[str, str]: + """Names bound by a plain top-level import and by nothing else in the module (so the name + certainly is that module or attribute when an annotation is evaluated).""" + out: dict[str, str] = {} + for stmt in tree.body: + if isinstance(stmt, ast.Import): + for alias in stmt.names: + if alias.asname: + out[alias.asname] = alias.name + else: + root = alias.name.split(".")[0] + out[root] = root + elif isinstance(stmt, ast.ImportFrom) and stmt.level == 0 and stmt.module: + for alias in stmt.names: + if alias.name != "*": + out[alias.asname or alias.name] = f"{stmt.module}.{alias.name}" + return {n: q for n, q in out.items() if len(bindings.get(n, [])) == 1} + + +def _qualified(node: ast.expr, info: _ModuleInfo) -> str | None: + """`np.float64` -> "numpy.float64" through the module's own imports, else None.""" + parts: list[str] = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if not isinstance(node, ast.Name) or node.id not in info.imports: + return None + return ".".join([info.imports[node.id], *reversed(parts)]) + + +_BUFFER_ELEMENTS = {"numpy.float64": Type.F64_ARRAY, "numpy.int64": Type.I64_ARRAY} + + +def _buffer_kind(annotation: ast.expr, info: _ModuleInfo) -> tuple[Type, str] | None: + """The buffer-array reading of an annotation (#149), else None (an ordinary object parameter): + `array.array[float]` / `array.array[int]` (any spelling that resolves, through this module's + own imports, to `array.array`), and `numpy.typing.NDArray[numpy.float64]` / `[numpy.int64]`. + A bare `array.array`, `np.ndarray`, another dtype, or a name bound more than once is not one.""" + if not isinstance(annotation, ast.Subscript): + return None + base = _qualified(annotation.value, info) + arg = annotation.slice + if base == "array.array": + if isinstance(arg, ast.Name) and arg.id in ("float", "int") and not info.bound(arg.id): + return ARRAY_KIND[f"list[{arg.id}]"], "array" + return None + if base == "numpy.typing.NDArray": + element = _qualified(arg, info) + if element in _BUFFER_ELEMENTS: + return _BUFFER_ELEMENTS[element], "numpy" + return None def _scalar_globals(tree: ast.Module, bindings: dict[str, list[tuple[int, str]]]) -> dict[str, Type]: @@ -492,7 +548,8 @@ def _targets( return found -def _signature(fn: ast.FunctionDef, classes: dict[str, ir.ClassDecl] | None = None) -> _Signature: +def _signature(fn: ast.FunctionDef, classes: dict[str, ir.ClassDecl] | None = None, + info: _ModuleInfo | None = None) -> _Signature: args = fn.args line = fn.lineno if args.posonlyargs: @@ -510,13 +567,16 @@ def _signature(fn: ast.FunctionDef, classes: dict[str, ir.ClassDecl] | None = No if a.annotation is None: raise Skip(a.lineno, f"parameter `{a.arg}` has no annotation") kind = _annotation_kind(a.annotation) - if kind in ARRAY_KIND: + buffer = _buffer_kind(a.annotation, info) if info is not None else None + if buffer is not None: + params.append(ir.ArrayParam(a.arg, buffer[0], False, buffer[1])) # `stored` set later + elif kind in ARRAY_KIND: params.append(ir.ArrayParam(a.arg, ARRAY_KIND[kind], False)) # `stored` set later elif (c := _class_of(kind, classes or {})) is not None: 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) + n_lists = sum(isinstance(p, ir.ArrayParam) and p.buffer is None 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). @@ -717,7 +777,8 @@ def run(self) -> ir.Function: locals_[name] = self.local_type(name, self.bindings[name][0].line) locals_.update(self.temps) params = tuple( - ir.ArrayParam(p.name, p.type, p.name in self.stored) if isinstance(p, ir.ArrayParam) + ir.ArrayParam(p.name, p.type, p.name in self.stored, p.buffer) + if isinstance(p, ir.ArrayParam) else p for p in self.sig.params ) pure = not self.impure @@ -769,6 +830,13 @@ def _find_local_arrays(self) -> dict[str, Type]: kind = _creation_kind(value) if kind is not None and name not in self.params: creations.setdefault(name, []).append((stmt, kind, value)) + buffers = {p.name for p in self.sig.params if isinstance(p, ir.ArrayParam) and p.buffer} + for cs in creations.values(): + for stmt, kind, value in cs: + if kind == "copy" and value.value.id in buffers: # type: ignore[attr-defined] + raise Skip(stmt.lineno, f"`{_src(value)}` slices buffer parameter " + f"`{value.value.id}`: a numpy slice is a view, " # type: ignore[attr-defined] + "not a native copy (a buffer array is never copied)") known = set(self.arrays) | {n for n, cs in creations.items() if any(k != "copy" for _, k, _ in cs)} while True: diff --git a/python-multiplatform-ksp/src/main/python/typedpython/ir.py b/python-multiplatform-ksp/src/main/python/typedpython/ir.py index 642eade6..39ee40af 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/ir.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/ir.py @@ -553,6 +553,18 @@ class ArrayParam: name: str type: Type # F64_ARRAY or I64_ARRAY stored: bool # whether the body stores into it (write-back needed) + # Buffer array (#149): None for a `list`. "array" (an exact `array.array`) or "numpy" (an exact + # `numpy.ndarray`) means the argument is NOT copied: the entry guard takes a Py_buffer export + # (exact type, 1-D, C-contiguous, aligned 8-byte items, format `d` for F64_ARRAY and `q` or `l` + # for I64_ARRAY, writable when `stored`), holds it until the function returns on every exit, and + # every Index/StoreIndex is one relaxed atomic 8-byte load/store on the buffer memory, no + # copy-in, no write-back, no critical section, no aliasing guard (two buffers may share memory + # and reads then see writes in program order, as in the interpreter). `len` is the export's + # shape[0], fixed for the call because the export forbids resizing. Closedness (#203) applies + # unchanged; in a function with a "numpy" buffer a division or modulo whose divisor is not a + # nonzero Const is rejected (an np.float64 / np.int64 element divides by zero without raising). + # Every other rule above for lists (stored, array-open, Index/StoreIndex/Len only) holds too. + buffer: str | None = None @dataclass(frozen=True) 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 8e342894..e9acb0ba 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -88,6 +88,54 @@ loop does not poll (below), so it delays other threads' stop-the-world requests 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. +## Buffer arrays (issue #149; `ArrayParam.buffer` = "array" | "numpy") +An exact `array.array` (`array.array[float]` / `array.array[int]`) or `numpy.ndarray` +(`numpy.typing.NDArray[numpy.float64]` / `[numpy.int64]`; the names must resolve through the module's +own top-level imports) argument is not copied. + typedef struct { Py_buffer view; Py_ssize_t len; void *data; int held; } tp_buf; + int tp_buf_enter(PyObject *obj, PyObject *exact, const char *fmt1, const char *fmt2, int writable, tp_buf *b); + void tp_buf_exit(tp_buf *b); /* PyBuffer_Release if held; zeroes b; idempotent */ + int tp_buf_slot_array(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot); /* 0 / -1, array.array's IndexError text */ + int tp_buf_slot_numpy(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot); /* 0 / -1, numpy's IndexError text */ + double tp_buf_ld_f64(const tp_buf *b, Py_ssize_t i); void tp_buf_st_f64(const tp_buf *b, Py_ssize_t i, double v); + int64_t tp_buf_ld_i64(const tp_buf *b, Py_ssize_t i); void tp_buf_st_i64(const tp_buf *b, Py_ssize_t i, int64_t v); + int tp_import_attr(const char *mod, const char *attr, PyObject **out); /* exec slot: new ref or NULL (cleared); -1 MemoryError */ +`enter` returns 0 (held), 1 (deopt: b zeroed, nothing held, no exception) or -1 (MemoryError). It +deopts unless: `exact` is non-NULL and `Py_IS_TYPE(obj, exact)` (a subclass may override +`__getitem__`); `PyObject_GetBuffer(C_CONTIGUOUS | FORMAT [| WRITABLE when the function stores])` +succeeds; ndim 1, itemsize 8, no suboffsets, `shape[0] * 8 == len`; the format string is exactly +`d` (floats) or `q` / `l` (ints; a byte-order prefix, `Q`, `i` ... fail); the data is 8-byte aligned. +The wrapper calls `exit` on every path (ok, error, deopt), so the export lives exactly as long as the +compiled call. Element access is one relaxed atomic 8-byte load or store (`__atomic_load_n` / +`__atomic_store_n` on `uint64_t`, bit-cast with `memcpy`); `-DTP_BUFFER_PLAIN` replaces them with +plain loads and stores, for measuring what the atomics cost only. `len` is `shape[0]`, fixed for the +call by the export. + +Rules that go with it (verifier / front end): +* Closed (#203) like a list: a function with a buffer runs no user code (`verify/array-open`). +* No limit on the number of buffer parameters (no critical section); lists keep the limit of two. + A buffer and a list cannot alias (different types). Two buffers may share memory (the same array + twice, two numpy views): per-element access makes reads see earlier writes in program order, as in + the interpreter, which a copy-in could not. +* `y = x[:]` of a buffer is not a native copy (a numpy slice is a view): the function stays interpreted. +* numpy only: a `/`, `//` or `%` whose divisor is not a nonzero Const (and any float `//` or `%`) + is rejected (`verify/buffer`): an `np.float64` / `np.int64` element divides by zero without + raising, the compiled Python-float code would raise `ZeroDivisionError`. `array.array` elements + are Python floats and ints, so no such rule applies. +* The accumulator of a numpy loop needs an annotation (`s: float = 0.0`): Pyrefly types `s += x[i]` + with the numpy scalar type, which the front end does not know. +* Known, documented difference: a scalar read from a numpy buffer and returned is a Python `float` / + `int`, where the interpreter returns `np.float64` / `np.int64`. Integer arithmetic that overflows + i64 deopts to the interpreter in a pure function, which then wraps as numpy does. +* OPEN POINT (decision pending in #149), deliberately not decided here and not tested: holding the + export makes another thread's `append` / `resize` / `extend` on the same `array.array` raise + `BufferError` for the duration of the call; what a concurrent resize should observe instead is not + settled. for an ndarray the export keeps the array object (and so its memory) alive; whether numpy's + own resize is refused during an export was not examined. +* Free-threaded: no critical section; every element access is an atomic, so a concurrent writer of the + same element yields one whole value, never a torn one. A function holding a buffer does not poll the + eval breaker (same liveness note as lists, #152). + ## 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") */ @@ -269,6 +317,8 @@ static or module-state field must be added here): | `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 | +| `buf_types` (array.array, numpy.ndarray) | module state | immutable after init | written by `tp_exec` (and `tp_clear`); read-only type pointers compared by identity | +| buffer arguments of array parameters | caller's array.array / ndarray memory | atomic | relaxed 8-byte `__atomic_load_n` / `__atomic_store_n` per element; the Py_buffer export is held for the call (open point on concurrent resize, #149) | | 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 | 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 14c00bf3..e1c0e71e 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 @@ -865,6 +865,169 @@ static inline int tp_i64_array_exit(tp_i64_array *a) return rc; } +/* ------------------------------------------------------------------------------------------ + * Buffer arrays (issue #149; ir.ArrayParam.buffer): an exact array.array or numpy.ndarray argument + * is NOT copied. tp_buf_enter takes one Py_buffer export and tp_buf_exit releases it; the wrapper + * calls exit on every path (ok, error, deopt), so the export, which forbids a resize of the + * array.array and pins the memory of the ndarray, is held exactly as long as the compiled call. + * + * enter(obj, exact, fmt1, fmt2, writable, b): rc 0 = held (b->data, b->len set); rc 1 = deopt, b + * is zeroed and nothing is held, nothing changed, no exception left; rc -1 = MemoryError set. + * Deopt (never memory access) unless ALL hold: `exact` is non-NULL and Py_IS_TYPE(obj, exact) + * (a subclass could override __getitem__), PyObject_GetBuffer succeeds with C_CONTIGUOUS | FORMAT + * (| WRITABLE when the function stores), ndim == 1, itemsize == 8, no suboffsets, shape[0] * 8 == + * view.len, the format is exactly fmt1 or fmt2 ("d"; "q" or "l": the 8-byte signed ints, a byte + * order prefix or an unsigned/other format fails), and the data is 8-byte aligned (the access is + * an 8-byte atomic). An empty buffer is held too (len 0, no element is ever touched). + * + * Element access (tp_buf_ld_* / tp_buf_st_*): one relaxed atomic 8-byte load or store of the bit + * pattern on the buffer memory, bit-cast with memcpy. Another thread (or a numpy view of the same + * memory) may write the same element concurrently: that is a data race in the Python program, and + * the atomic makes it a torn-value-free one (an element is read or written whole), the closest a + * compiled loop gets to what the interpreter's own element read/write does. -DTP_BUFFER_PLAIN + * replaces the atomics with plain loads and stores; it exists only to measure what the atomics + * cost, it is not a supported configuration for threads. len is the export's shape[0], fixed for + * the call (the export forbids resizing). + * ------------------------------------------------------------------------------------------ */ +typedef struct { Py_buffer view; Py_ssize_t len; void *data; int held; } tp_buf; + +/* `mod.attr` as a new reference in *out; not importable (or no such attribute): *out stays NULL + * and the error is cleared, so every guard against it deopts. -1 only for MemoryError. */ +static inline int tp_import_attr(const char *mod, const char *attr, PyObject **out) +{ + PyObject *m = PyImport_ImportModule(mod); + *out = NULL; + if (m != NULL) { + *out = PyObject_GetAttrString(m, attr); + Py_DECREF(m); + } + if (*out == NULL) { + if (PyErr_ExceptionMatches(PyExc_MemoryError)) { + return -1; + } + PyErr_Clear(); + } + return 0; +} + +static inline void tp_buf_exit(tp_buf *b) +{ + if (b->held) { + PyBuffer_Release(&b->view); + } + memset(b, 0, sizeof(*b)); +} + +static inline int tp_buf_enter(PyObject *obj, PyObject *exact, const char *fmt1, const char *fmt2, + int writable, tp_buf *b) +{ + int flags = PyBUF_C_CONTIGUOUS | PyBUF_FORMAT; + memset(b, 0, sizeof(*b)); + if (exact == NULL || !Py_IS_TYPE(obj, (PyTypeObject *)exact)) { + return 1; + } + if (writable) { + flags |= PyBUF_WRITABLE; + } + if (PyObject_GetBuffer(obj, &b->view, flags) != 0) { + if (PyErr_ExceptionMatches(PyExc_MemoryError)) { + return -1; + } + PyErr_Clear(); /* read-only, not contiguous, ...: the object path */ + memset(b, 0, sizeof(*b)); + return 1; + } + b->held = 1; + if (b->view.ndim != 1 || b->view.itemsize != 8 || b->view.shape == NULL + || b->view.suboffsets != NULL || b->view.shape[0] < 0 + || b->view.shape[0] * 8 != b->view.len || b->view.format == NULL + || !(strcmp(b->view.format, fmt1) == 0 || (fmt2 != NULL && strcmp(b->view.format, fmt2) == 0)) + || (writable && b->view.readonly) + || (b->view.shape[0] > 0 && (b->view.buf == NULL || ((uintptr_t)b->view.buf & 7) != 0))) { + tp_buf_exit(b); + return 1; + } + b->len = b->view.shape[0]; + b->data = b->view.buf; + return 0; +} + +/* index: a negative index counts from the end; out of range -> IndexError with the text CPython's + * own container uses (array.array: "array index out of range"; numpy: "index %zd is out of bounds + * for axis 0 with size %zd") */ +static inline int tp_buf_slot_array(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot) +{ + if (i < 0) { + i += (int64_t)b->len; + if (i < 0) goto out_of_range; + } + else if (i >= (int64_t)b->len) { + goto out_of_range; + } + *slot = (Py_ssize_t)i; + return 0; +out_of_range: + PyErr_SetString(PyExc_IndexError, + store ? "array assignment index out of range" : "array index out of range"); + return -1; +} + +static inline int tp_buf_slot_numpy(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot) +{ + int64_t j = i; + (void)store; + if (j < 0) { + j += (int64_t)b->len; + if (j < 0) goto out_of_range; + } + else if (j >= (int64_t)b->len) { + goto out_of_range; + } + *slot = (Py_ssize_t)j; + return 0; +out_of_range: + PyErr_Format(PyExc_IndexError, "index %lld is out of bounds for axis 0 with size %zd", + (long long)i, b->len); + return -1; +} + +#ifdef TP_BUFFER_PLAIN +static inline double tp_buf_ld_f64(const tp_buf *b, Py_ssize_t i) { return ((const double *)b->data)[i]; } +static inline int64_t tp_buf_ld_i64(const tp_buf *b, Py_ssize_t i) { return ((const int64_t *)b->data)[i]; } +static inline void tp_buf_st_f64(const tp_buf *b, Py_ssize_t i, double v) { ((double *)b->data)[i] = v; } +static inline void tp_buf_st_i64(const tp_buf *b, Py_ssize_t i, int64_t v) { ((int64_t *)b->data)[i] = v; } +#else +static inline double tp_buf_ld_f64(const tp_buf *b, Py_ssize_t i) +{ + uint64_t u = __atomic_load_n((const uint64_t *)b->data + i, __ATOMIC_RELAXED); + double d; + memcpy(&d, &u, sizeof d); + return d; +} + +static inline int64_t tp_buf_ld_i64(const tp_buf *b, Py_ssize_t i) +{ + uint64_t u = __atomic_load_n((const uint64_t *)b->data + i, __ATOMIC_RELAXED); + int64_t v; + memcpy(&v, &u, sizeof v); + return v; +} + +static inline void tp_buf_st_f64(const tp_buf *b, Py_ssize_t i, double v) +{ + uint64_t u; + memcpy(&u, &v, sizeof u); + __atomic_store_n((uint64_t *)b->data + i, u, __ATOMIC_RELAXED); +} + +static inline void tp_buf_st_i64(const tp_buf *b, Py_ssize_t i, int64_t v) +{ + uint64_t u; + memcpy(&u, &v, sizeof u); + __atomic_store_n((uint64_t *)b->data + i, u, __ATOMIC_RELAXED); +} +#endif + /* ------------------------------------------------------------------------------------------ * Local arrays (ir.NewArray / CopyArray): owned by the function, no list object behind them. * Same structs as above with list == NULL and dirty == NULL; the slot helpers work on them diff --git a/python-multiplatform-ksp/src/main/python/typedpython/verify.py b/python-multiplatform-ksp/src/main/python/typedpython/verify.py index 9854a0f4..7e378da9 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/verify.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/verify.py @@ -45,6 +45,11 @@ verify/redo `Call.redo` where it is not legal (see below) verify/entry-global-open a function with entry_globals is not closed verify/array-open a function with an ArrayParam runs user code (#203) + verify/buffer a malformed `ArrayParam.buffer`, a CopyArray of a buffer array, or, in a function with a "numpy" + buffer array, a `/`, `//` or `%` whose divisor is not a nonzero Const + (and any F64 `//` or `%`): an np.float64 / np.int64 element divides + by zero without raising, so the compiled Python-float code would + differ (#149) verify/class a class named by FieldGet/FieldSet/New/IsExact/CheckExact/Param.cls is not a (valid) ClassDecl of the module, a field is not one of its fields, a New names a class without trivial_init or has the wrong arity, or a @@ -230,6 +235,7 @@ REDO = "verify/redo" ENTRY_OPEN = "verify/entry-global-open" ARRAY_OPEN = "verify/array-open" +BUFFER = "verify/buffer" CLASS = "verify/class" FIELD = "verify/field-unproved" @@ -265,6 +271,7 @@ class Diagnostic: _FULL = (_I64_MIN, _I64_MAX) _LEN_MAX = 2 ** 62 _PROVABLE_OPS = frozenset({BinOpKind.ADD, BinOpKind.SUB, BinOpKind.MUL}) +_DIVISIONS = frozenset({BinOpKind.TRUEDIV, BinOpKind.FLOORDIV, BinOpKind.MOD}) _POW2_OPS = frozenset({BinOpKind.FLOORDIV, BinOpKind.MOD}) # `proven` means shift / mask _NEGATE = {CompareKind.EQ: CompareKind.NE, CompareKind.NE: CompareKind.EQ, CompareKind.LT: CompareKind.GE, CompareKind.GE: CompareKind.LT, @@ -328,6 +335,8 @@ def __init__(self, function: Function, signatures: dict[str, Function], self.loops: list[_Loop] = [] self.scalars: dict[str, Type] = {} # name -> type of every scalar/OBJ variable self.arrays: dict[str, Type] = {} # name -> array type of every ArrayParam + self.numpy = False # some ArrayParam is a numpy buffer (#149) + self.buffers: set[str] = set() # names of every buffer ArrayParam self.larrays: dict[str, Type] = {} # name -> array type of every local array self.assigns: dict[str, int] = {} # local array -> number of Assign statements to it self.lens: dict[str, tuple[int, int]] = {} # single-assignment local array -> length @@ -351,8 +360,13 @@ def declarations(self) -> frozenset[str]: self.error(TYPE, f"array parameter '{p.name}' has non-array type {p.type}") elif not isinstance(p.stored, bool): self.error(TYPE, f"array parameter '{p.name}' has a non-bool `stored`") + elif p.buffer not in (None, "array", "numpy"): + self.error(BUFFER, f"array parameter '{p.name}' has buffer kind {p.buffer!r}") else: self.arrays[p.name] = p.type + self.numpy = self.numpy or p.buffer == "numpy" + if p.buffer: + self.buffers.add(p.name) elif type(p) is Param: if p.type not in _VALUE_TYPES: self.error(TYPE, f"parameter '{p.name}' has type {p.type}; arrays must be " @@ -366,7 +380,7 @@ 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: + if sum(type(p) is ArrayParam and p.buffer is None 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)") @@ -721,6 +735,14 @@ def e_BinOp(self, e: BinOp, state: _State) -> ir.Expr: f"{e.type.value}") if lt is Type.I64 and e.op in _I64_DEOPT_OPS and not proven: self.can_deopt = True + if self.numpy and lt in (Type.I64, Type.F64) and e.op in _DIVISIONS: + nonzero = (type(right) is Const and type(right.value) in (int, float) + and right.value != 0) + if not nonzero or (lt is Type.F64 and e.op is not BinOpKind.TRUEDIV): + self.error(BUFFER, f"{lt.value} {e.op.value} in a function with a numpy buffer " + "array: an np.float64 / np.int64 element divides by zero " + "without raising, so only `/`, `//`, `%` by a nonzero " + "Const (`/` for floats) are compiled") if lt is Type.OBJ: self.effects.append(f"OBJ {e.op.value} (runs user methods)") self.opens.append(f"OBJ {e.op.value} (runs user methods)") @@ -1106,6 +1128,9 @@ def allocation(self, e: NewArray | CopyArray, target: str, state: _State) -> ir. src = self.arrays.get(e.src) or self.larrays.get(e.src) if not isinstance(e.src, str) or src is None: self.error(ARRAY_USE, f"CopyArray of '{e.src}', which is not an array") + elif e.src in self.buffers: + self.error(BUFFER, f"CopyArray of buffer array '{e.src}': a buffer array is never " + "copied (a numpy slice is a view)") else: self.array(e.src, "copy", state) if src is not e.type: diff --git a/python-multiplatform-ksp/src/test/python/test_buffer_arrays.py b/python-multiplatform-ksp/src/test/python/test_buffer_arrays.py new file mode 100644 index 00000000..9fd610fb --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_buffer_arrays.py @@ -0,0 +1,776 @@ +"""Zero-copy buffer-protocol array parameters (issue #149, identity-safe part; SPEC N-8 / N-9). + +A parameter annotated `array.array[float]`, `array.array[int]`, `npt.NDArray[np.float64]` or +`npt.NDArray[np.int64]` is a *buffer array*: the entry guard takes a `Py_buffer` export of the exact +type (1-D, C-contiguous, 8-byte items, format `d` or `q`/`l`, aligned, writable when the function +stores), holds it until the function returns on every exit, and every element access goes straight +to the buffer memory as one relaxed atomic 8-byte load or store. No copy-in, no write-back. + +Everything here is single-threaded and differential against the interpreted function +(`__typedpython_interpreted__`): same result, same exception type and text, same final contents of +every argument. Tests that a path was *compiled* compare `__typedpython_deopts__` before and after, +so a function that silently stayed interpreted fails them (that is what the red run shows). + +Not covered, on purpose: what a concurrent resize by another thread observes (open point of #149; +API.md "Buffer arrays"). +""" +from __future__ import annotations + +import array +import dataclasses +import importlib.machinery +import importlib.util +import sys +import textwrap + +import numpy as np +import pytest + +from typedpython import cbuild, cgen, frontend, verify + +SOURCE = textwrap.dedent(''' + # typedpython: compiled + import array + import math + import numpy as np + import numpy.typing as npt + + + def sum_ad(a: array.array[float]) -> float: + s = 0.0 + for i in range(len(a)): + s += a[i] + return s + + + def sum_aq(a: array.array[int]) -> int: + s = 0 + for i in range(len(a)): + s += a[i] + return s + + + def sum_nf(x: npt.NDArray[np.float64]) -> float: + s: float = 0.0 + for i in range(len(x)): + s += x[i] + return s + + + def sum_ni(x: npt.NDArray[np.int64]) -> int: + s: int = 0 + for i in range(len(x)): + s += x[i] + return s + + + def get_ad(a: array.array[float], i: int) -> float: + return a[i] + + + def get_aq(a: array.array[int], i: int) -> int: + return a[i] + + + def get_nf(x: npt.NDArray[np.float64], i: int) -> float: + return x[i] + + + def get_ni(x: npt.NDArray[np.int64], i: int) -> int: + return x[i] + + + def set_ad(a: array.array[float], i: int, v: float) -> None: + a[i] = v + + + def set_aq(a: array.array[int], i: int, v: int) -> None: + a[i] = v + + + def set_nf(x: npt.NDArray[np.float64], i: int, v: float) -> None: + x[i] = v + + + def set_ni(x: npt.NDArray[np.int64], i: int, v: int) -> None: + x[i] = v + + + def length_nf(x: npt.NDArray[np.float64]) -> int: + return len(x) + + + def length_ad(a: array.array[float]) -> int: + return len(a) + + + def scale_nf(x: npt.NDArray[np.float64], k: float) -> None: + for i in range(len(x)): + x[i] = x[i] * k + + + def scale_ad(a: array.array[float], k: float) -> None: + for i in range(len(a)): + a[i] = a[i] * k + + + def iota_ni(x: npt.NDArray[np.int64]) -> None: + for i in range(len(x)): + x[i] = i + + + def iota_aq(a: array.array[int]) -> None: + for i in range(len(a)): + a[i] = i + + + def copy_nf(dst: npt.NDArray[np.float64], src: npt.NDArray[np.float64]) -> None: + for i in range(len(dst)): + dst[i] = src[i] + + + def copy_ad(dst: array.array[float], src: array.array[float]) -> None: + for i in range(len(dst)): + dst[i] = src[i] + + + def cascade_nf(a: npt.NDArray[np.float64], b: npt.NDArray[np.float64]) -> None: + for i in range(len(a)): + a[i] = b[i] + 1.0 + + + def fill_n(x: npt.NDArray[np.float64], n: int) -> None: + for i in range(n): + x[i] = 1.0 + + + def mixed(l: list[float], x: npt.NDArray[np.float64]) -> None: + for i in range(len(x)): + x[i] = l[i] + + + def three(a: npt.NDArray[np.float64], b: npt.NDArray[np.float64], c: npt.NDArray[np.float64], + d: array.array[float]) -> None: + for i in range(len(a)): + a[i] = b[i] + c[i] + d[i] + + + def half_nf(x: npt.NDArray[np.float64]) -> float: + return x[0] / 2.0 + + + def div_ad(a: array.array[float]) -> float: + return a[0] / a[1] + + + def div_nf(x: npt.NDArray[np.float64]) -> float: + return x[0] / x[1] + + + def open_nf(x: npt.NDArray[np.float64], o: object) -> float: + return x[0] + float(o.v) + + + def rebind_view(x: npt.NDArray[np.float64]) -> None: + y = x[:] + y[0] = 5.0 +''') + +COMPILED = { + "sum_ad", "sum_aq", "sum_nf", "sum_ni", "get_ad", "get_aq", "get_nf", "get_ni", "set_ad", + "set_aq", "set_nf", "set_ni", "length_nf", "length_ad", "scale_nf", "scale_ad", "iota_ni", + "iota_aq", "copy_nf", "copy_ad", "cascade_nf", "fill_n", "mixed", "three", "half_nf", "div_ad", +} +SKIPPED = {"div_nf", "open_nf", "rebind_view"} + + +def lower_verify(path): + lowered = frontend.lower(path) + return verify.verify(lowered) + + +def build_source(d, source, name="bufmod"): + path = d / f"{name}.py" + path.write_text(source) + proved, diags = lower_verify(path) + c = cgen.generate(proved, path, path.name) + return path, proved, diags, c + + +def load_so(so): + loader = importlib.machinery.ExtensionFileLoader(so.stem.split(".")[0], str(so)) + spec = importlib.util.spec_from_file_location(so.stem.split(".")[0], str(so), loader=loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +@dataclasses.dataclass +class Built: + proved: object + diags: list + c: str + module: object + + +@pytest.fixture(scope="module") +def built(tmp_path_factory): + d = tmp_path_factory.mktemp("bufmod") + path, proved, diags, c = build_source(d, SOURCE) + so = cbuild.build(c, path.stem, d / "out") + return Built(proved, diags, c, load_so(so)) + + +def assert_compiled(built, *names): + have = {f.name for f in built.proved.functions} + assert set(names) <= have, (sorted(set(names) - have), built.proved.skipped) + + +def deopts(m): + return m.__typedpython_deopts__ + + +def interp(m, name): + return m.__typedpython_interpreted__[name] + + +def snap(a): + return a.tolist() if hasattr(a, "tolist") else list(a) + + +def outcome(fn, args): + try: + return ("ok", fn(*args)) + except Exception as e: # noqa: BLE001 - the type and text are the observable + return ("raise", type(e), str(e)) + + +def same_value(a, b): + if a == b: + return True + return a != a and b != b # nan + + +def differential(m, name, make, expect_deopt=False): + """Run the compiled and the interpreted function on two fresh copies of the arguments; assert + equal result (or equal exception type and text) and equal final contents of every argument.""" + ca, ia = make(), make() + before = deopts(m) + c = outcome(getattr(m, name), ca) + after = deopts(m) + i = outcome(interp(m, name), ia) + assert c[0] == i[0], (c, i) + if c[0] == "ok": + assert same_value(c[1], i[1]), (c, i) + else: + assert c[1:] == i[1:], (c, i) + for x, y in zip(ca, ia): + if hasattr(x, "tolist") or isinstance(x, (array.array, list)): + assert snap(x) == snap(y) or all(same_value(p, q) for p, q in zip(snap(x), snap(y))), (x, y) + if expect_deopt: + assert after == before + 1, "expected a deopt (an object-path call), got none" + else: + assert after == before, "unexpected deopt: the compiled path did not run" + return c + + +F = [1.5, -2.25, 3.0, 4.5] +I = [3, -5, 7, 11] + + +def AD(v=F): return array.array("d", v) +def AQ(v=I): return array.array("q", v) +def NF(v=F): return np.array(v, dtype=np.float64) +def NI(v=I): return np.array(v, dtype=np.int64) + + +# --- what is compiled, and why the rest is not -------------------------------------------------- + +def test_compiled_set_and_skip_reasons(built): + names = {f.name for f in built.proved.functions} + assert names >= COMPILED, sorted(COMPILED - names) + for n in SKIPPED: + assert n in built.proved.skipped, n + assert "verify/array-open" in built.proved.skipped["open_nf"] + assert "numpy" in built.proved.skipped["div_nf"] + assert "view" in built.proved.skipped["rebind_view"] + + +def test_ir_shape_marks_buffer_params(built): + by = {f.name: f for f in built.proved.functions} + p = by["sum_nf"].params[0] + assert (p.buffer, p.stored) == ("numpy", False) + assert by["sum_ad"].params[0].buffer == "array" + assert by["scale_nf"].params[0].stored is True + assert [q.buffer for q in by["mixed"].params if hasattr(q, "buffer")] == [None, "numpy"] + + +def test_no_limit_on_buffer_parameters(built): + by = {f.name: f for f in built.proved.functions} + assert sum(getattr(p, "buffer", None) is not None for p in by["three"].params) == 4 + assert "tp_cs_begin" not in wrapper_text(built.c, "three") + n = 4 + x = [np.zeros(n) for _ in range(3)] + d = array.array("d", [1.0] * n) + x[1][:] = 2.0 + x[2][:] = 3.0 + built.module.three(x[0], x[1], x[2], d) + assert x[0].tolist() == [6.0] * n + + +def wrapper_text(c, name): + start = c.index(f"static PyObject *tp_wrap_{name}(") + return c[start:c.index("\n}\n", start)] + + +# --- reads, writes, len --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name,make", [ + ("sum_ad", lambda: (AD(),)), ("sum_aq", lambda: (AQ(),)), + ("sum_nf", lambda: (NF(),)), ("sum_ni", lambda: (NI(),)), + ("sum_ad", lambda: (AD([]),)), ("sum_nf", lambda: (NF([]),)), + ("sum_aq", lambda: (array.array("l", I),)), + ("sum_ni", lambda: (np.array(I, dtype=np.longlong),)), + ("length_nf", lambda: (NF(),)), ("length_ad", lambda: (AD([]),)), +]) +def test_reads_and_len(built, name, make): + differential(built.module, name, make) + + +@pytest.mark.parametrize("name,make", [ + ("get_ad", lambda: (AD(), 2)), ("get_ad", lambda: (AD(), -1)), ("get_ad", lambda: (AD(), -4)), + ("get_aq", lambda: (AQ(), 3)), ("get_aq", lambda: (AQ(), -4)), + ("get_nf", lambda: (NF(), 0)), ("get_nf", lambda: (NF(), -2)), + ("get_ni", lambda: (NI(), 1)), ("get_ni", lambda: (NI(), -1)), +]) +def test_index_and_negative_index(built, name, make): + differential(built.module, name, make) + + +@pytest.mark.parametrize("name,make", [ + ("get_ad", lambda: (AD(), 4)), ("get_ad", lambda: (AD(), -5)), ("get_ad", lambda: (AD([]), 0)), + ("get_aq", lambda: (AQ(), 99)), ("get_nf", lambda: (NF(), 4)), ("get_nf", lambda: (NF(), -5)), + ("get_ni", lambda: (NI(), 7)), + ("set_ad", lambda: (AD(), 4, 1.0)), ("set_aq", lambda: (AQ(), -5, 1)), + ("set_nf", lambda: (NF(), 4, 1.0)), ("set_ni", lambda: (NI(), -5, 1)), +]) +def test_index_errors_match_cpython_type_and_text(built, name, make): + c = differential(built.module, name, make) + assert c[0] == "raise" and c[1] is IndexError + + +@pytest.mark.parametrize("name,make", [ + ("set_ad", lambda: (AD(), 1, 9.5)), ("set_ad", lambda: (AD(), -1, 9.5)), + ("set_aq", lambda: (AQ(), 0, -77)), ("set_nf", lambda: (NF(), 3, 0.125)), + ("set_nf", lambda: (NF(), -4, 0.125)), ("set_ni", lambda: (NI(), 2, 2 ** 40)), + ("scale_ad", lambda: (AD(), 2.0)), ("scale_nf", lambda: (NF(), -0.5)), + ("iota_ni", lambda: (NI(),)), ("iota_aq", lambda: (AQ(),)), + ("scale_nf", lambda: (NF([]), 2.0)), + ("copy_nf", lambda: (NF(), NF([9.0, 8.0, 7.0, 6.0]))), + ("copy_ad", lambda: (AD(), AD([9.0, 8.0, 7.0, 6.0]))), +]) +def test_writes_are_visible_to_the_caller(built, name, make): + differential(built.module, name, make) + + +def test_a_write_really_lands_in_the_callers_memory(built): + assert_compiled(built, "set_nf", "scale_ad") + x = NF() + built.module.set_nf(x, 1, 42.0) + assert x[1] == 42.0 + a = AD() + built.module.scale_ad(a, 2.0) + assert a.tolist() == [3.0, -4.5, 6.0, 9.0] + + +def test_scalar_results_are_python_numbers(built): + # documented difference (API.md "Buffer arrays"): the compiled function returns a Python float + # or int where the interpreted one returns np.float64 / np.int64. + assert type(built.module.get_nf(NF(), 0)) is float + assert type(built.module.get_ni(NI(), 0)) is int + + +# --- exceptions mid-loop -------------------------------------------------------------------------- + +def test_exception_mid_loop_keeps_earlier_writes(built): + differential(built.module, "fill_n", lambda: (NF([0.0] * 3), 4)) + differential(built.module, "fill_n", lambda: (NF([0.0] * 5), 4)) + x = NF([0.0] * 3) + with pytest.raises(IndexError): + built.module.fill_n(x, 4) + assert x.tolist() == [1.0, 1.0, 1.0] + + +def test_division_semantics_that_numpy_changes(built): + # array.array elements are Python floats: ZeroDivisionError as interpreted + differential(built.module, "div_ad", lambda: (AD([1.0, 0.0]),)) + differential(built.module, "div_ad", lambda: (AD([1.0, 4.0]),)) + # division by a nonzero constant is the same for np.float64 + differential(built.module, "half_nf", lambda: (NF([3.0]),)) + + +# --- aliasing ------------------------------------------------------------------------------------- + +def test_two_views_of_one_numpy_array_see_each_others_writes(built): + def make(): + base = np.zeros(8) + return (base[1:], base[:-1], base) + + ca, ia = make(), make() + before = deopts(built.module) + built.module.cascade_nf(ca[0], ca[1]) + interp(built.module, "cascade_nf")(ia[0], ia[1]) + assert deopts(built.module) == before + # a read sees the earlier write through the other view: base[k] = k (a copy-in would give + # [0, 1, 1, 1, ...]) + assert ca[2].tolist() == ia[2].tolist() == [float(k) for k in range(8)] + + +def test_the_same_array_passed_twice(built): + assert_compiled(built, "copy_nf", "copy_ad") + x = NF() + before = deopts(built.module) + built.module.copy_nf(x, x) + assert deopts(built.module) == before + assert x.tolist() == F + a = AD() + before = deopts(built.module) + built.module.copy_ad(a, a) + assert deopts(built.module) == before + assert a.tolist() == F + + +def test_buffer_and_list_parameters_together(built): + l = [10.0, 20.0, 30.0, 40.0] + x = NF([0.0] * 4) + before = deopts(built.module) + built.module.mixed(l, x) + assert deopts(built.module) == before + assert x.tolist() == l and l == [10.0, 20.0, 30.0, 40.0] + # an array in place of the list is a deopt (guard), not an alias + differential(built.module, "mixed", lambda: (AD([1.0] * 4), NF([0.0] * 4)), expect_deopt=True) + + +# --- guards: anything else takes the object path -------------------------------------------------- + +class SubArray(array.array): + pass + + +class SubNd(np.ndarray): + pass + + +def misaligned(): + raw = bytearray(8 * 5 + 1) + return np.frombuffer(raw, dtype=np.float64, count=5, offset=1) + + +def readonly(v): + x = NF(v) + x.flags.writeable = False + return x + + +@pytest.mark.parametrize("name,make", [ + ("sum_nf", lambda: (np.array(F, dtype=np.float32),)), + ("sum_nf", lambda: (NF([1.0] * 8)[::2],)), + ("length_nf", lambda: (np.zeros((2, 2)),)), + ("sum_nf", lambda: (np.array(F, dtype=">f8"),)), + ("sum_nf", lambda: (np.array(I, dtype=np.int64),)), + ("sum_nf", lambda: (misaligned(),)), + ("sum_nf", lambda: (NF(F).view(SubNd),)), + ("sum_nf", lambda: (list(F),)), + ("sum_nf", lambda: (AD(),)), + ("sum_ni", lambda: (np.array(F, dtype=np.float64),)), + ("sum_ni", lambda: (np.array([3, 5, 7], dtype=np.uint64),)), + ("sum_ni", lambda: (np.array(I, dtype=np.int32),)), + ("sum_aq", lambda: (array.array("Q", [1, 2]),)), + ("sum_aq", lambda: (array.array("i", [1, 2]),)), + ("sum_ad", lambda: (array.array("f", [1.0, 2.0]),)), + ("sum_ad", lambda: (SubArray("d", F),)), + ("sum_ad", lambda: (NF(),)), + ("sum_ad", lambda: (None,)), +]) +def test_wrong_kind_takes_the_object_path_and_is_still_correct(built, name, make): + differential(built.module, name, make, expect_deopt=True) + + +def test_int64_overflow_in_a_numpy_sum_deopts_and_wraps_as_numpy(built): + import warnings + big = lambda: (np.array([2 ** 62, 2 ** 62, 2 ** 62], dtype=np.int64),) # noqa: E731 + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + c = differential(built.module, "sum_ni", big, expect_deopt=True) + assert c[0] == "ok" and c[1] == -(2 ** 62) # wrapped, as the interpreter does + + +def test_readonly_buffer(built): + # a reader works on a read-only numpy array without a deopt + differential(built.module, "sum_nf", lambda: (readonly(F),)) + # a storing function deopts (no writable export), and the interpreted function raises + c = differential(built.module, "scale_nf", lambda: (readonly(F), 2.0), expect_deopt=True) + assert c[0] == "raise" and c[1] is ValueError + + +def test_wrong_arity_and_keywords_still_reach_cpython(built): + assert_compiled(built, "sum_nf") + with pytest.raises(TypeError): + built.module.sum_nf() + with pytest.raises(TypeError): + built.module.sum_nf(NF(), NF()) + assert built.module.sum_nf(x=NF()) == sum(F) + + +# --- the export is released on every exit ---------------------------------------------------------- + +def can_resize(a): + try: + a.append(0.0) + except BufferError: + return False + return True + + +def test_export_released_after_ok_error_and_deopt(built): + m = built.module + a = AD() + m.sum_ad(a) + assert can_resize(a) # ok exit + a = AD() + with pytest.raises(IndexError): + m.get_ad(a, 99) + assert can_resize(a) # error exit, entry through the impl + a = AD() + with pytest.raises(IndexError): + m.set_ad(a, 99, 1.0) + assert can_resize(a) + q = array.array("q", [2 ** 62, 2 ** 62, 2 ** 62]) + before = deopts(m) + assert m.sum_aq(q) == 3 * 2 ** 62 # overflow of i64: deopt after entry + assert deopts(m) == before + 1 + q.append(1) # released before the interpreted redo + q = array.array("q", [1, 2]) + d = array.array("d", [1.0]) + before = deopts(m) + m.sum_aq(d) # export taken, then the format check fails + assert deopts(m) == before + 1 + assert can_resize(d) + s = SubArray("d", F) + m.sum_ad(s) # type guard fails before any export + assert can_resize(s) + dst, src = AD(), AD() + with pytest.raises(IndexError): + m.copy_ad(dst, AD([1.0])) # the second export is held at the error + assert can_resize(dst) + dst, src = AD(), AD() + m.copy_ad(dst, src) + assert can_resize(dst) and can_resize(src) + + +def test_numpy_export_is_released(built): + assert_compiled(built, "sum_nf", "get_nf", "sum_ni") + m = built.module + x = NF() + base = sys.getrefcount(x) + m.sum_nf(x) + assert sys.getrefcount(x) == base + with pytest.raises(IndexError): + m.get_nf(x, 99) + assert sys.getrefcount(x) == base + m.sum_ni(x) # dtype mismatch: export taken then dropped + assert sys.getrefcount(x) == base + + +def exports_leak_after_error(out, c): + so = cbuild.build(c, "bufmod", out) + mod = load_so(so) + a = AD() + with pytest.raises(IndexError): + mod.get_ad(a, 99) + return not can_resize(a) + + +def test_mutation_dropping_release_on_the_error_path_is_caught(built, tmp_path): + # the unmutated module releases (the assertion of the previous test); a build whose error path + # does not call tp_buf_exit leaks the export, and the same observation shows it + start = built.c.index("tp_error:", built.c.index("static PyObject *tp_wrap_get_ad(")) + end = built.c.index("tp_deopt:", start) + mutated = built.c[:start] + built.c[start:end].replace("tp_buf_exit(", "tp_buf_leak(") \ + + built.c[end:] + assert mutated != built.c + mutated = mutated.replace( + "static const char tp_source[]", + "static inline void tp_buf_leak(tp_buf *b) { (void)b; }\nstatic const char tp_source[]", 1) + assert exports_leak_after_error(tmp_path / "mutated", mutated) + assert not exports_leak_after_error(tmp_path / "clean", built.c) + + +def test_plain_switch_builds_and_agrees(built, tmp_path): + # -DTP_BUFFER_PLAIN: plain loads and stores instead of relaxed atomics (measurement only) + assert "TP_BUFFER_PLAIN" in (cbuild.RUNTIME_DIR / "tp_runtime.h").read_text() + so = cbuild.build(built.c, "bufmod", tmp_path / "plain", + flags=list(cbuild.DEFAULT_FLAGS) + ["-DTP_BUFFER_PLAIN"]) + m = load_so(so) + x = NF() + m.scale_nf(x, 2.0) + assert x.tolist() == [v * 2 for v in F] + assert m.sum_ni(NI()) == sum(I) + + +def test_atomic_access_is_the_default(built): + text = (cbuild.RUNTIME_DIR / "tp_runtime.h").read_text() + assert "#ifdef TP_BUFFER_PLAIN" in text + plain, _, atomic = text.partition("#ifdef TP_BUFFER_PLAIN")[2].partition("#else") + assert "__atomic_load_n" in atomic.split("#endif")[0] and "__atomic" not in plain + # the generated code reaches the elements only through the helpers + start = built.c.index("static int tp_impl_scale_nf(", built.c.index("static const char tp_source")) + while not built.c[built.c.index(")", start) + 1:].startswith("\n{"): + start = built.c.index("static int tp_impl_scale_nf(", start + 1) + body = built.c[start:built.c.index("\n}\n", start)] + assert "tp_buf_ld_f64(" in body and "tp_buf_st_f64(" in body and "->data[" not in body + + +# --- closedness (#203) ---------------------------------------------------------------------------- + +def test_function_that_would_run_user_code_stays_interpreted(built): + assert_compiled(built, "sum_nf") + class O: + v = 1.0 + x = NF([2.0]) + assert built.module.open_nf(x, O()) == 3.0 + assert "open_nf" not in {f.name for f in built.proved.functions} + + +# --- a numpy nbody-like kernel --------------------------------------------------------------------- + +NBODY = textwrap.dedent(''' + # typedpython: compiled + import math + import numpy as np + import numpy.typing as npt + + SOLAR_MASS: float = 39.47841760435743 + + + def advance_nodiv(dt: float, steps: int, x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], vx: npt.NDArray[np.float64], + vy: npt.NDArray[np.float64], vz: npt.NDArray[np.float64], + mass: npt.NDArray[np.float64]) -> None: + for _ in range(steps): + for i in range(5): + for j in range(i + 1, 5): + dx: float = x[i] - x[j] + dy: float = y[i] - y[j] + dz: float = z[i] - z[j] + d2: float = dx * dx + dy * dy + dz * dz + mag: float = dt * math.sqrt(d2) + vx[i] -= dx * mass[j] * mag + vy[i] -= dy * mass[j] * mag + vz[i] -= dz * mass[j] * mag + vx[j] += dx * mass[i] * mag + vy[j] += dy * mass[i] * mag + vz[j] += dz * mass[i] * mag + for i in range(5): + x[i] += dt * vx[i] + y[i] += dt * vy[i] + z[i] += dt * vz[i] + + + def advance(dt: float, steps: int, x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], vx: npt.NDArray[np.float64], + vy: npt.NDArray[np.float64], vz: npt.NDArray[np.float64], + mass: npt.NDArray[np.float64]) -> None: + for _ in range(steps): + for i in range(5): + for j in range(i + 1, 5): + dx: float = x[i] - x[j] + dy: float = y[i] - y[j] + dz: float = z[i] - z[j] + d2: float = dx * dx + dy * dy + dz * dz + mag: float = dt / (d2 * math.sqrt(d2)) + vx[i] -= dx * mass[j] * mag + + + def energy(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], + vx: npt.NDArray[np.float64], vy: npt.NDArray[np.float64], + vz: npt.NDArray[np.float64], mass: npt.NDArray[np.float64]) -> float: + e: float = 0.0 + for i in range(5): + e += 0.5 * mass[i] * (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i]) + return e +''') + + +def test_nbody_like_numpy_kernels(tmp_path): + path, proved, diags, c = build_source(tmp_path, NBODY, "nbodynp") + by = {f.name: f for f in proved.functions} + assert {"advance_nodiv", "energy"} <= set(by), proved.skipped + nbuf = {n: sum(getattr(p, "buffer", None) is not None for p in f.params) for n, f in by.items()} + assert nbuf == {"advance_nodiv": 7, "energy": 7} + # the real nbody divides by a non-constant: numpy would give inf where Python raises + assert "advance" in proved.skipped and "numpy" in proved.skipped["advance"] + so = cbuild.build(c, "nbodynp", tmp_path / "out") + m = load_so(so) + + def bodies(): + rng = np.random.default_rng(1) + return [rng.random(5) for _ in range(7)] + + ca, ia = bodies(), bodies() + before = deopts(m) + m.advance_nodiv(0.01, 20, *ca) + interp(m, "advance_nodiv")(0.01, 20, *ia) + assert deopts(m) == before + for p, q in zip(ca, ia): + assert p.tolist() == q.tolist() + assert m.energy(*ca) == interp(m, "energy")(*ia) + + +# --- verifier rules, on hand-built IR (the verifier never trusts the front end) ------------------- + +def test_verifier_numpy_division_rule(): + from test_verify import F64 as VF64, F64A as VF64A, assert_accepted, assert_rejected, fn, lf + from typedpython.ir import ArrayParam, BinOp, BinOpKind, Const, Index, Param, Return + + def divide(buffer, divisor, op=BinOpKind.TRUEDIV): + return fn("f", params=[ArrayParam("a", VF64A, False, buffer), Param("d", VF64)], returns=VF64, + pure=True, body=[Return(BinOp(VF64, op, Index(VF64, "a", Const(Type_I64, 0)), + divisor))]) + + from typedpython.ir import Type + Type_I64 = Type.I64 + assert_rejected("verify/buffer", divide("numpy", lf("d"))) # variable divisor + assert_rejected("verify/buffer", divide("numpy", Const(VF64, 0.0))) # zero + assert_rejected("verify/buffer", divide("numpy", Const(VF64, 2.0), BinOpKind.FLOORDIV)) + assert_rejected("verify/buffer", divide("numpy", Const(VF64, 2.0), BinOpKind.MOD)) + assert_accepted(divide("numpy", Const(VF64, 2.0))) # nonzero Const + assert_accepted(divide("array", lf("d"))) # array.array elements are Python floats + assert_accepted(divide(None, lf("d"))) # a list too + + +def test_verifier_buffer_kind_and_copy_rules(): + from test_verify import F64 as VF64, F64A as VF64A, assert_accepted, assert_rejected, fn + from typedpython.ir import ArrayParam, Assign, CopyArray, Index, Return, Const, Type + + assert_rejected("verify/buffer", fn("f", params=[ArrayParam("a", VF64A, False, "memoryview")], + returns=VF64, pure=True, + body=[Return(Index(VF64, "a", Const(Type.I64, 0)))])) + copy = fn("f", params=[ArrayParam("a", VF64A, False, "numpy")], returns=VF64, pure=True, + locals_={"b": VF64A}, + body=[Assign("b", CopyArray(VF64A, "a")), Return(Index(VF64, "b", Const(Type.I64, 0)))]) + assert_rejected("verify/buffer", copy) + + +def test_verifier_buffer_params_are_not_limited_but_lists_still_are(): + from test_verify import F64 as VF64, F64A as VF64A, assert_accepted, assert_rejected, fn + from typedpython.ir import ArrayParam, Index, Return, Const, Type + + def many(n, buffer): + return fn("f", params=[ArrayParam(f"a{k}", VF64A, False, buffer) for k in range(n)], + returns=VF64, pure=True, + body=[Return(Index(VF64, "a0", Const(Type.I64, 0)))]) + + assert_accepted(many(5, "array")) + assert_accepted(many(3, "numpy")) + assert_rejected("verify/structure", many(3, None)) From 4b229543cdae7b7b188831b876ad3aba72d954cd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:27:16 +0900 Subject: [PATCH 2/4] Fix: Drop the numpy buffer kind; array.array only (#149, numpy is #220) --- .../src/main/python/typedpython/cgen.py | 26 +- .../src/main/python/typedpython/frontend.py | 27 +- .../src/main/python/typedpython/ir.py | 8 +- .../main/python/typedpython/runtime/API.md | 37 +- .../python/typedpython/runtime/tp_runtime.h | 31 +- .../src/main/python/typedpython/verify.py | 22 +- .../src/test/python/test_buffer_arrays.py | 507 +++++------------- 7 files changed, 181 insertions(+), 477 deletions(-) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py index 86302b88..08bf18e0 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -37,8 +37,12 @@ (tp_runtime.h). Buffer arrays (issue #149, identity-safe part). A parameter annotated `array.array[float]`, -`array.array[int]`, `numpy.typing.NDArray[numpy.float64]` or `[numpy.int64]` is an `ArrayParam` with -`buffer` "array" or "numpy". `tp_wrap_f` does not copy it: after the scalar guards, `tp_buf_enter` +`array.array[int]` is an `ArrayParam` with `buffer` "array". numpy: not supported yet (#220): +a numpy element's arithmetic produces np.float64 / np.int64 / np.bool (compiled code returns float), +numpy scalar operations emit observable RuntimeWarnings and divide by zero without raising (compiled +double arithmetic does neither), and emulating that needs taint tracking of numpy-derived values, +numpy-scalar boxing and FP-exception-to-warning emulation. An `NDArray[...]` parameter is an ordinary +object parameter. `tp_wrap_f` does not copy it: after the scalar guards, `tp_buf_enter` checks the exact type (captured in `tp_state.buf_types` by the exec slot), takes one `Py_buffer` export (C-contiguous, 1-D, 8-byte items, format `d` / `q` / `l`, aligned, writable when the function stores) and any mismatch deopts before an effect. The export is released by `tp_buf_exit`, part of the @@ -166,7 +170,7 @@ class CGenError(Exception): _ARRAY_CT = {Type.F64_ARRAY: "tp_f64_array", Type.I64_ARRAY: "tp_i64_array"} # Buffer arrays (#149): `ArrayParam.buffer` -> the exact type the guard compares against (the index # into the module state's `buf_types`), and the format strings the export must carry. -_BUFFER_KINDS = {None: None, "array": 0, "numpy": 1} +_BUFFER_KINDS = {None: None, "array": 0} _BUFFER_FMT = {Type.F64_ARRAY: ('"d"', "NULL"), Type.I64_ARRAY: ('"q"', '"l"')} _BUFFER_SFX = {Type.F64_ARRAY: "f64", Type.I64_ARRAY: "i64"} _CT_ARRAY_REV = {"tp_f64_array": Type.F64_ARRAY, "tp_i64_array": Type.I64_ARRAY} @@ -585,7 +589,7 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No w(f" tp_class_rt cls_rt[{len(module.classes) + 1}]; /* version-tag / global guard state */") w(" int watch_id1; /* dict watcher id + 1 on the module dict; 0 = none */") w(" PyObject *breaker; /* `lambda: None`; calling it runs the eval breaker (#141) */") - w(" PyObject *buf_types[2]; /* strong: array.array, numpy.ndarray (buffer arrays, #149); NULL: not importable */") + w(" PyObject *buf_types[1]; /* strong: array.array (buffer arrays, #149); NULL: not importable */") w("} tp_state;") w("") w("static const char *const tp_name_strings[] = {") @@ -1354,7 +1358,7 @@ def ev_Len(self, e: ir.Len) -> str: return t def _buffer(self, name: str) -> str | None: - """`ArrayParam.buffer` of an array parameter ("array" / "numpy"), None for a list or local.""" + """`ArrayParam.buffer` of an array parameter ("array"), None for a list or local.""" a = self.arrays.get(name) return a.buffer if a is not None else None @@ -1367,7 +1371,7 @@ def _slot(self, at: Type, ptr: str, index: ir.Expr, proven: bool, store: int, return f"(Py_ssize_t)({i})" slot = self.tmp("Py_ssize_t") if buffer: - self.check(f"tp_buf_slot_{buffer}({ptr}, {i}, {store}, &{slot})") + self.check(f"tp_buf_slot({ptr}, {i}, {store}, &{slot})") else: self.check(f"{_ARRAY_PFX[at]}_slot({ptr}, {i}, {store}, &{slot})") return slot @@ -1920,7 +1924,7 @@ def _state_functions(mg: _ModGen) -> str: " size_t i;", " if (tp_st == NULL) return 0;", " Py_VISIT(tp_st->breaker);", - " for (i = 0; i < 2; i++) Py_VISIT(tp_st->buf_types[i]);", + " for (i = 0; i < 1; i++) Py_VISIT(tp_st->buf_types[i]);", " for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) Py_VISIT(tp_st->names[i]);", " for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) Py_VISIT(tp_st->kwnames[i]);", " for (i = 0; i < sizeof(tp_st->classes) / sizeof(tp_st->classes[0]); i++) Py_VISIT(tp_st->classes[i]);", @@ -1936,7 +1940,7 @@ def _state_functions(mg: _ModGen) -> str: " if (tp_st == NULL) return 0;", " if (tp_st->watch_id1 > 0) tp_globals_unwatch(PyModule_GetDict(tp_module), &tp_st->watch_id1);", " tp_release(&tp_st->breaker);", - " for (i = 0; i < 2; i++) tp_release(&tp_st->buf_types[i]);", + " for (i = 0; i < 1; i++) tp_release(&tp_st->buf_types[i]);", " for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) tp_release(&tp_st->names[i]);", " for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) tp_release(&tp_st->kwnames[i]);", " for (i = 0; i < sizeof(tp_st->cls_ok) / sizeof(tp_st->cls_ok[0]); i++) tp_st->cls_ok[i] = 0;", @@ -2034,10 +2038,8 @@ def _exec_function(mg: _ModGen) -> str: if isinstance(p, ir.ArrayParam) and p.buffer} # buffer arrays (#149): the exact types the entry guards compare against, taken after the # module source ran (so its own imports are done). Not importable: NULL, and every guard deopts. - for kind, (mod, attr) in (("array", ("array", "array")), ("numpy", ("numpy", "ndarray"))): - if kind in kinds: - L.append(f" if (tp_import_attr({c_string(mod)}, {c_string(attr)}, " - f"&tp_st->buf_types[{_BUFFER_KINDS[kind]}]) < 0) goto tp_done;") + if "array" in kinds: + L.append(' if (tp_import_attr("array", "array", &tp_st->buf_types[0]) < 0) goto tp_done;') L += [ " tp_table = PyDict_New();", " if (tp_table == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_interpreted__\", tp_table) < 0) goto tp_done;", diff --git a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py index cf320f27..5e42ddbd 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py @@ -134,7 +134,7 @@ class _ModuleInfo: scalar_globals: dict[str, Type] = field(default_factory=dict) # entry-global candidates classes: dict[str, ir.ClassDecl] = field(default_factory=dict) # compiled fixed-layout classes # Top-level `import x [as y]` / `from m import n [as k]` names bound exactly once in the module: - # local name -> dotted qualified name (`np` -> `numpy`, `NDArray` -> `numpy.typing.NDArray`). + # local name -> dotted qualified name (`a` -> `array`, `array` -> `array.array`). imports: dict[str, str] = field(default_factory=dict) def bound(self, name: str) -> bool: @@ -255,7 +255,7 @@ def _import_names(tree: ast.Module, bindings: dict[str, list[tuple[int, str]]]) def _qualified(node: ast.expr, info: _ModuleInfo) -> str | None: - """`np.float64` -> "numpy.float64" through the module's own imports, else None.""" + """`a.b` -> "array.b" through the module's own imports, else None.""" parts: list[str] = [] while isinstance(node, ast.Attribute): parts.append(node.attr) @@ -265,26 +265,19 @@ def _qualified(node: ast.expr, info: _ModuleInfo) -> str | None: return ".".join([info.imports[node.id], *reversed(parts)]) -_BUFFER_ELEMENTS = {"numpy.float64": Type.F64_ARRAY, "numpy.int64": Type.I64_ARRAY} - - def _buffer_kind(annotation: ast.expr, info: _ModuleInfo) -> tuple[Type, str] | None: """The buffer-array reading of an annotation (#149), else None (an ordinary object parameter): `array.array[float]` / `array.array[int]` (any spelling that resolves, through this module's - own imports, to `array.array`), and `numpy.typing.NDArray[numpy.float64]` / `[numpy.int64]`. - A bare `array.array`, `np.ndarray`, another dtype, or a name bound more than once is not one.""" + own imports, to `array.array`). A bare `array.array`, another element type, a numpy + `NDArray[...]` (not supported: its element arithmetic is numpy's), or a name bound more than + once is not one.""" if not isinstance(annotation, ast.Subscript): return None base = _qualified(annotation.value, info) arg = annotation.slice - if base == "array.array": - if isinstance(arg, ast.Name) and arg.id in ("float", "int") and not info.bound(arg.id): - return ARRAY_KIND[f"list[{arg.id}]"], "array" - return None - if base == "numpy.typing.NDArray": - element = _qualified(arg, info) - if element in _BUFFER_ELEMENTS: - return _BUFFER_ELEMENTS[element], "numpy" + if base == "array.array" and isinstance(arg, ast.Name) and arg.id in ("float", "int") \ + and not info.bound(arg.id): + return ARRAY_KIND[f"list[{arg.id}]"], "array" return None @@ -835,8 +828,8 @@ def _find_local_arrays(self) -> dict[str, Type]: for stmt, kind, value in cs: if kind == "copy" and value.value.id in buffers: # type: ignore[attr-defined] raise Skip(stmt.lineno, f"`{_src(value)}` slices buffer parameter " - f"`{value.value.id}`: a numpy slice is a view, " # type: ignore[attr-defined] - "not a native copy (a buffer array is never copied)") + f"`{value.value.id}`: a buffer array is never copied " # type: ignore[attr-defined] + "(`[:]` would be a native copy of a list)") known = set(self.arrays) | {n for n, cs in creations.items() if any(k != "copy" for _, k, _ in cs)} while True: diff --git a/python-multiplatform-ksp/src/main/python/typedpython/ir.py b/python-multiplatform-ksp/src/main/python/typedpython/ir.py index 39ee40af..97992cd1 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/ir.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/ir.py @@ -553,16 +553,16 @@ class ArrayParam: name: str type: Type # F64_ARRAY or I64_ARRAY stored: bool # whether the body stores into it (write-back needed) - # Buffer array (#149): None for a `list`. "array" (an exact `array.array`) or "numpy" (an exact - # `numpy.ndarray`) means the argument is NOT copied: the entry guard takes a Py_buffer export + # Buffer array (#149): None for a `list`. "array" (an exact `array.array`) means + # the argument is NOT copied: the entry guard takes a Py_buffer export # (exact type, 1-D, C-contiguous, aligned 8-byte items, format `d` for F64_ARRAY and `q` or `l` # for I64_ARRAY, writable when `stored`), holds it until the function returns on every exit, and # every Index/StoreIndex is one relaxed atomic 8-byte load/store on the buffer memory, no # copy-in, no write-back, no critical section, no aliasing guard (two buffers may share memory # and reads then see writes in program order, as in the interpreter). `len` is the export's # shape[0], fixed for the call because the export forbids resizing. Closedness (#203) applies - # unchanged; in a function with a "numpy" buffer a division or modulo whose divisor is not a - # nonzero Const is rejected (an np.float64 / np.int64 element divides by zero without raising). + # unchanged. numpy.ndarray is not a buffer kind (its element arithmetic is numpy's: np.float64 + # results, RuntimeWarnings, no ZeroDivisionError); an ndarray parameter is an ordinary object. # Every other rule above for lists (stored, array-open, Index/StoreIndex/Len only) holds too. buffer: str | None = 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 e9acb0ba..aa7ead08 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -88,15 +88,13 @@ loop does not poll (below), so it delays other threads' stop-the-world requests 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. -## Buffer arrays (issue #149; `ArrayParam.buffer` = "array" | "numpy") -An exact `array.array` (`array.array[float]` / `array.array[int]`) or `numpy.ndarray` -(`numpy.typing.NDArray[numpy.float64]` / `[numpy.int64]`; the names must resolve through the module's -own top-level imports) argument is not copied. +## Buffer arrays (issue #149; `ArrayParam.buffer` = "array") +An exact `array.array` argument (`array.array[float]` / `array.array[int]`; the names must resolve +through the module's own top-level imports) is not copied. typedef struct { Py_buffer view; Py_ssize_t len; void *data; int held; } tp_buf; int tp_buf_enter(PyObject *obj, PyObject *exact, const char *fmt1, const char *fmt2, int writable, tp_buf *b); void tp_buf_exit(tp_buf *b); /* PyBuffer_Release if held; zeroes b; idempotent */ - int tp_buf_slot_array(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot); /* 0 / -1, array.array's IndexError text */ - int tp_buf_slot_numpy(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot); /* 0 / -1, numpy's IndexError text */ + int tp_buf_slot(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot); /* 0 / -1, array.array's IndexError text */ double tp_buf_ld_f64(const tp_buf *b, Py_ssize_t i); void tp_buf_st_f64(const tp_buf *b, Py_ssize_t i, double v); int64_t tp_buf_ld_i64(const tp_buf *b, Py_ssize_t i); void tp_buf_st_i64(const tp_buf *b, Py_ssize_t i, int64_t v); int tp_import_attr(const char *mod, const char *attr, PyObject **out); /* exec slot: new ref or NULL (cleared); -1 MemoryError */ @@ -115,23 +113,20 @@ Rules that go with it (verifier / front end): * Closed (#203) like a list: a function with a buffer runs no user code (`verify/array-open`). * No limit on the number of buffer parameters (no critical section); lists keep the limit of two. A buffer and a list cannot alias (different types). Two buffers may share memory (the same array - twice, two numpy views): per-element access makes reads see earlier writes in program order, as in + passed twice): per-element access makes reads see earlier writes in program order, as in the interpreter, which a copy-in could not. -* `y = x[:]` of a buffer is not a native copy (a numpy slice is a view): the function stays interpreted. -* numpy only: a `/`, `//` or `%` whose divisor is not a nonzero Const (and any float `//` or `%`) - is rejected (`verify/buffer`): an `np.float64` / `np.int64` element divides by zero without - raising, the compiled Python-float code would raise `ZeroDivisionError`. `array.array` elements - are Python floats and ints, so no such rule applies. -* The accumulator of a numpy loop needs an annotation (`s: float = 0.0`): Pyrefly types `s += x[i]` - with the numpy scalar type, which the front end does not know. -* Known, documented difference: a scalar read from a numpy buffer and returned is a Python `float` / - `int`, where the interpreter returns `np.float64` / `np.int64`. Integer arithmetic that overflows - i64 deopts to the interpreter in a pure function, which then wraps as numpy does. +* `y = x[:]` of a buffer is not a native copy: the function stays interpreted. +* numpy: not supported yet (#220). An `NDArray[...]` / `np.ndarray` parameter is an ordinary object + parameter. Reasons: (1) arithmetic on numpy elements produces `np.float64`, `np.int64` and + `np.bool` in the interpreter (a loop `s += v` returns `np.float64`), compiled code returns `float`; + (2) numpy scalar operations emit `RuntimeWarning`s ("overflow encountered in scalar multiply", + "invalid value encountered in scalar add") and divide by zero warns instead of raising, which + compiled double arithmetic does neither, and warnings are observable; (3) emulating this needs taint + tracking of numpy-derived values, numpy-scalar boxing and FP-exception-to-warning emulation. * OPEN POINT (decision pending in #149), deliberately not decided here and not tested: holding the export makes another thread's `append` / `resize` / `extend` on the same `array.array` raise `BufferError` for the duration of the call; what a concurrent resize should observe instead is not - settled. for an ndarray the export keeps the array object (and so its memory) alive; whether numpy's - own resize is refused during an export was not examined. + settled. * Free-threaded: no critical section; every element access is an atomic, so a concurrent writer of the same element yields one whole value, never a torn one. A function holding a buffer does not poll the eval breaker (same liveness note as lists, #152). @@ -317,8 +312,8 @@ static or module-state field must be added here): | `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 | -| `buf_types` (array.array, numpy.ndarray) | module state | immutable after init | written by `tp_exec` (and `tp_clear`); read-only type pointers compared by identity | -| buffer arguments of array parameters | caller's array.array / ndarray memory | atomic | relaxed 8-byte `__atomic_load_n` / `__atomic_store_n` per element; the Py_buffer export is held for the call (open point on concurrent resize, #149) | +| `buf_types` (array.array) | module state | immutable after init | written by `tp_exec` (and `tp_clear`); read-only type pointers compared by identity | +| buffer arguments of array parameters | caller's array.array memory | atomic | relaxed 8-byte `__atomic_load_n` / `__atomic_store_n` per element; the Py_buffer export is held for the call (open point on concurrent resize, #149) | | 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 | 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 e1c0e71e..e78cb8e5 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 @@ -866,10 +866,10 @@ static inline int tp_i64_array_exit(tp_i64_array *a) } /* ------------------------------------------------------------------------------------------ - * Buffer arrays (issue #149; ir.ArrayParam.buffer): an exact array.array or numpy.ndarray argument + * Buffer arrays (issue #149; ir.ArrayParam.buffer): an exact array.array argument * is NOT copied. tp_buf_enter takes one Py_buffer export and tp_buf_exit releases it; the wrapper * calls exit on every path (ok, error, deopt), so the export, which forbids a resize of the - * array.array and pins the memory of the ndarray, is held exactly as long as the compiled call. + * array.array, is held exactly as long as the compiled call. * * enter(obj, exact, fmt1, fmt2, writable, b): rc 0 = held (b->data, b->len set); rc 1 = deopt, b * is zeroed and nothing is held, nothing changed, no exception left; rc -1 = MemoryError set. @@ -881,8 +881,7 @@ static inline int tp_i64_array_exit(tp_i64_array *a) * an 8-byte atomic). An empty buffer is held too (len 0, no element is ever touched). * * Element access (tp_buf_ld_* / tp_buf_st_*): one relaxed atomic 8-byte load or store of the bit - * pattern on the buffer memory, bit-cast with memcpy. Another thread (or a numpy view of the same - * memory) may write the same element concurrently: that is a data race in the Python program, and + * pattern on the buffer memory, bit-cast with memcpy. Another thread may write the same element concurrently: that is a data race in the program, and * the atomic makes it a torn-value-free one (an element is read or written whole), the closest a * compiled loop gets to what the interpreter's own element read/write does. -DTP_BUFFER_PLAIN * replaces the atomics with plain loads and stores; it exists only to measure what the atomics @@ -953,9 +952,8 @@ static inline int tp_buf_enter(PyObject *obj, PyObject *exact, const char *fmt1, } /* index: a negative index counts from the end; out of range -> IndexError with the text CPython's - * own container uses (array.array: "array index out of range"; numpy: "index %zd is out of bounds - * for axis 0 with size %zd") */ -static inline int tp_buf_slot_array(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot) + * own array.array uses ("array index out of range" / "array assignment index out of range") */ +static inline int tp_buf_slot(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot) { if (i < 0) { i += (int64_t)b->len; @@ -972,25 +970,6 @@ static inline int tp_buf_slot_array(const tp_buf *b, int64_t i, int store, Py_ss return -1; } -static inline int tp_buf_slot_numpy(const tp_buf *b, int64_t i, int store, Py_ssize_t *slot) -{ - int64_t j = i; - (void)store; - if (j < 0) { - j += (int64_t)b->len; - if (j < 0) goto out_of_range; - } - else if (j >= (int64_t)b->len) { - goto out_of_range; - } - *slot = (Py_ssize_t)j; - return 0; -out_of_range: - PyErr_Format(PyExc_IndexError, "index %lld is out of bounds for axis 0 with size %zd", - (long long)i, b->len); - return -1; -} - #ifdef TP_BUFFER_PLAIN static inline double tp_buf_ld_f64(const tp_buf *b, Py_ssize_t i) { return ((const double *)b->data)[i]; } static inline int64_t tp_buf_ld_i64(const tp_buf *b, Py_ssize_t i) { return ((const int64_t *)b->data)[i]; } diff --git a/python-multiplatform-ksp/src/main/python/typedpython/verify.py b/python-multiplatform-ksp/src/main/python/typedpython/verify.py index 7e378da9..ec71758a 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/verify.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/verify.py @@ -45,11 +45,8 @@ verify/redo `Call.redo` where it is not legal (see below) verify/entry-global-open a function with entry_globals is not closed verify/array-open a function with an ArrayParam runs user code (#203) - verify/buffer a malformed `ArrayParam.buffer`, a CopyArray of a buffer array, or, in a function with a "numpy" - buffer array, a `/`, `//` or `%` whose divisor is not a nonzero Const - (and any F64 `//` or `%`): an np.float64 / np.int64 element divides - by zero without raising, so the compiled Python-float code would - differ (#149) + verify/buffer a malformed `ArrayParam.buffer` (only None and "array"), or a + CopyArray of a buffer array (#149) verify/class a class named by FieldGet/FieldSet/New/IsExact/CheckExact/Param.cls is not a (valid) ClassDecl of the module, a field is not one of its fields, a New names a class without trivial_init or has the wrong arity, or a @@ -271,7 +268,6 @@ class Diagnostic: _FULL = (_I64_MIN, _I64_MAX) _LEN_MAX = 2 ** 62 _PROVABLE_OPS = frozenset({BinOpKind.ADD, BinOpKind.SUB, BinOpKind.MUL}) -_DIVISIONS = frozenset({BinOpKind.TRUEDIV, BinOpKind.FLOORDIV, BinOpKind.MOD}) _POW2_OPS = frozenset({BinOpKind.FLOORDIV, BinOpKind.MOD}) # `proven` means shift / mask _NEGATE = {CompareKind.EQ: CompareKind.NE, CompareKind.NE: CompareKind.EQ, CompareKind.LT: CompareKind.GE, CompareKind.GE: CompareKind.LT, @@ -335,7 +331,6 @@ def __init__(self, function: Function, signatures: dict[str, Function], self.loops: list[_Loop] = [] self.scalars: dict[str, Type] = {} # name -> type of every scalar/OBJ variable self.arrays: dict[str, Type] = {} # name -> array type of every ArrayParam - self.numpy = False # some ArrayParam is a numpy buffer (#149) self.buffers: set[str] = set() # names of every buffer ArrayParam self.larrays: dict[str, Type] = {} # name -> array type of every local array self.assigns: dict[str, int] = {} # local array -> number of Assign statements to it @@ -360,11 +355,10 @@ def declarations(self) -> frozenset[str]: self.error(TYPE, f"array parameter '{p.name}' has non-array type {p.type}") elif not isinstance(p.stored, bool): self.error(TYPE, f"array parameter '{p.name}' has a non-bool `stored`") - elif p.buffer not in (None, "array", "numpy"): + elif p.buffer not in (None, "array"): self.error(BUFFER, f"array parameter '{p.name}' has buffer kind {p.buffer!r}") else: self.arrays[p.name] = p.type - self.numpy = self.numpy or p.buffer == "numpy" if p.buffer: self.buffers.add(p.name) elif type(p) is Param: @@ -735,14 +729,6 @@ def e_BinOp(self, e: BinOp, state: _State) -> ir.Expr: f"{e.type.value}") if lt is Type.I64 and e.op in _I64_DEOPT_OPS and not proven: self.can_deopt = True - if self.numpy and lt in (Type.I64, Type.F64) and e.op in _DIVISIONS: - nonzero = (type(right) is Const and type(right.value) in (int, float) - and right.value != 0) - if not nonzero or (lt is Type.F64 and e.op is not BinOpKind.TRUEDIV): - self.error(BUFFER, f"{lt.value} {e.op.value} in a function with a numpy buffer " - "array: an np.float64 / np.int64 element divides by zero " - "without raising, so only `/`, `//`, `%` by a nonzero " - "Const (`/` for floats) are compiled") if lt is Type.OBJ: self.effects.append(f"OBJ {e.op.value} (runs user methods)") self.opens.append(f"OBJ {e.op.value} (runs user methods)") @@ -1130,7 +1116,7 @@ def allocation(self, e: NewArray | CopyArray, target: str, state: _State) -> ir. self.error(ARRAY_USE, f"CopyArray of '{e.src}', which is not an array") elif e.src in self.buffers: self.error(BUFFER, f"CopyArray of buffer array '{e.src}': a buffer array is never " - "copied (a numpy slice is a view)") + "copied)") else: self.array(e.src, "copy", state) if src is not e.type: diff --git a/python-multiplatform-ksp/src/test/python/test_buffer_arrays.py b/python-multiplatform-ksp/src/test/python/test_buffer_arrays.py index 9fd610fb..b1d0246a 100644 --- a/python-multiplatform-ksp/src/test/python/test_buffer_arrays.py +++ b/python-multiplatform-ksp/src/test/python/test_buffer_arrays.py @@ -1,15 +1,17 @@ """Zero-copy buffer-protocol array parameters (issue #149, identity-safe part; SPEC N-8 / N-9). -A parameter annotated `array.array[float]`, `array.array[int]`, `npt.NDArray[np.float64]` or -`npt.NDArray[np.int64]` is a *buffer array*: the entry guard takes a `Py_buffer` export of the exact -type (1-D, C-contiguous, 8-byte items, format `d` or `q`/`l`, aligned, writable when the function -stores), holds it until the function returns on every exit, and every element access goes straight -to the buffer memory as one relaxed atomic 8-byte load or store. No copy-in, no write-back. +A parameter annotated `array.array[float]` or `array.array[int]` is a *buffer array*: the entry +guard takes a `Py_buffer` export of the exact type, holds it until the function returns on every +exit, and every element access goes straight to the buffer memory as one relaxed atomic 8-byte load +or store. No copy-in, no write-back. + +numpy is not supported (#220): an `NDArray[...]` parameter is an ordinary object parameter and the +function runs as the interpreter runs it (one test below pins that). Everything here is single-threaded and differential against the interpreted function (`__typedpython_interpreted__`): same result, same exception type and text, same final contents of every argument. Tests that a path was *compiled* compare `__typedpython_deopts__` before and after, -so a function that silently stayed interpreted fails them (that is what the red run shows). +so a function that silently stayed interpreted fails them. Not covered, on purpose: what a concurrent resize by another thread observes (open point of #149; API.md "Buffer arrays"). @@ -20,18 +22,16 @@ import dataclasses import importlib.machinery import importlib.util -import sys import textwrap import numpy as np import pytest -from typedpython import cbuild, cgen, frontend, verify +from typedpython import cbuild, cgen, frontend, ir, verify SOURCE = textwrap.dedent(''' # typedpython: compiled import array - import math import numpy as np import numpy.typing as npt @@ -50,20 +50,6 @@ def sum_aq(a: array.array[int]) -> int: return s - def sum_nf(x: npt.NDArray[np.float64]) -> float: - s: float = 0.0 - for i in range(len(x)): - s += x[i] - return s - - - def sum_ni(x: npt.NDArray[np.int64]) -> int: - s: int = 0 - for i in range(len(x)): - s += x[i] - return s - - def get_ad(a: array.array[float], i: int) -> float: return a[i] @@ -72,14 +58,6 @@ def get_aq(a: array.array[int], i: int) -> int: return a[i] - def get_nf(x: npt.NDArray[np.float64], i: int) -> float: - return x[i] - - - def get_ni(x: npt.NDArray[np.int64], i: int) -> int: - return x[i] - - def set_ad(a: array.array[float], i: int, v: float) -> None: a[i] = v @@ -88,121 +66,86 @@ def set_aq(a: array.array[int], i: int, v: int) -> None: a[i] = v - def set_nf(x: npt.NDArray[np.float64], i: int, v: float) -> None: - x[i] = v - - - def set_ni(x: npt.NDArray[np.int64], i: int, v: int) -> None: - x[i] = v - - - def length_nf(x: npt.NDArray[np.float64]) -> int: - return len(x) - - def length_ad(a: array.array[float]) -> int: return len(a) - def scale_nf(x: npt.NDArray[np.float64], k: float) -> None: - for i in range(len(x)): - x[i] = x[i] * k - - def scale_ad(a: array.array[float], k: float) -> None: for i in range(len(a)): a[i] = a[i] * k - def iota_ni(x: npt.NDArray[np.int64]) -> None: - for i in range(len(x)): - x[i] = i - - def iota_aq(a: array.array[int]) -> None: for i in range(len(a)): a[i] = i - def copy_nf(dst: npt.NDArray[np.float64], src: npt.NDArray[np.float64]) -> None: - for i in range(len(dst)): - dst[i] = src[i] - - def copy_ad(dst: array.array[float], src: array.array[float]) -> None: for i in range(len(dst)): dst[i] = src[i] - def cascade_nf(a: npt.NDArray[np.float64], b: npt.NDArray[np.float64]) -> None: + def cascade_ad(a: array.array[float], b: array.array[float]) -> None: for i in range(len(a)): a[i] = b[i] + 1.0 - def fill_n(x: npt.NDArray[np.float64], n: int) -> None: + def fill_n(a: array.array[float], n: int) -> None: for i in range(n): - x[i] = 1.0 + a[i] = 1.0 - def mixed(l: list[float], x: npt.NDArray[np.float64]) -> None: - for i in range(len(x)): - x[i] = l[i] + def mixed(l: list[float], a: array.array[float]) -> None: + for i in range(len(a)): + a[i] = l[i] - def three(a: npt.NDArray[np.float64], b: npt.NDArray[np.float64], c: npt.NDArray[np.float64], - d: array.array[float]) -> None: + def four(a: array.array[float], b: array.array[float], c: array.array[float], + d: array.array[float]) -> None: for i in range(len(a)): a[i] = b[i] + c[i] + d[i] - def half_nf(x: npt.NDArray[np.float64]) -> float: - return x[0] / 2.0 - - def div_ad(a: array.array[float]) -> float: return a[0] / a[1] - def div_nf(x: npt.NDArray[np.float64]) -> float: - return x[0] / x[1] + def open_ad(a: array.array[float], o: object) -> float: + return a[0] + float(o.v) - def open_nf(x: npt.NDArray[np.float64], o: object) -> float: - return x[0] + float(o.v) + def rebind_view(a: array.array[float]) -> None: + y = a[:] + y[0] = 5.0 - def rebind_view(x: npt.NDArray[np.float64]) -> None: - y = x[:] - y[0] = 5.0 + def nd_first(x: npt.NDArray[np.float64]) -> object: + return x[0] + + + def nd_sum(x: npt.NDArray[np.float64]) -> object: + s = 0.0 + for i in range(len(x)): + s += x[i] + return s ''') COMPILED = { - "sum_ad", "sum_aq", "sum_nf", "sum_ni", "get_ad", "get_aq", "get_nf", "get_ni", "set_ad", - "set_aq", "set_nf", "set_ni", "length_nf", "length_ad", "scale_nf", "scale_ad", "iota_ni", - "iota_aq", "copy_nf", "copy_ad", "cascade_nf", "fill_n", "mixed", "three", "half_nf", "div_ad", + "sum_ad", "sum_aq", "get_ad", "get_aq", "set_ad", "set_aq", "length_ad", "scale_ad", "iota_aq", + "copy_ad", "cascade_ad", "fill_n", "mixed", "four", "div_ad", } -SKIPPED = {"div_nf", "open_nf", "rebind_view"} - - -def lower_verify(path): - lowered = frontend.lower(path) - return verify.verify(lowered) +SKIPPED = {"open_ad", "rebind_view", "nd_first", "nd_sum"} def build_source(d, source, name="bufmod"): path = d / f"{name}.py" path.write_text(source) - proved, diags = lower_verify(path) - c = cgen.generate(proved, path, path.name) - return path, proved, diags, c + proved, diags = verify.verify(frontend.lower(path)) + return path, proved, diags, cgen.generate(proved, path, path.name) def load_so(so): - loader = importlib.machinery.ExtensionFileLoader(so.stem.split(".")[0], str(so)) - spec = importlib.util.spec_from_file_location(so.stem.split(".")[0], str(so), loader=loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module + return cbuild.load(so.stem.split(".")[0], so) @dataclasses.dataclass @@ -234,10 +177,6 @@ def interp(m, name): return m.__typedpython_interpreted__[name] -def snap(a): - return a.tolist() if hasattr(a, "tolist") else list(a) - - def outcome(fn, args): try: return ("ok", fn(*args)) @@ -246,9 +185,7 @@ def outcome(fn, args): def same_value(a, b): - if a == b: - return True - return a != a and b != b # nan + return a == b or (a != a and b != b) # nan def differential(m, name, make, expect_deopt=False): @@ -265,8 +202,8 @@ def differential(m, name, make, expect_deopt=False): else: assert c[1:] == i[1:], (c, i) for x, y in zip(ca, ia): - if hasattr(x, "tolist") or isinstance(x, (array.array, list)): - assert snap(x) == snap(y) or all(same_value(p, q) for p, q in zip(snap(x), snap(y))), (x, y) + if isinstance(x, (array.array, list)): + assert len(x) == len(y) and all(same_value(p, q) for p, q in zip(x, y)), (x, y) if expect_deopt: assert after == before + 1, "expected a deopt (an object-path call), got none" else: @@ -280,8 +217,6 @@ def differential(m, name, make, expect_deopt=False): def AD(v=F): return array.array("d", v) def AQ(v=I): return array.array("q", v) -def NF(v=F): return np.array(v, dtype=np.float64) -def NI(v=I): return np.array(v, dtype=np.int64) # --- what is compiled, and why the rest is not -------------------------------------------------- @@ -291,31 +226,25 @@ def test_compiled_set_and_skip_reasons(built): assert names >= COMPILED, sorted(COMPILED - names) for n in SKIPPED: assert n in built.proved.skipped, n - assert "verify/array-open" in built.proved.skipped["open_nf"] - assert "numpy" in built.proved.skipped["div_nf"] - assert "view" in built.proved.skipped["rebind_view"] + assert "verify/array-open" in built.proved.skipped["open_ad"] + assert "never copied" in built.proved.skipped["rebind_view"] def test_ir_shape_marks_buffer_params(built): by = {f.name: f for f in built.proved.functions} - p = by["sum_nf"].params[0] - assert (p.buffer, p.stored) == ("numpy", False) - assert by["sum_ad"].params[0].buffer == "array" - assert by["scale_nf"].params[0].stored is True - assert [q.buffer for q in by["mixed"].params if hasattr(q, "buffer")] == [None, "numpy"] + assert (by["sum_ad"].params[0].buffer, by["sum_ad"].params[0].stored) == ("array", False) + assert by["scale_ad"].params[0].stored is True + assert [q.buffer for q in by["mixed"].params] == [None, "array"] def test_no_limit_on_buffer_parameters(built): by = {f.name: f for f in built.proved.functions} - assert sum(getattr(p, "buffer", None) is not None for p in by["three"].params) == 4 - assert "tp_cs_begin" not in wrapper_text(built.c, "three") + assert sum(p.buffer is not None for p in by["four"].params) == 4 + assert "tp_cs_begin" not in wrapper_text(built.c, "four") n = 4 - x = [np.zeros(n) for _ in range(3)] - d = array.array("d", [1.0] * n) - x[1][:] = 2.0 - x[2][:] = 3.0 - built.module.three(x[0], x[1], x[2], d) - assert x[0].tolist() == [6.0] * n + a, b, c, d = (array.array("d", [float(k)] * n) for k in range(4)) + built.module.four(a, b, c, d) + assert a.tolist() == [6.0] * n def wrapper_text(c, name): @@ -323,15 +252,29 @@ def wrapper_text(c, name): return c[start:c.index("\n}\n", start)] +# --- numpy is not supported (#220): an ordinary object parameter ---------------------------------- + +def test_numpy_parameter_is_an_ordinary_object_and_keeps_numpy_results(built): + for f in built.proved.functions: + assert all(not isinstance(p, ir.ArrayParam) or p.buffer == "array" or p.buffer is None + for p in f.params) + assert not (f.name.startswith("nd_") and any(isinstance(p, ir.ArrayParam) for p in f.params)) + x = np.array(F, dtype=np.float64) + r = built.module.nd_first(x) + assert type(r) is np.float64 and r == F[0] + r = built.module.nd_sum(x) # never compiled: the module's plain function + ref = 0.0 + for v in x: + ref += v + assert type(r) is np.float64 and r == ref + + # --- reads, writes, len --------------------------------------------------------------------------- @pytest.mark.parametrize("name,make", [ ("sum_ad", lambda: (AD(),)), ("sum_aq", lambda: (AQ(),)), - ("sum_nf", lambda: (NF(),)), ("sum_ni", lambda: (NI(),)), - ("sum_ad", lambda: (AD([]),)), ("sum_nf", lambda: (NF([]),)), - ("sum_aq", lambda: (array.array("l", I),)), - ("sum_ni", lambda: (np.array(I, dtype=np.longlong),)), - ("length_nf", lambda: (NF(),)), ("length_ad", lambda: (AD([]),)), + ("sum_ad", lambda: (AD([]),)), ("sum_aq", lambda: (array.array("l", I),)), + ("length_ad", lambda: (AD(),)), ("length_ad", lambda: (AD([]),)), ]) def test_reads_and_len(built, name, make): differential(built.module, name, make) @@ -340,8 +283,6 @@ def test_reads_and_len(built, name, make): @pytest.mark.parametrize("name,make", [ ("get_ad", lambda: (AD(), 2)), ("get_ad", lambda: (AD(), -1)), ("get_ad", lambda: (AD(), -4)), ("get_aq", lambda: (AQ(), 3)), ("get_aq", lambda: (AQ(), -4)), - ("get_nf", lambda: (NF(), 0)), ("get_nf", lambda: (NF(), -2)), - ("get_ni", lambda: (NI(), 1)), ("get_ni", lambda: (NI(), -1)), ]) def test_index_and_negative_index(built, name, make): differential(built.module, name, make) @@ -349,10 +290,8 @@ def test_index_and_negative_index(built, name, make): @pytest.mark.parametrize("name,make", [ ("get_ad", lambda: (AD(), 4)), ("get_ad", lambda: (AD(), -5)), ("get_ad", lambda: (AD([]), 0)), - ("get_aq", lambda: (AQ(), 99)), ("get_nf", lambda: (NF(), 4)), ("get_nf", lambda: (NF(), -5)), - ("get_ni", lambda: (NI(), 7)), + ("get_aq", lambda: (AQ(), 99)), ("set_ad", lambda: (AD(), 4, 1.0)), ("set_aq", lambda: (AQ(), -5, 1)), - ("set_nf", lambda: (NF(), 4, 1.0)), ("set_ni", lambda: (NI(), -5, 1)), ]) def test_index_errors_match_cpython_type_and_text(built, name, make): c = differential(built.module, name, make) @@ -361,12 +300,9 @@ def test_index_errors_match_cpython_type_and_text(built, name, make): @pytest.mark.parametrize("name,make", [ ("set_ad", lambda: (AD(), 1, 9.5)), ("set_ad", lambda: (AD(), -1, 9.5)), - ("set_aq", lambda: (AQ(), 0, -77)), ("set_nf", lambda: (NF(), 3, 0.125)), - ("set_nf", lambda: (NF(), -4, 0.125)), ("set_ni", lambda: (NI(), 2, 2 ** 40)), - ("scale_ad", lambda: (AD(), 2.0)), ("scale_nf", lambda: (NF(), -0.5)), - ("iota_ni", lambda: (NI(),)), ("iota_aq", lambda: (AQ(),)), - ("scale_nf", lambda: (NF([]), 2.0)), - ("copy_nf", lambda: (NF(), NF([9.0, 8.0, 7.0, 6.0]))), + ("set_aq", lambda: (AQ(), 0, -77)), ("set_aq", lambda: (AQ(), 2, 2 ** 40)), + ("scale_ad", lambda: (AD(), 2.0)), ("scale_ad", lambda: (AD([]), 2.0)), + ("iota_aq", lambda: (AQ(),)), ("copy_ad", lambda: (AD(), AD([9.0, 8.0, 7.0, 6.0]))), ]) def test_writes_are_visible_to_the_caller(built, name, make): @@ -374,81 +310,56 @@ def test_writes_are_visible_to_the_caller(built, name, make): def test_a_write_really_lands_in_the_callers_memory(built): - assert_compiled(built, "set_nf", "scale_ad") - x = NF() - built.module.set_nf(x, 1, 42.0) - assert x[1] == 42.0 + assert_compiled(built, "set_ad", "scale_ad") a = AD() + built.module.set_ad(a, 1, 42.0) + assert a[1] == 42.0 built.module.scale_ad(a, 2.0) - assert a.tolist() == [3.0, -4.5, 6.0, 9.0] + assert a.tolist() == [3.0, 84.0, 6.0, 9.0] -def test_scalar_results_are_python_numbers(built): - # documented difference (API.md "Buffer arrays"): the compiled function returns a Python float - # or int where the interpreted one returns np.float64 / np.int64. - assert type(built.module.get_nf(NF(), 0)) is float - assert type(built.module.get_ni(NI(), 0)) is int - - -# --- exceptions mid-loop -------------------------------------------------------------------------- - def test_exception_mid_loop_keeps_earlier_writes(built): - differential(built.module, "fill_n", lambda: (NF([0.0] * 3), 4)) - differential(built.module, "fill_n", lambda: (NF([0.0] * 5), 4)) - x = NF([0.0] * 3) + differential(built.module, "fill_n", lambda: (AD([0.0] * 3), 4)) + differential(built.module, "fill_n", lambda: (AD([0.0] * 5), 4)) + a = AD([0.0] * 3) with pytest.raises(IndexError): - built.module.fill_n(x, 4) - assert x.tolist() == [1.0, 1.0, 1.0] + built.module.fill_n(a, 4) + assert a.tolist() == [1.0, 1.0, 1.0] -def test_division_semantics_that_numpy_changes(built): - # array.array elements are Python floats: ZeroDivisionError as interpreted +def test_array_array_elements_are_python_floats_division_raises(built): differential(built.module, "div_ad", lambda: (AD([1.0, 0.0]),)) differential(built.module, "div_ad", lambda: (AD([1.0, 4.0]),)) - # division by a nonzero constant is the same for np.float64 - differential(built.module, "half_nf", lambda: (NF([3.0]),)) # --- aliasing ------------------------------------------------------------------------------------- -def test_two_views_of_one_numpy_array_see_each_others_writes(built): - def make(): - base = np.zeros(8) - return (base[1:], base[:-1], base) - - ca, ia = make(), make() - before = deopts(built.module) - built.module.cascade_nf(ca[0], ca[1]) - interp(built.module, "cascade_nf")(ia[0], ia[1]) - assert deopts(built.module) == before - # a read sees the earlier write through the other view: base[k] = k (a copy-in would give - # [0, 1, 1, 1, ...]) - assert ca[2].tolist() == ia[2].tolist() == [float(k) for k in range(8)] - - def test_the_same_array_passed_twice(built): - assert_compiled(built, "copy_nf", "copy_ad") - x = NF() - before = deopts(built.module) - built.module.copy_nf(x, x) - assert deopts(built.module) == before - assert x.tolist() == F + assert_compiled(built, "copy_ad", "cascade_ad") a = AD() before = deopts(built.module) built.module.copy_ad(a, a) assert deopts(built.module) == before assert a.tolist() == F + # the same array as both operands: each read sees the program-order state + a = AD([0.0, 0.0, 0.0]) + differential(built.module, "cascade_ad", lambda: (lambda x: (x, x))(AD([1.0, 2.0, 3.0]))) + built.module.cascade_ad(a, a) + assert a.tolist() == [1.0, 1.0, 1.0] + a = AD([0.0, 0.0, 0.0]) + interp(built.module, "cascade_ad")(a, a) + assert a.tolist() == [1.0, 1.0, 1.0] def test_buffer_and_list_parameters_together(built): l = [10.0, 20.0, 30.0, 40.0] - x = NF([0.0] * 4) + a = AD([0.0] * 4) before = deopts(built.module) - built.module.mixed(l, x) + built.module.mixed(l, a) assert deopts(built.module) == before - assert x.tolist() == l and l == [10.0, 20.0, 30.0, 40.0] + assert a.tolist() == l and l == [10.0, 20.0, 30.0, 40.0] # an array in place of the list is a deopt (guard), not an alias - differential(built.module, "mixed", lambda: (AD([1.0] * 4), NF([0.0] * 4)), expect_deopt=True) + differential(built.module, "mixed", lambda: (AD([1.0] * 4), AD([0.0] * 4)), expect_deopt=True) # --- guards: anything else takes the object path -------------------------------------------------- @@ -457,69 +368,28 @@ class SubArray(array.array): pass -class SubNd(np.ndarray): - pass - - -def misaligned(): - raw = bytearray(8 * 5 + 1) - return np.frombuffer(raw, dtype=np.float64, count=5, offset=1) - - -def readonly(v): - x = NF(v) - x.flags.writeable = False - return x - - @pytest.mark.parametrize("name,make", [ - ("sum_nf", lambda: (np.array(F, dtype=np.float32),)), - ("sum_nf", lambda: (NF([1.0] * 8)[::2],)), - ("length_nf", lambda: (np.zeros((2, 2)),)), - ("sum_nf", lambda: (np.array(F, dtype=">f8"),)), - ("sum_nf", lambda: (np.array(I, dtype=np.int64),)), - ("sum_nf", lambda: (misaligned(),)), - ("sum_nf", lambda: (NF(F).view(SubNd),)), - ("sum_nf", lambda: (list(F),)), - ("sum_nf", lambda: (AD(),)), - ("sum_ni", lambda: (np.array(F, dtype=np.float64),)), - ("sum_ni", lambda: (np.array([3, 5, 7], dtype=np.uint64),)), - ("sum_ni", lambda: (np.array(I, dtype=np.int32),)), + ("sum_aq", lambda: (AD(),)), ("sum_aq", lambda: (array.array("Q", [1, 2]),)), ("sum_aq", lambda: (array.array("i", [1, 2]),)), ("sum_ad", lambda: (array.array("f", [1.0, 2.0]),)), ("sum_ad", lambda: (SubArray("d", F),)), - ("sum_ad", lambda: (NF(),)), + ("sum_ad", lambda: (list(F),)), + ("sum_ad", lambda: (np.array(F),)), + ("sum_ad", lambda: (memoryview(AD()),)), ("sum_ad", lambda: (None,)), ]) def test_wrong_kind_takes_the_object_path_and_is_still_correct(built, name, make): differential(built.module, name, make, expect_deopt=True) -def test_int64_overflow_in_a_numpy_sum_deopts_and_wraps_as_numpy(built): - import warnings - big = lambda: (np.array([2 ** 62, 2 ** 62, 2 ** 62], dtype=np.int64),) # noqa: E731 - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - c = differential(built.module, "sum_ni", big, expect_deopt=True) - assert c[0] == "ok" and c[1] == -(2 ** 62) # wrapped, as the interpreter does - - -def test_readonly_buffer(built): - # a reader works on a read-only numpy array without a deopt - differential(built.module, "sum_nf", lambda: (readonly(F),)) - # a storing function deopts (no writable export), and the interpreted function raises - c = differential(built.module, "scale_nf", lambda: (readonly(F), 2.0), expect_deopt=True) - assert c[0] == "raise" and c[1] is ValueError - - def test_wrong_arity_and_keywords_still_reach_cpython(built): - assert_compiled(built, "sum_nf") + assert_compiled(built, "sum_ad") with pytest.raises(TypeError): - built.module.sum_nf() + built.module.sum_ad() with pytest.raises(TypeError): - built.module.sum_nf(NF(), NF()) - assert built.module.sum_nf(x=NF()) == sum(F) + built.module.sum_ad(AD(), AD()) + assert built.module.sum_ad(a=AD()) == sum(F) # --- the export is released on every exit ---------------------------------------------------------- @@ -540,7 +410,7 @@ def test_export_released_after_ok_error_and_deopt(built): a = AD() with pytest.raises(IndexError): m.get_ad(a, 99) - assert can_resize(a) # error exit, entry through the impl + assert can_resize(a) # error exit a = AD() with pytest.raises(IndexError): m.set_ad(a, 99, 1.0) @@ -550,7 +420,6 @@ def test_export_released_after_ok_error_and_deopt(built): assert m.sum_aq(q) == 3 * 2 ** 62 # overflow of i64: deopt after entry assert deopts(m) == before + 1 q.append(1) # released before the interpreted redo - q = array.array("q", [1, 2]) d = array.array("d", [1.0]) before = deopts(m) m.sum_aq(d) # export taken, then the format check fails @@ -559,7 +428,7 @@ def test_export_released_after_ok_error_and_deopt(built): s = SubArray("d", F) m.sum_ad(s) # type guard fails before any export assert can_resize(s) - dst, src = AD(), AD() + dst = AD() with pytest.raises(IndexError): m.copy_ad(dst, AD([1.0])) # the second export is held at the error assert can_resize(dst) @@ -568,23 +437,8 @@ def test_export_released_after_ok_error_and_deopt(built): assert can_resize(dst) and can_resize(src) -def test_numpy_export_is_released(built): - assert_compiled(built, "sum_nf", "get_nf", "sum_ni") - m = built.module - x = NF() - base = sys.getrefcount(x) - m.sum_nf(x) - assert sys.getrefcount(x) == base - with pytest.raises(IndexError): - m.get_nf(x, 99) - assert sys.getrefcount(x) == base - m.sum_ni(x) # dtype mismatch: export taken then dropped - assert sys.getrefcount(x) == base - - def exports_leak_after_error(out, c): - so = cbuild.build(c, "bufmod", out) - mod = load_so(so) + mod = load_so(cbuild.build(c, "bufmod", out)) a = AD() with pytest.raises(IndexError): mod.get_ad(a, 99) @@ -592,8 +446,8 @@ def exports_leak_after_error(out, c): def test_mutation_dropping_release_on_the_error_path_is_caught(built, tmp_path): - # the unmutated module releases (the assertion of the previous test); a build whose error path - # does not call tp_buf_exit leaks the export, and the same observation shows it + # a build whose error path does not call tp_buf_exit leaks the export; the same observation + # as the release test shows it, and the unmutated build does not leak start = built.c.index("tp_error:", built.c.index("static PyObject *tp_wrap_get_ad(")) end = built.c.index("tp_deopt:", start) mutated = built.c[:start] + built.c[start:end].replace("tp_buf_exit(", "tp_buf_leak(") \ @@ -612,10 +466,10 @@ def test_plain_switch_builds_and_agrees(built, tmp_path): so = cbuild.build(built.c, "bufmod", tmp_path / "plain", flags=list(cbuild.DEFAULT_FLAGS) + ["-DTP_BUFFER_PLAIN"]) m = load_so(so) - x = NF() - m.scale_nf(x, 2.0) - assert x.tolist() == [v * 2 for v in F] - assert m.sum_ni(NI()) == sum(I) + a = AD() + m.scale_ad(a, 2.0) + assert a.tolist() == [v * 2 for v in F] + assert m.sum_aq(AQ()) == sum(I) def test_atomic_access_is_the_default(built): @@ -624,9 +478,9 @@ def test_atomic_access_is_the_default(built): plain, _, atomic = text.partition("#ifdef TP_BUFFER_PLAIN")[2].partition("#else") assert "__atomic_load_n" in atomic.split("#endif")[0] and "__atomic" not in plain # the generated code reaches the elements only through the helpers - start = built.c.index("static int tp_impl_scale_nf(", built.c.index("static const char tp_source")) + start = built.c.index("static int tp_impl_scale_ad(", built.c.index("static const char tp_source")) while not built.c[built.c.index(")", start) + 1:].startswith("\n{"): - start = built.c.index("static int tp_impl_scale_nf(", start + 1) + start = built.c.index("static int tp_impl_scale_ad(", start + 1) body = built.c[start:built.c.index("\n}\n", start)] assert "tp_buf_ld_f64(" in body and "tp_buf_st_f64(" in body and "->data[" not in body @@ -634,129 +488,25 @@ def test_atomic_access_is_the_default(built): # --- closedness (#203) ---------------------------------------------------------------------------- def test_function_that_would_run_user_code_stays_interpreted(built): - assert_compiled(built, "sum_nf") + assert_compiled(built, "sum_ad") + class O: v = 1.0 - x = NF([2.0]) - assert built.module.open_nf(x, O()) == 3.0 - assert "open_nf" not in {f.name for f in built.proved.functions} - - -# --- a numpy nbody-like kernel --------------------------------------------------------------------- - -NBODY = textwrap.dedent(''' - # typedpython: compiled - import math - import numpy as np - import numpy.typing as npt - - SOLAR_MASS: float = 39.47841760435743 - - - def advance_nodiv(dt: float, steps: int, x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], - z: npt.NDArray[np.float64], vx: npt.NDArray[np.float64], - vy: npt.NDArray[np.float64], vz: npt.NDArray[np.float64], - mass: npt.NDArray[np.float64]) -> None: - for _ in range(steps): - for i in range(5): - for j in range(i + 1, 5): - dx: float = x[i] - x[j] - dy: float = y[i] - y[j] - dz: float = z[i] - z[j] - d2: float = dx * dx + dy * dy + dz * dz - mag: float = dt * math.sqrt(d2) - vx[i] -= dx * mass[j] * mag - vy[i] -= dy * mass[j] * mag - vz[i] -= dz * mass[j] * mag - vx[j] += dx * mass[i] * mag - vy[j] += dy * mass[i] * mag - vz[j] += dz * mass[i] * mag - for i in range(5): - x[i] += dt * vx[i] - y[i] += dt * vy[i] - z[i] += dt * vz[i] - - - def advance(dt: float, steps: int, x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], - z: npt.NDArray[np.float64], vx: npt.NDArray[np.float64], - vy: npt.NDArray[np.float64], vz: npt.NDArray[np.float64], - mass: npt.NDArray[np.float64]) -> None: - for _ in range(steps): - for i in range(5): - for j in range(i + 1, 5): - dx: float = x[i] - x[j] - dy: float = y[i] - y[j] - dz: float = z[i] - z[j] - d2: float = dx * dx + dy * dy + dz * dz - mag: float = dt / (d2 * math.sqrt(d2)) - vx[i] -= dx * mass[j] * mag - - - def energy(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], - vx: npt.NDArray[np.float64], vy: npt.NDArray[np.float64], - vz: npt.NDArray[np.float64], mass: npt.NDArray[np.float64]) -> float: - e: float = 0.0 - for i in range(5): - e += 0.5 * mass[i] * (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i]) - return e -''') - - -def test_nbody_like_numpy_kernels(tmp_path): - path, proved, diags, c = build_source(tmp_path, NBODY, "nbodynp") - by = {f.name: f for f in proved.functions} - assert {"advance_nodiv", "energy"} <= set(by), proved.skipped - nbuf = {n: sum(getattr(p, "buffer", None) is not None for p in f.params) for n, f in by.items()} - assert nbuf == {"advance_nodiv": 7, "energy": 7} - # the real nbody divides by a non-constant: numpy would give inf where Python raises - assert "advance" in proved.skipped and "numpy" in proved.skipped["advance"] - so = cbuild.build(c, "nbodynp", tmp_path / "out") - m = load_so(so) - - def bodies(): - rng = np.random.default_rng(1) - return [rng.random(5) for _ in range(7)] - - ca, ia = bodies(), bodies() - before = deopts(m) - m.advance_nodiv(0.01, 20, *ca) - interp(m, "advance_nodiv")(0.01, 20, *ia) - assert deopts(m) == before - for p, q in zip(ca, ia): - assert p.tolist() == q.tolist() - assert m.energy(*ca) == interp(m, "energy")(*ia) + assert built.module.open_ad(AD([2.0]), O()) == 3.0 + assert "open_ad" not in {f.name for f in built.proved.functions} # --- verifier rules, on hand-built IR (the verifier never trusts the front end) ------------------- -def test_verifier_numpy_division_rule(): - from test_verify import F64 as VF64, F64A as VF64A, assert_accepted, assert_rejected, fn, lf - from typedpython.ir import ArrayParam, BinOp, BinOpKind, Const, Index, Param, Return - - def divide(buffer, divisor, op=BinOpKind.TRUEDIV): - return fn("f", params=[ArrayParam("a", VF64A, False, buffer), Param("d", VF64)], returns=VF64, - pure=True, body=[Return(BinOp(VF64, op, Index(VF64, "a", Const(Type_I64, 0)), - divisor))]) - - from typedpython.ir import Type - Type_I64 = Type.I64 - assert_rejected("verify/buffer", divide("numpy", lf("d"))) # variable divisor - assert_rejected("verify/buffer", divide("numpy", Const(VF64, 0.0))) # zero - assert_rejected("verify/buffer", divide("numpy", Const(VF64, 2.0), BinOpKind.FLOORDIV)) - assert_rejected("verify/buffer", divide("numpy", Const(VF64, 2.0), BinOpKind.MOD)) - assert_accepted(divide("numpy", Const(VF64, 2.0))) # nonzero Const - assert_accepted(divide("array", lf("d"))) # array.array elements are Python floats - assert_accepted(divide(None, lf("d"))) # a list too - - def test_verifier_buffer_kind_and_copy_rules(): - from test_verify import F64 as VF64, F64A as VF64A, assert_accepted, assert_rejected, fn - from typedpython.ir import ArrayParam, Assign, CopyArray, Index, Return, Const, Type - - assert_rejected("verify/buffer", fn("f", params=[ArrayParam("a", VF64A, False, "memoryview")], - returns=VF64, pure=True, - body=[Return(Index(VF64, "a", Const(Type.I64, 0)))])) - copy = fn("f", params=[ArrayParam("a", VF64A, False, "numpy")], returns=VF64, pure=True, + from test_verify import F64 as VF64, F64A as VF64A, assert_rejected, fn + from typedpython.ir import ArrayParam, Assign, Const, CopyArray, Index, Return, Type + + for kind in ("numpy", "memoryview"): + assert_rejected("verify/buffer", fn("f", params=[ArrayParam("a", VF64A, False, kind)], + returns=VF64, pure=True, + body=[Return(Index(VF64, "a", Const(Type.I64, 0)))])) + copy = fn("f", params=[ArrayParam("a", VF64A, False, "array")], returns=VF64, pure=True, locals_={"b": VF64A}, body=[Assign("b", CopyArray(VF64A, "a")), Return(Index(VF64, "b", Const(Type.I64, 0)))]) assert_rejected("verify/buffer", copy) @@ -764,7 +514,7 @@ def test_verifier_buffer_kind_and_copy_rules(): def test_verifier_buffer_params_are_not_limited_but_lists_still_are(): from test_verify import F64 as VF64, F64A as VF64A, assert_accepted, assert_rejected, fn - from typedpython.ir import ArrayParam, Index, Return, Const, Type + from typedpython.ir import ArrayParam, Const, Index, Return, Type def many(n, buffer): return fn("f", params=[ArrayParam(f"a{k}", VF64A, False, buffer) for k in range(n)], @@ -772,5 +522,4 @@ def many(n, buffer): body=[Return(Index(VF64, "a0", Const(Type.I64, 0)))]) assert_accepted(many(5, "array")) - assert_accepted(many(3, "numpy")) assert_rejected("verify/structure", many(3, None)) From 437131212ea38fe6d8066544a6179e92e2be5ed4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:50:49 +0900 Subject: [PATCH 3/4] Bench: Record the atomic vs plain buffer access cost (#149) --- .../2026-10-17-buffer-atomic-kernels.py | 21 +++++++++++++++++++ .../2026-10-17-buffer-atomic-vs-plain.txt | 4 ++++ 2 files changed, 25 insertions(+) create mode 100644 benchmarks/typedpython/results/2026-10-17-buffer-atomic-kernels.py create mode 100644 benchmarks/typedpython/results/2026-10-17-buffer-atomic-vs-plain.txt diff --git a/benchmarks/typedpython/results/2026-10-17-buffer-atomic-kernels.py b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-kernels.py new file mode 100644 index 00000000..a0cd59c5 --- /dev/null +++ b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-kernels.py @@ -0,0 +1,21 @@ +# typedpython: compiled +import array + + +def dot_arr(a: array.array[float], b: array.array[float]) -> float: + s: float = 0.0 + for i in range(len(a)): + s += a[i] * b[i] + return s + + +def scale_arr(a: array.array[float], k: float) -> None: + for i in range(len(a)): + a[i] = a[i] * k + + +def dot_list(a: list[float], b: list[float]) -> float: + s: float = 0.0 + for i in range(len(a)): + s += a[i] * b[i] + return s diff --git a/benchmarks/typedpython/results/2026-10-17-buffer-atomic-vs-plain.txt b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-vs-plain.txt new file mode 100644 index 00000000..2e3a1039 --- /dev/null +++ b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-vs-plain.txt @@ -0,0 +1,4 @@ +# atomic vs plain buffer element access, 2026-10-17, quiet window (end load 1.15), interleaved 11 pairs, n=2,000,000 doubles, CPython 3.15.0rc1t +compiled: ['dot_arr', 'dot_list', 'scale_arr'] +{'dot': 1.001, 'scale': 2.539} (atomic / plain, median of 11) +dot_arr atomic 1.94 ms, plain 1.92 ms, dot_list (copy-in) 12.28 ms From 8642e98677d26be86462efa4b273d61088f355bd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:51:00 +0900 Subject: [PATCH 4/4] Docs: SPEC N-9 row for array.array buffer parameters (#149) --- docs/SPEC.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index 9638d54e..2f6f55ce 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -611,6 +611,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. | Array access in bounds, or a runtime bounds check kept | the check stays (never an unchecked access) | | Array parameters only indexed, stored, `len`-ed; no aliasing (entry guard) | not compiled; diagnostic | | Integer ops proven safe by the verifier's intervals (constants, `len`, `range` variables, guards such as `if x < e` / `while lo < hi`, joined local assignments, `//` and `%` by constants) | emitted unchecked (a shift/mask for `//`/`%` by a power of two of a non-negative value); the sanitizer CI run builds with `TP_CHECK_PROVEN` and turns a wrong proof into a SystemError (#148) | + | `array.array[float]` / `array.array[int]` parameters (exact `array.array`, typecode `d` or `q`/`l`, 8-byte items) are read and written in place: the buffer export is held for the call and released on every exit, and every element access is one relaxed atomic 8-byte load or store, so each access is atomic like the interpreter's `a[i]`; no copy-in and no limit on the number of buffers. Open (#149): while the export is held, another thread's resize of that array raises BufferError. numpy arrays are not compiled yet (#220) | anything else: object path | | A function holding an array parameter runs no user code: closed, and no slot read or `C(args)` whose slow path runs user code, in its whole call tree (#203) | not compiled; diagnostic | | OBJ references follow one ownership rule; no IR form for free, borrowed alias or NULL read | not compiled; diagnostic | | Integer overflow checked and promoted (deopt); an op marked `proven` must re-prove its interval fits i64 | not compiled; diagnostic `verify/proven-overflow` |