Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/typedpython.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
run: |
env $(.venv/bin/python tools/sanitizers.py env) \
.venv/bin/python -m pytest -q -m "not measurement and not race_mutant" \
src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py src/test/python/test_interval_proofs.py
src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py src/test/python/test_interval_proofs.py src/test/python/test_virtual_objects.py

app-fixture:
# #45: a TypedPython-compiled module calling Kotlin through the upcall table, in the app
Expand Down
16 changes: 15 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,21 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
`gc.callbacks` of *unrelated* cyclic garbage run. The language reference leaves this to the
implementation ("An implementation is allowed to postpone garbage collection or omit it
altogether", Data model §3.1), and CPython itself changed it in 3.14 (incremental GC). Everything
else above stays exact. `Status: planned (design)`.
else above stays exact. `Status: N-12a implemented (below); the rest planned (design)`.
**N-12a (first step, #143): function-local virtual objects by scalar replacement.** In a `pure` function
whose every node is one of Const, Local, scalar BinOp/UnaryOp/Compare/And/Or, ToFloat, ToInt, Box,
Unbox, MathCall, Len, Index, NewArray, CopyArray, Is, IsExact, CheckExact, FieldGet, New (so no call,
no global or attribute read, no user code at all), an OBJ local whose every assignment is `New(C, ...)`
of one N-11 class, whose New arguments are `Box` of a scalar, a `str`/`None` Const or an OBJ parameter,
and whose every read is the object of a `FieldGet` of C, is virtual: no object is allocated and its
fields live in C locals. The class guard of `New` is kept (a changed class is a deopt, as before).
Because the function has no unknown code, nothing can observe a virtual object (`id`, `gc`,
`tracemalloc`, `getrefcount`) before it dies; a field read returns the very object that was stored,
so `is` on field values is unchanged. The verifier recomputes the set (`Function.virtual`, never
trusted from the front end; a forged entry is `verify/virtual-escape`); anything else compiles as
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-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`
Expand Down
51 changes: 51 additions & 0 deletions python-multiplatform-ksp/src/main/python/typedpython/cgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,19 @@ def _ident(prefix: str, name: str) -> str:
return f"{prefix}x_{name.encode('utf-8').hex()}"


def _vfield(local: str, k: int) -> str:
"""The name of field `k` of the virtual object held by `local` (not a Python identifier)."""
return f"{local}\x00v{k}"


def _assigns(body):
for s in body:
if isinstance(s, ir.Assign):
yield s
for nested in (getattr(s, "then", ()), getattr(s, "orelse", ()), getattr(s, "body", ())):
yield from _assigns(nested)


def _i64_lit(v: int) -> str:
if isinstance(v, bool) or not isinstance(v, int) or not (_INT64_MIN <= v <= _INT64_MAX):
raise CGenError(f"I64 constant out of range or not an int: {v!r}")
Expand Down Expand Up @@ -934,6 +947,16 @@ def __init__(self, mg: _ModGen, f: ir.Function, fast: bool = False):
if t not in _CT:
raise CGenError(f"{f.name}: local {name} has type {t}")
self.types[name] = t
# SPEC N-12a: a virtual object lives as one OBJ C local per field (`_vfield`), no object
self.virtual: dict[str, str] = {}
for x in f.virtual:
classes = {e.value.cls for e in _assigns(f.body) if e.target == x
and isinstance(e.value, ir.New)}
if len(classes) != 1 or self.types.get(x) is not Type.OBJ:
raise CGenError(f"{f.name}: virtual local {x} is not assigned one New class")
self.virtual[x] = next(iter(classes))
for k in range(len(mg.cls(f.name, self.virtual[x]).fields)):
self.types[_vfield(x, k)] = Type.OBJ

# emit primitives

Expand Down Expand Up @@ -1475,6 +1498,11 @@ def ev_CheckMethod(self, e: ir.CheckMethod) -> str:
return o

def ev_FieldGet(self, e: ir.FieldGet) -> str:
if isinstance(e.obj, ir.Local) and e.obj.name in self.virtual:
cd = self.mg.cls(self.f.name, e.cls)
if e.cls != self.virtual[e.obj.name] or e.field not in cd.fields:
raise CGenError(f"{self.f.name}: FieldGet {e.cls}.{e.field} on virtual {e.obj.name}")
return self._own_copy(_ident("l", _vfield(e.obj.name, cd.fields.index(e.field))))
slot = self.mg.slot(self.f.name, e.cls, e.field)
o = self._obj_operand(e.obj, "FieldGet")
t = self.tmp("PyObject *")
Expand All @@ -1493,6 +1521,26 @@ def ev_FieldGet(self, e: ir.FieldGet) -> str:
self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}")
return t

def _assign_virtual(self, s: ir.Assign) -> None:
"""`x = New(C, args)` of a virtual local: the same guards and argument order as `ev_New`
(LOAD_GLOBAL C first, then the arguments, then the class check; a failure is a deopt, the
function is pure), but no object: each argument moves into the field local of `x`."""
e = s.value
cd = self.mg.cls(self.f.name, e.cls)
if not isinstance(e, ir.New) or len(e.args) != len(cd.fields) or not self.f.pure:
raise CGenError(f"{self.f.name}: virtual {s.target} needs a New of {len(cd.fields)} "
f"fields in a pure function")
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)
items = [self._obj_operand(a, "New argument") for a in e.args]
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)
for i, t in enumerate(items):
self._own_move(_ident("l", _vfield(s.target, i)), t)

def ev_New(self, e: ir.New) -> str:
cd = self.mg.cls(self.f.name, e.cls)
if not cd.trivial_init:
Expand Down Expand Up @@ -1752,6 +1800,9 @@ def st_Assign(self, s: ir.Assign) -> None:
if s.target in self.local_arrays:
self._assign_array(s, self.local_arrays[s.target])
return
if s.target in self.virtual:
self._assign_virtual(s)
return
t = self.types.get(s.target)
if t is None:
raise CGenError(f"{self.f.name}: assignment to unknown local {s.target}")
Expand Down
5 changes: 5 additions & 0 deletions python-multiplatform-ksp/src/main/python/typedpython/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,11 @@ class Function:
# back end does not replace a module global with it; it installs a descriptor in the class dict
# (cgen.py "Methods"). None: a module-level function.
owner: str | None = None
# SPEC N-12a: OBJ locals that are virtual objects (scalar-replaced `New(C, ...)`: no object is
# allocated, the fields live in C locals). Set only by `verify`, which recomputes the set and
# rejects a function whose declared entries differ (`verify/virtual-escape`). The front end
# leaves it empty.
virtual: tuple[str, ...] = ()


@dataclass(frozen=True)
Expand Down
96 changes: 95 additions & 1 deletion python-multiplatform-ksp/src/main/python/typedpython/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
STRUCTURE = "verify/structure"
CONDITION_ONLY = "verify/condition-only"
PROVEN = "verify/proven-overflow"
VIRTUAL = "verify/virtual-escape"
REDO = "verify/redo"
ENTRY_OPEN = "verify/entry-global-open"
ARRAY_OPEN = "verify/array-open"
Expand Down Expand Up @@ -1667,7 +1668,100 @@ def run(self) -> Function:
self.error(ARRAY_STORED, f"array '{p.name}' has stored={p.stored} but the body "
f"{'stores' if p.name in self.stored else 'never stores'}"
f" into it")
return dataclasses.replace(fn, body=body)
virtual = _virtual_locals(fn, body) if not self.diags else {}
if fn.virtual and set(fn.virtual) != set(virtual):
self.error(VIRTUAL, f"virtual locals {sorted(fn.virtual)} declared, but only "
f"{sorted(virtual)} are provably virtual (SPEC N-12a)")
return dataclasses.replace(fn, body=body, virtual=tuple(sorted(virtual)))


# --- virtual objects (SPEC N-12a) ----------------------------------------------------------------------

# The expression nodes a function may contain for its objects to be virtual: none of them runs user
# code, reads a global or an attribute, or calls anything (a Call can redo in the interpreter), so
# nothing can observe a live virtual object. OBJ BinOp is excluded in `_virtual_locals`.
_VIRTUAL_OK = frozenset({Const, Local, BinOp, UnaryOp, Compare, And, Or, ToFloat, ToInt, Box, Unbox,
MathCall, Len, Index, NewArray, CopyArray, Is, IsExact, CheckExact,
FieldGet, New})
_VIRTUAL_STMTS = frozenset({Assign, StoreIndex, If, While, ForRange, Return, Break, Continue})


def _exprs_of(node: object):
"""Every ir expression directly under `node` (an expression or statement), in field order."""
for f in dataclasses.fields(node):
v = getattr(node, f.name)
for x in (v if isinstance(v, tuple) else (v,)):
if isinstance(x, ir.Expr):
yield x


def _virtual_locals(fn: Function, body: tuple) -> dict[str, str]:
"""local -> class of the virtual objects of `fn` (SPEC N-12a), or {}. A local is virtual when
`fn` is pure and contains only `_VIRTUAL_OK` nodes (no unknown code at all), and the local is
never a parameter, is only assigned `New(C, args)` of one class with args that cannot run a
finalizer when dropped (Box of a scalar, a str/None Const, an OBJ parameter), and is read only as
the object of a `FieldGet` of C. Anything else stays an ordinary object (N-11)."""
if fn.pure is not True or not isinstance(body, tuple):
return {}
params = {p.name for p in fn.params}
safe_reads: dict[str, list[str]] = {}
other_reads: set[str] = set()
ok = True

def expr(e: object) -> None:
nonlocal ok
if type(e) not in _VIRTUAL_OK or (type(e) is BinOp and e.type is Type.OBJ):
ok = False
return
if type(e) is FieldGet and type(e.obj) is Local:
safe_reads.setdefault(e.obj.name, []).append(e.cls)
return
if type(e) is Local:
other_reads.add(e.name)
for x in _exprs_of(e):
expr(x)

def block(stmts: object) -> None:
nonlocal ok
if not isinstance(stmts, tuple):
ok = False
return
for st in stmts:
if type(st) not in _VIRTUAL_STMTS:
ok = False
return
for x in _exprs_of(st):
expr(x)
for nested in (getattr(st, "then", ()), getattr(st, "orelse", ()), getattr(st, "body", ())):
block(nested)

block(body)
if not ok:
return {}
assigned: dict[str, list[object]] = {}
_assigned_values(body, assigned)
out: dict[str, str] = {}
for name, values in assigned.items():
if (fn.locals.get(name) is not Type.OBJ or name in params or name in other_reads
or name not in safe_reads):
continue
if not all(type(v) is New and isinstance(v.cls, str) for v in values):
continue
classes = {v.cls for v in values}
if len(classes) != 1 or set(safe_reads[name]) != classes:
continue
if not all(_virtual_arg(a, params) for v in values for a in v.args):
continue
out[name] = next(iter(classes))
return out


def _virtual_arg(a: object, params: set[str]) -> bool:
if type(a) is Box:
return a.operand.type in ir.SCALARS
if type(a) is Const:
return a.type is Type.OBJ
return type(a) is Local and a.type is Type.OBJ and a.name in params


def _assigned(body: object, counts: dict[str, int] | None = None) -> dict[str, int]:
Expand Down
Loading