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
4 changes: 2 additions & 2 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ fail, then implement.
extracts it. `Status: partial`, exercised by the shared suite on the simulator; no iOS-specific
lifecycle test beyond `PM/iosSimulatorArm64Test/.../AsyncioAvailabilityProbeTest.kt`. An installed
app carries its own stdlib: L-11.
- **L-8** Initialisation sets `builtins.compiled` to an identity decorator (an existing one is kept), so `@compiled` needs no import on any platform (N-7, issue #42). `Status: implemented`, `PM/commonTest/.../ffi/BuiltinCompiledTest.kt`.
- **L-8** Initialisation sets `builtins.compiled` to an identity decorator accepting both `@compiled` (`compiled(f)`) and `@compiled(**opts)` (`compiled(**opts)(f)`), leaving an existing `builtins.compiled` alone, so `@compiled` needs no import on any platform (N-7, issue #42, #358). `Status: implemented`, `PM/commonTest/.../ffi/BuiltinCompiledTest.kt`.
- **L-9** Desktop: a packaged application starts with no `PYTHONHOME` in its environment (issue #60,
ROADMAP §15.4). The Gradle plugin copies the prefix `stagePythonHome` stages into Compose Desktop's
application resources (every `prepare*AppResources` task, so `createDistributable` and the
Expand Down Expand Up @@ -985,7 +985,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
- **N-15** TypedPython parallel execution of order-independent loops on free-threaded CPython (#151;
`docs/design/typedpython-parallel-loops.md`). A compiled `for ... in range(...)` loop may run its
iterations on several threads only if its result cannot depend on iteration order, in a function the
user marked with a `# typedpython: parallel` comment line directly above the `def` (above its decorators if any); module-level functions only, and then the
user marked `@compiled(parallel=True)` (#358; the earlier `# typedpython: parallel` comment line was removed); module-level functions only, and then the
result, the exception, the partial writes and the deopt behaviour are bit-for-bit those of the
sequential loop (INTENT §1.4; N-12: every execution is indistinguishable from some valid CPython
execution).
Expand Down
15 changes: 8 additions & 7 deletions docs/design/typedpython-parallel-loops.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ checkable on the existing IR (`typedpython/ir.py`), and says where that rule giv
(§7, decision 3). Thread creation cost is measured together with the threshold.
6. A cost rule decides, at run time, whether a region is worth its fixed cost; below the threshold
the sequential code runs (§8). The threshold is measured, not guessed here.
7. v1 parallelises only functions the user marks (decision 5). The spelling of the marking is a design
detail settled at implementation. Widening to unmarked functions later is easy; narrowing is hard.
7. v1 parallelises only functions the user marks `@compiled(parallel=True)` (decision 5, #358). Widening to unmarked functions later is easy; narrowing is hard.

## 2. What exists today (the facts this design stands on)

Expand Down Expand Up @@ -78,7 +77,7 @@ condition that failed (like `verify/array-open`), so users can see why.
- The function holds a copy-in list array, a local array, or only scalars
(a pure scalar loop, e.g. a reduction over `range`). A function whose loop touches a buffer array
(`array.array`) is not parallelised (decision 1).
- The user marked the function (decision 5).
- The user marked the function `@compiled(parallel=True)` (decision 5, #358).
- Not under `entry_globals` mutation: entry globals are read once at entry and are locals in the body,
so they are read-only and fine.

Expand Down Expand Up @@ -503,9 +502,11 @@ Recorded 2026-10-25 from the maintainer. The body and SPEC N-15 follow them.
measured together with the threshold (`TP_PAR_MIN_WORK`).
4. **Float `+` and `*` reductions are excluded in v1.** Not permanently: a fastmath opt-in (#153)
awaits the owner's decision, and N-15 follows that decision. Until then they stay sequential.
5. **Only functions the user marks are parallelised in v1.** How the marking is spelled (decorator
argument, comment, other) is a design detail to settle at implementation. Widening later (implicit
parallelisation) is easy; narrowing later is hard, so v1 starts narrow.
5. **Only functions the user marks are parallelised in v1.** Marked with `@compiled(parallel=True)`
(maintainer decision 2026-10-30, #358: `@compiled` is the only compilation mark, and options are
keyword arguments on `@compiled`, matching `#332` `@compiled(fastmath=True)`; the earlier
`# typedpython: parallel` comment marker was removed). Widening later (implicit parallelisation) is
easy; narrowing later is hard, so v1 starts narrow.
6. **`min`/`max` reductions are in v1 only if exactly equal to Python's** (§6). If an implementation
cannot match it, they move to a later step and v1 keeps the integer and bitwise reductions only.
7. **Thresholds are measured, not chosen here.** `TP_PAR_MIN_WORK` and the thread cap (default 8) are
Expand All @@ -516,4 +517,4 @@ Recorded 2026-10-25 from the maintainer. The body and SPEC N-15 follow them.
- (Settled: free-threaded 3.15t is the only build, #158, so there is no GIL-build variant.)
- Which devices the threshold is measured on (the Mac mini only, or also an Android device). The
measurement itself is decided (item 7); the device set is not.
- The spelling of the marking (item 5), settled at implementation.
- (Settled 2026-10-30, #358: the marking is `@compiled(parallel=True)`.)
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def _demo(module: Path, module_argv: list[str], out: Path) -> int:

def interpreted() -> object:
import builtins # the module's `@compiled` is a builtin name; typedpython_builtins.pth installs it
builtins.compiled = getattr(builtins, "compiled", lambda f, /: f)
builtins.compiled = getattr(builtins, "compiled", lambda f=None, /, **_k: (lambda g: g) if f is None else f)
spec = importlib.util.spec_from_file_location(module.stem + "_interpreted", module)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# TypedPython's builtin names (docs/design/typedpython.md §2.3). A `__builtins__.pyi` on the type
# checker's search path declares names the interpreter provides without an import.
from collections.abc import Callable
from typing import overload

@overload
def compiled[T](f: T, /) -> T: ...
@overload
def compiled[T](*, parallel: bool = ..., fastmath: bool = ...) -> Callable[[T], T]: ...
85 changes: 56 additions & 29 deletions python-multiplatform-ksp/src/main/python/typedpython/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ def lower(path: Path) -> ir.Module:
info.classes = class_decls
skipped.update(class_skips)
signatures: dict[str, _Signature] = {}
for node, reason in _targets(tree, source, info):
marked: set[str] = set()
for node, reason, parallel in _targets(tree, source, info):
if reason is not None:
skipped[node.name] = reason
continue
Expand All @@ -199,6 +200,9 @@ def lower(path: Path) -> ir.Module:
signatures[node.name] = _signature(node, info.classes, info)
except Skip as e:
skipped[node.name] = e.reason
else:
if parallel:
marked.add(node.name)
for owner, node, reason in _method_targets(tree, info):
q = f"{owner}.{node.name}"
if reason is not None:
Expand Down Expand Up @@ -236,28 +240,10 @@ def lower(path: Path) -> ir.Module:
else:
skipped[name] = result
functions.sort(key=lambda f: f.source_line)
marked = _parallel_marked(tree, source)
functions = [dataclasses.replace(f, parallel=True) if f.name in marked else f for f in functions]
return ir.Module(path.stem, tuple(functions), skipped, tuple(class_decls.values()))


PARALLEL_MARKER = "# typedpython: parallel"


def _parallel_marked(tree: ast.Module, source: str) -> set[str]:
"""Module-level functions the user marked for SPEC N-15: the comment line `# typedpython: parallel`
directly above the `def` (above its decorators if it has any). A comment,
so the interpreted module is unchanged and needs no runtime name."""
lines = source.splitlines()
out: set[str] = set()
for node in tree.body:
if isinstance(node, ast.FunctionDef):
first = min([node.lineno] + [d.lineno for d in node.decorator_list])
if first >= 2 and lines[first - 2].strip() == PARALLEL_MARKER:
out.add(node.name)
return out


def _module_info(tree: ast.Module) -> _ModuleInfo:
bindings: dict[str, list[tuple[int, str]]] = {}

Expand Down Expand Up @@ -431,13 +417,45 @@ def _binding_targets(stmt: ast.stmt) -> list[ast.expr]:
SLOT_ACCESS_DUNDERS = ("__getattribute__", "__getattr__", "__setattr__", "__delattr__", "__new__")


def _is_compiled_decorator(d: ast.expr) -> bool:
return (isinstance(d, ast.Name) and d.id == "compiled") or (
isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "compiled"
)


def _compiled_decorator_options(d: ast.expr) -> tuple[bool, str | None]:
"""Parse `@compiled` or `@compiled(parallel=...)` -> (parallel, error_reason_or_None)."""
if isinstance(d, ast.Name):
return False, None
assert isinstance(d, ast.Call)
if d.args:
return False, (f"line {d.lineno}: `@compiled` takes keyword arguments only "
f"(got positional `{_src(d.args[0])}`)")
parallel = False
seen: set[str] = set()
for kw in d.keywords:
if kw.arg is None:
return False, f"line {d.lineno}: `**{_src(kw.value)}` in `@compiled(...)` is not supported"
if kw.arg != "parallel":
return False, (f"line {d.lineno}: `@compiled` keyword argument `{kw.arg}` is not "
"supported (only `parallel=True` or `parallel=False`)")
if kw.arg in seen:
return False, f"line {d.lineno}: `@compiled` keyword argument `{kw.arg}` is repeated"
seen.add(kw.arg)
if not (isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, bool)):
return False, (f"line {d.lineno}: `@compiled(parallel=...)` requires a literal `True` "
f"or `False`, got `{_src(kw.value)}`")
parallel = kw.value.value
return parallel, None


def is_opted_in(path: Path) -> bool:
"""Whether the front end considers the module for compilation at all: a module-level `def` / `class`
decorated `@compiled` (the same test `_targets` and `_classes` apply)."""
source = Path(path).read_text()
return any(
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and any(isinstance(d, ast.Name) and d.id == "compiled" for d in node.decorator_list)
and any(_is_compiled_decorator(d) for d in node.decorator_list)
for node in ast.parse(source, filename=str(path)).body)


Expand Down Expand Up @@ -596,28 +614,37 @@ def _class_of(kind: str, classes: dict[str, ir.ClassDecl]) -> tuple[str, bool] |

def _targets(
tree: ast.Module, source: str, info: _ModuleInfo,
) -> list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None]]:
found: list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None]] = []
) -> list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None, bool]]:
found: list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None, bool]] = []
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
marked = [d for d in node.decorator_list if isinstance(d, ast.Name) and d.id == "compiled"]
marked = [d for d in node.decorator_list if _is_compiled_decorator(d)]
if not marked:
continue
reason = None
parallel = False
if marked and info.bound("compiled"):
line = info.bindings["compiled"][0][0]
reason = (f"line {node.lineno}: `compiled` is bound in this module (line {line}), "
"so `@compiled` is not the builtin")
elif other := [d for d in node.decorator_list if d not in marked]:
reason = (f"line {other[0].lineno}: decorator `@{_src(other[0])}` is not supported "
"(only `@compiled`)")
elif isinstance(node, ast.AsyncFunctionDef):
reason = f"line {node.lineno}: async function is not supported"
elif len(info.bindings.get(node.name, [])) > 1:
lines = ", ".join(str(line) for line, _ in info.bindings[node.name])
reason = f"line {node.lineno}: `{node.name}` is bound more than once (lines {lines})"
found.append((node, reason))
else:
for d in marked:
par, r = _compiled_decorator_options(d)
if r is not None:
reason = r
break
parallel = parallel or par
if reason is None:
if isinstance(node, ast.AsyncFunctionDef):
reason = f"line {node.lineno}: async function is not supported"
elif len(info.bindings.get(node.name, [])) > 1:
lines = ", ".join(str(line) for line, _ in info.bindings[node.name])
reason = f"line {node.lineno}: `{node.name}` is bound more than once (lines {lines})"
found.append((node, reason, parallel))
return found


Expand Down
4 changes: 2 additions & 2 deletions python-multiplatform-ksp/src/main/python/typedpython/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,8 +817,8 @@ class Function:
# rejects a function whose declared entries differ (`verify/virtual-escape`). The front end
# leaves it empty.
virtual: tuple[str, ...] = ()
# 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`.
# SPEC N-15: the user marked this function `@compiled(parallel=True)` (#358).
# 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`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
import builtins; builtins.compiled = getattr(builtins, "compiled", lambda f, /: f)
import builtins; builtins.compiled = getattr(builtins, "compiled", lambda f=None, /, **_k: (lambda g: g) if f is None else f)
4 changes: 2 additions & 2 deletions python-multiplatform-ksp/src/test/python/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
import pytest

# The `typedpython_builtins.pth` of an installed wheel; the test interpreter has no such install.
builtins.compiled = getattr(builtins, "compiled", lambda f, /: f)
builtins.compiled = getattr(builtins, "compiled", lambda f=None, /, **_k: (lambda g: g) if f is None else f)

# Prepended to every child-process script that loads a module whose source uses `@compiled`: a plain
# CPython has the builtin only when `typedpython_builtins.pth` is installed, so the child installs it.
COMPILED_SHIM = "import builtins\nif not hasattr(builtins, 'compiled'):\n builtins.compiled = lambda f: f\n"
COMPILED_SHIM = "import builtins\nif not hasattr(builtins, 'compiled'):\n builtins.compiled = lambda f=None, /, **_k: (lambda g: g) if f is None else f\n"

FIXTURES = Path(__file__).parent / "fixtures"

Expand Down
13 changes: 11 additions & 2 deletions python-multiplatform-ksp/src/test/python/test_builtin_compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ def test_gate_accepts_compiled_without_an_import(write):
def f(x: int) -> int:
return x + 1

y: int = f(1)
@compiled(parallel=True)
def g(x: int) -> int:
return x + 2

@compiled(fastmath=True)
def h(x: float) -> float:
return x * 2.0

y: int = f(1) + g(2)
z: float = h(1.5)
""")
assert errors(gate.check([path])) == []

Expand Down Expand Up @@ -82,7 +91,7 @@ def test_decorator_works_with_no_import(installed):

def test_compiled_is_the_identity(installed):
python, _ = installed
r = py(python, "-c", "def f(): pass\nprint(compiled(f) is f)")
r = py(python, "-c", "def f(): pass\nprint(compiled(f) is f and compiled(parallel=True)(f) is f and compiled()(f) is f)")
assert r.stdout.strip() == "True", r.stderr


Expand Down
23 changes: 23 additions & 0 deletions python-multiplatform-ksp/src/test/python/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,29 @@ def c(x: float) -> float:
assert set(m.skipped) == {"c"} and "decorator" in m.skipped["c"]


def test_compiled_parallel_option_and_former_comment_marker(tmp_path):
m = lower(tmp_path, """
@compiled(parallel=True)
def p(x: float) -> float:
return x

@compiled(parallel=False)
def s(x: float) -> float:
return -x

# typedpython: parallel
@compiled
def c(x: float) -> float:
return x * 2.0

@compiled(unknown=True)
def bad(x: float) -> float:
return x
""")
assert [(f.name, f.parallel) for f in m.functions] == [("p", True), ("s", False), ("c", False)]
assert set(m.skipped) == {"bad"} and "parallel" in m.skipped["bad"]


def test_the_former_module_pragma_comment_compiles_nothing(tmp_path):
m = lower(tmp_path, """
# typedpython: compiled
Expand Down
Loading