diff --git a/benchmarks/typedpython/py/binary_trees_region.py b/benchmarks/typedpython/py/binary_trees_region.py new file mode 100644 index 000000000..095e04544 --- /dev/null +++ b/benchmarks/typedpython/py/binary_trees_region.py @@ -0,0 +1,51 @@ +import sys + + +class Node: + __slots__ = ("left", "right") + left: "Node | None" + right: "Node | None" + + def __init__(self, left: "Node | None", right: "Node | None") -> None: + self.left = left + self.right = right + + +def make(depth: int) -> Node: + if depth > 0: + return Node(make(depth - 1), make(depth - 1)) + return Node(None, None) + + +def check(node: Node) -> int: + left: Node | None = node.left + right: Node | None = node.right + if left is None or right is None: + return 1 + return 1 + check(left) + check(right) + + +def check_made(depth: int) -> int: + # make and check are compiled, so the tree never leaves compiled code: SPEC N-12b keeps its nodes in a + # region. binary_trees.py is the program as written (main holds the tree); this variant measures N-12b. + return check(make(depth)) + + +def main() -> None: + n: int = int(sys.argv[1]) + min_depth: int = 4 + max_depth: int = max(min_depth + 2, n) + stretch: int = check_made(max_depth + 1) + long_lived: Node = make(max_depth) + total: int = 0 + for depth in range(min_depth, max_depth + 1, 2): + iterations: int = 1 << (max_depth - depth + min_depth) + chk: int = 0 + for _ in range(iterations): + chk += check_made(depth) + total += chk + print(f"{stretch} {total} {check(long_lived)}") + + +if __name__ == "__main__": + main() diff --git a/docs/SPEC.md b/docs/SPEC.md index ec0edc758..86691389a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -788,6 +788,54 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. N-11. **Known difference:** an object whose allocation was removed raises no `MemoryError` where CPython's allocation would (same nature as escape analysis in PyPy). Impure functions, `FieldSet` on a virtual object, escape with materialisation, borrowed reads and regions are not part of N-12a. + **N-12b (#268): regions for unbounded virtual objects.** `Status: first step implemented` (design: + `docs/design/typedpython-regions.md`; tests: `test_regions.py`). N-12a + cannot hold an unbounded number of objects (a tree built by recursion). In a **pure** compiled host, + objects of a region class that are built by compiled producers and consumed by compiled consumers live + as C structs in a bump region owned by one statement of that host; no PyObject exists for them, so no + other thread, `gc.get_objects()`, `gc.get_referrers()` or `tracemalloc` can find one. Real objects are + never placed in a region (N-12), so a tree that escapes (returned, stored, passed to unknown code, kept + in a global or container, interpreted caller) is built by the ordinary N-11 entry as before. Facts the + verifier recomputes (never trusted from the front end; a forged entry is `verify/region-escape`): + - a **region class** is an N-11 class without `__del__` and `__weakref__`, no `FieldSet` anywhere in the + project, whose every `New` argument is a scalar `Box`, a `None` Const or a value of a region class + (fields are scalars, `None` and region pointers only; no other object reference is ever stored, so + freeing the region runs no user code and needs no refcount; a field that mixes kinds, or points to two + classes, is not a region class); + - a **producer** is a pure function returning a region class whose every `Return` is a `New` or a + `Call` of a producer; a **consumer** is a pure function with a region-class parameter that returns a + scalar, reads that parameter only through `FieldGet`, `Is None`, and passes it or its fields only to + consumers; both use no node outside `_REGION_OK` (the `_VIRTUAL_OK` set plus `Call` of a pure + compiled function and `Return`, no unknown code); + - a **host** is a pure function (only pure functions host a region) in which a producer `Call` result is used only as the region argument + of consumer `Call`s in the same statement (`check(make(d))`; the local form + `t = make(d); ... check(t)` is a later step), and the statement's value is a scalar. The region is + opened at the producer call and freed when the statement ends, on every exit (return, exception, deopt). + A producer and a consumer get a second, internal entry that takes the region (producer) or a region + pointer (consumer); their Python-visible entry is unchanged, so an interpreted caller always gets real + objects. Cases: + - **Deopt.** The host is pure, so a deopt anywhere in the span (a guard, an overflow, a changed class + checked by every region `New`, an allocation failure) frees the region, discards its objects (none + was ever observable) and redoes the host call interpreted from its arguments. + - **MemoryError.** A failed region allocation is a deopt, so the interpreter's redo raises `MemoryError` + itself where CPython would. **Known difference** (same nature as N-12a, maintainer decision + 2026-10-25): a region node is smaller than a PyObject, so a call may succeed where CPython's + allocation would fail. **Known difference** (same decision): `tracemalloc` peak and + `sys.getallocatedblocks` during the call differ, because no PyObject exists for the tree (region + memory is plain `malloc`); both return to their starting values after the call. + - **Free-threading.** Region memory is reachable only from the host's C stack. No atomics, critical + sections or refcounts are involved because no region object holds a reference. The host span is + atomic for other threads (they observe the state before the first node or after the last free), which + is a valid CPython interleaving. + - **N-14.** A traced call enters the interpreted host, so events are CPython's; internal region entries + are not re-checked (the host is pure, as for any pure callee). + - **Differential tests.** `id`, `is` between nodes, default `hash`/`repr`: not applicable, a host + using any of them is not a region (negative cases in `test_regions.py`, with a mutation test). + `gc.get_objects` count and order, `gc.get_referrers`: unchanged before and after a call, and a + thread scanning `gc.get_objects()` during calls never finds a node. `sys.getrefcount`: no object + reference is stored in a region node, so no refcount changes. `tracemalloc`: current memory returns to + the start; the peak is the known difference above. `MemoryError` during construction: injected + allocation failure equals the interpreter. Results equal the interpreter for every depth. - **N-13** TypedPython native ints in impure functions (#232; INTENT §1.4: compiled code computes what CPython computes). An impure function cannot deopt after an effect, so its `int` locals were CPython objects and every `+`, `//`, `%` and comparison was a generic `PyNumber_*` / `PyObject_RichCompare` diff --git a/docs/design/typedpython-regions.md b/docs/design/typedpython-regions.md new file mode 100644 index 000000000..8ff6fdc6e --- /dev/null +++ b/docs/design/typedpython-regions.md @@ -0,0 +1,182 @@ +# TypedPython region allocation (SPEC N-12b, #268) + +Status: first step implemented (#268). Tests: `python-multiplatform-ksp/src/test/python/test_regions.py`. +Parent design: `typedpython.md` §4.3.4. Intent: INTENT §1.4, compiled code computes what CPython computes. + +## 1. The problem and the scope the issue settled on + +N-12a scalar-replaces function-local objects: one C local per field, so the count of objects is fixed by +the source text. A tree built by recursion (`make(d)`) has a count that depends on the data. + +The completion criterion was changed on 2026-10-25: binary-trees as written (`main` is interpreted and +receives `make(d)`) cannot use a region, because the tree escapes to an interpreted caller and real objects +are never placed in a region (on 3.15t every real object is reachable by `gc.get_objects()`). The target +is therefore the case where the whole life of the tree is inside compiled code: + + def tree_sum(d: int) -> int: + return check(make(d)) # make and check compiled, tree_sum compiled and pure + +This design cannot find, and does not claim, a speed-up for an interpreted `main` that holds the tree. +That stays N-11 plus whatever N-12 refcount work can prove. + +## 2. Why a region object is unobservable + +A region object is a C struct in malloc'd chunks. No `PyObject` header exists, so it is not in any GC +generation, has no `id`, no refcount, no `__class__` that any code can ask. The only references are C +pointers on the host's stack and inside other region structs. For it to be observable, some code must +(a) obtain it, or (b) enumerate objects. + +- (a) is excluded by verifier facts (section 3): the pointer never reaches a return, a global, a container, + a real object's field, a call to unknown code, `id`/`hash`/`repr`/`is`. +- (b) `gc.get_objects`, `gc.get_referrers`, `tracemalloc` and other threads can only run during the span + if the host calls unknown code or another thread runs. The host contains no unknown code (pure), and + other threads cannot see anything (no PyObject), so they observe the world before the first node or after + the last free, both valid CPython interleavings. This is the same argument as N-12a, applied to a span + with a loop or recursion in it. It is why N-12b is restricted to pure hosts: an impure host would have + to materialise the whole tree at every unknown call (the N-12 rule), and a tree of 2^20 nodes + materialised for a `print` is a cost and an order-of-allocation question this step does not take on. + +## 3. Verifier facts (recomputed, never trusted) + +Names as implemented: `Function.regions` (the producers a host opens a region for), `Module.region_variants` +(the producers and consumers with an internal entry), `ClassDecl.has_del`, `verify._REGION_OK`, +`verify._RegionScan` (the escape check), rule `verify/region-escape`. The front end sets none of them +(`has_del` aside, which is a fact about the source); `verify` recomputes the rest and rejects a function +whose declared `regions` differ, and reports a module whose declared `region_variants` differ. + +1. **Region class** C: N-11 class, no `__del__` (`ClassDecl.has_del`), no `__weakref__` slot (the exact + `__slots__` rule already excludes it), no `FieldSet` on C anywhere in the + project (the compiled project is closed-world in compiled mode, N-11 gate), and every `New(C, args)` + argument is `Box` of a scalar, a `None` Const, or an expression of a region class. Stored values are + therefore scalars or other region pointers: no refcount, nothing to run on free. + Per-field kind is the join over all `New` sites: `i64`, `f64`, `bool`, or a pointer to one class (NULL is + `None`). A field that is `None` at one site and `i64` at another is not a region class (no tagged unions + in step 1), nor is a pointer field that points to two classes (a pointer has no class tag). +2. **Producer** P: pure function, return type a region class, node set within `_REGION_OK`, every `Return` + is a `New` of a region class or a `Call` to a producer (itself: `make` is self-recursive). +3. **Consumer** K: pure function with a non-optional region-class parameter `n`, scalar return, `n` used only + as the object of `FieldGet`, as the operand of `Is None`, or as an argument at a consumer's region + parameter; `FieldGet` results of pointer kind are locals used under the same restriction. Reading a scalar + field (`Unbox(FieldGet)`) yields the same value the unboxed path would. The front end cannot express that + read today (`c.val` lowers to an object and `v: int = c.val` is refused), so scalar fields are covered + by hand-built IR in `test_regions.py`; classes with pointer fields are covered end to end. +4. **Host** H: pure function, node set `_REGION_OK`, and in some statement a producer `Call` result is an + argument at a consumer's region parameter, with nothing else holding it. The statement's value is a + scalar (the region cannot be returned by construction). A region scope = that statement. +5. **Escape** is whatever fails 1-4: `Return` of the value, `Assign` to a global/field/container, any + `CallObject`/`GetAttr`/`Global` read, `Is` between two nodes (`make(d) is make(d)`), `id`. Anything + unproved compiles as N-11. + +`_REGION_OK` = `_VIRTUAL_OK` plus `Call`; `_RegionScan` narrows it (no array node, no OBJ value outside the +three shapes above, no `Is` except against `None`). A `Call` is allowed here (it was +not in N-12a) because it is a pure compiled call: no user code runs. A pure callee can deopt, which is +handled in section 6. + +The verifier computes producers, consumers and hosts together as a fixpoint over the module: a function is +a producer only if every `Call` in its body is to a producer or consumer or a plain pure function. + +## 4. Mechanism: two entry points, region as a hidden parameter of the internal one only + +Options considered: + +| Option | Verdict | +|---|---| +| One entry per function, region as a hidden parameter | No. The exported entry must keep its Python signature, and an interpreted caller (`main`) calls it. A hidden parameter would need a NULL-means-real-objects mode on every `New` and every field read. | +| **Two entries**: the Python-visible one unchanged (real objects, N-11), and an internal `make__r(region*, depth)` / `check__r(node*)` with no Python-visible name | **Chosen.** The internal entry cannot be reached from Python, so a region pointer can never leak. Code size doubles for producers and consumers only. | +| Only when every call site is a compiled caller that keeps the result inside | Not needed: the two-entry scheme keeps correctness for all call sites, the region is used only at the call sites the host fact proves. | + +A producer's `New(C, args)` in the region variant becomes `tp_region_alloc(r, sizeof(C_r))` plus field +stores. The class guard of `New` is kept (a changed class is a deopt, section 6). A consumer's `FieldGet` +becomes a struct member load: scalar kinds are loaded unboxed (`Unbox` of the `Box` the front end put there +folds away), `REF` kinds give a pointer. `Is None` is a NULL test. + +Struct layout (as implemented): fields in declaration order, one 8-byte slot per field (a union of `i64`, +`f64`, pointer; `bool` is stored in the integer member), no header, so `Node` is 16 bytes. Chunks: the first is +4 KiB, each next one doubles up to 8 MiB; a bump pointer. A region is a C stack value of the host, one per +site (`tp_rgK`): `tp_region_open` right before the producer runs (reuses the chunks the previous execution of +the site grew), `tp_region_end` right after the consumer returned (the statement is over, the value is a +scalar), and `tp_region_free` in the host's common exit block, which every exit edge (return, deopt, error) +reaches. In a loop the chunks are therefore grown once and reused by every iteration. A bounded host (one +with a fast twin) uses real objects in the twin; the verifier's facts do not depend on that. + +## 5. Lifetime and loops + +The region belongs to a statement, not to the function. In `for _ in range(n): t += check(make(d))` the +region is freed (chunks kept for reuse, bump pointer reset) at the end of each statement, so memory is +bounded by one tree. The local form `t = make(d)` assigned once and read by consumers only is the same +fact over a longer span and is deferred to a second step: it adds "no read after reassignment" and +"freed at last use or function exit". + +## 6. Deopt, errors, free-threading, tracing + +- **Deopt in a pure host.** The existing contract: a pure function that deopts redoes the call interpreted + from its arguments, because it has had no effect. A region object was never observable, so nothing has to + be materialised: the host frees the region and returns the deopt signal. A deopt in a producer/consumer + variant (int overflow in `check`, a changed class in a `New`, an argument guard) propagates exactly as a + pure callee's deopt does today. +- **MemoryError.** `tp_region_alloc` fails when `malloc` of a chunk fails: this is a deopt, not a raise. + The redo runs the interpreter, which raises `MemoryError` where CPython's own allocation fails, so the + exception, its traceback and its timing come from the real thing. Known differences: a region node + (about 16 bytes for `Node`) is smaller than a PyObject node (about 56), so a call may succeed where + CPython would fail; and `tracemalloc` peak differs. Same nature as N-12a. A test hook + (`__typedpython_region_fail_after__(n)`) fails the nth node allocation once to test the path; counters are + `__typedpython_region_stats__()` = (opened, freed, objects) and `__typedpython_region_chunks__()` = + (allocated, freed) chunks. +- **Recursion.** `make`/`check` recursion depth is the same as for compiled recursion today + (`test_recursion.py`); a region does not change the C stack use of a frame by more than one pointer. +- **Free-threading (3.15t).** Region memory is reachable only from one thread's C stack. No refcount, no + critical section, no atomic. The "no real object is thread-local" paragraph of N-12 does not apply + because there is no real object. The counters used by tests are the only shared state (relaxed atomics). + Tests: four threads call the host while a fifth scans `gc.get_objects()` for `Node`; it must find none. +- **N-14 tracing.** A traced thread's call of the host enters the interpreted function, so every event + comes from CPython's frames. The internal region entries have no Python frame and are only reachable from + a compiled pure host, which N-14 already says does not re-check on internal calls. If tracing starts + while a host runs (only possible from another thread's `sys.monitoring` global event), behaviour is the + documented N-14 known difference. + +## 7. Which differential observations apply + +| Observation | Applies? | Why / test | +|---|---|---| +| `id(x)`, default `hash`, `repr` | No: no region object can be passed to them; a host using them is not a region | negatives `ident`, `same` + mutation test | +| `is` between region nodes | No: `Is` of two producer results is outside the facts | `same` | +| `gc.get_objects` count/order, `gc.get_referrers` | Yes | count unchanged after calls; a scanner thread never finds a `Node` | +| `sys.getrefcount` | Trivially: no object reference is stored, so no counted reference changes | no field holds an object reference by fact 1 | +| `tracemalloc` current/peak | current: yes (no leak). peak: known difference | test pins the difference (peak far below the interpreter's) | +| `MemoryError` during construction | Yes | injected failure equals the interpreter | +| Results, exceptions | Yes | every depth equals the interpreter, deopt cases | +| Timing of cyclic GC | Outside the identity (user decision 2026-10-04) | not tested | + +## 8. First implementation step (done) + +Exactly the shape of `test_regions.py`: `tree_sum(d) = check(make(d))` and the loop form, `make`/`check` as +module-level compiled functions of one region class (`Node` with two pointer fields). Done: verifier facts +and `Function.regions` / `Module.region_variants`; cgen variants (`tp_rimpl_`, no wrapper, no Python +name); runtime `tp_region_*`; counters, the failure hook, the deopt, error and thread tests; a benchmark +variant (`benchmarks/typedpython/py/binary_trees_region.py`). Not done: the native-image check (rule 13: the +region runtime is plain C, no reflection, but the image build has not been run for this change). +Out of scope for step 1: local-variable form, consumers returning region pointers, mixed field kinds, +methods (`n.check()`), impure hosts, region classes with object-typed fields, `FieldSet`. + +## 9. What this audit cannot find + +The criterion "can the tree be proved unobservable inside one pure compiled statement" cannot find +speed-ups for trees that cross an interpreted boundary (binary-trees `main`), for impure hosts, or for +classes with object-typed fields. Those are real losses, not an absence of work; see the open questions. + +## 10. Decisions (maintainer, 2026-10-25) + +1. **Only pure functions host a region.** An impure host (it prints, or calls Kotlin) is not a region host; + no materialisation of a region is specified. +2. **A failed region allocation deopts and the interpreter's redo raises `MemoryError`.** That a region call + can succeed where CPython would fail (the nodes are smaller) is a known difference, recorded in SPEC + N-12b next to N-12a's. +3. **Fields are scalars, `None` and region pointers only.** Object-typed fields (strings, ints beyond i64) + would be a separate issue later. +4. **`tracemalloc` peak and `sys.getallocatedblocks` differences during a call are a known difference,** + recorded in SPEC N-12b. +5. **`benchmarks/typedpython/py/binary_trees.py` stays unchanged.** A separate file, + `benchmarks/typedpython/py/binary_trees_region.py`, has a compiled `check_made(d) = check(make(d))` helper + that the main loop calls. `run.py` takes its benchmarks from the hard-coded `BENCHMARKS` table, not from + a directory listing, so the extra file changes no output of `run.py verify` or `measure` until someone + registers it; no CI workflow or test reads the directory (`test_frontend*.py` name their files). diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py index e26730eed..9bb919672 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -560,6 +560,37 @@ def __init__(self, module: ir.Module): self.gnames: list[str] = [] # cached global/builtin reads, one tp_gslot each self._uses: dict[str, set[str]] = {} self.bounded = _bounded_functions(self.functions) # name -> k (module docstring, "Fast entry") + # SPEC N-12b: producers and consumers that get an internal region entry, and per region class the + # kind of each field ("i64", "f64", "bool", "ref"), read off the producers' `New`s. + self.rvariants: set[str] = set() + self.rfield: dict[str, tuple[str, ...]] = {} + self._init_regions() + + def _init_regions(self) -> None: + cand = {n for n in self.module.region_variants if n in self.functions} + used: set[str] = set() + todo: list[str] = [] + + def add(e) -> None: + if isinstance(e, ir.Call) and e.function in cand and e.function not in used: + used.add(e.function) + todo.append(e.function) + + for f in self.module.functions: + if f.regions: + for e in _all_exprs(f): + add(e) + while todo: + for e in _all_exprs(self.functions[todo.pop()]): + add(e) + self.rvariants = used + for n in sorted(used): + for e in _all_exprs(self.functions[n]): + if isinstance(e, ir.New): + kinds = tuple(ir.REGION_SCALAR_KINDS.get(a.operand.type) if isinstance(a, ir.Box) + else "ref" for a in e.args) + if None in kinds or self.rfield.setdefault(e.cls, kinds) != kinds: + raise CGenError(f"{n}: New {e.cls} has no single region layout") def cls(self, fname: str, name: str) -> ir.ClassDecl: cd = self.classes.get(name) @@ -673,6 +704,8 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No for f in module.functions: mg.name_index(f.name) bodies = [_FnGen(mg, f).generate() for f in module.functions] + bodies += [_FnGen(mg, mg.functions[n], rmode="p" if mg.functions[n].returns is Type.OBJ else "c").generate() + for n in sorted(mg.rvariants)] init_name = module.name.rpartition(".")[2] if not (init_name.isascii() and init_name.isidentifier()): raise CGenError(f"module name {module.name!r} cannot name a PyInit_ function") @@ -748,6 +781,8 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No w(_impl_signature(f) + ";") if f.name in mg.bounded: w(_impl_signature(f, fast=True) + ";") + for n in sorted(mg.rvariants): + w(_region_signature(mg.functions[n]) + ";") w("") w(_TEMPLATE_REDO) for b in bodies: @@ -767,6 +802,8 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No w(_class_info_function(mg)) if mg.has_par: w(_par_stats_function()) + if mg.rvariants: + w(_region_stats_functions()) w(_exec_function(mg)) w(_state_functions(mg)) w("static PyModuleDef_Slot tp_slots[] = {") @@ -928,12 +965,65 @@ def _impl_call_args(f: ir.Function, fd: str | None = None) -> str: return ", ".join(args) +def _region_signature(f: ir.Function) -> str: + """The internal entry of a region producer (takes the region, returns a region pointer) or consumer + (takes region pointers): no Python name, no wrapper, reachable only from compiled pure code + (SPEC N-12b).""" + params = ["PyObject *tp_module", "PyThreadState *tp_ts", "tp_tls *tp_t"] + if f.returns is Type.OBJ: + params.append("tp_region *tp_r") + for p in f.params: + params.append(f"tp_rptr {_ident('l', p.name)}" if p.type is Type.OBJ + else f"{_CT[p.type]} {_ident('l', p.name)}") + params.append("tp_rptr *tp_out" if f.returns is Type.OBJ else f"{_CT[f.returns]} *tp_out") + return f"static int {_ident('tp_rimpl', f.name)}({', '.join(params)})" + + +def _region_stats_functions() -> str: + return "\n".join([ + "static PyObject *tp_region_stats_fn(PyObject *tp_module, PyObject *tp_unused)", + "{", + " uint64_t tp_v[3];", + " (void)tp_module; (void)tp_unused;", + " tp_region_stats(tp_v);", + " return Py_BuildValue(\"(KKK)\", (unsigned long long)tp_v[0], (unsigned long long)tp_v[1], (unsigned long long)tp_v[2]);", + "}", + "", + "static PyObject *tp_region_chunks_fn(PyObject *tp_module, PyObject *tp_unused)", + "{", + " uint64_t tp_v[2];", + " (void)tp_module; (void)tp_unused;", + " tp_region_chunk_stats(tp_v);", + " return Py_BuildValue(\"(KK)\", (unsigned long long)tp_v[0], (unsigned long long)tp_v[1]);", + "}", + "", + "static PyObject *tp_region_fail_fn(PyObject *tp_module, PyObject *tp_n)", + "{", + " long long tp_v = PyLong_AsLongLong(tp_n);", + " (void)tp_module;", + " if (tp_v == -1 && PyErr_Occurred()) return NULL;", + " tp_region_set_fail((int64_t)tp_v);", + " Py_RETURN_NONE;", + "}", + "", + "static PyMethodDef tp_region_stats_def = {\"__typedpython_region_stats__\", tp_region_stats_fn, METH_NOARGS, NULL};", + "static PyMethodDef tp_region_chunks_def = {\"__typedpython_region_chunks__\", tp_region_chunks_fn, METH_NOARGS, NULL};", + "static PyMethodDef tp_region_fail_def = {\"__typedpython_region_fail_after__\", tp_region_fail_fn, METH_O, NULL};", + "", + ]) + + # --- function bodies ----------------------------------------------------------------------------- class _FnGen: - def __init__(self, mg: _ModGen, f: ir.Function, fast: bool = False): + def __init__(self, mg: _ModGen, f: ir.Function, fast: bool = False, rmode: str | None = None): self.mg, self.f = mg, f self.fast = fast # the uncounted twin of a bounded function + # SPEC N-12b: "p" / "c": the internal region entry of a producer / consumer (OBJ values are + # region pointers, never counted references); `rgs`: the region variables of a host's sites + self.rmode = rmode + self.rgs: list[str] = [] + self.host = bool(f.regions) and not fast and rmode is None and bool(mg.rvariants) self.lines: list[str] = [] self.depth = 1 self.temps: list[tuple[str, str]] = [] # (C type, name) @@ -1017,6 +1107,8 @@ def _own_release(self, *ts: str) -> None: self.emit(f"tp_release(&{t});") def _own_release_all(self) -> list[str]: + if self.rmode: # region pointers are not counted references + return [] objs = [_ident("l", n) for n, t in self.types.items() if t is Type.OBJ] objs += [n for ct, n in self.temps if ct == "PyObject *"] out = [f" tp_release(&{n});" for n in objs] @@ -1033,6 +1125,8 @@ def _own_release_all(self) -> list[str]: # OBJ results are always owned temporaries. def ev(self, e: ir.Expr) -> str: + if self.rmode and e.type is Type.OBJ: + return self._rev(e) m = getattr(self, "ev_" + type(e).__name__, None) if m is None: raise CGenError(f"{self.f.name}: no lowering for {type(e).__name__}") @@ -1294,6 +1388,19 @@ def ev_Box(self, e: ir.Box) -> str: return self._own_new(f"{_BOX[ot]}({v})") def ev_Unbox(self, e: ir.Unbox) -> str: + if self.rmode: + op = e.operand + if not isinstance(op, ir.FieldGet): + raise CGenError(f"{self.f.name}: region Unbox of {type(op).__name__}") + idx = self.mg.cls(self.f.name, op.cls).fields.index(op.field) + kind = self.mg.rfield.get(op.cls, ())[idx:idx + 1] + if kind != (ir.REGION_SCALAR_KINDS.get(e.type),): + raise CGenError(f"{self.f.name}: region field {op.cls}.{op.field} is {kind}, read as {e.type}") + p = self._r_ptr_name(op.obj) + self._rguard(op.cls) + t = self.tmp(_CT[e.type]) + self.emit(f"{t} = {p}[{idx}].{'f' if e.type is Type.F64 else 'i'};") + return t if e.operand.type is not Type.OBJ or e.type not in _UNBOX: raise CGenError(f"{self.f.name}: Unbox {e.operand.type} -> {e.type}") o = self.ev(e.operand) @@ -1313,6 +1420,13 @@ def ev_MathCall(self, e: ir.MathCall) -> str: def ev_Call(self, e: ir.Call) -> str | None: callee = self.mg.functions[e.function] + if e.function in self.mg.rvariants: + if self.rmode: + return self._rcall(e) + if self.host and callee.returns is not Type.OBJ and any( + isinstance(a, ir.Call) and a.function in self.mg.rvariants and a.type is Type.OBJ + for a in e.args): + return self._host_call(e) if len(e.args) != len(callee.params): raise CGenError(f"{self.f.name}: Call of {e.function} with {len(e.args)} args") args = [] @@ -1463,6 +1577,11 @@ def _obj_operand(self, e: ir.Expr, what: str) -> str: return self.ev(e) def ev_Is(self, e: ir.Is) -> str: + if self.rmode: + a, b = self._r_ptr_name(e.left), self._r_ptr_name(e.right) + t = self.tmp("int") + self.emit(f"{t} = ({a} {'!=' if e.negate else '=='} {b});") + return t a = self._obj_operand(e.left, "Is") b = self._obj_operand(e.right, "Is") t = self.tmp("int") @@ -1488,6 +1607,11 @@ def _slow(self, cls: str) -> None: self.emit("tp_rc = 1; goto tp_exit;") def ev_IsExact(self, e: ir.IsExact) -> str: + if self.rmode: # a region pointer has one class: exact means not None + p = self._r_ptr_name(e.obj) + t = self.tmp("int") + self.emit(f"{t} = ({p} != NULL);") + return t k = self._class(e.cls) o = self._obj_operand(e.obj, "IsExact") t = self.tmp("int") @@ -1622,6 +1746,107 @@ def ev_New(self, e: ir.New) -> str: self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}") return t + # -- regions (SPEC N-12b) -- + + def _rguard(self, cls: str) -> None: + """The per-access class check of a real FieldGet (`ev_FieldGet`): the class is still the one + captured at init (version tag, slot descriptors). A change (a property installed by a signal + handler that tp_poll ran, or by another thread) is a deopt: the host is pure, so it frees its + region and the call is redone interpreted.""" + self.emit(f"tp_s = {self._current('NULL', cls)};") + self.emit("if (tp_s < 0) { tp_rc = -1; goto tp_exit; }") + self.emit("if (!tp_s) { tp_class_note_slow(&tp_st->cls_rt[%d]); tp_rc = 1; goto tp_exit; }" % self._class(cls)) + + def _rev(self, e: ir.Expr) -> str: + """An OBJ-typed expression of a region variant: a region pointer (never a counted reference).""" + if isinstance(e, ir.New): + return self._rnew(e) + if isinstance(e, ir.Call): + return self._rcall(e) + return self._r_ptr_name(e) + + def _r_ptr_name(self, e: ir.Expr) -> str: + if isinstance(e, ir.Local) and self.types.get(e.name) is Type.OBJ: + return _ident("l", e.name) + if isinstance(e, ir.Const) and e.type is Type.OBJ and e.value is None: + return "NULL" + if isinstance(e, ir.CheckExact): # type(p) is C for a pointer of one class: not None + p = self._r_ptr_name(e.obj) + self.emit(f"if ({p} == NULL) {{ tp_rc = 1; goto tp_exit; }}") + return p + if isinstance(e, ir.FieldGet): + idx = self.mg.cls(self.f.name, e.cls).fields.index(e.field) + if self.mg.rfield.get(e.cls, ())[idx:idx + 1] != ("ref",): + raise CGenError(f"{self.f.name}: region field {e.cls}.{e.field} is not a pointer") + p = self._r_ptr_name(e.obj) + self._rguard(e.cls) + return f"{p}[{idx}].p" + raise CGenError(f"{self.f.name}: {type(e).__name__} is not a region pointer expression") + + def _rnew(self, e: ir.New) -> str: + """`New(C, args)` of a producer's region variant: the same class guards and argument order as + `_assign_virtual`, then the node comes from the region. A failed allocation is a deopt.""" + kinds = self.mg.rfield.get(e.cls) + if self.rmode != "p" or kinds is None or len(e.args) != len(kinds): + raise CGenError(f"{self.f.name}: New {e.cls} is not a region New") + k = self._class(e.cls) + self.emit(f"tp_s = {self._global_ok(e.cls)};") + self.emit("if (tp_s < 0) { tp_rc = -1; goto tp_exit; }") + self.emit("if (!tp_s) { tp_class_note_slow(&tp_st->cls_rt[%d]); tp_rc = 1; goto tp_exit; }" % k) + vals = [self._rev(a) if kind == "ref" else self.ev(a.operand) for a, kind in zip(e.args, kinds)] + self.emit(f"tp_s = {self._current('NULL', e.cls)};") + self.emit("if (tp_s < 0) { tp_rc = -1; goto tp_exit; }") + self.emit("if (!tp_s) { tp_class_note_slow(&tp_st->cls_rt[%d]); tp_rc = 1; goto tp_exit; }" % k) + t = self.tmp("tp_rptr") + self.emit(f"{t} = tp_region_alloc(tp_r, {len(kinds)});") + self.emit(f"if ({t} == NULL) {{ tp_rc = 1; goto tp_exit; }}") # allocation failure: deopt + for i, (v, kind) in enumerate(zip(vals, kinds)): + self.emit(f"{t}[{i}].{'p' if kind == 'ref' else 'f' if kind == 'f64' else 'i'} = {v};") + return t + + def _rcall(self, e: ir.Call) -> str: + """A Call of a region variant from a region variant: producers share the caller's region.""" + callee = self.mg.functions[e.function] + args = ["tp_r"] if callee.returns is Type.OBJ else [] + for p, a in zip(callee.params, e.args): + args.append(self._r_ptr_name(a) if p.type is Type.OBJ else self.ev(a)) + t = self.tmp("tp_rptr" if callee.returns is Type.OBJ else _CT[callee.returns]) + self.emit(f"tp_s = {_ident('tp_rimpl', e.function)}(tp_module, tp_ts, tp_t" + f"{''.join(', ' + x for x in args)}, &{t});") + self.emit("if (tp_s != 0) { tp_rc = tp_s; goto tp_exit; }") + return t + + def _host_call(self, e: ir.Call) -> str: + """A region site: consumer call `check(make(d))` of a pure host. Each producer call argument gets + a region variable of its own, opened right before the producer runs, ended right after the + consumer returned (the value is a scalar, so no pointer outlives it) and freed on every exit by + the common exit block.""" + callee = self.mg.functions[e.function] + args, opened = [], [] + for p, a in zip(callee.params, e.args): + if p.type is not Type.OBJ: + args.append(self.ev(a)) + continue + if not (isinstance(a, ir.Call) and a.function in self.mg.rvariants): + raise CGenError(f"{self.f.name}: region argument of {e.function} is not a producer call") + rg = f"tp_rg{len(self.rgs)}" + self.rgs.append(rg) + pargs = [self.ev(x) for x in a.args] + t = self.tmp("tp_rptr") + self.emit(f"tp_region_open(&{rg});") + self.emit(f"tp_s = {_ident('tp_rimpl', a.function)}(tp_module, tp_ts, tp_t, &{rg}" + f"{''.join(', ' + x for x in pargs)}, &{t});") + self.emit("if (tp_s != 0) { tp_rc = tp_s; goto tp_exit; }") + args.append(t) + opened.append(rg) + out = self.tmp(_CT[callee.returns]) + self.emit(f"tp_s = {_ident('tp_rimpl', e.function)}(tp_module, tp_ts, tp_t" + f"{''.join(', ' + x for x in args)}, &{out});") + for rg in opened: + self.emit(f"tp_region_end(&{rg});") + self.emit("if (tp_s != 0) { tp_rc = tp_s; goto tp_exit; }") + return out + def _class(self, name: str) -> int: self.mg.cls(self.f.name, name) return self.mg.class_index[name] @@ -1841,7 +2066,9 @@ def st_Assign(self, s: ir.Assign) -> None: if s.value.type is not t: raise CGenError(f"{self.f.name}: {s.target} is {t}, assigned {s.value.type}") v = self.ev(s.value) - if t is Type.OBJ: + if t is Type.OBJ and self.rmode: + self.emit(f"{_ident('l', s.target)} = {v};") + elif t is Type.OBJ: self._own_move(_ident("l", s.target), v) elif t is Type.INT: self.emit(f"tp_int_assign(&{_ident('l', s.target)}, &{v});") @@ -2355,7 +2582,9 @@ def st_Return(self, s: ir.Return) -> None: if s.value is None or s.value.type is not rt: raise CGenError(f"{self.f.name}: Return type mismatch (function returns {rt})") v = self.ev(s.value) - if rt is Type.OBJ: + if rt is Type.OBJ and self.rmode: + self.emit(f"*tp_out = {v};") + elif rt is Type.OBJ: self._own_move_out(v) else: self.emit(f"*tp_out = {v};") @@ -2376,7 +2605,7 @@ def impl(self) -> str: f = self.f prologue = [] for p in f.params: - if not isinstance(p, ir.ArrayParam) and p.type is Type.OBJ: + if not isinstance(p, ir.ArrayParam) and p.type is Type.OBJ and not self.rmode: # adopt the borrowed argument: the local owns its own reference (rule 1) prologue.append(f" {_ident('l', p.name)} = Py_NewRef({_ident('p', p.name)});") self.stmts(f.body) @@ -2387,7 +2616,7 @@ def impl(self) -> str: self.emit("tp_rc = -1;") self.emit("goto tp_exit;") - out = [_impl_signature(f, fast=self.fast), "{", + out = [_region_signature(f) if self.rmode else _impl_signature(f, fast=self.fast), "{", " int tp_rc = 0;", " int tp_s = 0;", " int tp_pc = TP_POLL_INTERVAL; /* loop poll countdown (#141) */", @@ -2401,16 +2630,18 @@ def impl(self) -> str: for name, t in self.local_arrays.items(): out.append(f" {_ARRAY_CT[t]} {_ident('l', name)} = {{0}};") for name, t in self.types.items(): - if name in params and t is not Type.OBJ: + if name in params and (t is not Type.OBJ or self.rmode): continue init = "NULL" if t is Type.OBJ else "{0, NULL}" if t is Type.INT else "0" - ct = _CT[t] + ct = "tp_rptr" if self.rmode and t is Type.OBJ else _CT[t] out.append(f" {ct}{'' if ct.endswith('*') else ' '}{_ident('l', name)} = {init};") for ct, n in self.temps: - init = ("NULL" if ct == "PyObject *" else "{0}" if ct in _CT_ARRAY_REV + init = ("NULL" if ct in ("PyObject *", "tp_rptr") else "{0}" if ct in _CT_ARRAY_REV else "{0, NULL}" if ct == "tp_int" else "0") sep = "" if ct.endswith("*") else " " out.append(f" {ct}{sep}{n} = {init};") + for rg in self.rgs: # SPEC N-12b: one region variable per site of a host + out.append(f" tp_region {rg} = {{0}};") # (a fast twin declares tp_st / tp_dict only when its body reads them: PyModule_GetState and # PyModule_GetDict are external calls, and a leaf must stay free of them to inline well) out.append(" (void)tp_s; (void)tp_pc;" + (" (void)tp_fd;" if self.fast else " (void)tp_ts;") + " (void)tp_t;" @@ -2421,10 +2652,13 @@ def impl(self) -> str: # Depth guard (issue #57): nothing is owned yet, so a refusal returns directly and counts # nothing; every other exit goes through tp_exit, which leaves exactly once. for p in f.params: - if isinstance(p, ir.Param) and p.cls is not None: + if isinstance(p, ir.Param) and p.cls is not None and self.rmode: + # a region pointer is of the one class the verifier proved; None is the only other value + out.append(f" if ({_ident('l', p.name)} == NULL) return 1;") + elif isinstance(p, ir.Param) and p.cls is not None: k = self._class(p.cls) out.append(f" if ({_param_cls_fail(_ident('p', p.name), k, p.optional, f.owner is not None and p is f.params[0])}) return 1;") - k_bound = self.mg.bounded.get(f.name) + k_bound = None if self.rmode else self.mg.bounded.get(f.name) if self.fast: # Fast twin of a bounded function (module docstring, "Fast entry"): the caller proved # room for every frame this call tree can have, so there is nothing to count or refuse. @@ -2451,6 +2685,7 @@ def impl(self) -> str: out += self.lines out.append("tp_exit:") out += self._own_release_all() + out += [f" tp_region_free(&{rg});" for rg in self.rgs] if self.snapshot: out.append(" tp_t->snapshot_depth--;") if not self.fast: @@ -2463,6 +2698,8 @@ def impl(self) -> str: def generate(self) -> str: """The impl (preceded by its fast twin when bounded) and the wrapper.""" parts = [] + if self.rmode: + return self.impl() if self.f.name in self.mg.bounded: twin = _FnGen(self.mg, self.f, fast=True) twin_text = twin.impl() @@ -3061,6 +3298,18 @@ def _exec_function(mg: _ModGen) -> str: " if (tp_cf == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_class_info__\", tp_cf) < 0) goto tp_done;", " tp_release(&tp_cf);", ] + if mg.rvariants: + L += [ + " tp_cf = PyCFunction_NewEx(&tp_region_stats_def, tp_module, NULL);", + " if (tp_cf == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_region_stats__\", tp_cf) < 0) goto tp_done;", + " tp_release(&tp_cf);", + " tp_cf = PyCFunction_NewEx(&tp_region_chunks_def, tp_module, NULL);", + " if (tp_cf == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_region_chunks__\", tp_cf) < 0) goto tp_done;", + " tp_release(&tp_cf);", + " tp_cf = PyCFunction_NewEx(&tp_region_fail_def, tp_module, NULL);", + " if (tp_cf == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_region_fail_after__\", tp_cf) < 0) goto tp_done;", + " tp_release(&tp_cf);", + ] if mg.has_par: L += [ " tp_cf = PyCFunction_NewEx(&tp_par_stats_def, tp_module, NULL);", diff --git a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py index 34335c573..c4e18f57f 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py @@ -504,6 +504,7 @@ def _class_decl(node: ast.ClassDef, tree: ast.Module, info: _ModuleInfo) -> ir.C slots: ast.expr | None = None annotated: list[str] = [] init: ast.FunctionDef | None = None + has_del = False for st in node.body: if isinstance(st, ast.Expr) and isinstance(st.value, ast.Constant) \ and isinstance(st.value.value, str): @@ -520,6 +521,8 @@ def _class_decl(node: ast.ClassDef, tree: ast.Module, info: _ModuleInfo) -> ir.C return f"{at}: defines `{st.name}`, which runs user code on slot access" if st.name == "__init__": init = st + if st.name == "__del__": + has_del = True else: return (f"{at}: class body statement `{_src(st).splitlines()[0]}` (line {st.lineno}) " "is not supported (only `__slots__`, bare field annotations and methods)") @@ -535,7 +538,7 @@ def _class_decl(node: ast.ClassDef, tree: ast.Module, info: _ModuleInfo) -> ir.C if set(fields) != set(annotated): return (f"{at}: `__slots__` {fields} does not equal its annotated fields " f"{tuple(dict.fromkeys(annotated))}") - return ir.ClassDecl(name, fields, init is not None and _trivial_init(init, fields)) + return ir.ClassDecl(name, fields, init is not None and _trivial_init(init, fields), has_del) def _assigned_attributes(n: ast.AST) -> list[ast.Attribute]: diff --git a/python-multiplatform-ksp/src/main/python/typedpython/ir.py b/python-multiplatform-ksp/src/main/python/typedpython/ir.py index f367380b1..4882949da 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/ir.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/ir.py @@ -51,7 +51,7 @@ """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from enum import Enum @@ -820,6 +820,11 @@ class Function: # SPEC N-15: the user marked this function with the comment line `# typedpython: parallel` # directly above its `def`. Only a marked function can hold a `ForRange.par`. parallel: bool = False + # SPEC N-12b (#268): the producers whose result this pure function keeps inside one statement + # (`check(make(d))`), so each such call builds its objects in a region. Set only by `verify`, + # which recomputes it and rejects a function whose declared entries differ + # (`verify/region-escape`). The front end leaves it empty. + regions: tuple[str, ...] = () @dataclass(frozen=True) @@ -832,6 +837,9 @@ class ClassDecl: name: str fields: tuple[str, ...] trivial_init: bool # __init__(self, *fields-in-order) doing exactly `self.f = f` once each + # The class defines `__del__` (a finalizer): its objects are never placed in a region (SPEC N-12b), + # freeing a region would skip the finalizer. + has_del: bool = False @dataclass(frozen=True) @@ -841,3 +849,70 @@ class Module: # Functions the front end left interpreted, with the reason (for `demo` and the coverage table). skipped: dict[str, str] = field(default_factory=dict) classes: tuple[ClassDecl, ...] = () + # SPEC N-12b: the producers and consumers that get a second, internal entry taking a region. + # Set only by `verify`, which recomputes it (a declared set that differs is `verify/region-escape`). + region_variants: tuple[str, ...] = () + + +# --- region field kinds (SPEC N-12b), shared by `verify` (proof) and `cgen` (layout) --------------------- + +REGION_SCALAR_KINDS = {Type.I64: "i64", Type.F64: "f64", Type.BOOL: "bool"} + + +def region_arg_kind(a: object, ref_class) -> object: + """The region kind one `New` argument gives its field: "i64" / "f64" / "bool" for a `Box` of that + scalar, "null" for the `None` constant, ("ref", class) for a value of a region class (`ref_class(a)` + names the class of a `New` or a producer `Call`, or is None). None: the argument cannot be stored in a + region at all (any other object reference).""" + if type(a) is Box and a.np is None: + return REGION_SCALAR_KINDS.get(a.operand.type) + if type(a) is Const and a.type is Type.OBJ and a.value is None: + return "null" + c = ref_class(a) + return ("ref", c) if c is not None else None + + +def region_field_kinds(functions, ref_class) -> dict[str, list[set]]: + """class -> per field the set of kinds observed over every `New` of the class in `functions` + (a kind of None means an argument that cannot live in a region).""" + out: dict[str, list[set]] = {} + + def visit(e: object) -> None: + if type(e) is New: + slots = out.setdefault(e.cls, [set() for _ in e.args]) + if len(slots) == len(e.args): + for s, a in zip(slots, e.args): + s.add(region_arg_kind(a, ref_class)) + for x in _children_of(e): + visit(x) + + for f in functions: + for st in f.body: + visit(st) + return out + + +def _children_of(node: object): + for fl in fields(node): + v = getattr(node, fl.name) + for x in (v if isinstance(v, tuple) else (v,)): + if isinstance(x, (Expr, Stmt)): + yield x + + +def region_field_layout(kinds: list[set]) -> tuple | None: + """The one kind of each field, or None when some field mixes kinds (a scalar and a pointer, two + scalar types, pointers to two classes) or holds an argument a region cannot store. A field that is + only ever `None` is ("ref", None).""" + out = [] + for s in kinds: + real = s - {"null"} + if None in s or len(real) > 1: + return None + if real: + out.append(next(iter(real))) + elif "null" in s: + out.append(("ref", None)) + else: + return None + return tuple(out) 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 3426d8e2d..6691ef7f8 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -540,6 +540,32 @@ for i64 and for f64 without NaN (ties and signed zeros keep the first). An f64 c `TP_PAR_BAD`, and `tp_par_combine_ext_f64` returns 1 for a NaN initial accumulator: the loop then runs sequentially (`[1.0] | [nan, 0.0]` is the case a chunked fold gets wrong). +## Regions (SPEC N-12b, issue #268) +The objects of a region class that a pure compiled host builds and consumes inside one statement +(`check(make(d))`) are C structs of 8-byte slots in bump-allocated chunks: no `PyObject` header, no +refcount, no GC, so nothing but the host's C stack can reach them. Only the internal region entries +(`tp_rimpl_`, no Python name) use them; the Python-visible entry of a producer or consumer is +unchanged and still builds real objects. + typedef union tp_rslot { int64_t i; double f; union tp_rslot *p; } tp_rslot; /* one field: i64 or bool in .i, f64 in .f, a pointer (NULL is None) in .p */ + typedef tp_rslot *tp_rptr; /* an object of a region class: its first slot */ + typedef struct { tp_rchunk *head; tp_rchunk *cur; uint64_t objs; int open; } tp_region; /* zero-initialised C local of the host, one per site */ + #define TP_REGION_FIRST_SLOTS 512u /* 4 KiB; chunks double up to TP_REGION_MAX_SLOTS (1 << 20 slots) */ + void tp_region_open(tp_region *r); /* a statement starts: reuse the chunks grown before, count one opened */ + tp_rptr tp_region_alloc(tp_region *r, size_t n); /* n slots, NULL when malloc fails (or the test hook fires): the caller deopts, the interpreter redoes the call and raises its own MemoryError */ + tp_rptr tp_region_grow(tp_region *r, size_t n); /* the slow path of tp_region_alloc: the next chunk */ + void tp_region_end(tp_region *r); /* the statement is over: count one freed and the objects built; chunks stay; idempotent */ + void tp_region_free(tp_region *r); /* every exit of the host (the common exit block): tp_region_end, then free all chunks; idempotent */ + void tp_region_stats(uint64_t out[3]); /* opened, freed, objects since import */ + void tp_region_chunk_stats(uint64_t out[2]); /* chunks malloc'd, chunks freed since import: equal whenever no host is running */ + void tp_region_set_fail(int64_t n); /* test hook: the nth allocation from now fails once; -1 off (the default) */ +A module with a region variant exports `__typedpython_region_stats__()` returning `(opened, freed, +objects)`, `__typedpython_region_chunks__()` returning `(allocated, freed)` chunks, and +`__typedpython_region_fail_after__(n)` (the hook). Reference convention: a `tp_rptr` is +never a counted reference; it is valid until the `tp_region_open` of the same region var or its free. +The region holds scalars, `None` and region pointers only (the verifier's region class rule), so +freeing runs no finalizer and drops no reference. Free-threaded state: the three counters and the hook +are relaxed atomics; a region is reachable from one thread's C stack only. + ## Development-mode import gate (Python API, not C; SPEC N-7b) typedpython.importgate.install(source_roots) # meta path finder; checks project modules before they run typedpython.importgate.uninstall() 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 fefa303cb..73dec7dcc 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 @@ -3273,4 +3273,134 @@ static inline void tp_par_rerun_note(void) __atomic_fetch_add(&tp_par_reruns_v, 1, __ATOMIC_RELAXED); } +/* ---- Regions (SPEC N-12b, #268; API.md "Regions") ---- + * The objects of a region class that a pure compiled host builds and consumes inside one statement are + * C structs of 8-byte slots in bump chunks: no PyObject header, no refcount, no GC, no thread but the + * host's can reach them. A region is a C stack value of the host (one per site); the chunks it grew + * are reused by the next statement execution and freed once, on the host's exit. Counters are relaxed + * atomics (the only shared state). */ +typedef union tp_rslot { int64_t i; double f; union tp_rslot *p; } tp_rslot; +typedef tp_rslot *tp_rptr; +typedef struct tp_rchunk { struct tp_rchunk *next; size_t cap; size_t used; } tp_rchunk; /* slots follow */ +typedef struct { tp_rchunk *head; tp_rchunk *cur; uint64_t objs; int open; } tp_region; + +#define TP_REGION_FIRST_SLOTS 512u /* first chunk: 4 KiB */ +#define TP_REGION_MAX_SLOTS (1u << 20) /* chunks double up to 8 MiB */ + +static uint64_t tp_region_opened_v = 0, tp_region_freed_v = 0, tp_region_objs_v = 0; +static uint64_t tp_region_chunks_v = 0, tp_region_chunks_freed_v = 0; /* chunks malloc'd / freed */ +static int64_t tp_region_fail_v = -1; /* test hook: the nth allocation fails; -1 off */ + +static inline void tp_region_stats(uint64_t out[3]) +{ + out[0] = __atomic_load_n(&tp_region_opened_v, __ATOMIC_RELAXED); + out[1] = __atomic_load_n(&tp_region_freed_v, __ATOMIC_RELAXED); + out[2] = __atomic_load_n(&tp_region_objs_v, __ATOMIC_RELAXED); +} + +static inline void tp_region_chunk_stats(uint64_t out[2]) +{ + out[0] = __atomic_load_n(&tp_region_chunks_v, __ATOMIC_RELAXED); + out[1] = __atomic_load_n(&tp_region_chunks_freed_v, __ATOMIC_RELAXED); +} + +static inline void tp_region_set_fail(int64_t n) +{ + __atomic_store_n(&tp_region_fail_v, n, __ATOMIC_RELAXED); +} + +static inline void tp_region_open(tp_region *r) +{ + r->cur = r->head; + if (r->cur != NULL) { + r->cur->used = 0; + } + r->objs = 0; + r->open = 1; + (void)__atomic_fetch_add(&tp_region_opened_v, 1, __ATOMIC_RELAXED); +} + +/* The next chunk with room for n slots (reused when the list has one, else a new malloc'd one), or NULL. */ +static inline tp_rptr tp_region_grow(tp_region *r, size_t n) +{ + tp_rchunk *c = r->cur; + tp_rchunk *nx = c != NULL ? c->next : r->head; + size_t cap; + if (nx != NULL && nx->cap >= n) { + nx->used = 0; + } else { + cap = c == NULL ? TP_REGION_FIRST_SLOTS : c->cap * 2u; + if (cap > TP_REGION_MAX_SLOTS) { + cap = TP_REGION_MAX_SLOTS; + } + if (cap < n) { + cap = n; + } + nx = (tp_rchunk *)malloc(sizeof(tp_rchunk) + cap * sizeof(tp_rslot)); + if (nx == NULL) { + return NULL; + } + nx->cap = cap; + nx->used = 0; + (void)__atomic_fetch_add(&tp_region_chunks_v, 1, __ATOMIC_RELAXED); + if (c != NULL) { + nx->next = c->next; /* keep the chunks that were already grown */ + c->next = nx; + } else { + nx->next = r->head; + r->head = nx; + } + } + r->cur = nx; + nx->used = n; + r->objs++; + return (tp_rslot *)(void *)(nx + 1); +} + +/* n slots (one object), or NULL on failure: the caller deopts and the interpreter redoes the call. */ +static inline tp_rptr tp_region_alloc(tp_region *r, size_t n) +{ + tp_rchunk *c = r->cur; + tp_rslot *p; + if (__builtin_expect(__atomic_load_n(&tp_region_fail_v, __ATOMIC_RELAXED) >= 0, 0)) { + if (__atomic_sub_fetch(&tp_region_fail_v, 1, __ATOMIC_RELAXED) == 0) { + __atomic_store_n(&tp_region_fail_v, -1, __ATOMIC_RELAXED); + return NULL; + } + } + if (c != NULL && c->cap - c->used >= n) { + p = (tp_rslot *)(void *)(c + 1) + c->used; + c->used += n; + r->objs++; + return p; + } + return tp_region_grow(r, n); +} + +/* The statement is over: count it. The chunks stay for the next execution. Idempotent. */ +static inline void tp_region_end(tp_region *r) +{ + if (r->open) { + r->open = 0; + (void)__atomic_fetch_add(&tp_region_freed_v, 1, __ATOMIC_RELAXED); + (void)__atomic_fetch_add(&tp_region_objs_v, r->objs, __ATOMIC_RELAXED); + r->objs = 0; + } +} + +/* Every exit of the host: end the open statement, if any, and free all chunks. Idempotent. */ +static inline void tp_region_free(tp_region *r) +{ + tp_rchunk *c = r->head; + tp_region_end(r); + while (c != NULL) { + tp_rchunk *nx = c->next; + free(c); + (void)__atomic_fetch_add(&tp_region_chunks_freed_v, 1, __ATOMIC_RELAXED); + c = nx; + } + r->head = NULL; + r->cur = NULL; +} + #endif /* TP_RUNTIME_H */ diff --git a/python-multiplatform-ksp/src/main/python/typedpython/verify.py b/python-multiplatform-ksp/src/main/python/typedpython/verify.py index 6afd86671..10e756cb1 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/verify.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/verify.py @@ -55,6 +55,9 @@ New names a class without trivial_init or has the wrong arity, or a ClassDecl itself is malformed (bad name/fields, duplicates) verify/field-unproved a FieldGet/FieldSet whose object is not proved to be exactly the class + verify/region-escape `Function.regions` (or `Module.region_variants`) names a producer the + analysis of SPEC N-12b does not find confined to one statement of a pure + host (see "Regions" below `_REGION_OK`) Fixed-layout classes (SPEC N-11). The verifier never trusts the front end for them either: @@ -301,6 +304,7 @@ CONDITION_ONLY = "verify/condition-only" PROVEN = "verify/proven-overflow" VIRTUAL = "verify/virtual-escape" +REGION_ESCAPE = "verify/region-escape" REDO = "verify/redo" ENTRY_OPEN = "verify/entry-global-open" ARRAY_OPEN = "verify/array-open" @@ -1992,6 +1996,344 @@ def _virtual_arg(a: object, params: set[str]) -> bool: return type(a) is Local and a.type is Type.OBJ and a.name in params +# --- regions (SPEC N-12b, #268) -------------------------------------------------------------------------- + +# The nodes a producer, a consumer or a host may contain: the virtual-object set plus a Call of a pure +# compiled function. None of them runs user code, so nothing can observe a region object. The scan +# below narrows it further (no array node, no OBJ value outside the three allowed shapes). +_REGION_OK = _VIRTUAL_OK | frozenset({Call}) +_REGION_STMTS = frozenset({Assign, If, While, ForRange, Return, Break, Continue}) +_REGION_SCALARS = (Type.I64, Type.F64, Type.BOOL) +_REGION_ARRAY_NODES = frozenset({Len, Index, NewArray, CopyArray}) + + +def _nodes(x: object): + """Every ir node under `x` (an expression, a statement or a tuple of them), `x` itself first.""" + if isinstance(x, tuple): + for y in x: + yield from _nodes(y) + elif isinstance(x, (ir.Expr, ir.Stmt)): + yield x + for f in dataclasses.fields(x): + yield from _nodes(getattr(x, f.name)) + + +def _is_none(x: object) -> bool: + return type(x) is Const and x.type is Type.OBJ and x.value is None + + +class _RegionScan: + """One function checked against one role: "p" producer, "c" consumer, "h" host. `ok` is False as + soon as something outside the shapes of SPEC N-12b appears. That is the escape rule: a value of a + region class may be built by a producer (`New`, `Call` of a producer, as a `Return` value or a `New` + argument), passed by a host straight to a consumer, and read by a consumer (`FieldGet`, `Is None`, + passed on to a consumer); nothing else, so it is never returned, stored, boxed, compared by identity + or handed to unknown code. `sites` are the (producer, consumer) calls a host makes.""" + + def __init__(self, fn: Function, role: str, funcs: dict[str, Function], cds: dict[str, ClassDecl], + R: dict[str, tuple], P: dict[str, str], K: set[str]): + self.fn, self.role, self.funcs, self.cds, self.R, self.P, self.K = fn, role, funcs, cds, R, P, K + self.ok = True + self.sites: list[tuple[str, str]] = [] + self.ptr: dict[str, str | None] = {} # consumer: pointer parameter / local -> class + self.scalars = {p.name: p.type for p in fn.params if isinstance(p, Param)} + self.scalars.update(fn.locals) + + def fail(self) -> None: + self.ok = False + + def field_kind(self, cls: str, field: str): + lay, cd = self.R.get(cls), self.cds.get(cls) + if lay is None or cd is None or field not in cd.fields: + return None + return lay[cd.fields.index(field)] + + # -- pointer expressions (consumer only) -- + + def ptrclass(self, e: object) -> tuple[bool, str | None]: + """(True, class | None) when `e` is a pointer expression of a consumer: a pointer parameter or + local, a `CheckExact` of one (a NULL test), or a pointer-kind `FieldGet` of one.""" + t = type(e) + if self.role != "c": + return False, None + if t is Local and e.type is Type.OBJ and e.name in self.ptr: + return True, self.ptr[e.name] + if t is CheckExact: + ok, c = self.ptrclass(e.obj) + return (True, c) if ok and c is not None and c == e.cls else (False, None) + if t is FieldGet: + ok, c = self.ptrclass(e.obj) + kind = self.field_kind(e.cls, e.field) if ok and c == e.cls else None + if kind is not None and kind[0] == "ref": + return True, kind[1] + return False, None + + def infer_ptrs(self) -> None: + for p in self.fn.params: + if isinstance(p, Param) and p.type is Type.OBJ: + if p.cls in self.R and not p.optional: + self.ptr[p.name] = p.cls + else: + self.fail() + for _ in range(8): + changed = False + for st in _nodes(self.fn.body): + if type(st) is Assign and self.scalars.get(st.target) is Type.OBJ and st.target not in self.ptr: + ok, c = self.ptrclass(st.value) + if ok: + self.ptr[st.target] = c + changed = True + if not changed: + break + for st in _nodes(self.fn.body): # every assignment agrees with the one class of its target + if type(st) is Assign and self.scalars.get(st.target) is Type.OBJ: + ok, c = self.ptrclass(st.value) + if not ok or self.ptr.get(st.target, "?") != c: + self.fail() + for name, t in self.scalars.items(): + if t is Type.OBJ and name not in self.ptr: + self.fail() + + # -- expressions -- + + def sc(self, e: object) -> None: + """A scalar-valued expression.""" + t = type(e) + et = getattr(e, "type", None) + if t not in _REGION_OK or t in _REGION_ARRAY_NODES or et not in _REGION_SCALARS: + self.fail() + return + if t is Const or t is Local: + return + if t is Unbox: + op = e.operand + if type(op) is FieldGet: + ok, c = self.ptrclass(op.obj) + kind = self.field_kind(op.cls, op.field) if ok and c == op.cls else None + if kind is not None and kind == ir.REGION_SCALAR_KINDS.get(e.type): + return + self.fail() + return + if t is Call: + self.call(e) + return + if t is Is: + if (_is_none(e.right) and self.ptrclass(e.left)[0]) or (_is_none(e.left) and self.ptrclass(e.right)[0]): + return + self.fail() + return + if t is IsExact: + ok, c = self.ptrclass(e.obj) + if not (ok and c == e.cls): + self.fail() + return + for x in _exprs_of(e): + self.sc(x) + + def call(self, e: Call) -> None: + g = self.funcs.get(e.function) + if g is None or e.redo or len(e.args) != len(g.params) or e.function in self.P: + self.fail() # (a producer's value is only a New / Return / site argument) + return + if e.function in self.K: + if self.role == "p": + self.fail() + return + for p, a in zip(g.params, e.args): + if not isinstance(p, Param): + self.fail() + elif p.type is Type.OBJ: + if self.role == "c": + ok, c = self.ptrclass(a) + if not (ok and c == p.cls): + self.fail() + elif (type(a) is Call and a.function in self.P and self.P[a.function] == p.cls + and a.type is Type.OBJ and not a.redo + and len(a.args) == len(self.funcs[a.function].params)): + for x in a.args: + self.sc(x) + self.sites.append((a.function, e.function)) + else: + self.fail() + else: + self.sc(a) + return + # any other pure compiled function with scalar parameters and a scalar result + if not (g.pure is True and g.returns in _REGION_SCALARS + and all(isinstance(p, Param) and p.type in _REGION_SCALARS for p in g.params)): + self.fail() + return + for a in e.args: + self.sc(a) + + def ref(self, e: object, cls: str | None) -> None: + """A value of a region class built here (a producer's `Return`, a `New` argument): a `New`, or a + `Call` of a producer, of class `cls`.""" + t = type(e) + if t is New and e.cls in self.R and (cls is None or e.cls == cls): + lay = self.R[e.cls] + if len(e.args) != len(lay): + self.fail() + return + for a, kind in zip(e.args, lay): + if isinstance(kind, str): + if type(a) is Box and a.np is None and ir.REGION_SCALAR_KINDS.get(a.operand.type) == kind: + self.sc(a.operand) + else: + self.fail() + elif not _is_none(a): + self.ref(a, kind[1]) + return + if t is Call and e.function in self.P and (cls is None or self.P[e.function] == cls) \ + and e.type is Type.OBJ and not e.redo and len(e.args) == len(self.funcs[e.function].params): + for a in e.args: + self.sc(a) + return + self.fail() + + # -- statements -- + + def block(self, body: object) -> None: + if not isinstance(body, tuple): + self.fail() + return + for st in body: + t = type(st) + if t not in _REGION_STMTS: + self.fail() + elif t is Assign: + target = self.scalars.get(st.target) + if target is Type.OBJ: + if not self.ptrclass(st.value)[0]: + self.fail() + elif target in _REGION_SCALARS: + self.sc(st.value) + else: + self.fail() + elif t is If: + self.sc(st.cond) + self.block(st.then) + self.block(st.orelse) + elif t is While: + self.sc(st.cond) + self.block(st.body) + elif t is ForRange: + if st.par is not None or self.scalars.get(st.var) not in _REGION_SCALARS: + self.fail() + self.sc(st.start) + self.sc(st.stop) + self.block(st.body) + elif t is Return: + if st.value is None: + self.fail() + elif self.role == "p": + self.ref(st.value, self.P.get(self.fn.name)) + else: + self.sc(st.value) + + def run(self) -> "_RegionScan": + """Every condition is checked, none returns early: `fail` is the one place a function is refused.""" + fn = self.fn + if fn.pure is not True or fn.owner is not None or (fn.entry_globals and self.role != "h"): + self.fail() + for p in fn.params: + if not isinstance(p, Param) or (p.type not in _REGION_SCALARS and not ( + p.type is Type.OBJ and self.role == "c" and p.cls is not None)): + self.fail() + if any(t not in _REGION_SCALARS and not (t is Type.OBJ and self.role == "c") + for t in fn.locals.values()): + self.fail() + if (fn.returns is Type.OBJ) != (self.role == "p") or ( + self.role != "p" and fn.returns not in _REGION_SCALARS): + self.fail() + if self.role == "c": + self.infer_ptrs() + self.block(fn.body) + return self + + +def _producer_class(fn: Function, known: dict[str, str]) -> str | None: + """The one class every `Return` of `fn` builds: a `New`, or a `Call` of a producer whose class is + already `known`; a `Call` of a not yet known one is skipped, anything else is no producer.""" + classes: set = set() + for st in _nodes(fn.body): + if type(st) is Return: + v = st.value + if type(v) is New: + classes.add(v.cls) + elif type(v) is Call: + if v.function in known: + classes.add(known[v.function]) + else: + return None + return next(iter(classes)) if len(classes) == 1 else None + + +def _region_analysis(funcs: dict[str, Function], cds: dict[str, ClassDecl] + ) -> tuple[dict[str, tuple[str, ...]], tuple[str, ...]]: + """(host -> the producers it opens a region for, the producers and consumers that need a region + entry), recomputed from the proved functions alone (SPEC N-12b). A greatest fixpoint: start from every + class and function of the right shape, remove what fails a scan, repeat until nothing changes.""" + written = {n.cls for f in funcs.values() for n in _nodes(f.body) if type(n) is FieldSet} + R: dict[str, tuple] = {n: () for n, cd in cds.items() + if cd.trivial_init and cd.fields and not cd.has_del and n not in written} + P: dict[str, str] = {} + K: set[str] = set() + for n, f in funcs.items(): + if f.pure is True and f.owner is None and isinstance(f.body, tuple): + if f.returns is Type.OBJ and all(isinstance(p, Param) and p.type in _REGION_SCALARS + for p in f.params): + P[n] = "" + elif f.returns in _REGION_SCALARS and any(isinstance(p, Param) and p.type is Type.OBJ + for p in f.params): + K.add(n) + while True: + before = (frozenset(R), frozenset(P), frozenset(K)) + known: dict[str, str] = {} + for _ in range(len(P) + 1): + for n in P: + c = _producer_class(funcs[n], known) + if c is not None: + known[n] = c + P = {n: c for n, c in known.items() if c in R and n in P} + + def ref_class(a: object): + if type(a) is New: + return a.cls if a.cls in R else None + return P.get(a.function) if type(a) is Call else None + + kinds = ir.region_field_kinds(funcs.values(), ref_class) + for c in list(R): + lay = ir.region_field_layout(kinds[c]) if c in kinds else None + if lay is None or any(isinstance(k, tuple) and k[1] is not None and k[1] not in R for k in lay): + del R[c] + else: + R[c] = lay + for n in list(P): + if not _RegionScan(funcs[n], "p", funcs, cds, R, P, K).run().ok: + del P[n] + for n in list(K): + if not _RegionScan(funcs[n], "c", funcs, cds, R, P, K).run().ok: + K.discard(n) + if before == (frozenset(R), frozenset(P), frozenset(K)): + break + hosts: dict[str, tuple[str, ...]] = {} + used: set[str] = set() + if P and K: + for n, f in funcs.items(): + s = _RegionScan(f, "h", funcs, cds, R, P, K).run() + if s.ok and s.sites: + hosts[n] = tuple(sorted({p for p, _ in s.sites})) + used.update(x for site in s.sites for x in site) + todo = list(used) + while todo: + for x in _nodes(funcs[todo.pop()].body): + if type(x) is Call and (x.function in P or x.function in K) and x.function not in used: + used.add(x.function) + todo.append(x.function) + return hosts, tuple(sorted(used)) + + def _assigned(body: object, counts: dict[str, int] | None = None) -> dict[str, int]: """Every name assigned by an Assign statement anywhere in `body` (nested blocks included), with the number of Assign statements. Tolerates a malformed body (the checker reports it).""" @@ -2264,25 +2606,48 @@ def reject(fn_name: str, line: int, rule: str, message: str) -> None: # through any Call: each flag is proved on the function's own body against its callees' # declared flags, so a cycle through a may_deopt callee is legal only in a pure function. - # A call into a function that is not compiled cannot be a C call: propagate to a fixpoint. - changed = True - while changed: - changed = False - for name, cs in callees.items(): - if name in reasons: - continue - bad = sorted(c for c in cs if c in reasons) - if bad: - reject(name, sigs[name].source_line, UNVERIFIED, - f"calls '{bad[0]}', which is not compiled ({reasons[bad[0]]})") - changed = True - - kept = tuple(checked[g.name] for g in functions + # A call into a function that is not compiled cannot be a C call: propagate to a fixpoint. Then the + # regions (SPEC N-12b) are recomputed over the survivors; a function that declares regions the + # analysis does not find is rejected, which can reject its callers, so both repeat until stable. + region_hosts: dict[str, tuple[str, ...]] = {} + region_variants: tuple[str, ...] = () + while True: + changed = True + while changed: + changed = False + for name, cs in callees.items(): + if name in reasons: + continue + bad = sorted(c for c in cs if c in reasons) + if bad: + reject(name, sigs[name].source_line, UNVERIFIED, + f"calls '{bad[0]}', which is not compiled ({reasons[bad[0]]})") + changed = True + alive = {n: checked[n] for n in checked if n not in reasons} + region_hosts, region_variants = _region_analysis(alive, classes) + forged = [n for n, g in alive.items() + if isinstance(g.regions, tuple) and g.regions and g.regions != region_hosts.get(n, ())] + for n in forged: + reject(n, alive[n].source_line, REGION_ESCAPE, + f"regions {sorted(alive[n].regions)} declared, but only " + f"{sorted(region_hosts.get(n, ()))} are provably confined to one statement (SPEC N-12b)") + if not forged: + break + if isinstance(module.region_variants, tuple) and module.region_variants \ + and module.region_variants != region_variants: + d = Diagnostic(module.name, REGION_ESCAPE, + f"region_variants {sorted(module.region_variants)} declared, but the analysis finds " + f"{sorted(region_variants)} (SPEC N-12b)", 0) + if d not in diags: + diags.append(d) + kept = tuple(dataclasses.replace(checked[g.name], regions=region_hosts.get(g.name, ())) + if region_hosts.get(g.name) else checked[g.name] + for g in functions if type(g) is Function and g.name in checked and g.name not in reasons) skipped = dict(module.skipped) for name, reason in {**skipped_classes, **reasons}.items(): skipped.setdefault(name, reason) - return Module(module.name, kept, skipped, tuple(classes.values())), diags + return Module(module.name, kept, skipped, tuple(classes.values()), region_variants), diags def _owner_problem(g: Function, classes: dict[str, ClassDecl]) -> str | None: diff --git a/python-multiplatform-ksp/src/test/python/test_regions.py b/python-multiplatform-ksp/src/test/python/test_regions.py new file mode 100644 index 000000000..3456586dd --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_regions.py @@ -0,0 +1,633 @@ +"""TypedPython region allocation for unbounded virtual objects (SPEC N-12b, #268). + +A recursive tree built by compiled `make(d)` and consumed by compiled `check(t)` inside one compiled +host (`check(make(d))`) is never observable, so its nodes live in a bump-allocated region of that +activation: no PyObject exists. Design: docs/design/typedpython-regions.md. + +Red record: written before any implementation (11 red, 5 green on the first run). The test names keep the +RED / GREEN suffix of that record; the suffix says what the test was before the implementation, all pass +now. A "RED:" message marked a missing feature, not a regression. + +Names asserted here (`Function.regions`, `Module.region_variants`, rule `verify/region-escape`, +`__typedpython_region_stats__`, `__typedpython_region_fail_after__`, C symbol `tp_region_open`) are the +implemented ones; rename them in one place (the constants below). +""" +from __future__ import annotations + +import dataclasses +import gc +import sys +import textwrap +import threading +import tracemalloc +import types + +import pytest + +from test_cgen import runtime_dir, need_runtime +from typedpython import cbuild, cgen, cost, frontend, verify + +REGION_ESCAPE = "verify/region-escape" +FN_FIELD = "regions" # Function.regions: the region scopes the function opens +MOD_FIELD = "region_variants" # Module.region_variants: functions that get a hidden-region clone +C_OPEN = "tp_region_open" +STATS = "__typedpython_region_stats__" # () -> (opened, freed, objects) +FAIL_AFTER = "__typedpython_region_fail_after__" # (n) -> the nth region object allocation fails; -1 off + +SOURCE = textwrap.dedent('''\ + # typedpython: compiled + """typedpython region test module.""" + + KEPT = None + + + class Node: + __slots__ = ("left", "right") + left: "Node | None" + right: "Node | None" + + def __init__(self, left: "Node | None", right: "Node | None") -> None: + self.left = left + self.right = right + + + def make(depth: int) -> Node: + if depth > 0: + return Node(make(depth - 1), make(depth - 1)) + return Node(None, None) + + + def check(node: Node) -> int: + left: Node | None = node.left + right: Node | None = node.right + if left is None or right is None: + return 1 + return 1 + check(left) + check(right) + + + def tree_sum(d: int) -> int: + return check(make(d)) + + + def repeat(d: int, n: int) -> int: + t: int = 0 + for _ in range(n): + t += check(make(d)) + return t + + + def escapes(d: int) -> Node: + return make(d) + + + def keeps(d: int) -> int: + global KEPT + KEPT = make(d) + return check(KEPT) + + + def prints(d: int) -> int: + n = make(d) + print("unknown code while the tree is alive") + return check(n) + + + def ident(d: int) -> int: + return id(make(d)) * 0 + + + def same(d: int) -> bool: + return make(d) is make(d) + + + def mixed(d: int) -> int: + t = make(d) + return check(make(d)) + check(t) + + + def noisy(d: int) -> object: + print("unknown code") + return check(make(d)) +''') + +HOSTS = ("tree_sum", "repeat") # must open a region +NOT_HOSTS = ("escapes", "keeps", "prints", "ident", "same", "mixed", "noisy", "make", "check") + + +def region_of(fn) -> tuple: + return tuple(getattr(fn, FN_FIELD, ()) or ()) + + +def red(cond: bool, what: str) -> None: + assert cond, f"RED: not implemented yet: {what}" + + +@pytest.fixture(scope="module") +def built(tmp_path_factory): + need_runtime() + d = tmp_path_factory.mktemp("regmod") + path = d / "regmod.py" + path.write_text(SOURCE) + proved, diags = verify.verify(frontend.lower(path)) + proved, diags = cost.apply(proved, diags) + text = cgen.generate(proved, path, path.name) + so = cbuild.build(text, "regmod", d / "out", runtime_dir=runtime_dir()) + ref = types.ModuleType("regref") + exec(compile(SOURCE, "regref", "exec"), ref.__dict__) + return types.SimpleNamespace(path=path, proved=proved, diags=diags, text=text, ref=ref, + m=cbuild.load("regmod", so), + fns={f.name: f for f in proved.functions}) + + +def nodes(d: int) -> int: + return 2 ** (d + 1) - 1 + + +# --- the verifier proves the region (RED) -------------------------------------------------------------- + +def test_hosts_open_a_region_RED(built): + for name in HOSTS: + assert name in built.fns, f"{name} must compile (it is pure)" + red(len(region_of(built.fns[name])) >= 1, f"{name} opens no region (Function.{FN_FIELD})") + + +def test_make_and_check_get_region_variants_RED(built): + variants = set(getattr(built.proved, MOD_FIELD, ()) or ()) + red(variants == {"make", "check"}, f"Module.{MOD_FIELD} is {sorted(variants)}, want make and check") + + +def test_a_forged_region_on_an_escaping_function_is_rejected_RED(built): + red(any(f.name == "regions" for f in dataclasses.fields(type(built.fns["escapes"]))), + f"ir.Function has no field {FN_FIELD}") + forged = dataclasses.replace(built.fns["escapes"], **{FN_FIELD: ("make",)}) + mod = dataclasses.replace(built.proved, functions=tuple( + forged if f.name == "escapes" else f for f in built.proved.functions)) + _, diags = verify.verify(mod) + assert any(d.rule == REGION_ESCAPE for d in diags) + + +def test_the_non_hosts_open_no_region_GREEN(built): + """Negative cases (return, global store, unknown code alive, id, is): green now only because + nothing is ever a region; the mutation test below is what makes this able to fail.""" + for name in NOT_HOSTS: + if name in built.fns: + assert region_of(built.fns[name]) == (), name + + +def test_the_region_node_set_holds_no_unknown_code(built): + from typedpython import ir + assert hasattr(verify, "_REGION_OK"), "verify._REGION_OK (the node set a region host may contain)" + assert ir.Call in verify._REGION_OK and ir.New in verify._REGION_OK + for node in (ir.CallObject, ir.GetAttr, ir.Global, ir.Truth, ir.CompareObj, ir.FieldSet): + assert node not in verify._REGION_OK, node.__name__ + + +def test_mutation_disabling_the_escape_check_turns_the_escaping_cases_into_hosts(built, monkeypatch): + """The escape check (`verify._RegionScan.fail`: every way a region value or unknown code can get + into a function) is what keeps `mixed` (a producer result held in a local) out of a region. With the + check disabled it becomes a host. (`noisy`, an impure function that prints, stays a non-host even + then: an impure caller's calls carry `redo`, which is not the shape of a region site either.) + + The earlier version of this test mutated `verify._REGION_OK` with CallObject and expected `prints` to + become a host. That cannot be expressed: `prints` is impure, the front end does not compile it at + all (`check(n)` is an object in an impure function), and the host rule also requires purity, which + is not a node-set property. `mixed` is compiled, so the mutation is observable.""" + assert "mixed" in built.fns and region_of(built.fns["mixed"]) == () + monkeypatch.setattr(verify._RegionScan, "fail", lambda self: None) + mutated, _ = verify.verify(frontend.lower(built.path)) + got = {f.name: region_of(f) for f in mutated.functions if f.name == "mixed"} + assert got == {"mixed": ("make",)}, got + + +# --- the generated C ------------------------------------------------------------------------------------ + +def test_the_host_opens_a_region_in_c_RED(built): + red(C_OPEN in built.text, f"no {C_OPEN} in the generated C") + + +# --- the runtime: counters, no PyObject, cost (RED) ----------------------------------------------------- + +def test_one_region_per_call_holding_every_node_RED(built): + stats = getattr(built.m, STATS, None) + red(stats is not None, STATS) + o0, f0, n0 = stats() + assert built.m.tree_sum(6) == nodes(6) + o1, f1, n1 = stats() + assert (o1 - o0, f1 - f0, n1 - n0) == (1, 1, nodes(6)) + + +def test_a_loop_resets_the_region_per_statement_RED(built): + stats = getattr(built.m, STATS, None) + red(stats is not None, STATS) + o0, f0, n0 = stats() + assert built.m.repeat(4, 50) == 50 * nodes(4) + o1, f1, n1 = stats() + # one region per statement execution: memory is bounded by one tree, not by 50 trees + assert (o1 - o0, f1 - f0, n1 - n0) == (50, 50, 50 * nodes(4)) + + +def test_no_python_object_is_allocated_for_the_tree_RED(built): + """tracemalloc sees every PyMem allocation: the interpreter's tree is large, the region's is not.""" + d = 12 + + def peak(fn): + tracemalloc.start() + try: + tracemalloc.reset_peak() + fn(d) + return tracemalloc.get_traced_memory()[1] + finally: + tracemalloc.stop() + + interp = peak(built.ref.tree_sum) + compiled = peak(built.m.tree_sum) + assert interp > 100_000 + red(compiled < interp // 10, f"compiled peak {compiled} B is not far below interpreted {interp} B") + + +def test_region_allocation_failure_deopts_and_frees_RED(built): + fail = getattr(built.m, FAIL_AFTER, None) + red(fail is not None, FAIL_AFTER) + stats = getattr(built.m, STATS) + d0 = built.m.__typedpython_deopts__ + o0, f0, _ = stats() + fail(10) + try: + assert built.m.tree_sum(5) == nodes(5) # the interpreter redoes the call + finally: + fail(-1) + o1, f1, _ = stats() + assert built.m.__typedpython_deopts__ == d0 + 1 + assert (o1 - o0, f1 - f0) == (1, 1) + + +def test_class_change_deopts_and_frees_the_region_RED(built): + stats = getattr(built.m, STATS, None) + red(stats is not None, STATS) + saved = built.m.Node + + class Node2: + __slots__ = ("left", "right") + + def __init__(self, left, right): + self.left, self.right = left, right + + o0, f0, _ = stats() + try: + built.m.Node = Node2 + assert built.m.tree_sum(3) == built.ref.tree_sum(3) + finally: + built.m.Node = saved + o1, f1, _ = stats() + assert o1 - o0 == f1 - f0 # whatever opened was freed + + +def test_threads_never_find_a_region_object_RED(built): + stats = getattr(built.m, STATS, None) + red(stats is not None, STATS) + stop = threading.Event() + seen = [] + + def finder(): + while not stop.is_set(): + seen.append(sum(1 for o in gc.get_objects() if type(o) is built.m.Node)) + + def work(): + for i in range(200): + assert built.m.tree_sum(i % 8) == nodes(i % 8) + + o0, f0, _ = stats() + t = threading.Thread(target=finder) + ws = [threading.Thread(target=work) for _ in range(4)] + t.start() + for w in ws: + w.start() + for w in ws: + w.join() + stop.set() + t.join() + o1, f1, _ = stats() + assert o1 - o0 == 800 and f1 - f0 == 800 + assert max(seen, default=0) == 0 # no Node was ever visible to gc + + +# --- differential results and observations (GREEN now, must stay) --------------------------------------- + +def test_results_equal_the_interpreter_GREEN(built): + for d in (0, 1, 5, 10): + assert built.m.tree_sum(d) == built.ref.tree_sum(d) + assert built.m.repeat(d, 3) == built.ref.repeat(d, 3) + assert built.m.prints(3) == built.ref.prints(3) + assert built.m.ident(3) == 0 and built.m.same(2) is False + + +def test_an_escaping_make_still_returns_real_nodes_GREEN(built): + t = built.m.escapes(3) + assert type(t) is built.m.Node and type(t.left) is built.m.Node + assert built.m.check(t) == nodes(3) # the normal entry of check, real objects + assert built.m.keeps(3) == nodes(3) and built.m.KEPT is not None + + +def test_gc_and_allocation_counts_are_unchanged_by_a_call_GREEN(built): + built.m.tree_sum(8) # warm up lazily created state + gc.collect() + nodes = lambda: sum(1 for o in gc.get_objects() if type(o) is built.m.Node) + n0, b0 = nodes(), sys.getallocatedblocks() + for _ in range(5): + built.m.tree_sum(8) + gc.collect() + assert nodes() == n0 # no Node is left behind (counting all objects saw pytest's own) + assert abs(sys.getallocatedblocks() - b0) < 1000 # a leaked tree is ~2300 blocks per call + + +def test_tracing_runs_the_interpreted_function_GREEN(built): + def run(fn): + events = [] + + def tracer(frame, event, arg): + if frame.f_code.co_name in ("tree_sum", "make", "check"): + events.append((frame.f_code.co_name, event)) + return tracer + + sys.settrace(tracer) + try: + r = fn(2) + finally: + sys.settrace(None) + return r, events + + assert run(built.m.tree_sum) == run(built.ref.tree_sum) + + +# --- more cases (added with the implementation) --------------------------------------------------------- + +def test_the_python_visible_entries_never_use_a_region(built): + stats = built.m.__typedpython_region_stats__ + s0 = stats() + t = built.m.make(6) + assert type(t) is built.m.Node and built.m.check(t) == nodes(6) + built.m.escapes(4) + assert stats() == s0 # no region was opened by a Python-visible entry + assert not [n for n in dir(built.m) if "rimpl" in n] # the internal entries have no Python name + + +def test_a_forged_module_level_variant_set_is_reported(built): + forged = dataclasses.replace(built.proved, region_variants=("tree_sum",)) + _, diags = verify.verify(forged) + assert any(d.rule == REGION_ESCAPE for d in diags) + + +def test_a_finalizer_or_a_field_store_keeps_the_class_out_of_a_region(tmp_path): + base = SOURCE.split("def escapes")[0] + with_del = base.replace(" self.right = right\n", + " self.right = right\n\n def __del__(self) -> None:\n pass\n", 1) + with_store = base + textwrap.dedent('''\ + def poke(node: Node) -> int: + node.left = None + return 0 + ''') + for name, text in (("plain", base), ("del", with_del), ("store", with_store)): + path = tmp_path / f"{name}.py" + path.write_text(text) + mod, _ = verify.verify(frontend.lower(path)) + hosts = {f.name for f in mod.functions if f.regions} + assert hosts == ({"tree_sum", "repeat"} if name == "plain" else set()), (name, hosts) + + +# --- scalar and mixed fields: hand-built IR, the front end cannot read a scalar field ---------------- + +SCALAR_SOURCE = textwrap.dedent('''\ + class Cell: + __slots__ = ("val", "w", "ok", "next") + + def __init__(self, val, w, ok, next): + self.val = val + self.w = w + self.ok = ok + self.next = next + + + def build(n, big): + if n > 0: + return Cell(n * big, n * 0.5, n % 2 == 0, build(n - 1, big)) + return Cell(0, 0.0, True, None) + + + def total(c): + acc = c.w + c.val + if c.ok: + acc += 1.0 + if c.next is None: + return acc + return acc + total(c.next) + + + def chain_total(n, big): + return total(build(n, big)) +''') + + +def _scalar_module(): + from typedpython import ir + from typedpython.ir import (Assign, BinOp, BinOpKind, Box, Call, ClassDecl, Compare, CompareKind, Const, + FieldGet, If, Is, Module, New, Param, Return, ToFloat, Type, Unbox) + from test_cgen import I64, F64, BOOL, OBJ, c, L, fn + cell = ClassDecl("Cell", ("val", "w", "ok", "next"), True) + + def get(field): + return FieldGet(OBJ, L("c", OBJ), "Cell", field) + + n, big = L("n", I64), L("big", I64) + node = New(OBJ, "Cell", ( + Box(OBJ, BinOp(I64, BinOpKind.MUL, n, big)), + Box(OBJ, BinOp(F64, BinOpKind.MUL, ToFloat(F64, n), c(0.5))), + Box(OBJ, Compare(BOOL, CompareKind.EQ, BinOp(I64, BinOpKind.MOD, n, c(2)), c(0))), + Call(OBJ, "build", (BinOp(I64, BinOpKind.SUB, n, c(1)), big)))) + leaf = New(OBJ, "Cell", (Box(OBJ, c(0)), Box(OBJ, c(0.0)), Box(OBJ, c(True)), Const(OBJ, None))) + build = fn("build", [Param("n", I64), Param("big", I64)], OBJ, {}, + [If(Compare(BOOL, CompareKind.GT, n, c(0)), (Return(node),)), Return(leaf)], + pure=True, may_deopt=True) + acc = L("acc", F64) + total = fn("total", [Param("c", OBJ, cls="Cell")], F64, {"acc": F64}, + [Assign("acc", BinOp(F64, BinOpKind.ADD, Unbox(F64, get("w")), + ToFloat(F64, Unbox(I64, get("val"))))), + If(Unbox(BOOL, get("ok")), (Assign("acc", BinOp(F64, BinOpKind.ADD, acc, c(1.0))),)), + If(Is(BOOL, get("next"), Const(OBJ, None)), (Return(acc),)), + Return(BinOp(F64, BinOpKind.ADD, acc, Call(F64, "total", (get("next"),))))], + pure=True, may_deopt=True) + host = fn("chain_total", [Param("n", I64), Param("big", I64)], F64, {}, + [Return(Call(F64, "total", (Call(OBJ, "build", (n, big)),)))], pure=True, may_deopt=True) + return Module(name="tp_region_scalar", functions=(build, total, host), classes=(cell,)) + + +@pytest.fixture(scope="module") +def scalar(tmp_path_factory): + need_runtime() + d = tmp_path_factory.mktemp("regscalar") + path = d / "tp_region_scalar.py" + path.write_text(SCALAR_SOURCE) + proved, diags = verify.verify(_scalar_module()) + assert not diags, diags + text = cgen.generate(proved, path, path.name) + so = cbuild.build(text, "tp_region_scalar", d / "out", runtime_dir=runtime_dir()) + return types.SimpleNamespace(proved=proved, text=text, m=cbuild.load("tp_region_scalar", so)) + + +def test_scalar_and_pointer_fields_share_one_node(scalar): + assert scalar.proved.region_variants == ("build", "total") + assert {f.name: f.regions for f in scalar.proved.functions}["chain_total"] == ("build",) + for member in (".i =", ".f =", ".p ="): + assert member in scalar.text, member + m = scalar.m + ref = m.__typedpython_interpreted__ + stats = m.__typedpython_region_stats__ + for n in (0, 1, 2, 9, 40): + o0, f0, k0 = stats() + assert m.chain_total(n, 3) == ref["chain_total"](n, 3) + o1, f1, k1 = stats() + assert (o1 - o0, f1 - f0, k1 - k0) == (1, 1, n + 1) + + +def test_an_overflow_inside_the_region_deopts_and_frees(scalar): + m = scalar.m + stats = m.__typedpython_region_stats__ + d0 = m.__typedpython_deopts__ + o0, f0, _ = stats() + big = 2 ** 62 # n * big leaves i64 for n >= 2 + got = m.chain_total(5, big) + o1, f1, _ = stats() + d1 = m.__typedpython_deopts__ + assert got == m.__typedpython_interpreted__["chain_total"](5, big) + assert d1 >= d0 + 1 # the host deopted (the interpreted redo may deopt callees too) + assert o1 - o0 == f1 - f0 >= 1 + + +def test_the_recursion_limit_error_frees_the_region(scalar): + m = scalar.m + stats = m.__typedpython_region_stats__ + o0, f0, _ = stats() + with pytest.raises(RecursionError): + m.chain_total(sys.getrecursionlimit() + 500, 1) + with pytest.raises(RecursionError): + m.__typedpython_interpreted__["chain_total"](sys.getrecursionlimit() + 500, 1) + o1, f1, _ = stats() + assert o1 - o0 == f1 - f0 >= 1 + + +def _chunks(m): + return m.__typedpython_region_chunks__() + + +def test_every_chunk_is_freed_on_every_exit_edge(built): + """The common exit block frees the chunks on the normal, deopt and error edges alike.""" + m = built.m + a0, f0 = _chunks(m) + assert m.tree_sum(10) == nodes(10) # normal exit, several chunks grown + assert m.repeat(6, 20) == 20 * nodes(6) # loop: chunks reused, freed once + m.__typedpython_region_fail_after__(100) + try: + assert m.tree_sum(9) == nodes(9) # allocation failure: deopt edge + finally: + m.__typedpython_region_fail_after__(-1) + saved = m.Node + + class Node2: + __slots__ = ("left", "right") + + def __init__(self, left, right): + self.left, self.right = left, right + + try: + m.Node = Node2 + m.tree_sum(3) # class change: deopt edge + finally: + m.Node = saved + a1, f1 = _chunks(m) + assert a1 - a0 >= 3 and a1 - a0 == f1 - f0 # chunks were used, and all came back + + +def test_an_exception_raised_while_the_tree_is_being_built_frees_the_region(built): + """The error edge (-1) after nodes were allocated: a signal handler raises at the eval-breaker poll + that every compiled entry makes, in the middle of building a tree of 8M nodes.""" + import signal + + def boom(*_): + raise TimeoutError("tick") + + m = built.m + a0, f0 = _chunks(m) + o0, fr0, k0 = m.__typedpython_region_stats__() + old = signal.signal(signal.SIGALRM, boom) + try: + signal.setitimer(signal.ITIMER_REAL, 0.02) + with pytest.raises(TimeoutError): + m.tree_sum(22) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old) + a1, f1 = _chunks(m) + o1, fr1, k1 = m.__typedpython_region_stats__() + assert a1 - a0 >= 1 and a1 - a0 == f1 - f0 + assert (o1 - o0, fr1 - fr0) == (1, 1) and 0 < k1 - k0 < nodes(22) + + +def test_a_loop_grows_the_region_once_not_per_statement(built): + m = built.m + a0, _ = _chunks(m) + assert m.repeat(5, 200) == 200 * nodes(5) # 63 nodes * 16 B < one 4 KiB chunk, reused 200 times + a1, _ = _chunks(m) + assert a1 - a0 == 1 + + +# --- a class change while a consumer runs (signal handler at a poll) ---------------------------------- + +def _with_property_installed_by_handler(fn, node_cls, delay): + import signal + + def boom(*_): + node_cls.left = property(lambda self: None, lambda self, v: None) # what `n.left` means changes + + slot = node_cls.__dict__["left"] + old = signal.signal(signal.SIGALRM, boom) + try: + signal.setitimer(signal.ITIMER_REAL, delay) + return fn() + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old) + node_cls.left = slot # the next attempt starts from the original slot descriptor + + +def test_a_class_change_by_a_signal_handler_in_the_consumer_deopts_the_region_host(tmp_path): + """The handler runs at a tp_poll inside the call and installs a property `left` on Node. The timer is + set to 85% of a measured call, so it lands mostly in `check` (the producer's per-New guard cannot + be what catches it). Every read of a region field repeats the class check of a real FieldGet; without + it the compiled call returns the full node count, which no CPython execution can (a read of + `left` after the handler ran is None, so `check` returns 1 at the first read after it).""" + import time + need_runtime() + path = tmp_path / "regsig.py" + path.write_text(SOURCE) + proved, _ = verify.verify(frontend.lower(path)) + text = cgen.generate(proved, path, path.name) + so = cbuild.build(text, "regsig", tmp_path / "out", runtime_dir=runtime_dir()) + m = cbuild.load("regsig", so) + d = 21 + t0 = time.perf_counter() + assert m.tree_sum(d) == nodes(d) + delay = 0.85 * (time.perf_counter() - t0) + caught = False + for k in range(5): # under load the timer can land after the call; then try again + d0 = m.__typedpython_deopts__ + o0, f0, _ = m.__typedpython_region_stats__() + got = _with_property_installed_by_handler(lambda: m.tree_sum(d), m.Node, + delay * (0.85, 0.8, 0.9, 0.75, 0.7)[k] / 0.85) + o1, f1, _ = m.__typedpython_region_stats__() + assert o1 - o0 == f1 - f0 + if m.__typedpython_deopts__ > d0: # the host noticed and redid the call + # the redo ran interpreted with the property in place: `left` is None, so check returns 1 + assert got == 1 + caught = True + break + assert got == nodes(d) # no deopt: the handler ran after the call + assert caught, "the handler never landed inside the call (or the class guard is missing)"