Skip to content

Commit 4c92997

Browse files
dsblankclaude
andcommitted
Document PyPy compatibility, JIT mechanism, and v1.4.8 comparison
Verified this session that Calysto Scheme's JIT needs no PyPy-specific support: it compiles Scheme closures to Python source text and runs them via compile()/exec(), which are Python-language builtins, not a CPython API -- so the same code path produces CPython bytecode under CPython and PyPy's own internal representation under PyPy, with no branching on which runtime is hosting the process. Full 442-test suite passes unmodified under PyPy 7.3.15 in an isolated venv, with metakernel/ yasi/jupyter_client/ipykernel/pyzmq all installing and importing cleanly (numpy, needed only for notebook/%plot features, wasn't exercised). Measured, steady-state, in-process: PyPy's own tracing JIT stacks with this project's JIT rather than substituting for it -- ~2.1-2.4x on JIT'd non-tail recursion (fib), ~60x on JIT'd tail loops (PyPy excels specifically at the tight while-True loop shape Phase 4 already flattens tail calls into). With (use-jit #f) forcing the plain trampoline, PyPy alone is 9.5-13.6x faster than CPython at running it, but still 57x-1000x+ slower than either runtime running the JIT'd version -- confirming the two optimizations aren't substitutes. Also measured v1.4.8 (the pre-JIT baseline; no perf-relevant changes exist through v1.4.8) directly via a git worktree, rather than reusing the older cross-version table's numbers from a different session/ methodology. Combined PyPy+JIT speedup over v1.4.8 ranges ~2,500x (fib, non-tail recursion) to ~175,000x (large tail loops), with two of the four sizes extrapolated from a measured per-call/per-iteration rate since actually running v1.4.8 at those sizes would take 10-55 minutes each. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent eb1643d commit 4c92997

1 file changed

Lines changed: 137 additions & 1 deletion

File tree

calysto_scheme/src/README-PERFORMANCE.md

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,142 @@ bytecode) that runs without interpreter overhead.
14101410

14111411
---
14121412

1413+
## Running under PyPy
1414+
1415+
### How the JIT runs under PyPy — it doesn't target CPython at all
1416+
1417+
`_jit_compile_proc` (`Scheme.py:1372`) doesn't compile to a VM or a bytecode
1418+
format. It walks the Scheme closure's AST and assembles a plain **Python
1419+
source string** — literally building up lines like `"def _jit_fn(n):"`,
1420+
`" while True:"`, etc. (`Scheme.py:1394–1416`) — then hands that string to
1421+
the built-in `compile(fn_src, '<scheme-jit>', 'exec')` followed by
1422+
`exec(...)` (`Scheme.py:1419`). No bytecode manipulation, no `ast` module, no
1423+
CPython C-API calls: string concatenation, then two language builtins.
1424+
1425+
`compile()`/`exec()` are part of the Python *language* — every conforming
1426+
implementation provides them — not a CPython-specific API, and nothing in
1427+
this code path branches on which Python is running it. So the JIT compiles
1428+
to **Python source text**, and target-specific compilation happens one level
1429+
down, invisibly, inside whichever `exec()` receives that text:
1430+
1431+
- Under CPython, `compile()` produces CPython bytecode; `exec()` runs it in
1432+
CPython's bytecode interpreter.
1433+
- Under PyPy, the identical call — same string, same code path — produces
1434+
PyPy's own internal representation instead, run by PyPy's interpreter.
1435+
1436+
This is also why PyPy's own tracing JIT can speed up the generated code
1437+
further: a function produced by `exec()` is completely ordinary from the
1438+
runtime's point of view, indistinguishable from one defined statically in a
1439+
`.py` file. Once `_jit_fn` is called enough times to cross PyPy's hotness
1440+
threshold, PyPy traces and compiles it to machine code exactly like any
1441+
other hot function — a second, independent optimization stacked on top of
1442+
this project's own AST-to-Python restructuring, with neither side aware the
1443+
other exists.
1444+
1445+
### Measured: full test suite and benchmarks under PyPy 7.3.15
1446+
1447+
Verified by actually installing PyPy 7.3.15 (Python 3.9-compatible) in an
1448+
isolated virtualenv and running this project's test suite and benchmark
1449+
scripts against it, unmodified — no code changes were needed anywhere in
1450+
`Scheme.py`/`interpreter-cps.ss`/`translate_rm.py`.
1451+
1452+
- **Correctness:** full pytest suite (442 tests, including the JIT
1453+
differential fuzzers and `amb`/`choose` backtracking edge cases) passes
1454+
unmodified under PyPy.
1455+
- **Dependencies:** `metakernel`, `yasi`, and the transitive Jupyter stack
1456+
(`jupyter_client`, `ipykernel`, `pyzmq`) all installed and imported
1457+
cleanly — `pyzmq` shipped a prebuilt PyPy wheel
1458+
(`pp39-pypy39_pp73-manylinux...`). `numpy` (only needed for
1459+
notebook/`%plot` features, not the core interpreter) was not exercised by
1460+
this pass and remains untested on PyPy.
1461+
1462+
**JIT enabled** (steady-state, measured in-process with `current-time` after
1463+
a warmup call, same methodology as the cross-version table above):
1464+
1465+
| Benchmark | CPython | PyPy | PyPy vs. CPython |
1466+
|---|---|---|---|
1467+
| `fib(32)`, non-tail recursion | ~0.19–0.22s | ~0.08–0.10s | **~2.1–2.4×** |
1468+
| tail loop, 30,000,000 iters | ~1.14s | ~0.019s | **~60×** |
1469+
1470+
**`(use-jit #f)`** — forcing every call onto the plain register-machine
1471+
trampoline, Phase 2/JIT bypassed entirely — measured single-shot:
1472+
1473+
| Benchmark | CPython, JIT off | PyPy, JIT off | PyPy vs. CPython (trampoline) |
1474+
|---|---|---|---|
1475+
| `fib(26)` | 7.564s | 0.797s | **~9.5×** |
1476+
| tail loop, 2,000,000 iters | 51.414s | 3.771s | **~13.6×** |
1477+
1478+
For scale, the same two benchmarks with the JIT enabled:
1479+
1480+
| Benchmark | CPython, JIT on | PyPy, JIT on |
1481+
|---|---|---|
1482+
| `fib(26)` | 0.0109s | 0.0141s (single-shot warmup noise; see the fib(32) steady-state row above for the honest ratio) |
1483+
| tail loop, 2,000,000 iters | 0.0658s | 0.0037s (**~17.6×** vs. CPython, JIT on) |
1484+
1485+
**Interpretation:** PyPy is dramatically better than CPython at running the
1486+
*plain trampoline* (9.5–13.6×) — expected, since the trampoline is exactly
1487+
the polymorphic, heap-allocating, generic-dispatch interpreter loop PyPy's
1488+
tracing JIT specializes best. But this project's own JIT still wins by a
1489+
landslide over PyPy-on-trampoline: PyPy's trampoline `fib(26)` (0.797s) is
1490+
still ~57× slower than PyPy running the *JIT'd* version (0.014s); on the
1491+
tail loop, PyPy-trampoline (3.77s) is over 1000× slower than PyPy-JIT'd
1492+
(0.0037s). The two optimizations aren't substitutes for each other — PyPy
1493+
helps the slow path a lot, but this project's JIT removing that path's
1494+
overhead structurally is still categorically bigger, and the two stack for
1495+
a combined win larger than either alone (see the tail-loop JIT-on row above:
1496+
~17.6× on top of gains already in the hundreds-to-thousands-fold range over
1497+
the original interpreter).
1498+
1499+
### PyPy + JIT vs. the original v1.4.8
1500+
1501+
To answer "how much faster is all of this, combined, than where the project
1502+
started": `v1.4.8` (the pre-JIT baseline; no performance-relevant changes
1503+
exist between `v1.4.6`, `v1.4.7`, and `v1.4.8`, so this baseline applies to
1504+
all three) was checked out into its own `git worktree` and measured directly,
1505+
in-process, with the same `current-time` methodology used throughout this
1506+
document — not reused from the older cross-version table above, since
1507+
machine/environment differences make numbers from different sessions
1508+
unsafe to combine directly.
1509+
1510+
Two of the four sizes below (`fib(32)`; the 30,000,000-iteration loop) would
1511+
take `v1.4.8` an estimated 10–55 minutes each to actually run. Rather than
1512+
block on that, those two rows are **extrapolated** from a per-call/
1513+
per-iteration rate measured at a smaller, fast-to-run size on the same
1514+
`v1.4.8` checkout — marked explicitly, following this document's existing
1515+
practice of labeling calculated estimates separately from timed runs (see
1516+
"Methodology" below).
1517+
1518+
| Benchmark | v1.4.8 | v2.1.8 + CPython JIT | v2.1.8 + PyPy JIT | PyPy+JIT speedup over v1.4.8 |
1519+
|---|---|---|---|---|
1520+
| `fib(20)` | 2.348s (measured) | 0.00096s (measured) | too small/noisy — PyPy's own JIT never warms up in 21,891 calls | ~2,454× (CPython+JIT figure) |
1521+
| `fib(26)` | 35.517s (measured) | 0.0109s | 0.0141s | **~2,519×** |
1522+
| `fib(32)` | **~637s** (extrapolated: 7,049,155 calls × 90.4µs/call, rate from the `fib(26)` measurement) | ~0.19–0.22s | ~0.08–0.10s | **~7,080×** |
1523+
| tail loop, 2,000,000 iters | **~222s** (extrapolated: 2,000,000 × 111µs/iter, rate from a 200,000-iteration measurement) | 0.0658s | 0.0037s | **~60,000×** |
1524+
| tail loop, 30,000,000 iters | **~3,331s / ~55.5 min** (extrapolated, same rate) | 1.14s | 0.019s | **~175,300×** |
1525+
1526+
**Bottom line: roughly 2,500× to 175,000×, depending on the shape of code.**
1527+
Non-tail recursion (`fib`) lands in the low-thousands×, since even PyPy's
1528+
JIT can't eliminate the per-call closure/continuation cost this project's
1529+
own JIT removes structurally. Tail loops land at tens-of-thousands to
1530+
~175,000×, because that's exactly the shape both this project's tail-call
1531+
flattening *and* PyPy's tracing JIT are individually strongest at, and they
1532+
stack multiplicatively rather than substitute for one another.
1533+
1534+
Caveats:
1535+
- The `fib(32)`/30M-loop `v1.4.8` numbers are calculated projections, not
1536+
timed runs. The linearity assumption is solid for the tail loop (already
1537+
validated at two points in the cross-version table above: 3,000 vs. 6,000
1538+
iterations scaled linearly) and reasonable for `fib` (constant per-call
1539+
dispatch cost regardless of which Fibonacci number is being computed), but
1540+
they remain extrapolations.
1541+
- `fib(20)`/`fib(26)`'s PyPy figures are single-shot (first call), not
1542+
steady-state after a warmup call — small enough that PyPy's own JIT may
1543+
not have fully engaged, which is why `fib(20)`'s PyPy number isn't
1544+
reportable at all. The `fib(32)` row is the trustworthy one for
1545+
PyPy-on-recursion, since it's steady-state after warmup.
1546+
1547+
---
1548+
14131549
## Potential further improvements
14141550

14151551
Measured (not estimated) on this machine, see methodology after the table.
@@ -1425,7 +1561,7 @@ paths in `scheme.py` — not projected from the fib table.
14251561
| ~~JIT: allow a parameter used in operator position~~ | **Done — see Phase 6 above.** `(define (apply-twice f x) (f (f x)))`-shaped functions now JIT-compile; ~0.39s → ~0.19s on a 4,800-call benchmark (work time drops to immeasurably small, consistent with other JIT gains here). Surfaced and fixed a related missed-optimization bug in `_jit_call` itself | ~~Low~~ |
14261562
| ~~JIT: drop the forward-ref-cell indirection for self-recursive (non-tail) calls~~ | **Done — see Phase 7 above.** `fib`-shaped naive recursion **~2.3–2.8×** across three scales (`fib(20)`, `fib(30)`, `fib(37)`); tail/mutual-recursion/closure/HOF/`map`/`set!` benchmarks unaffected, as expected | ~~Low~~ |
14271563
| ~~Interpreter: Phase 2's fallback re-executes a closure's entire body, including already-completed side effects, instead of resuming or staying on the trampoline from the point of failure~~ | **Done — see Phase 8 above.** `_is_phase2_safe` gates `apply_proc`'s Phase-2 attempt on a static, transitive proof of safety instead of discovering failure mid-execution; the old silent retry is gone (a soundness gap in the checker would now crash loudly instead). Costs Phase 5/6's speedup for dynamic-dispatch shapes (~1.1–1.85× slower, measured); naive/tail/mutual recursion unaffected. Recovering that speed safely is tracked as a separate follow-up, not bundled with the correctness fix | ~~Medium–High~~ |
1428-
| Run on PyPy | 5–20× additional on top of existing gains (measured ~2.5×–10× on real workloads) — a user/deployment decision, not pursued further here | Zero code changes |
1564+
| ~~Run on PyPy~~ | **Measured — see "Running under PyPy" above.** Full test suite (442 tests) passes unmodified under PyPy 7.3.15; JIT-on steady-state gains **~2.1–2.4×** (`fib`, non-tail recursion) to **~60×** (tail loops); a user/deployment decision, not a code change | ~~Zero code changes~~ |
14291565

14301566
**Bonus finding, not in the original list:** `(use-stack-trace #f)` — an
14311567
*already-shipped*, zero-code-change toggle — gives **~10–13%** wall-clock and

0 commit comments

Comments
 (0)