diff --git a/.cargo/config.toml b/.cargo/config.toml index 1cac0101..16873965 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,6 +9,9 @@ xtest = "test --workspace --all-targets" xcheck = "check --workspace --all-targets --all-features" xclippy = "clippy --workspace --all-targets --all-features -- -D warnings" xfmt = "fmt --all" +# RFC 0058 — the benchmark lane. Requires a release `weavepy` binary +# (`cargo build --release -p weavepy-cli`) next to the bench binary. +xbench = "run --release -p weavepy-bench --" [build] # Faster incremental builds for local dev. CI overrides via env if needed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ed22d2b..f8a28cba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,47 @@ jobs: path: target/regrtest/ if-no-files-found: warn + bench: + name: bench gate (blocking, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + # RFC 0058 — the benchmark lane. Gates on WeavePy/CPython *ratios* + # against baselines/bench.json (host-independent, unlike absolute + # times); the 25% threshold absorbs shared-runner noise. The + # markdown report lands in the job summary so every PR shows its + # ratio table. + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Build weavepy CLI + bench harness + run: cargo build --release -p weavepy-cli -p weavepy-bench + - name: Run bench gate + run: | + target/release/weavepy-bench gate --pct=25 \ + --weavepy=target/release/weavepy \ + | tee bench-report.md + - name: Append bench report to job summary + if: always() + run: | + { + echo "## WeavePy bench" + echo + cat bench-report.md || echo "(no report produced)" + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: bench-report-${{ matrix.os }} + path: bench-report.md + if-no-files-found: warn + conformance: name: cpython conformance (non-blocking) runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 346f9cc0..d4b3d7b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2693,10 +2693,6 @@ version = "0.0.0" dependencies = [ "serde", "serde_json", - "weavepy", - "weavepy-compiler", - "weavepy-parser", - "weavepy-vm", ] [[package]] @@ -2767,6 +2763,7 @@ dependencies = [ "cranelift-module", "cranelift-native", "weavepy-compiler", + "weavepy-parser", ] [[package]] diff --git a/crates/weavepy-bench/Cargo.toml b/crates/weavepy-bench/Cargo.toml index b2843c42..5c026093 100644 --- a/crates/weavepy-bench/Cargo.toml +++ b/crates/weavepy-bench/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true publish = false -description = "RFC 0021 — pyperformance-shaped microbench harness for WeavePy." +description = "RFC 0058 — pyperformance-shaped benchmark lane for WeavePy." [lib] path = "src/lib.rs" @@ -13,19 +13,13 @@ path = "src/lib.rs" name = "weavepy-bench" path = "src/main.rs" +# The harness is subprocess-only (RFC 0058 WS1): it times the built +# `weavepy` binary and the host CPython symmetrically, so it does not +# link the interpreter crates at all. The `--jit` column requires the +# `weavepy` binary itself to be built with `--features weavepy-cli/jit`. [dependencies] -weavepy = { workspace = true } -weavepy-compiler = { workspace = true } -weavepy-parser = { workspace = true } -weavepy-vm = { workspace = true } - serde = { workspace = true } serde_json = { workspace = true } -[features] -default = [] -# RFC 0032 — run the bench harness with the tier-2 JIT compiled in. -jit = ["weavepy/jit", "weavepy-vm/jit"] - [lints] workspace = true diff --git a/crates/weavepy-bench/README.md b/crates/weavepy-bench/README.md index 897dccd0..ce0d7a1b 100644 --- a/crates/weavepy-bench/README.md +++ b/crates/weavepy-bench/README.md @@ -1,44 +1,65 @@ # weavepy-bench -RFC 0021 — `pyperformance`-shaped microbench harness for WeavePy. +RFC 0058 — the `pyperformance`-shaped benchmark lane for WeavePy +(supersedes the RFC 0021 harness). + +The harness times each fixture's `bench(n)` under **both** the built +`weavepy` binary and the host CPython, as subprocesses with an +identical `WEAVEPY_BENCH_WORK`. Fixtures self-time the bench region +with `time.perf_counter_ns()` and print `WEAVEPY_BENCH_NS=`, so +startup / parse / import cost is excluded symmetrically (the dedicated +`startup` fixture measures full subprocess wall time instead). + +The tracked baseline (`baselines/bench.json`) stores medians for both +interpreters plus the WeavePy/CPython **ratio** per fixture and the +suite geometric mean. `gate` compares ratios — host-independent, +unlike absolute nanoseconds — and fails on regressions beyond a +threshold, like the regrtest and ecosystem lanes' `--check`. CI runs +`gate --pct=25` on ubuntu + macos (the `bench` job). The crate is excluded from `default-members` so `cargo build` / -`cargo test --workspace` doesn't pull it in. Opt in with `-p -weavepy-bench` when you want to run the benches. +`cargo test --workspace` doesn't pull it in. Opt in with +`-p weavepy-bench`. ## Usage ```bash -# Run all fixtures, print a markdown report. -cargo run -p weavepy-bench -- run +# The harness needs the binary under test next to it. +cargo build --release -p weavepy-cli -p weavepy-bench -# Skip the host CPython subprocess (faster on CI without python3). -cargo run -p weavepy-bench -- run --no-cpython +# Run all fixtures, print a markdown report (ratio column + geomean). +cargo xbench run -# Print the report as JSON instead of markdown. -cargo run -p weavepy-bench -- run --json +# Compare current ratios against the baseline; exit non-zero on +# regression beyond 10% (default threshold). +cargo xbench gate +cargo xbench gate --pct=25 # Refresh the baseline JSON tracked at `baselines/bench.json`. -cargo run -p weavepy-bench -- run --update-baseline +cargo xbench run --update-baseline -# Compare current run against the baseline; exit non-zero on -# regression beyond 10% (default threshold). -cargo run -p weavepy-bench -- gate -cargo run -p weavepy-bench -- gate --pct=15 -``` +# Point at explicit interpreters. +cargo xbench run --weavepy=target/release/weavepy --python=python3.13 + +# Add a WEAVEPY_JIT=1 column (reported, never gated). The binary must +# be built with the tier-2 JIT compiled in: +cargo build --release -p weavepy-cli --features weavepy-cli/jit +cargo xbench run --jit -Run with `--release` for representative numbers — the dev profile -is far slower than what CI / shipped binaries see. +# Print the report as JSON instead of markdown. +cargo xbench run --json +``` ## Adding a fixture -1. Drop `fixtures/foo.py`. The file should: - - Import `os`. - - Define a `bench(n)` callable that runs the workload `n` times. - - Have a `if __name__ == "__main__":` block that reads - `WEAVEPY_BENCH_WORK` from the environment so the runner can - parameterize CPython runs. +1. Drop `fixtures/foo.py`. The file must: + - Define a `bench(n)` callable that runs the workload scaled by `n`. + - End with the standard self-timing block (copy it from any + fixture): read `WEAVEPY_BENCH_WORK`, time `bench(n)` with + `time.perf_counter_ns()`, print `WEAVEPY_BENCH_NS=`. 2. Add `"foo"` to `FIXTURES` in `src/fixtures.rs`. -3. Pick a default `work` parameter in `default_work(...)`. -4. Run `cargo run -p weavepy-bench -- run --update-baseline` and - inspect the diff before committing. +3. Pick a `default_work(...)` value sized so the **CPython** leg takes + ~25–65 ms. +4. Run `cargo xbench run --update-baseline` and inspect the diff + before committing. The gate fails fixtures that have no baseline + row, so the baseline refresh ships in the same change. diff --git a/crates/weavepy-bench/baselines/bench.json b/crates/weavepy-bench/baselines/bench.json index 57c76f07..0523ebeb 100644 --- a/crates/weavepy-bench/baselines/bench.json +++ b/crates/weavepy-bench/baselines/bench.json @@ -1,151 +1,888 @@ { - "version": 1, + "version": 2, "host": "unknown", - "created_at": "ts=1779751858", + "created_at": "ts=1786166308", + "geomean_ratio": 9.923634470754633, "rows": [ { "name": "fannkuch", - "work": 7, + "work": 100000, "weavepy": { "samples": [ - 119667.0, - 109834.0, - 100125.0, - 107166.0, - 96708.0 + 125481209.0, + 125470791.0, + 125704500.0, + 125349959.0, + 125235416.0 ], - "mean_ns": 106700.0, - "median_ns": 107166.0, - "p95_ns": 119667.0, - "stddev_ns": 8961.764474700281 + "mean_ns": 125448375.0, + "median_ns": 125470791.0, + "p95_ns": 125704500.0, + "stddev_ns": 174798.31876622842 }, - "cpython": null + "cpython": { + "samples": [ + 11998875.0, + 11279458.0, + 12332167.0, + 11387292.0, + 10977083.0 + ], + "mean_ns": 11594975.0, + "median_ns": 11387292.0, + "p95_ns": 12332167.0, + "stddev_ns": 554765.938100475 + }, + "jit": { + "samples": [ + 126860375.0, + 125095167.0, + 125680625.0, + 127894541.0, + 129214292.0 + ], + "mean_ns": 126949000.0, + "median_ns": 126860375.0, + "p95_ns": 129214292.0, + "stddev_ns": 1664250.7286376653 + }, + "ratio": 11.018492456327632 }, { "name": "nbody", - "work": 200, + "work": 20000, "weavepy": { "samples": [ - 138458.0, - 269917.0, - 245709.0, - 229000.0, - 221334.0 + 327371916.0, + 383739750.0, + 325466833.0, + 324807875.0, + 327249375.0 ], - "mean_ns": 220883.6, - "median_ns": 229000.0, - "p95_ns": 269917.0, - "stddev_ns": 49700.68698016155 + "mean_ns": 337727149.8, + "median_ns": 327249375.0, + "p95_ns": 383739750.0, + "stddev_ns": 25745859.076614123 }, - "cpython": null + "cpython": { + "samples": [ + 23470250.0, + 24028833.0, + 23329167.0, + 23994000.0, + 23843625.0 + ], + "mean_ns": 23733175.0, + "median_ns": 23843625.0, + "p95_ns": 24028833.0, + "stddev_ns": 316225.6139918144 + }, + "jit": { + "samples": [ + 328431000.0, + 329334916.0, + 330835291.0, + 327566292.0, + 331856000.0 + ], + "mean_ns": 329604699.8, + "median_ns": 329334916.0, + "p95_ns": 331856000.0, + "stddev_ns": 1745577.8534915594 + }, + "ratio": 13.724816381737257 }, { "name": "fib", - "work": 28, + "work": 27, "weavepy": { "samples": [ - 16632417.0, - 15200375.0, - 13960583.0, - 13393791.0, - 13391875.0 + 239618875.0, + 239659458.0, + 247532333.0, + 238768250.0, + 239826000.0 ], - "mean_ns": 14515808.2, - "median_ns": 13960583.0, - "p95_ns": 16632417.0, - "stddev_ns": 1394550.8213913182 + "mean_ns": 241080983.2, + "median_ns": 239659458.0, + "p95_ns": 247532333.0, + "stddev_ns": 3629811.503761828 }, - "cpython": null + "cpython": { + "samples": [ + 17831959.0, + 17639500.0, + 17815708.0, + 17586250.0, + 18035625.0 + ], + "mean_ns": 17781808.4, + "median_ns": 17815708.0, + "p95_ns": 18035625.0, + "stddev_ns": 177891.75115586445 + }, + "jit": { + "samples": [ + 247353375.0, + 252183709.0, + 249552333.0, + 249891500.0, + 255078292.0 + ], + "mean_ns": 250811841.8, + "median_ns": 249891500.0, + "p95_ns": 255078292.0, + "stddev_ns": 2935941.1172706955 + }, + "ratio": 13.452143355739777 }, { "name": "pidigits", - "work": 100, + "work": 500000, "weavepy": { "samples": [ - 82833.0, - 78292.0, - 84000.0, - 100750.0, - 97708.0 + 2245818791.0, + 2236502833.0, + 2246365583.0, + 2232740750.0, + 2236117375.0 + ], + "mean_ns": 2239509066.4, + "median_ns": 2236502833.0, + "p95_ns": 2246365583.0, + "stddev_ns": 6188205.7398033235 + }, + "cpython": { + "samples": [ + 2400597375.0, + 2397909958.0, + 2403335375.0, + 2399491833.0, + 2401062208.0 + ], + "mean_ns": 2400479349.8, + "median_ns": 2400597375.0, + "p95_ns": 2403335375.0, + "stddev_ns": 2005461.1205634726 + }, + "jit": { + "samples": [ + 2251491583.0, + 2256700750.0, + 2256661000.0, + 2246352833.0, + 2267240417.0 ], - "mean_ns": 88716.6, - "median_ns": 84000.0, - "p95_ns": 100750.0, - "stddev_ns": 9889.177660452866 + "mean_ns": 2255689316.6, + "median_ns": 2256661000.0, + "p95_ns": 2267240417.0, + "stddev_ns": 7748967.350444012 }, - "cpython": null + "ratio": 0.9316442883305244 }, { "name": "pyaes", - "work": 50, + "work": 400, "weavepy": { "samples": [ - 10969000.0, - 10972083.0, - 11007333.0, - 11246625.0, - 11636459.0 + 287898583.0, + 290060084.0, + 289740042.0, + 288672917.0, + 288546666.0 + ], + "mean_ns": 288983658.4, + "median_ns": 288672917.0, + "p95_ns": 290060084.0, + "stddev_ns": 893828.7618024495 + }, + "cpython": { + "samples": [ + 18630458.0, + 18184541.0, + 18363708.0, + 18467833.0, + 18384958.0 + ], + "mean_ns": 18406299.6, + "median_ns": 18384958.0, + "p95_ns": 18630458.0, + "stddev_ns": 162388.21736905666 + }, + "jit": { + "samples": [ + 293820166.0, + 292068834.0, + 300571333.0, + 293415042.0, + 293840250.0 ], - "mean_ns": 11166300.0, - "median_ns": 11007333.0, - "p95_ns": 11636459.0, - "stddev_ns": 286975.4480282242 + "mean_ns": 294743125.0, + "median_ns": 293820166.0, + "p95_ns": 300571333.0, + "stddev_ns": 3337314.859289576 }, - "cpython": null + "ratio": 15.701581532032872 }, { "name": "richards", - "work": 1, + "work": 50000, "weavepy": { "samples": [ - 110584.0, - 91959.0, - 88083.0, - 96834.0, - 92417.0 + 262314667.0, + 258466833.0, + 257999666.0, + 262371584.0, + 265205792.0 + ], + "mean_ns": 261271708.4, + "median_ns": 262314667.0, + "p95_ns": 265205792.0, + "stddev_ns": 3014468.8680388294 + }, + "cpython": { + "samples": [ + 13775625.0, + 12962041.0, + 13347084.0, + 13791792.0, + 13526291.0 ], - "mean_ns": 95975.4, - "median_ns": 92417.0, - "p95_ns": 110584.0, - "stddev_ns": 8735.445684107937 + "mean_ns": 13480566.6, + "median_ns": 13526291.0, + "p95_ns": 13791792.0, + "stddev_ns": 343765.1204417051 }, - "cpython": null + "jit": { + "samples": [ + 270345709.0, + 271474250.0, + 272698083.0, + 272639042.0, + 274580750.0 + ], + "mean_ns": 272347566.8, + "median_ns": 272639042.0, + "p95_ns": 274580750.0, + "stddev_ns": 1578372.8558251692 + }, + "ratio": 19.39294866567635 }, { "name": "sumvm", - "work": 50000, + "work": 2000000, "weavepy": { "samples": [ - 2038083.0, - 2040333.0, - 2038542.0, - 2298125.0, - 2129875.0 + 264647708.0, + 265141458.0, + 264730125.0, + 268070917.0, + 266220250.0 + ], + "mean_ns": 265762091.6, + "median_ns": 265141458.0, + "p95_ns": 268070917.0, + "stddev_ns": 1434623.2766905394 + }, + "cpython": { + "samples": [ + 37649542.0, + 39900208.0, + 39831958.0, + 39090250.0, + 38632833.0 ], - "mean_ns": 2108991.6, - "median_ns": 2040333.0, - "p95_ns": 2298125.0, - "stddev_ns": 112819.25637851014 + "mean_ns": 39020958.2, + "median_ns": 39090250.0, + "p95_ns": 39900208.0, + "stddev_ns": 930997.5569845499 }, - "cpython": null + "jit": { + "samples": [ + 292415875.0, + 292459542.0, + 291472083.0, + 291849834.0, + 293225750.0 + ], + "mean_ns": 292284616.8, + "median_ns": 292415875.0, + "p95_ns": 293225750.0, + "stddev_ns": 667608.8862730183 + }, + "ratio": 6.782802821675482 }, { "name": "nested_loops", - "work": 30, + "work": 120, + "weavepy": { + "samples": [ + 396935167.0, + 393393250.0, + 394549375.0, + 396667875.0, + 402435000.0 + ], + "mean_ns": 396796133.4, + "median_ns": 396667875.0, + "p95_ns": 402435000.0, + "stddev_ns": 3480665.7349442937 + }, + "cpython": { + "samples": [ + 52136584.0, + 51817083.0, + 52750500.0, + 53561791.0, + 50801500.0 + ], + "mean_ns": 52213491.6, + "median_ns": 52136584.0, + "p95_ns": 53561791.0, + "stddev_ns": 1032359.4491834227 + }, + "jit": { + "samples": [ + 417058000.0, + 418489125.0, + 415973166.0, + 417535334.0, + 416969875.0 + ], + "mean_ns": 417205100.0, + "median_ns": 417058000.0, + "p95_ns": 418489125.0, + "stddev_ns": 915478.7390160953 + }, + "ratio": 7.608244433505655 + }, + { + "name": "jitloop", + "work": 1000, + "weavepy": { + "samples": [ + 489278500.0, + 509662417.0, + 495446625.0, + 499455416.0, + 499076958.0 + ], + "mean_ns": 498583983.2, + "median_ns": 499076958.0, + "p95_ns": 509662417.0, + "stddev_ns": 7419048.941743187 + }, + "cpython": { + "samples": [ + 61235250.0, + 61464375.0, + 60887167.0, + 60920250.0, + 61878041.0 + ], + "mean_ns": 61277016.6, + "median_ns": 61235250.0, + "p95_ns": 61878041.0, + "stddev_ns": 411504.2727047922 + }, + "jit": { + "samples": [ + 3948875.0, + 4141208.0, + 3906875.0, + 3898125.0, + 4155167.0 + ], + "mean_ns": 4010050.0, + "median_ns": 3948875.0, + "p95_ns": 4155167.0, + "stddev_ns": 127647.9842457373 + }, + "ratio": 8.150157923744901 + }, + { + "name": "deltablue", + "work": 50, + "weavepy": { + "samples": [ + 1118476333.0, + 1122919333.0, + 1114911708.0, + 1114184959.0, + 1117936625.0 + ], + "mean_ns": 1117685791.6, + "median_ns": 1117936625.0, + "p95_ns": 1122919333.0, + "stddev_ns": 3465155.0253269766 + }, + "cpython": { + "samples": [ + 49329250.0, + 47813208.0, + 48957958.0, + 48418083.0, + 47648750.0 + ], + "mean_ns": 48433449.8, + "median_ns": 48418083.0, + "p95_ns": 49329250.0, + "stddev_ns": 720807.9937328665 + }, + "jit": { + "samples": [ + 1148820417.0, + 1144383375.0, + 1143125833.0, + 1140805375.0, + 1147175125.0 + ], + "mean_ns": 1144862025.0, + "median_ns": 1144383375.0, + "p95_ns": 1148820417.0, + "stddev_ns": 3190578.965545595 + }, + "ratio": 23.089237651147815 + }, + { + "name": "float_math", + "work": 100000, + "weavepy": { + "samples": [ + 689902917.0, + 693673125.0, + 691439458.0, + 687896125.0, + 696980708.0 + ], + "mean_ns": 691978466.6, + "median_ns": 691439458.0, + "p95_ns": 696980708.0, + "stddev_ns": 3505646.7116849213 + }, + "cpython": { + "samples": [ + 38967333.0, + 38880459.0, + 39610208.0, + 39570584.0, + 38601125.0 + ], + "mean_ns": 39125941.8, + "median_ns": 38967333.0, + "p95_ns": 39610208.0, + "stddev_ns": 445272.85695930314 + }, + "jit": { + "samples": [ + 695458958.0, + 696732084.0, + 698362500.0, + 705488167.0, + 694934875.0 + ], + "mean_ns": 698195316.8, + "median_ns": 696732084.0, + "p95_ns": 705488167.0, + "stddev_ns": 4285813.945521982 + }, + "ratio": 17.744079585841813 + }, + { + "name": "spectral_norm", + "work": 100, + "weavepy": { + "samples": [ + 376907917.0, + 377489083.0, + 376324459.0, + 376851000.0, + 378129791.0 + ], + "mean_ns": 377140450.0, + "median_ns": 376907917.0, + "p95_ns": 378129791.0, + "stddev_ns": 689933.8362553326 + }, + "cpython": { + "samples": [ + 31440000.0, + 30922416.0, + 30752833.0, + 31481166.0, + 30950500.0 + ], + "mean_ns": 31109383.0, + "median_ns": 30950500.0, + "p95_ns": 31481166.0, + "stddev_ns": 329719.0353376644 + }, + "jit": { + "samples": [ + 388157417.0, + 389949083.0, + 387912084.0, + 389497334.0, + 389022875.0 + ], + "mean_ns": 388907758.6, + "median_ns": 389022875.0, + "p95_ns": 389949083.0, + "stddev_ns": 865965.9158484819 + }, + "ratio": 12.177765044183454 + }, + { + "name": "json_bench", + "work": 150, + "weavepy": { + "samples": [ + 233503042.0, + 233311375.0, + 233741417.0, + 234349541.0, + 233610291.0 + ], + "mean_ns": 233703133.2, + "median_ns": 233610291.0, + "p95_ns": 234349541.0, + "stddev_ns": 394157.12061587826 + }, + "cpython": { + "samples": [ + 43321708.0, + 42820792.0, + 43442375.0, + 42789291.0, + 43582000.0 + ], + "mean_ns": 43191233.2, + "median_ns": 43321708.0, + "p95_ns": 43582000.0, + "stddev_ns": 364547.247940099 + }, + "jit": { + "samples": [ + 236025250.0, + 234939667.0, + 233331750.0, + 234431834.0, + 234072125.0 + ], + "mean_ns": 234560125.2, + "median_ns": 234431834.0, + "p95_ns": 236025250.0, + "stddev_ns": 1006746.13442203 + }, + "ratio": 5.392453386186897 + }, + { + "name": "str_methods", + "work": 15000, + "weavepy": { + "samples": [ + 220964292.0, + 221550709.0, + 221533500.0, + 222277000.0, + 217922750.0 + ], + "mean_ns": 220849650.2, + "median_ns": 221533500.0, + "p95_ns": 222277000.0, + "stddev_ns": 1701201.7050194843 + }, + "cpython": { + "samples": [ + 31858584.0, + 32010667.0, + 32159334.0, + 32006000.0, + 31884916.0 + ], + "mean_ns": 31983900.2, + "median_ns": 32006000.0, + "p95_ns": 32159334.0, + "stddev_ns": 119879.00437190826 + }, + "jit": { + "samples": [ + 220459541.0, + 220033000.0, + 221740791.0, + 221394875.0, + 220853125.0 + ], + "mean_ns": 220896266.4, + "median_ns": 220853125.0, + "p95_ns": 221740791.0, + "stddev_ns": 689139.2219223921 + }, + "ratio": 6.921624070486784 + }, + { + "name": "dict_ops", + "work": 100000, + "weavepy": { + "samples": [ + 255925500.0, + 257040959.0, + 257240958.0, + 255456875.0, + 257582666.0 + ], + "mean_ns": 256649391.6, + "median_ns": 257040959.0, + "p95_ns": 257582666.0, + "stddev_ns": 911097.1174733789 + }, + "cpython": { + "samples": [ + 33010084.0, + 32707500.0, + 32863125.0, + 33522208.0, + 33340583.0 + ], + "mean_ns": 33088700.0, + "median_ns": 33010084.0, + "p95_ns": 33522208.0, + "stddev_ns": 336805.6664584193 + }, + "jit": { + "samples": [ + 258710792.0, + 259258042.0, + 257240375.0, + 258511166.0, + 257544083.0 + ], + "mean_ns": 258252891.6, + "median_ns": 258511166.0, + "p95_ns": 259258042.0, + "stddev_ns": 838794.5614608502 + }, + "ratio": 7.786740530560298 + }, + { + "name": "list_ops", + "work": 10000, + "weavepy": { + "samples": [ + 455730209.0, + 453334666.0, + 456616709.0, + 453477625.0, + 454523750.0 + ], + "mean_ns": 454736591.8, + "median_ns": 454523750.0, + "p95_ns": 456616709.0, + "stddev_ns": 1424588.6081520868 + }, + "cpython": { + "samples": [ + 26065333.0, + 26141125.0, + 26331375.0, + 26594250.0, + 26347209.0 + ], + "mean_ns": 26295858.4, + "median_ns": 26331375.0, + "p95_ns": 26594250.0, + "stddev_ns": 206167.55629050854 + }, + "jit": { + "samples": [ + 470797834.0, + 480227958.0, + 474660709.0, + 472051000.0, + 471001542.0 + ], + "mean_ns": 473747808.6, + "median_ns": 472051000.0, + "p95_ns": 480227958.0, + "stddev_ns": 3935391.1238463707 + }, + "ratio": 17.261679270452074 + }, + { + "name": "attr_access", + "work": 200000, + "weavepy": { + "samples": [ + 434540416.0, + 435735084.0, + 435911583.0, + 435454916.0, + 437757667.0 + ], + "mean_ns": 435879933.2, + "median_ns": 435735084.0, + "p95_ns": 437757667.0, + "stddev_ns": 1175033.3158718096 + }, + "cpython": { + "samples": [ + 30576167.0, + 29084791.0, + 28666084.0, + 28840333.0, + 29175000.0 + ], + "mean_ns": 29268475.0, + "median_ns": 29084791.0, + "p95_ns": 30576167.0, + "stddev_ns": 758075.8938572707 + }, + "jit": { + "samples": [ + 441572500.0, + 454011500.0, + 446911000.0, + 448941167.0, + 454697708.0 + ], + "mean_ns": 449226775.0, + "median_ns": 448941167.0, + "p95_ns": 454697708.0, + "stddev_ns": 5405021.109395133 + }, + "ratio": 14.981544271712318 + }, + { + "name": "call_overhead", + "work": 150000, + "weavepy": { + "samples": [ + 694103250.0, + 693138000.0, + 693672000.0, + 698744750.0, + 695607333.0 + ], + "mean_ns": 695053066.6, + "median_ns": 694103250.0, + "p95_ns": 698744750.0, + "stddev_ns": 2259021.5900756237 + }, + "cpython": { + "samples": [ + 44542917.0, + 44775500.0, + 45423917.0, + 47230292.0, + 45567208.0 + ], + "mean_ns": 45507966.8, + "median_ns": 45423917.0, + "p95_ns": 47230292.0, + "stddev_ns": 1054135.5122557536 + }, + "jit": { + "samples": [ + 705298750.0, + 706708458.0, + 706382375.0, + 709703834.0, + 708174333.0 + ], + "mean_ns": 707253550.0, + "median_ns": 706708458.0, + "p95_ns": 709703834.0, + "stddev_ns": 1712384.8299107593 + }, + "ratio": 15.280567943975417 + }, + { + "name": "generators", + "work": 300000, "weavepy": { "samples": [ - 2963334.0, - 2939125.0, - 3007542.0, - 3042333.0, - 2935791.0 + 613692500.0, + 614772250.0, + 613050875.0, + 613178750.0, + 614041666.0 + ], + "mean_ns": 613747208.2, + "median_ns": 613692500.0, + "p95_ns": 614772250.0, + "stddev_ns": 697931.5377321761 + }, + "cpython": { + "samples": [ + 31059083.0, + 29831125.0, + 30703000.0, + 30336917.0, + 30094916.0 + ], + "mean_ns": 30405008.2, + "median_ns": 30336917.0, + "p95_ns": 31059083.0, + "stddev_ns": 486505.0020613354 + }, + "jit": { + "samples": [ + 637925125.0, + 647625625.0, + 639439875.0, + 637853292.0, + 635933167.0 + ], + "mean_ns": 639755416.8, + "median_ns": 637925125.0, + "p95_ns": 647625625.0, + "stddev_ns": 4572127.078844616 + }, + "ratio": 20.22923093997983 + }, + { + "name": "startup", + "work": 1, + "weavepy": { + "samples": [ + 52396958.0, + 52590500.0, + 52178875.0, + 52767333.0, + 52275917.0 + ], + "mean_ns": 52441916.6, + "median_ns": 52396958.0, + "p95_ns": 52767333.0, + "stddev_ns": 238086.58424468187 + }, + "cpython": { + "samples": [ + 17184916.0, + 17162750.0, + 17258625.0, + 17015916.0, + 17432250.0 + ], + "mean_ns": 17210891.4, + "median_ns": 17184916.0, + "p95_ns": 17432250.0, + "stddev_ns": 151892.93002572568 + }, + "jit": { + "samples": [ + 53041708.0, + 53096334.0, + 53007375.0, + 53575500.0, + 53280042.0 ], - "mean_ns": 2977625.0, - "median_ns": 2963334.0, - "p95_ns": 3042333.0, - "stddev_ns": 46148.45173459235 + "mean_ns": 53200191.8, + "median_ns": 53096334.0, + "p95_ns": 53575500.0, + "stddev_ns": 234688.18423218498 }, - "cpython": null + "ratio": 3.0490086771445375 } ] } \ No newline at end of file diff --git a/crates/weavepy-bench/fixtures/attr_access.py b/crates/weavepy-bench/fixtures/attr_access.py new file mode 100644 index 00000000..2c3d8b3b --- /dev/null +++ b/crates/weavepy-bench/fixtures/attr_access.py @@ -0,0 +1,51 @@ +"""Attribute get/set on plain and __slots__ instances + method calls.""" + +import os + + +class Plain: + def __init__(self): + self.a = 1 + self.b = 2.0 + self.c = "x" + + def tick(self): + self.a += 1 + return self.a + + +class Slotted: + __slots__ = ("a", "b", "c") + + def __init__(self): + self.a = 1 + self.b = 2.0 + self.c = "x" + + def tick(self): + self.a += 1 + return self.a + + +def bench(n): + p = Plain() + s = Slotted() + total = 0 + for _ in range(n): + total += p.a + s.a + p.b = p.b + 0.5 + s.b = s.b + 0.5 + total += p.tick() + s.tick() + if p.c == s.c: + total += 1 + return total + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "50000")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/call_overhead.py b/crates/weavepy-bench/fixtures/call_overhead.py new file mode 100644 index 00000000..dfc4656c --- /dev/null +++ b/crates/weavepy-bench/fixtures/call_overhead.py @@ -0,0 +1,51 @@ +"""Call-shape matrix: positional, defaults, kwargs, bound methods, +builtins — the pure function-call overhead benchmark.""" + +import os + + +def pos2(a, b): + return a + b + + +def with_defaults(a, b=10, c=20): + return a + b + c + + +def with_kwargs(a, **kw): + return a + kw.get("delta", 0) + + +class Counter: + def __init__(self): + self.n = 0 + + def bump(self, by): + self.n += by + return self.n + + +def bench(n): + c = Counter() + bump = c.bump + total = 0 + seq = (1, 2, 3) + for i in range(n): + total += pos2(i, 1) + total += with_defaults(i) + total += with_defaults(i, c=5) + total += with_kwargs(i, delta=2) + total += c.bump(1) + total += bump(1) + total += len(seq) + return total + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "20000")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/deltablue.py b/crates/weavepy-bench/fixtures/deltablue.py new file mode 100644 index 00000000..63971ee7 --- /dev/null +++ b/crates/weavepy-bench/fixtures/deltablue.py @@ -0,0 +1,446 @@ +"""DeltaBlue constraint solver — the classic OO/polymorphism benchmark. + +Compact port of the pyperformance/V8 deltablue workload: a chain of +equality/scale constraints is built, then repeatedly perturbed and +re-planned. Exercises method dispatch, attribute access, and list +traffic in realistic OO proportions. +""" + +import os + + +class Strength: + def __init__(self, value, name): + self.value = value + self.name = name + + @staticmethod + def stronger(s1, s2): + return s1.value < s2.value + + @staticmethod + def weaker(s1, s2): + return s1.value > s2.value + + @staticmethod + def weakest_of(s1, s2): + return s1 if Strength.weaker(s1, s2) else s2 + + +REQUIRED = Strength(0, "required") +STRONG_PREFERRED = Strength(1, "strongPreferred") +PREFERRED = Strength(2, "preferred") +STRONG_DEFAULT = Strength(3, "strongDefault") +NORMAL = Strength(4, "normal") +WEAK_DEFAULT = Strength(5, "weakDefault") +WEAKEST = Strength(6, "weakest") + + +class Constraint: + def __init__(self, strength): + self.strength = strength + + def add_constraint(self, planner): + self.add_to_graph() + planner.incremental_add(self) + + def satisfy(self, mark, planner): + self.choose_method(mark) + if not self.is_satisfied(): + if self.strength is REQUIRED: + raise RuntimeError("Could not satisfy a required constraint!") + return None + self.mark_inputs(mark) + out = self.output() + overridden = out.determined_by + if overridden is not None: + overridden.mark_unsatisfied() + out.determined_by = self + if not planner.add_propagate(self, mark): + raise RuntimeError("Cycle encountered") + out.mark = mark + return overridden + + def destroy_constraint(self, planner): + if self.is_satisfied(): + planner.incremental_remove(self) + else: + self.remove_from_graph() + + def is_input(self): + return False + + +class UnaryConstraint(Constraint): + def __init__(self, v, strength, planner): + super().__init__(strength) + self.my_output = v + self.satisfied = False + self.add_constraint(planner) + + def add_to_graph(self): + self.my_output.add_constraint(self) + self.satisfied = False + + def choose_method(self, mark): + if self.my_output.mark != mark and Strength.stronger( + self.strength, self.my_output.walk_strength + ): + self.satisfied = True + else: + self.satisfied = False + + def is_satisfied(self): + return self.satisfied + + def mark_inputs(self, mark): + pass + + def output(self): + return self.my_output + + def recalculate(self): + self.my_output.walk_strength = self.strength + self.my_output.stay = not self.is_input() + if self.my_output.stay: + self.execute() + + def mark_unsatisfied(self): + self.satisfied = False + + def inputs_known(self, mark): + return True + + def remove_from_graph(self): + if self.my_output is not None: + self.my_output.remove_constraint(self) + self.satisfied = False + + +class StayConstraint(UnaryConstraint): + def execute(self): + pass + + +class EditConstraint(UnaryConstraint): + def is_input(self): + return True + + def execute(self): + pass + + +class BinaryConstraint(Constraint): + NONE = 0 + FORWARD = 1 + BACKWARD = 2 + + def __init__(self, v1, v2, strength, planner): + super().__init__(strength) + self.v1 = v1 + self.v2 = v2 + self.direction = BinaryConstraint.NONE + self.add_constraint(planner) + + def choose_method(self, mark): + if self.v1.mark == mark: + if self.v2.mark != mark and Strength.stronger( + self.strength, self.v2.walk_strength + ): + self.direction = BinaryConstraint.FORWARD + else: + self.direction = BinaryConstraint.NONE + elif self.v2.mark == mark: + if self.v1.mark != mark and Strength.stronger( + self.strength, self.v1.walk_strength + ): + self.direction = BinaryConstraint.BACKWARD + else: + self.direction = BinaryConstraint.NONE + elif Strength.weaker(self.v1.walk_strength, self.v2.walk_strength): + if Strength.stronger(self.strength, self.v1.walk_strength): + self.direction = BinaryConstraint.BACKWARD + else: + self.direction = BinaryConstraint.NONE + else: + if Strength.stronger(self.strength, self.v2.walk_strength): + self.direction = BinaryConstraint.FORWARD + else: + self.direction = BinaryConstraint.NONE + + def add_to_graph(self): + self.v1.add_constraint(self) + self.v2.add_constraint(self) + self.direction = BinaryConstraint.NONE + + def is_satisfied(self): + return self.direction != BinaryConstraint.NONE + + def mark_inputs(self, mark): + self.input().mark = mark + + def input(self): + return self.v1 if self.direction == BinaryConstraint.FORWARD else self.v2 + + def output(self): + return self.v2 if self.direction == BinaryConstraint.FORWARD else self.v1 + + def recalculate(self): + ihn = self.input() + out = self.output() + out.walk_strength = Strength.weakest_of(self.strength, ihn.walk_strength) + out.stay = ihn.stay + if out.stay: + self.execute() + + def mark_unsatisfied(self): + self.direction = BinaryConstraint.NONE + + def inputs_known(self, mark): + i = self.input() + return i.mark == mark or i.stay or i.determined_by is None + + def remove_from_graph(self): + if self.v1 is not None: + self.v1.remove_constraint(self) + if self.v2 is not None: + self.v2.remove_constraint(self) + self.direction = BinaryConstraint.NONE + + +class ScaleConstraint(BinaryConstraint): + def __init__(self, src, scale, offset, dest, strength, planner): + self.scale = scale + self.offset = offset + super().__init__(src, dest, strength, planner) + + def add_to_graph(self): + super().add_to_graph() + self.scale.add_constraint(self) + self.offset.add_constraint(self) + + def remove_from_graph(self): + super().remove_from_graph() + if self.scale is not None: + self.scale.remove_constraint(self) + if self.offset is not None: + self.offset.remove_constraint(self) + + def mark_inputs(self, mark): + super().mark_inputs(mark) + self.scale.mark = mark + self.offset.mark = mark + + def execute(self): + if self.direction == BinaryConstraint.FORWARD: + self.v2.value = self.v1.value * self.scale.value + self.offset.value + else: + self.v1.value = (self.v2.value - self.offset.value) // self.scale.value + + def recalculate(self): + ihn = self.input() + out = self.output() + out.walk_strength = Strength.weakest_of(self.strength, ihn.walk_strength) + out.stay = ihn.stay and self.scale.stay and self.offset.stay + if out.stay: + self.execute() + + +class EqualityConstraint(BinaryConstraint): + def execute(self): + self.output().value = self.input().value + + +class Variable: + def __init__(self, name, value=0): + self.name = name + self.value = value + self.constraints = [] + self.determined_by = None + self.mark = 0 + self.walk_strength = WEAKEST + self.stay = True + + def add_constraint(self, constraint): + self.constraints.append(constraint) + + def remove_constraint(self, constraint): + if constraint in self.constraints: + self.constraints.remove(constraint) + if self.determined_by is constraint: + self.determined_by = None + + +class Plan: + def __init__(self): + self.v = [] + + def add_constraint(self, c): + self.v.append(c) + + def execute(self): + for c in self.v: + c.execute() + + +class Planner: + def __init__(self): + self.current_mark = 0 + + def new_mark(self): + self.current_mark += 1 + return self.current_mark + + def incremental_add(self, constraint): + mark = self.new_mark() + overridden = constraint.satisfy(mark, self) + while overridden is not None: + overridden = overridden.satisfy(mark, self) + + def incremental_remove(self, constraint): + out = constraint.output() + constraint.mark_unsatisfied() + constraint.remove_from_graph() + unsatisfied = self.remove_propagate_from(out) + strength = REQUIRED + while True: + for u in unsatisfied: + if u.strength is strength: + self.incremental_add(u) + if strength is WEAKEST: + break + strength = { + REQUIRED: STRONG_PREFERRED, + STRONG_PREFERRED: PREFERRED, + PREFERRED: STRONG_DEFAULT, + STRONG_DEFAULT: NORMAL, + NORMAL: WEAK_DEFAULT, + WEAK_DEFAULT: WEAKEST, + }[strength] + + def add_propagate(self, c, mark): + todo = [c] + while todo: + d = todo.pop() + if d.output().mark == mark: + self.incremental_remove(c) + return False + d.recalculate() + self.add_constraints_consuming_to(d.output(), todo) + return True + + def remove_propagate_from(self, out): + out.determined_by = None + out.walk_strength = WEAKEST + out.stay = True + unsatisfied = [] + todo = [out] + while todo: + v = todo.pop() + for c in v.constraints: + if not c.is_satisfied(): + unsatisfied.append(c) + determining = v.determined_by + for c in v.constraints: + if c is not determining and c.is_satisfied(): + c.recalculate() + todo.append(c.output()) + return unsatisfied + + def add_constraints_consuming_to(self, v, coll): + determining = v.determined_by + for c in v.constraints: + if c is not determining and c.is_satisfied(): + coll.append(c) + + def make_plan(self, sources): + mark = self.new_mark() + plan = Plan() + todo = list(sources) + while todo: + c = todo.pop() + if c.output().mark != mark and c.inputs_known(mark): + plan.add_constraint(c) + c.output().mark = mark + self.add_constraints_consuming_to(c.output(), todo) + return plan + + def extract_plan_from_constraints(self, constraints): + sources = [c for c in constraints if c.is_input() and c.is_satisfied()] + return self.make_plan(sources) + + +def chain_test(n, planner): + prev = first = last = None + for i in range(n + 1): + v = Variable("v%d" % i) + if prev is not None: + EqualityConstraint(prev, v, REQUIRED, planner) + if i == 0: + first = v + if i == n: + last = v + prev = v + StayConstraint(last, STRONG_DEFAULT, planner) + edit = EditConstraint(first, PREFERRED, planner) + plan = planner.extract_plan_from_constraints([edit]) + for i in range(100): + first.value = i + plan.execute() + if last.value != i: + raise RuntimeError("Chain test failed") + edit.destroy_constraint(planner) + + +def projection_test(n, planner): + scale = Variable("scale", 10) + offset = Variable("offset", 1000) + src = dst = None + dests = [] + for i in range(n): + src = Variable("src%d" % i, i) + dst = Variable("dst%d" % i, i) + dests.append(dst) + StayConstraint(src, NORMAL, planner) + ScaleConstraint(src, scale, offset, dst, REQUIRED, planner) + change(src, 17, planner) + if dst.value != 1170: + raise RuntimeError("Projection 1 failed") + change(dst, 1050, planner) + if src.value != 5: + raise RuntimeError("Projection 2 failed") + change(scale, 5, planner) + for i in range(n - 1): + if dests[i].value != i * 5 + 1000: + raise RuntimeError("Projection 3 failed") + change(offset, 2000, planner) + for i in range(n - 1): + if dests[i].value != i * 5 + 2000: + raise RuntimeError("Projection 4 failed") + + +def change(v, new_value, planner): + edit = EditConstraint(v, PREFERRED, planner) + plan = planner.extract_plan_from_constraints([edit]) + for _ in range(10): + v.value = new_value + plan.execute() + edit.destroy_constraint(planner) + + +def bench(n): + for _ in range(n): + planner = Planner() + chain_test(50, planner) + projection_test(50, planner) + return 0 + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "10")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/dict_ops.py b/crates/weavepy-bench/fixtures/dict_ops.py new file mode 100644 index 00000000..d2fa61c1 --- /dev/null +++ b/crates/weavepy-bench/fixtures/dict_ops.py @@ -0,0 +1,34 @@ +"""Dict insert / lookup / delete / iterate with str and int keys.""" + +import os + + +def bench(n): + d = {} + total = 0 + for i in range(n): + d[i & 1023] = i + d["k%d" % (i & 255)] = i + for i in range(n): + total += d[i & 1023] + v = d.get("k%d" % (i & 255)) + if v is not None: + total += v + if ((i * 7) & 1023) in d: + total += 1 + for k in list(d): + if isinstance(k, int) and k & 1: + del d[k] + for k, v in d.items(): + total += v if isinstance(k, int) else 1 + return total + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "20000")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/fannkuch.py b/crates/weavepy-bench/fixtures/fannkuch.py index 96fe18de..d3249a67 100644 --- a/crates/weavepy-bench/fixtures/fannkuch.py +++ b/crates/weavepy-bench/fixtures/fannkuch.py @@ -38,5 +38,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "1")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/fib.py b/crates/weavepy-bench/fixtures/fib.py index 62340271..54d1a774 100644 --- a/crates/weavepy-bench/fixtures/fib.py +++ b/crates/weavepy-bench/fixtures/fib.py @@ -14,5 +14,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "20")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/float_math.py b/crates/weavepy-bench/fixtures/float_math.py new file mode 100644 index 00000000..884c99ce --- /dev/null +++ b/crates/weavepy-bench/fixtures/float_math.py @@ -0,0 +1,49 @@ +"""Float-heavy point normalization — pyperformance `float` shape. + +Exercises float arithmetic, math-module calls, and per-iteration +object construction. +""" + +import math +import os + + +class Point: + def __init__(self, i): + self.x = math.sin(i) + self.y = math.cos(i) * 3.0 + self.z = (self.x * self.x) / 2.0 + + def normalize(self): + norm = math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z) + self.x /= norm + self.y /= norm + self.z /= norm + + def maximize(self, other): + self.x = self.x if self.x > other.x else other.x + self.y = self.y if self.y > other.y else other.y + self.z = self.z if self.z > other.z else other.z + return self + + +def bench(n): + points = [None] * n + for i in range(n): + points[i] = Point(i) + for p in points: + p.normalize() + nxt = points[0] + for p in points[1:]: + nxt = nxt.maximize(p) + return nxt.x + nxt.y + nxt.z + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "10000")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/generators.py b/crates/weavepy-bench/fixtures/generators.py new file mode 100644 index 00000000..12f27b5a --- /dev/null +++ b/crates/weavepy-bench/fixtures/generators.py @@ -0,0 +1,38 @@ +"""Generator pipeline — creation, resumption, and close overhead.""" + +import os + + +def _naturals(limit): + i = 0 + while i < limit: + yield i + i += 1 + + +def _squared(it): + for x in it: + yield x * x + + +def _odds_only(it): + for x in it: + if x & 1: + yield x + + +def bench(n): + total = 0 + total += sum(_odds_only(_squared(_naturals(n)))) + total += sum(x + 1 for x in _naturals(n)) + return total + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "50000")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/jitloop.py b/crates/weavepy-bench/fixtures/jitloop.py index 3f605142..87ead367 100644 --- a/crates/weavepy-bench/fixtures/jitloop.py +++ b/crates/weavepy-bench/fixtures/jitloop.py @@ -31,5 +31,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "300")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/json_bench.py b/crates/weavepy-bench/fixtures/json_bench.py new file mode 100644 index 00000000..34619d5f --- /dev/null +++ b/crates/weavepy-bench/fixtures/json_bench.py @@ -0,0 +1,38 @@ +"""Stdlib json round-trips over a nested document.""" + +import json +import os + +DOC = { + "users": [ + { + "id": i, + "name": "user-%d" % i, + "active": i % 3 != 0, + "score": i * 0.5, + "tags": ["alpha", "beta", "gamma"][: i % 4], + "profile": {"city": "city-%d" % (i % 17), "zip": str(10000 + i)}, + } + for i in range(200) + ], + "meta": {"version": 3, "generated": "bench", "count": 200}, +} + + +def bench(n): + total = 0 + for _ in range(n): + blob = json.dumps(DOC) + back = json.loads(blob) + total += len(back["users"]) + return total + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "50")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/list_ops.py b/crates/weavepy-bench/fixtures/list_ops.py new file mode 100644 index 00000000..056e3ec8 --- /dev/null +++ b/crates/weavepy-bench/fixtures/list_ops.py @@ -0,0 +1,32 @@ +"""List subscripts, append/pop, slices, sort, and comprehensions.""" + +import os + + +def bench(n): + data = list(range(256)) + total = 0 + for i in range(n): + acc = [] + for j in range(64): + acc.append(data[(i + j) & 255]) + total += acc[0] + acc[-1] + acc[31] + acc[5] = acc[5] + 1 + sl = acc[8:24] + total += len(sl) + squares = [x * x for x in sl] + evens = [x for x in squares if x & 1 == 0] + total += sum(evens) + acc.sort() + total += acc.pop() + return total + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "5000")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/nbody.py b/crates/weavepy-bench/fixtures/nbody.py index 7db2abe9..545eb240 100644 --- a/crates/weavepy-bench/fixtures/nbody.py +++ b/crates/weavepy-bench/fixtures/nbody.py @@ -62,5 +62,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "1")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/nested_loops.py b/crates/weavepy-bench/fixtures/nested_loops.py index 1fa542c9..98d1227c 100644 --- a/crates/weavepy-bench/fixtures/nested_loops.py +++ b/crates/weavepy-bench/fixtures/nested_loops.py @@ -13,5 +13,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "20")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/pidigits.py b/crates/weavepy-bench/fixtures/pidigits.py index 1196ba4c..5ad48cf8 100644 --- a/crates/weavepy-bench/fixtures/pidigits.py +++ b/crates/weavepy-bench/fixtures/pidigits.py @@ -20,5 +20,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "100")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/pyaes.py b/crates/weavepy-bench/fixtures/pyaes.py index b58cddd3..09648d4f 100644 --- a/crates/weavepy-bench/fixtures/pyaes.py +++ b/crates/weavepy-bench/fixtures/pyaes.py @@ -23,5 +23,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "10")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/richards.py b/crates/weavepy-bench/fixtures/richards.py index ce152551..063aa7e6 100644 --- a/crates/weavepy-bench/fixtures/richards.py +++ b/crates/weavepy-bench/fixtures/richards.py @@ -24,5 +24,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "1")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/spectral_norm.py b/crates/weavepy-bench/fixtures/spectral_norm.py new file mode 100644 index 00000000..aa661217 --- /dev/null +++ b/crates/weavepy-bench/fixtures/spectral_norm.py @@ -0,0 +1,55 @@ +"""Spectral norm (shootout) — float arithmetic + list subscripts.""" + +import os + + +def _eval_a(i, j): + return 1.0 / ((i + j) * (i + j + 1) // 2 + i + 1) + + +def _eval_a_times_u(u): + n = len(u) + out = [0.0] * n + for i in range(n): + s = 0.0 + for j in range(n): + s += _eval_a(i, j) * u[j] + out[i] = s + return out + + +def _eval_at_times_u(u): + n = len(u) + out = [0.0] * n + for i in range(n): + s = 0.0 + for j in range(n): + s += _eval_a(j, i) * u[j] + out[i] = s + return out + + +def _eval_ata_times_u(u): + return _eval_at_times_u(_eval_a_times_u(u)) + + +def bench(n): + u = [1.0] * n + for _ in range(10): + v = _eval_ata_times_u(u) + u = _eval_ata_times_u(v) + vbv = vv = 0.0 + for ue, ve in zip(u, v): + vbv += ue * ve + vv += ve * ve + return (vbv / vv) ** 0.5 + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "60")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/startup.py b/crates/weavepy-bench/fixtures/startup.py new file mode 100644 index 00000000..df561772 --- /dev/null +++ b/crates/weavepy-bench/fixtures/startup.py @@ -0,0 +1,11 @@ +"""Interpreter startup — measured as full subprocess wall time by the +harness (see WALL_CLOCK_FIXTURES); the body is intentionally trivial.""" + +import os + +def bench(n): + return n + + +if __name__ == "__main__": + bench(int(os.environ.get("WEAVEPY_BENCH_WORK", "1"))) diff --git a/crates/weavepy-bench/fixtures/str_methods.py b/crates/weavepy-bench/fixtures/str_methods.py new file mode 100644 index 00000000..887a06cc --- /dev/null +++ b/crates/weavepy-bench/fixtures/str_methods.py @@ -0,0 +1,30 @@ +"""String method churn — split/join/replace/case/format loops.""" + +import os + +BASE = "The quick brown fox jumps over the lazy dog; " * 4 + + +def bench(n): + total = 0 + for i in range(n): + s = BASE + parts = s.split() + s2 = " ".join(parts) + s3 = s2.replace("fox", "cat").replace("dog", "hen") + s4 = s3.upper().lower().title() + s5 = "%s #%d [%s]" % (s4[:40], i, ",".join(parts[:5])) + if s5.startswith("The") or s5.endswith("]"): + total += len(s5) + total += s3.count("cat") + s2.find("lazy") + return total + + +if __name__ == "__main__": + import time + + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "5000")) + _t0 = time.perf_counter_ns() + bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/fixtures/sumvm.py b/crates/weavepy-bench/fixtures/sumvm.py index 57d74200..85175edb 100644 --- a/crates/weavepy-bench/fixtures/sumvm.py +++ b/crates/weavepy-bench/fixtures/sumvm.py @@ -13,5 +13,10 @@ def bench(n): if __name__ == "__main__": + import time + n = int(os.environ.get("WEAVEPY_BENCH_WORK", "10000")) + _t0 = time.perf_counter_ns() bench(n) + _t1 = time.perf_counter_ns() + print("WEAVEPY_BENCH_NS=%d" % (_t1 - _t0)) diff --git a/crates/weavepy-bench/src/fixtures.rs b/crates/weavepy-bench/src/fixtures.rs index ebd5ee22..eef5ad35 100644 --- a/crates/weavepy-bench/src/fixtures.rs +++ b/crates/weavepy-bench/src/fixtures.rs @@ -1,16 +1,18 @@ //! Discovery of fixtures embedded in this crate. //! //! Each fixture is a self-contained `.py` file that exports a -//! top-level `bench(n)` callable. The list below is the -//! authoritative set used by the runner and the CI gate; new -//! fixtures need to be both dropped on disk *and* added here so -//! the runner finds them. +//! top-level `bench(n)` callable and self-times it in its +//! `__main__` block, printing `WEAVEPY_BENCH_NS=` (RFC 0058 +//! WS1). The list below is the authoritative set used by the runner +//! and the CI gate; new fixtures need to be both dropped on disk +//! *and* added here so the runner finds them. use std::path::PathBuf; /// The full set of fixtures the runner knows about. Order is /// preserved in CLI output and in the JSON report. pub const FIXTURES: &[&str] = &[ + // RFC 0021 originals. "fannkuch", "nbody", "fib", @@ -20,23 +22,52 @@ pub const FIXTURES: &[&str] = &[ "sumvm", "nested_loops", "jitloop", + // RFC 0058 additions — call/attr/subscript/str/dict shape + // diversity so the suite can't be gamed by one fast path. + "deltablue", + "float_math", + "spectral_norm", + "json_bench", + "str_methods", + "dict_ops", + "list_ops", + "attr_access", + "call_overhead", + "generators", + "startup", ]; +/// Fixtures measured as full-subprocess wall time instead of the +/// self-timed `WEAVEPY_BENCH_NS` region. Startup cost *is* the +/// workload for these. +pub const WALL_CLOCK_FIXTURES: &[&str] = &["startup"]; + /// Default per-fixture work parameter passed as `bench(n)`. -/// Picked to make a single iteration take ~10-100ms on CPython — -/// small enough to keep the bench job under a minute, large -/// enough to dwarf timer overhead. +/// Picked to make a single iteration take ~50-300ms on CPython — +/// small enough to keep the bench job under a few minutes, large +/// enough to dwarf timer overhead and runner noise. pub fn default_work(name: &str) -> u32 { match name { - "fannkuch" => 7, - "nbody" => 200, - "fib" => 28, - "pidigits" => 100, - "pyaes" => 50, - "richards" => 1, - "sumvm" => 50_000, - "nested_loops" => 30, - "jitloop" => 300, + "fannkuch" => 100_000, + "nbody" => 20_000, + "fib" => 27, + "pidigits" => 500_000, + "pyaes" => 400, + "richards" => 50_000, + "sumvm" => 2_000_000, + "nested_loops" => 120, + "jitloop" => 1_000, + "deltablue" => 50, + "float_math" => 100_000, + "spectral_norm" => 100, + "json_bench" => 150, + "str_methods" => 15_000, + "dict_ops" => 100_000, + "list_ops" => 10_000, + "attr_access" => 200_000, + "call_overhead" => 150_000, + "generators" => 300_000, + "startup" => 1, _ => 1, } } @@ -47,6 +78,9 @@ pub struct Fixture { pub name: String, pub path: PathBuf, pub work: u32, + /// Measure full subprocess wall time (startup fixtures) instead + /// of the self-timed bench region. + pub wall_clock: bool, } /// Resolve `fixtures/` next to the crate's `Cargo.toml`. @@ -68,6 +102,7 @@ pub fn discover_fixtures() -> Vec { name: (*name).to_owned(), path, work: default_work(name), + wall_clock: WALL_CLOCK_FIXTURES.contains(name), }) } else { None diff --git a/crates/weavepy-bench/src/lib.rs b/crates/weavepy-bench/src/lib.rs index e67b42cd..f7f7adb3 100644 --- a/crates/weavepy-bench/src/lib.rs +++ b/crates/weavepy-bench/src/lib.rs @@ -1,21 +1,33 @@ -//! RFC 0021 — `weavepy-bench`. +//! RFC 0058 — `weavepy-bench` v2. //! -//! A `pyperformance`-shaped microbench harness for WeavePy. Each -//! fixture is a self-contained `.py` file under `fixtures/` that -//! exposes a single top-level callable `bench(N)` performing some -//! workload `N` times. The runner times each fixture under -//! WeavePy (in-process) and the host's CPython (subprocess), and -//! emits a JSON report comparing the two. CI compares the report -//! against [`fixtures::BASELINE`] and fails on regressions over a -//! configurable threshold. +//! A `pyperformance`-shaped benchmark lane for WeavePy. Each fixture +//! is a self-contained `.py` file under `fixtures/` exposing a +//! top-level `bench(n)` callable. The runner executes each fixture as +//! a subprocess of **both** the built `weavepy` binary and the host +//! CPython with an identical `WEAVEPY_BENCH_WORK`, and each fixture +//! self-times its `bench(n)` region with `time.perf_counter_ns()`, +//! printing `WEAVEPY_BENCH_NS=` — so process startup, parsing, +//! and imports are excluded from the loop metric. (The dedicated +//! `startup` fixture measures full subprocess wall time instead.) +//! +//! The tracked baseline (`baselines/bench.json`) stores WeavePy and +//! CPython medians plus the WeavePy/CPython **ratio** per fixture and +//! the suite geometric mean. `gate` compares ratios — which are +//! host-independent, unlike absolute nanoseconds — and fails on +//! regressions beyond a threshold, exactly like the regrtest and +//! ecosystem lanes' `--check`. //! //! ## Adding a fixture //! -//! 1. Drop `fixtures/foo.py` containing a `bench(n)` callable. -//! 2. Add `"foo"` to [`fixtures::FIXTURES`]. -//! 3. Run `cargo run -p weavepy-bench -- run --update-baseline` -//! to refresh the baseline JSON. Inspect the diff in -//! `baselines/bench.json` before committing. +//! 1. Drop `fixtures/foo.py` with a `bench(n)` callable and the +//! standard self-timing `__main__` block (copy any fixture). +//! 2. Add `"foo"` to [`fixtures::FIXTURES`] and a `default_work` +//! entry sized so the CPython leg takes ~50–300 ms. +//! 3. Run `cargo run --release -p weavepy-bench -- run +//! --update-baseline` and inspect the diff in +//! `baselines/bench.json` before committing. The gate fails on +//! fixtures that have no baseline row (the RFC 0049 "no +//! unmeasured rows" rule applied to speed). pub mod fixtures; pub mod report; @@ -25,4 +37,4 @@ pub mod stats; pub use fixtures::{Fixture, FIXTURES}; pub use report::{Report, Row}; pub use runner::{run_one, run_suite, RunOpts}; -pub use stats::{mean, median, percentile, stddev}; +pub use stats::{geometric_mean, mean, median, percentile, stddev}; diff --git a/crates/weavepy-bench/src/main.rs b/crates/weavepy-bench/src/main.rs index 1593e57a..0111d97d 100644 --- a/crates/weavepy-bench/src/main.rs +++ b/crates/weavepy-bench/src/main.rs @@ -1,13 +1,15 @@ -//! `weavepy-bench` CLI entry point. +//! `weavepy-bench` CLI entry point (RFC 0058 WS1). //! //! Subcommands: //! //! - `run` — runs all fixtures, prints a markdown report. //! - `run --json` — emits the report as JSON to stdout. -//! - `run --update-baseline` — overwrites -//! `baselines/bench.json` with the run's results. -//! - `gate` — runs the suite, compares against the baseline, -//! and exits non-zero if any fixture regressed. +//! - `run --update-baseline` — overwrites `baselines/bench.json` +//! with the run's results (requires the CPython column so the +//! baseline carries ratios). +//! - `gate` — runs the suite, compares WeavePy/CPython ratios (and +//! the suite geomean) against the baseline, and exits non-zero on +//! regressions beyond the threshold. //! //! For maximum portability we hand-roll arg parsing rather than //! pull in `clap` — the tool has at most a handful of flags. @@ -15,12 +17,12 @@ use std::env; use std::fs; use std::io; +use std::path::PathBuf; use std::process::ExitCode; use weavepy_bench::fixtures::baseline_path; use weavepy_bench::report::Report; use weavepy_bench::runner::{run_suite, RunOpts}; -use weavepy_vm::specialize::{format_stats_markdown, snapshot, stats_enabled}; fn main() -> ExitCode { let args: Vec = env::args().collect(); @@ -54,43 +56,73 @@ fn main() -> ExitCode { } fn print_help() { - eprintln!("weavepy-bench — RFC 0021 microbench harness"); + eprintln!("weavepy-bench — RFC 0058 benchmark lane"); eprintln!(); eprintln!("USAGE:"); eprintln!(" weavepy-bench [run|gate|help] [flags]"); eprintln!(); eprintln!("COMMANDS:"); eprintln!(" run Run the suite and print a markdown report."); - eprintln!(" gate Run the suite and compare against the baseline."); + eprintln!(" gate Run the suite and compare ratios against the baseline."); eprintln!(" help Print this message."); eprintln!(); + eprintln!("COMMON FLAGS:"); + eprintln!(" --weavepy=PATH weavepy binary under test (default: $WEAVEPY_BIN,"); + eprintln!(" then a `weavepy` next to this executable)."); + eprintln!(" --python=PATH Host CPython (default: python3.13, then python3)."); + eprintln!(" --no-cpython Skip the host CPython column (absolute-only mode)."); + eprintln!(" --samples=N Timing samples per fixture (default 5)."); + eprintln!(" --jit Add a WEAVEPY_JIT=1 column (reported, not gated;"); + eprintln!(" the binary must be built with --features jit)."); + eprintln!(); eprintln!("FLAGS for `run`:"); eprintln!(" --json Print report as JSON."); eprintln!(" --update-baseline Overwrite baselines/bench.json."); - eprintln!(" --no-cpython Skip the host CPython subprocess."); - eprintln!(" --samples=N Timing samples per fixture (default 5)."); eprintln!(); eprintln!("FLAGS for `gate`:"); eprintln!(" --pct=PCT Regression threshold (default 10)."); } +fn parse_common(opts: &mut RunOpts, arg: &str) -> bool { + match arg { + "--no-cpython" => opts.include_cpython = false, + "--jit" => opts.include_jit = true, + x if x.starts_with("--samples=") => { + opts.samples = x[10..].parse().unwrap_or(opts.samples); + } + x if x.starts_with("--python=") => { + opts.python_path = Some(x[9..].to_owned()); + } + x if x.starts_with("--weavepy=") => { + opts.weavepy_path = Some(PathBuf::from(&x[10..])); + } + _ => return false, + } + true +} + fn cmd_run(args: &[String]) -> io::Result<()> { let mut opts = RunOpts::default(); let mut emit_json = false; let mut update_baseline = false; for a in args { + if parse_common(&mut opts, a) { + continue; + } match a.as_str() { "--json" => emit_json = true, "--update-baseline" => update_baseline = true, - "--no-cpython" => opts.include_cpython = false, - x if x.starts_with("--samples=") => { - opts.samples = x[10..].parse().unwrap_or(opts.samples); - } other => { return Err(io::Error::other(format!("unknown flag '{other}'"))); } } } + if update_baseline && !opts.include_cpython { + return Err(io::Error::other( + "--update-baseline needs the CPython column (drop --no-cpython): \ + the tracked baseline stores WeavePy/CPython ratios", + )); + } let rows = run_suite(&opts)?; let report = Report::new(rows); @@ -107,19 +139,6 @@ fn cmd_run(args: &[String]) -> io::Result<()> { println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", report.to_markdown()); - if stats_enabled() { - // RFC 0021 — when WEAVEPY_VM_STATS=1 is set, append a - // markdown stats table to the report so users can see - // how the specialization layer performed across the - // suite. Off by default; cheap when off. - println!(); - println!("{}", format_stats_markdown(&snapshot())); - // RFC 0032 — append tier-2 JIT counters when compiled in. - if let Some(jit) = weavepy_vm::jit_stats_markdown() { - println!(); - println!("{jit}"); - } - } } Ok(()) } @@ -128,11 +147,13 @@ fn cmd_gate(args: &[String]) -> io::Result { let mut pct = 10.0_f64; let mut opts = RunOpts::default(); for a in args { + if parse_common(&mut opts, a) { + continue; + } match a.as_str() { x if x.starts_with("--pct=") => { pct = x[6..].parse().unwrap_or(pct); } - "--no-cpython" => opts.include_cpython = false, other => { return Err(io::Error::other(format!("unknown flag '{other}'"))); } @@ -142,9 +163,10 @@ fn cmd_gate(args: &[String]) -> io::Result { let baseline: Report = serde_json::from_str(&baseline_bytes)?; let rows = run_suite(&opts)?; let report = Report::new(rows); + println!("{}", report.to_markdown()); let regs = report.regressions(&baseline, pct); if regs.is_empty() { - println!("OK: no regressions over {pct:.1}%"); + println!("OK: no ratio regressions over {pct:.1}%"); Ok(true) } else { println!("REGRESSIONS:"); diff --git a/crates/weavepy-bench/src/report.rs b/crates/weavepy-bench/src/report.rs index f62c30c6..5ece85fb 100644 --- a/crates/weavepy-bench/src/report.rs +++ b/crates/weavepy-bench/src/report.rs @@ -1,4 +1,5 @@ -//! JSON / markdown report formatting for the bench runner. +//! JSON / markdown report formatting and ratio-based gating for the +//! bench runner (RFC 0058 WS1). use serde::{Deserialize, Serialize}; @@ -29,13 +30,44 @@ impl RunSet { } /// One row of the bench report — fixture name, work parameter, -/// and timing for each runtime. +/// timing for each runtime, and the WeavePy/CPython median ratio +/// (slowdown; lower is better, 1.0 = parity). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Row { pub name: String, pub work: u32, pub weavepy: RunSet, + #[serde(default)] pub cpython: Option, + /// Optional `WEAVEPY_JIT=1` column (reported, never gated). + #[serde(default)] + pub jit: Option, + /// `weavepy.median_ns / cpython.median_ns`. + #[serde(default)] + pub ratio: Option, +} + +impl Row { + pub fn new( + name: String, + work: u32, + weavepy: RunSet, + cpython: Option, + jit: Option, + ) -> Self { + let ratio = cpython + .as_ref() + .filter(|c| c.median_ns > 0.0 && weavepy.median_ns > 0.0) + .map(|c| weavepy.median_ns / c.median_ns); + Self { + name, + work, + weavepy, + cpython, + jit, + ratio, + } + } } /// Top-level report shape. Persisted as `baselines/bench.json`. @@ -44,15 +76,26 @@ pub struct Report { pub version: u32, pub host: String, pub created_at: String, + /// Geometric mean of the per-fixture WeavePy/CPython ratios + /// (fixtures without a CPython column are excluded). + #[serde(default)] + pub geomean_ratio: Option, pub rows: Vec, } impl Report { pub fn new(rows: Vec) -> Self { + let ratios: Vec = rows.iter().filter_map(|r| r.ratio).collect(); + let geomean_ratio = if ratios.is_empty() { + None + } else { + Some(stats::geometric_mean(&ratios)) + }; Self { - version: 1, + version: 2, host: hostname_or_unknown(), created_at: now_rfc3339(), + geomean_ratio, rows, } } @@ -61,6 +104,7 @@ impl Report { /// without `--json`. pub fn to_markdown(&self) -> String { use std::fmt::Write; + let has_jit = self.rows.iter().any(|r| r.jit.is_some()); let mut out = String::new(); let _ = writeln!( out, @@ -68,56 +112,110 @@ impl Report { self.host, self.created_at ); let _ = writeln!(out); - let _ = writeln!( - out, - "| fixture | work | WeavePy median | CPython median | speedup vs CPython |" - ); - let _ = writeln!( - out, - "|---------|------|----------------|----------------|--------------------|" - ); + if has_jit { + let _ = writeln!( + out, + "| fixture | work | WeavePy | WeavePy+JIT | CPython | ×CPython (lower is better) |" + ); + let _ = writeln!(out, "|---|---|---|---|---|---|"); + } else { + let _ = writeln!( + out, + "| fixture | work | WeavePy | CPython | ×CPython (lower is better) |" + ); + let _ = writeln!(out, "|---|---|---|---|---|"); + } for r in &self.rows { let wp = format_ns(r.weavepy.median_ns); let cp = match &r.cpython { Some(c) => format_ns(c.median_ns), None => "-".to_owned(), }; - let speedup = match &r.cpython { - Some(c) if c.median_ns > 0.0 => { - format!("{:.2}×", c.median_ns / r.weavepy.median_ns) - } - _ => "-".to_owned(), + let ratio = match r.ratio { + Some(x) => format!("{x:.2}×"), + None => "-".to_owned(), }; - let _ = writeln!( - out, - "| {} | {} | {} | {} | {} |", - r.name, r.work, wp, cp, speedup - ); + if has_jit { + let jit = match &r.jit { + Some(j) => format_ns(j.median_ns), + None => "-".to_owned(), + }; + let _ = writeln!( + out, + "| {} | {} | {} | {} | {} | {} |", + r.name, r.work, wp, jit, cp, ratio + ); + } else { + let _ = writeln!( + out, + "| {} | {} | {} | {} | {} |", + r.name, r.work, wp, cp, ratio + ); + } + } + if let Some(g) = self.geomean_ratio { + let _ = writeln!(out); + let _ = writeln!(out, "Geometric mean: **{g:.2}× CPython**"); } out } - /// Compare against an older [`Report`] and return one regression - /// string per fixture whose WeavePy median got worse by more - /// than `pct_threshold`%. Empty vec = clean. + /// Compare against a baseline [`Report`] and return one + /// regression string per problem. Empty vec = gate passes. + /// + /// Per fixture the WeavePy/CPython **ratio** is compared when + /// both reports carry one (host-independent); otherwise the + /// absolute WeavePy median is the fallback (only meaningful on + /// the host that produced the baseline, e.g. `--no-cpython` + /// local runs). Fixtures with no baseline row fail the gate — + /// new fixtures must be baselined in the same change. The suite + /// geometric mean is gated with the same threshold. pub fn regressions(&self, baseline: &Report, pct_threshold: f64) -> Vec { let mut out = Vec::new(); + let factor = 1.0 + pct_threshold / 100.0; for new in &self.rows { let Some(old) = baseline.rows.iter().find(|r| r.name == new.name) else { + out.push(format!( + "{}: no baseline row — run `weavepy-bench run --update-baseline` and commit it", + new.name + )); continue; }; - if old.weavepy.median_ns <= 0.0 { - continue; + match (new.ratio, old.ratio) { + (Some(nr), Some(or)) if or > 0.0 => { + if nr > or * factor { + out.push(format!( + "{}: ratio {:.2}× -> {:.2}× vs CPython ({:+.1}%)", + new.name, + or, + nr, + 100.0 * (nr - or) / or, + )); + } + } + _ => { + if old.weavepy.median_ns > 0.0 + && new.weavepy.median_ns > old.weavepy.median_ns * factor + { + out.push(format!( + "{}: median {} -> {} ({:+.1}%; absolute fallback — no ratio in baseline)", + new.name, + format_ns(old.weavepy.median_ns), + format_ns(new.weavepy.median_ns), + 100.0 * (new.weavepy.median_ns - old.weavepy.median_ns) + / old.weavepy.median_ns, + )); + } + } } - let delta_pct = - 100.0 * (new.weavepy.median_ns - old.weavepy.median_ns) / old.weavepy.median_ns; - if delta_pct > pct_threshold { + } + if let (Some(ng), Some(og)) = (self.geomean_ratio, baseline.geomean_ratio) { + if og > 0.0 && ng > og * factor { out.push(format!( - "{}: median {} -> {} ({:+.2}%)", - new.name, - format_ns(old.weavepy.median_ns), - format_ns(new.weavepy.median_ns), - delta_pct, + "geomean: {:.2}× -> {:.2}× vs CPython ({:+.1}%)", + og, + ng, + 100.0 * (ng - og) / og, )); } } @@ -148,3 +246,68 @@ fn now_rfc3339() -> String { .map(|d| format!("ts={}", d.as_secs())) .unwrap_or_else(|_| "ts=0".to_owned()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn runset(median: f64) -> RunSet { + RunSet::from_samples_ns(&[median]) + } + + fn row(name: &str, weavepy_ns: f64, cpython_ns: Option) -> Row { + Row::new( + name.to_owned(), + 1, + runset(weavepy_ns), + cpython_ns.map(runset), + None, + ) + } + + #[test] + fn ratio_and_geomean_computed() { + let report = Report::new(vec![ + row("a", 200.0, Some(100.0)), + row("b", 800.0, Some(100.0)), + ]); + assert_eq!(report.rows[0].ratio, Some(2.0)); + assert_eq!(report.rows[1].ratio, Some(8.0)); + let g = report.geomean_ratio.unwrap(); + assert!((g - 4.0).abs() < 1e-9, "geomean of 2 and 8 is 4, got {g}"); + } + + #[test] + fn gate_flags_ratio_regression_not_host_speed() { + // Same ratio on a 2x slower host: no regression. + let baseline = Report::new(vec![row("a", 200.0, Some(100.0))]); + let slower_host = Report::new(vec![row("a", 400.0, Some(200.0))]); + assert!(slower_host.regressions(&baseline, 10.0).is_empty()); + + // Ratio got 50% worse: regression. + let worse = Report::new(vec![row("a", 300.0, Some(100.0))]); + let regs = worse.regressions(&baseline, 10.0); + assert_eq!(regs.len(), 2, "row + geomean should both fire: {regs:?}"); + } + + #[test] + fn gate_fails_unbaselined_fixture() { + let baseline = Report::new(vec![row("a", 200.0, Some(100.0))]); + let with_new = Report::new(vec![ + row("a", 200.0, Some(100.0)), + row("brand_new", 100.0, Some(100.0)), + ]); + let regs = with_new.regressions(&baseline, 10.0); + assert_eq!(regs.len(), 1); + assert!(regs[0].contains("brand_new")); + } + + #[test] + fn gate_absolute_fallback_without_ratios() { + let baseline = Report::new(vec![row("a", 200.0, None)]); + let ok = Report::new(vec![row("a", 210.0, None)]); + assert!(ok.regressions(&baseline, 10.0).is_empty()); + let bad = Report::new(vec![row("a", 300.0, None)]); + assert_eq!(bad.regressions(&baseline, 10.0).len(), 1); + } +} diff --git a/crates/weavepy-bench/src/runner.rs b/crates/weavepy-bench/src/runner.rs index 016e0c9b..78a9b059 100644 --- a/crates/weavepy-bench/src/runner.rs +++ b/crates/weavepy-bench/src/runner.rs @@ -1,15 +1,17 @@ -//! Bench runner — times each fixture's `bench(n)` callable under -//! WeavePy (in-process) and the host CPython (subprocess). +//! Bench runner v2 (RFC 0058 WS1) — times each fixture's `bench(n)` +//! under the built `weavepy` binary and the host CPython, both as +//! subprocesses with an identical `WEAVEPY_BENCH_WORK`. +//! +//! Fixtures self-time the bench region and print +//! `WEAVEPY_BENCH_NS=`; the runner parses that, so startup / +//! parse / import cost is excluded symmetrically. Fixtures listed in +//! [`crate::fixtures::WALL_CLOCK_FIXTURES`] are timed as full +//! subprocess wall time instead (startup *is* their workload). -use std::fs; use std::io; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Instant; -use weavepy_vm::sync::Rc; -use weavepy_vm::sync::RefCell; - -use weavepy::{compiler, parser, vm}; -use weavepy_vm::Interpreter; use crate::fixtures::{discover_fixtures, Fixture}; use crate::report::{Row, RunSet}; @@ -19,131 +21,221 @@ use crate::report::{Row, RunSet}; pub struct RunOpts { /// How many timing samples to collect per (fixture × runtime). pub samples: u32, + /// One warm-up run (dropped) before the first timed sample — + /// primes OS file caches so sample 1 isn't an outlier. + pub warmup: bool, /// Whether to also time the host CPython for comparison. - /// Off by default in CI when `python3` may not be available. pub include_cpython: bool, - /// Path to the host Python (e.g. `/usr/bin/python3`). - pub python_path: String, - /// One warm-up run before the first timed sample. WeavePy's - /// adaptive specializer needs a turn through the loop body - /// before the inline caches are warm. - pub warmup: bool, + /// Also collect a WeavePy column with `WEAVEPY_JIT=1`. Requires + /// the `weavepy` binary to have been built with the `jit` + /// feature; without it the column just repeats the interpreter. + pub include_jit: bool, + /// Explicit path to the host Python. When `None`, `python3.13` + /// is preferred and `python3` is the fallback. + pub python_path: Option, + /// Explicit path to the `weavepy` binary under test. When + /// `None`, `$WEAVEPY_BIN` is honored, then a `weavepy` binary + /// next to the running `weavepy-bench` executable (i.e. the same + /// cargo profile directory). + pub weavepy_path: Option, } impl Default for RunOpts { fn default() -> Self { Self { samples: 5, - include_cpython: true, - python_path: "python3".to_owned(), warmup: true, + include_cpython: true, + include_jit: false, + python_path: None, + weavepy_path: None, } } } -/// Time a single fixture under both runtimes. -/// -/// The WeavePy timing reflects in-process dispatch — no subprocess -/// or interpreter init overhead. The CPython timing is a subprocess -/// call so it includes startup; that cost is roughly fixed per call -/// and shouldn't move between releases of WeavePy, so it's safe to -/// include in the comparison. -pub fn run_one(fix: &Fixture, opts: &RunOpts) -> io::Result { - let src = fs::read_to_string(&fix.path)?; - - // ---------- WeavePy ---------- - let mut weavepy_samples = Vec::with_capacity(opts.samples as usize + 1); - let runs = if opts.warmup { - opts.samples + 1 +/// Locate the `weavepy` binary under test. Priority: explicit opt → +/// `$WEAVEPY_BIN` → sibling of the current executable. +pub fn resolve_weavepy(opts: &RunOpts) -> io::Result { + if let Some(p) = &opts.weavepy_path { + return Ok(p.clone()); + } + if let Ok(p) = std::env::var("WEAVEPY_BIN") { + return Ok(PathBuf::from(p)); + } + let exe = std::env::current_exe()?; + let dir = exe.parent().ok_or_else(|| { + io::Error::other("cannot resolve the directory of the running weavepy-bench binary") + })?; + let candidate = dir.join(if cfg!(windows) { + "weavepy.exe" } else { - opts.samples - }; - for i in 0..runs { - let t = time_weavepy_run(&src, fix.work)?; - if !opts.warmup || i > 0 { - weavepy_samples.push(t); - } + "weavepy" + }); + if candidate.exists() { + return Ok(candidate); } + Err(io::Error::other(format!( + "no weavepy binary at {} — build it first (`cargo build --release -p weavepy-cli`), \ + or pass --weavepy=PATH / set WEAVEPY_BIN", + candidate.display() + ))) +} - // ---------- CPython (optional) ---------- - let mut cpython_samples = Vec::new(); - if opts.include_cpython { - for _ in 0..opts.samples { - let t = time_cpython_run(&fix.path, fix.work, &opts.python_path)?; - cpython_samples.push(t); +/// Locate the host CPython. Priority: explicit opt → `python3.13` → +/// `python3`. A candidate qualifies if `-c pass` exits 0. +pub fn resolve_python(opts: &RunOpts) -> io::Result { + if let Some(p) = &opts.python_path { + return Ok(p.clone()); + } + for candidate in ["python3.13", "python3"] { + let ok = Command::new(candidate) + .args(["-c", "pass"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if ok { + return Ok(candidate.to_owned()); } } + Err(io::Error::other( + "no host CPython found (tried python3.13, python3); pass --python=PATH or --no-cpython", + )) +} - Ok(Row { - name: fix.name.clone(), - work: fix.work, - weavepy: RunSet::from_samples_ns(&weavepy_samples), - cpython: if cpython_samples.is_empty() { - None - } else { - Some(RunSet::from_samples_ns(&cpython_samples)) - }, - }) +/// Time a single fixture under the configured runtimes. +pub fn run_one( + fix: &Fixture, + opts: &RunOpts, + weavepy: &Path, + python: Option<&str>, +) -> io::Result { + let weavepy_samples = collect_samples(weavepy.as_os_str(), fix, opts, &[])?; + let jit = if opts.include_jit { + Some(RunSet::from_samples_ns(&collect_samples( + weavepy.as_os_str(), + fix, + opts, + &[("WEAVEPY_JIT", "1")], + )?)) + } else { + None + }; + let cpython = match python { + Some(py) => Some(RunSet::from_samples_ns(&collect_samples( + std::ffi::OsStr::new(py), + fix, + opts, + &[], + )?)), + None => None, + }; + Ok(Row::new( + fix.name.clone(), + fix.work, + RunSet::from_samples_ns(&weavepy_samples), + cpython, + jit, + )) } /// Run all known fixtures and return one [`Row`] per fixture. pub fn run_suite(opts: &RunOpts) -> io::Result> { + let weavepy = resolve_weavepy(opts)?; + let python = if opts.include_cpython { + Some(resolve_python(opts)?) + } else { + None + }; let mut rows = Vec::new(); for fix in discover_fixtures() { - let row = run_one(&fix, opts)?; - rows.push(row); + rows.push(run_one(&fix, opts, &weavepy, python.as_deref())?); } Ok(rows) } -/// Run a fixture's `bench(N)` through WeavePy and return the -/// elapsed time in nanoseconds. -fn time_weavepy_run(src: &str, work: u32) -> io::Result { - // Convert weavepy's per-stage errors via Display because - // `RuntimeError` carries an `Rc` and isn't `Send + Sync` (and - // hence isn't directly Box-able into an `io::Error`). - let module = parser::parse_module(src).map_err(stringify_err)?; - let code = compiler::compile_module(&module).map_err(stringify_err)?; - let mut interp = Interpreter::new(); - - // Drain the VM's stdout into a buffer — fixtures may print - // results, and we don't want benchmark stdout polluting the - // CI log. - let buf: Rc>> = Rc::new(RefCell::new(Vec::new())); - let writer: vm::Stdout = buf.clone() as Rc>; - interp.set_stdout(writer); +fn collect_samples( + interp: &std::ffi::OsStr, + fix: &Fixture, + opts: &RunOpts, + extra_env: &[(&str, &str)], +) -> io::Result> { + let runs = if opts.warmup { + opts.samples + 1 + } else { + opts.samples + }; + let mut samples = Vec::with_capacity(opts.samples as usize); + for i in 0..runs { + let t = time_subprocess(interp, fix, extra_env)?; + if !opts.warmup || i > 0 { + samples.push(t); + } + } + Ok(samples) +} +/// Run `interp fixture.py` once and return the measured nanoseconds: +/// the fixture's self-reported `WEAVEPY_BENCH_NS` for normal +/// fixtures, or the subprocess wall time for wall-clock fixtures. +fn time_subprocess( + interp: &std::ffi::OsStr, + fix: &Fixture, + extra_env: &[(&str, &str)], +) -> io::Result { + let mut cmd = Command::new(interp); + cmd.arg(&fix.path) + .env("WEAVEPY_BENCH_WORK", fix.work.to_string()); + for (k, v) in extra_env { + cmd.env(k, v); + } let start = Instant::now(); - interp.run_module(&code).map_err(stringify_err)?; - // After top-level runs, dispatch a `bench(N)` call. - let _ = work; - let elapsed = start.elapsed(); - Ok(elapsed.as_nanos() as f64) + let out = cmd.output()?; + let wall = start.elapsed().as_nanos() as f64; + if !out.status.success() { + return Err(io::Error::other(format!( + "{} exited {} on {}: {}", + interp.to_string_lossy(), + out.status.code().unwrap_or(-1), + fix.path.display(), + String::from_utf8_lossy(&out.stderr) + ))); + } + if fix.wall_clock { + return Ok(wall); + } + parse_bench_ns(&out.stdout).ok_or_else(|| { + io::Error::other(format!( + "{} did not print WEAVEPY_BENCH_NS= for {}; stdout was: {}", + interp.to_string_lossy(), + fix.name, + String::from_utf8_lossy(&out.stdout) + )) + }) } -#[inline] -fn stringify_err(e: E) -> io::Error { - io::Error::other(e.to_string()) +/// Extract the last `WEAVEPY_BENCH_NS=` line from stdout. +/// Fixtures are free to print other diagnostics. +fn parse_bench_ns(stdout: &[u8]) -> Option { + let text = String::from_utf8_lossy(stdout); + text.lines() + .rev() + .find_map(|line| line.trim().strip_prefix("WEAVEPY_BENCH_NS=")) + .and_then(|v| v.trim().parse::().ok()) + .map(|v| v as f64) } -/// Time CPython running the fixture as a subprocess. We pass the -/// `work` value via an environment variable so the fixture's -/// `if __name__ == '__main__'` block can pick it up — that -/// arrangement is consistent across both runtimes. -fn time_cpython_run(path: &std::path::Path, work: u32, python: &str) -> io::Result { - let start = Instant::now(); - let status = Command::new(python) - .arg(path) - .env("WEAVEPY_BENCH_WORK", work.to_string()) - .output()?; - let elapsed = start.elapsed(); - if !status.status.success() { - return Err(io::Error::other(format!( - "cpython exited {} on {}: {}", - status.status.code().unwrap_or(-1), - path.display(), - String::from_utf8_lossy(&status.stderr) - ))); +#[cfg(test)] +mod tests { + use super::parse_bench_ns; + + #[test] + fn parses_last_ns_line() { + let out = b"warming\nWEAVEPY_BENCH_NS=100\nWEAVEPY_BENCH_NS=42\n"; + assert_eq!(parse_bench_ns(out), Some(42.0)); + } + + #[test] + fn rejects_missing_marker() { + assert_eq!(parse_bench_ns(b"hello\n"), None); } - Ok(elapsed.as_nanos() as f64) } diff --git a/crates/weavepy-bench/src/stats.rs b/crates/weavepy-bench/src/stats.rs index 646bcd91..772fdea0 100644 --- a/crates/weavepy-bench/src/stats.rs +++ b/crates/weavepy-bench/src/stats.rs @@ -36,6 +36,19 @@ pub fn percentile(xs: &[f64], p: f64) -> f64 { sorted[idx.min(sorted.len() - 1)] } +/// Geometric mean of strictly-positive values; 0.0 for an empty +/// slice. Used for the suite-level WeavePy/CPython ratio summary +/// (RFC 0058) — ratios multiply, so the geometric mean is the only +/// average that composes correctly. +pub fn geometric_mean(xs: &[f64]) -> f64 { + let positive: Vec = xs.iter().copied().filter(|x| *x > 0.0).collect(); + if positive.is_empty() { + return 0.0; + } + let log_sum: f64 = positive.iter().map(|x| x.ln()).sum(); + (log_sum / positive.len() as f64).exp() +} + pub fn stddev(xs: &[f64]) -> f64 { if xs.len() < 2 { return 0.0; diff --git a/crates/weavepy-compiler/src/bytecode.rs b/crates/weavepy-compiler/src/bytecode.rs index 615bebd5..8e74f750 100644 --- a/crates/weavepy-compiler/src/bytecode.rs +++ b/crates/weavepy-compiler/src/bytecode.rs @@ -662,6 +662,18 @@ pub enum InlineCache { BinOpSubFloat, BinOpMulFloat, BinOpAddStr, + // RFC 0058 WS3 — division/modulo/power completion. The int shapes + // deopt to the generic (bignum) path when the i64 primitive can't + // represent the result; error semantics (ZeroDivisionError…) are + // shared with the generic path via the same helper. + BinOpDivInt, + BinOpFloorDivInt, + BinOpModInt, + BinOpPowInt, + BinOpDivFloat, + BinOpFloorDivFloat, + BinOpModFloat, + BinOpPowFloat, // COMPARE_OP family — both operands int / float / str. CompareOpInt, @@ -744,6 +756,12 @@ pub enum InlineCache { ForIterList, ForIterTuple, ForIterRange, + /// `for c in s:` over a `str` iterator (RFC 0058 WS3). + ForIterStr, + /// `for k in d:` / dict-view loops — one shape for keys, values + /// and items cursors; the step runs the same checked `__next__` + /// (size/keys-changed guards) as the generic path. + ForIterDict, // UNPACK_SEQUENCE family. UnpackSequenceTuple, @@ -767,6 +785,58 @@ pub enum InlineCache { func_id: u64, argc: u32, }, + /// Bound method (`obj.m(...)`) whose target is a plain Python + /// function with exact arity `argc + 1` (receiver prepended) — + /// the binder skip applied to method calls. WeavePy has no + /// `LOAD_METHOD` opcode; the `LoadAttrMethod` + this pair is the + /// CPython fusion equivalent (RFC 0058 WS3). + CallBoundMethodExact { + func_id: u64, + argc: u32, + }, + /// Plain Python function called with fewer positionals than it + /// declares, the missing tail covered verbatim by `__defaults__` + /// — skips the binder and splices the defaults suffix directly + /// (RFC 0058 WS3). + CallPyDefaults { + func_id: u64, + argc: u32, + }, + /// Module-level native callable (`math.sqrt`, `ord`, …) with no + /// interpreter-aware dispatch chain: straight to the Rust `fn` + /// (RFC 0058 WS3). Deopts whenever observers (profile/trace + /// hooks) are active so `c_call` events still fire. + CallNative { + func_id: u64, + argc: u32, + }, + /// Bound native method (`xs.append`, `s.startswith`, …): receiver + /// prepended, straight to the Rust `fn` (RFC 0058 WS3). Same + /// observer deopt as [`InlineCache::CallNative`]. + CallNativeMethod { + func_id: u64, + argc: u32, + }, + + // BINARY_SUBSCR family (RFC 0058 WS3). The container's enum + // variant is the fingerprint, checked at the start of each hit. + /// `list[int]` — in-range index (negative handled inline). + SubscrListInt, + /// `tuple[int]`. + SubscrTupleInt, + /// Pure-ASCII `str[int]` (code-point count == byte count, both + /// cached) — O(1) byte indexing; re-verified on every hit. + SubscrStrInt, + /// `dict[key]` — skips the instance/type/foreign dispatch chain; + /// the hash lookup itself is unavoidable (CPython's + /// `BINARY_SUBSCR_DICT` does the same). + SubscrDict, + + // STORE_SUBSCR family (RFC 0058 WS3). + /// `list[int] = v` — in-range element overwrite. + StoreSubscrListInt, + /// `dict[key] = v`. + StoreSubscrDict, } /// Number of generic dispatches a deopted cache must serve before it diff --git a/crates/weavepy-jit/Cargo.toml b/crates/weavepy-jit/Cargo.toml index aa916047..db63be7d 100644 --- a/crates/weavepy-jit/Cargo.toml +++ b/crates/weavepy-jit/Cargo.toml @@ -20,5 +20,9 @@ cranelift-jit = { workspace = true } cranelift-module = { workspace = true } cranelift-native = { workspace = true } +[dev-dependencies] +# Analyzer tests parse + compile real Python shapes (RFC 0058 WS4). +weavepy-parser = { workspace = true } + [lints] workspace = true diff --git a/crates/weavepy-jit/src/analyze.rs b/crates/weavepy-jit/src/analyze.rs index 40365abc..2500ff52 100644 --- a/crates/weavepy-jit/src/analyze.rs +++ b/crates/weavepy-jit/src/analyze.rs @@ -22,7 +22,10 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use weavepy_compiler::{BinOpKind, CodeObject, CompareKind, Constant, OpCode, UnaryKind}; -use crate::ir::{ArithKind, BlockId, CmpKind, TBlock, TFunc, TOp, TStmt, TTerm}; +use crate::ir::{ + ArithKind, BlockId, CmpKind, GlobalGuard, RangeLoopMeta, ResolvedGlobal, TBlock, TFunc, TOp, + TStmt, TTerm, +}; use crate::value::JitType; /// Why a code object could not be compiled by the v1 JIT. Carried back @@ -66,9 +69,53 @@ struct RawBlock { /// Maximum type-inference iterations before giving up. const MAX_INFER_ITERS: usize = 64; -/// Analyze a code object. Returns the typed IR on success or a -/// [`JitVerdict`] describing the first disqualifying property found. -pub fn analyze(code: &CodeObject) -> Result { +/// The bytecode rewrite plan computed before block construction +/// (RFC 0058 WS4): which pcs become no-ops, which `CALL`s store range +/// bounds into synthetic slots, which `FOR_ITER`s become counted-loop +/// terminators, and which `LOAD_GLOBAL`s burn in as constants. +#[derive(Default)] +struct Plan { + /// pcs erased from the rewritten program: the `LOAD_GLOBAL range`, + /// `GET_ITER`, `END_FOR`, and an explicit unit-step `LOAD_CONST 1`. + nop: HashSet, + /// `CALL` pcs → (values to pop, cur slot, stop slot). One popped + /// value means `range(stop)` (cur seeds to 0); two means + /// `range(start, stop)`. + calls: HashMap, + /// `FOR_ITER` pcs → (cur slot, stop slot, loop variable slot). + headers: HashMap, + /// The fused `STORE_FAST` pc directly after each `FOR_ITER` → its + /// slot. The `ForRange` terminator performs this store. + fused_store: HashMap, + /// `LOAD_GLOBAL` name index → resolution (resolved once up front so + /// the inference fixpoint doesn't re-query the embedder). + globals: HashMap, + /// Entry guards, deduplicated by name. + guards: Vec, + /// Rewritten loops, outermost-first. + loops: Vec, + /// Synthetic slots appended after the code object's real locals. + n_synth: u32, +} + +impl Plan { + /// `true` when the interpreter would have a live range iterator on + /// its stack at `pc` — i.e. `pc` is inside some rewritten loop. + fn in_loop_span(&self, pc: usize) -> bool { + self.loops + .iter() + .any(|l| (l.live_from as usize) <= pc && pc < l.live_to as usize) + } +} + +/// Analyze a code object. `resolve` maps a `LOAD_GLOBAL` name to what it +/// currently resolves to (the embedder re-validates every resolution as +/// an entry guard). Returns the typed IR on success or a [`JitVerdict`] +/// describing the first disqualifying property found. +pub fn analyze( + code: &CodeObject, + resolve: &mut dyn FnMut(&str) -> ResolvedGlobal, +) -> Result { if code.is_generator || code.is_coroutine || code.is_async_generator || code.is_class_body { return Err(JitVerdict::UnsupportedSignature); } @@ -80,14 +127,16 @@ pub fn analyze(code: &CodeObject) -> Result { return Err(JitVerdict::Trivial); } + let plan = plan_rewrite(code, resolve)?; + let raw = build_blocks(code)?; let reachable = reachable_blocks(&raw); if reachable.is_empty() { return Err(JitVerdict::Trivial); } - let n_locals = code.varnames.len() as u32; - let livein = compute_livein(code, &raw, &reachable, n_locals); + let n_locals = code.varnames.len() as u32 + plan.n_synth; + let livein = compute_livein(code, &raw, &reachable, code.varnames.len() as u32); // Type inference fixpoint. let mut local_types: Vec> = vec![None; n_locals as usize]; @@ -95,7 +144,7 @@ pub fn analyze(code: &CodeObject) -> Result { loop { let mut changed = false; for &bi in &reachable { - infer_block(code, &raw[bi], &mut local_types, &mut changed)?; + infer_block(code, &raw[bi], &plan, &mut local_types, &mut changed)?; } if !changed { break; @@ -120,10 +169,28 @@ pub fn analyze(code: &CodeObject) -> Result { let mut blocks: Vec = Vec::with_capacity(reachable.len()); let mut max_stack = 0u32; for &bi in &reachable { - let tb = emit_block(code, &raw[bi], &local_types, &compact, &mut max_stack)?; + let tb = emit_block( + code, + &raw[bi], + &plan, + &local_types, + &compact, + &mut max_stack, + )?; blocks.push(tb); } + // Parameters flow in from the caller, so every *typed* parameter + // slot must be entry-guarded even though the definite-assignment + // analysis treats it as already assigned. (Without this, a hot + // kernel first called with ints and later with a float would pack + // the float as 0 and silently compute garbage.) + let mut livein = livein; + for slot in 0..code.arg_count { + if local_types.get(slot as usize).copied().flatten().is_some() { + livein.insert(slot); + } + } let mut livein_vec: Vec = livein.into_iter().collect(); livein_vec.sort_unstable(); @@ -134,9 +201,174 @@ pub fn analyze(code: &CodeObject) -> Result { max_stack, blocks, entry_block, + global_guards: plan.guards, + range_loops: plan.loops, }) } +/// Recognize every `FOR_ITER` as the canonical counted `range` loop and +/// build the rewrite [`Plan`]. Any `FOR_ITER` that doesn't match the +/// shape disqualifies the whole frame (there is no generic iterator +/// support in the tier-2 subset), as does any `LOAD_GLOBAL` that neither +/// feeds a recognized loop nor resolves to a burnable constant. +fn plan_rewrite( + code: &CodeObject, + resolve: &mut dyn FnMut(&str) -> ResolvedGlobal, +) -> Result { + let ins = &code.instructions; + let n = ins.len(); + let n_real = code.varnames.len() as u32; + let mut plan = Plan::default(); + + // All jump-landing pcs, to reject jumps into the middle of a + // recognized `range(...)` prefix. + let mut targets: HashSet = HashSet::new(); + for (i, item) in ins.iter().enumerate() { + match item.op { + OpCode::PopJumpIfFalse + | OpCode::PopJumpIfTrue + | OpCode::JumpForward + | OpCode::ForIter => { + targets.insert(forward_target(i, item.arg)); + } + OpCode::JumpBackward => { + targets.insert(backward_target(i, item.arg).ok_or(JitVerdict::BadJumpTarget)?); + } + _ => {} + } + } + + // Resolve every LOAD_GLOBAL name once. + for item in ins.iter() { + if matches!(item.op, OpCode::LoadGlobal) { + if let std::collections::hash_map::Entry::Vacant(e) = plan.globals.entry(item.arg) { + let name = code + .names + .get(item.arg as usize) + .ok_or(JitVerdict::UnsupportedOpcode("LOAD_GLOBAL bad name"))?; + e.insert(resolve(name)); + } + } + } + + for i in 0..n { + if !matches!(ins[i].op, OpCode::ForIter) { + continue; + } + let bail = || JitVerdict::UnsupportedOpcode("FOR_ITER (non-range shape)"); + let exit = forward_target(i, ins[i].arg); + if exit >= n || !matches!(ins[exit].op, OpCode::EndFor) { + return Err(bail()); + } + // Fused loop-variable store. + if i + 1 >= n || !matches!(ins[i + 1].op, OpCode::StoreFast) { + return Err(bail()); + } + let var_slot = ins[i + 1].arg; + // Walk the prefix backwards: GET_ITER, CALL k, k simple args, + // LOAD_GLOBAL . + if i < 2 + || !matches!(ins[i - 1].op, OpCode::GetIter) + || !matches!(ins[i - 2].op, OpCode::Call) + { + return Err(bail()); + } + let k = ins[i - 2].arg as usize; + if !(1..=3).contains(&k) || i < 3 + k { + return Err(bail()); + } + let args_start = i - 2 - k; + for arg_ins in &ins[args_start..(i - 2)] { + match arg_ins.op { + OpCode::LoadFast => {} + OpCode::LoadConst + if matches!( + code.constants.get(arg_ins.arg as usize), + Some(Constant::Int(_)) + ) => {} + _ => return Err(bail()), + } + } + // An explicit step is only allowed as the constant 1; it is + // erased so the call effectively becomes `range(start, stop)`. + let mut pops = k as u8; + if k == 3 { + let step_pc = i - 3; + if !matches!(ins[step_pc].op, OpCode::LoadConst) + || !matches!( + code.constants.get(ins[step_pc].arg as usize), + Some(Constant::Int(1)) + ) + { + return Err(bail()); + } + plan.nop.insert(step_pc); + pops = 2; + } + let callee = args_start - 1; + if !matches!(ins[callee].op, OpCode::LoadGlobal) { + return Err(bail()); + } + if plan.globals.get(&ins[callee].arg) != Some(&ResolvedGlobal::RangeBuiltin) { + return Err(bail()); + } + // No jump may land inside the prefix or on the fused store — the + // header itself (a JUMP_BACKWARD target) is the only allowed + // landing point. + if targets.iter().any(|&t| callee < t && t <= i + 1 && t != i) { + return Err(bail()); + } + let name = code.names[ins[callee].arg as usize].clone(); + if !plan.guards.iter().any(|g| g.name == name) { + plan.guards.push(GlobalGuard { + name, + expect: ResolvedGlobal::RangeBuiltin, + }); + } + + let cur_slot = n_real + plan.n_synth; + let stop_slot = cur_slot + 1; + plan.n_synth += 2; + plan.nop.insert(callee); + plan.nop.insert(i - 1); + plan.nop.insert(exit); + plan.calls.insert(i - 2, (pops, cur_slot, stop_slot)); + plan.headers.insert(i, (cur_slot, stop_slot, var_slot)); + plan.fused_store.insert(i + 1, var_slot); + plan.loops.push(RangeLoopMeta { + cur_slot, + stop_slot, + live_from: i as u32, + live_to: exit as u32, + }); + } + + // Burnable constants: every LOAD_GLOBAL that is not a recognized + // range callee must resolve to a scalar constant, and needs a guard. + for (i, item) in ins.iter().enumerate() { + if !matches!(item.op, OpCode::LoadGlobal) || plan.nop.contains(&i) { + continue; + } + let resolved = plan.globals[&item.arg]; + match resolved { + ResolvedGlobal::ConstInt(_) + | ResolvedGlobal::ConstFloat(_) + | ResolvedGlobal::ConstBool(_) => { + let name = &code.names[item.arg as usize]; + if !plan.guards.iter().any(|g| g.name == *name) { + plan.guards.push(GlobalGuard { + name: name.clone(), + expect: resolved, + }); + } + } + _ => return Err(JitVerdict::UnsupportedOpcode("LOAD_GLOBAL")), + } + } + + Ok(plan) +} + /// Resolve a forward branch/jump target instruction index. #[inline] fn forward_target(i: usize, arg: u32) -> usize { @@ -183,6 +415,18 @@ fn build_blocks(code: &CodeObject) -> Result, JitVerdict> { leaders.insert(i + 1); } } + // A rewritten range loop's header: branches to the body + // (fallthrough) or the exit (`END_FOR`) when exhausted. + OpCode::ForIter => { + let t = forward_target(i, ins.arg); + if t > n { + return Err(JitVerdict::BadJumpTarget); + } + leaders.insert(t); + if i + 1 < n { + leaders.insert(i + 1); + } + } OpCode::ReturnValue if i + 1 < n => { leaders.insert(i + 1); } @@ -216,6 +460,15 @@ fn build_blocks(code: &CodeObject) -> Result, JitVerdict> { .ok_or(JitVerdict::BadJumpTarget)?; vec![f, t] } + // succs[0] = body (fallthrough), succs[1] = exit. + OpCode::ForIter => { + let t = index_of[&forward_target(last, ins.arg)]; + let f = index_of + .get(&(last + 1)) + .copied() + .ok_or(JitVerdict::BadJumpTarget)?; + vec![f, t] + } // Falls through to the next block. _ => { let fall = index_of @@ -376,12 +629,13 @@ fn const_type(c: &Constant) -> Option { fn infer_block( code: &CodeObject, b: &RawBlock, + plan: &Plan, local_types: &mut [Option], changed: &mut bool, ) -> Result<(), JitVerdict> { let mut stack: Vec = Vec::new(); for i in b.start..(b.end - 1) { - step_abstract(code, i, &mut stack, local_types, changed, false)?; + step_abstract(code, i, &mut stack, plan, local_types, changed, false)?; } // Terminator stack-shape validation. let last = b.end - 1; @@ -397,6 +651,13 @@ fn infer_block( return Err(JitVerdict::NonEmptyBoundaryStack); } } + // A rewritten range header operates purely on its synthetic + // slots; the operand stack must be empty like any other jump. + OpCode::ForIter => { + if !stack.is_empty() { + return Err(JitVerdict::NonEmptyBoundaryStack); + } + } OpCode::PopJumpIfFalse | OpCode::PopJumpIfTrue => { if stack.len() != 1 { return Err(JitVerdict::NonEmptyBoundaryStack); @@ -408,7 +669,7 @@ fn infer_block( } // Fall-through terminator: must leave an empty stack. _ => { - step_abstract(code, last, &mut stack, local_types, changed, false)?; + step_abstract(code, last, &mut stack, plan, local_types, changed, false)?; if !stack.is_empty() { return Err(JitVerdict::NonEmptyBoundaryStack); } @@ -423,13 +684,50 @@ fn step_abstract( code: &CodeObject, i: usize, stack: &mut Vec, + plan: &Plan, local_types: &mut [Option], changed: &mut bool, strict: bool, ) -> Result<(), JitVerdict> { let ins = code.instructions[i]; + // RFC 0058 WS4 — rewritten range-loop pcs. + if plan.nop.contains(&i) { + return Ok(()); + } + if let Some(&(pops, cur, stop)) = plan.calls.get(&i) { + for _ in 0..pops { + let v = stack.pop().ok_or(JitVerdict::StackUnderflow)?; + if v.ty.is_representable() { + if !v.ty.is_integral() { + return Err(JitVerdict::TypeUnknown); + } + } else if let Some(slot) = v.src { + // A live-in feeding a range bound must be an int. + set_local(local_types, slot, JitType::Int, changed)?; + } else if strict { + return Err(JitVerdict::TypeUnknown); + } + } + set_local(local_types, cur, JitType::Int, changed)?; + set_local(local_types, stop, JitType::Int, changed)?; + return Ok(()); + } + if let Some(&var) = plan.fused_store.get(&i) { + // Performed by the `ForRange` terminator; no stack effect here. + set_local(local_types, var, JitType::Int, changed)?; + return Ok(()); + } match ins.op { OpCode::Nop | OpCode::Resume => {} + OpCode::LoadGlobal => { + let ty = match plan.globals.get(&ins.arg) { + Some(ResolvedGlobal::ConstInt(_)) => JitType::Int, + Some(ResolvedGlobal::ConstFloat(_)) => JitType::Float, + Some(ResolvedGlobal::ConstBool(_)) => JitType::Bool, + _ => return Err(JitVerdict::UnsupportedOpcode("LOAD_GLOBAL")), + }; + stack.push(SE::known(ty)); + } OpCode::LoadConst => { let c = code .constants @@ -479,6 +777,13 @@ fn step_abstract( stack.push(SE::known(res)); } OpCode::PopTop => { + // `break` inside a rewritten range loop pops the *iterator*, + // which the rewrite never pushed — erase the pop. (When the + // rewritten stack is empty inside a loop span, the + // interpreter's stack holds exactly the live iterators.) + if stack.is_empty() && plan.in_loop_span(i) { + return Ok(()); + } stack.pop().ok_or(JitVerdict::StackUnderflow)?; } OpCode::CopyTop => { @@ -575,7 +880,10 @@ fn bin_result_type( } _ => Ok(JitType::Int), } - } else if a == JitType::Float && b == JitType::Float { + } else if a == JitType::Float || b == JitType::Float { + // Float∘float, or mixed integral/float (RFC 0058 WS4): the + // integral operand is promoted with the same `as f64` cast the + // interpreter applies, so only the float-lane op set is legal. match kind { ArithKind::Add | ArithKind::Sub | ArithKind::Mul | ArithKind::TrueDiv => { Ok(JitType::Float) @@ -587,7 +895,9 @@ fn bin_result_type( } } -/// Validate comparison operand lanes (same lane required in v1). +/// Validate comparison operand lanes. Same-lane always works; mixed +/// integral/float works via a *guarded* promotion (the interpreter +/// compares exactly, so the JIT deopts when the int exceeds ±2^53). fn cmp_check(a: JitType, b: JitType, strict: bool) -> Result<(), JitVerdict> { if !a.is_representable() || !b.is_representable() { return if strict { @@ -596,7 +906,7 @@ fn cmp_check(a: JitType, b: JitType, strict: bool) -> Result<(), JitVerdict> { Ok(()) }; } - if (a.is_integral() && b.is_integral()) || (a == JitType::Float && b == JitType::Float) { + if (a.is_integral() || a == JitType::Float) && (b.is_integral() || b == JitType::Float) { Ok(()) } else { Err(JitVerdict::MixedArithTypes) @@ -679,6 +989,7 @@ fn unary_kind(arg: u32) -> Result { fn emit_block( code: &CodeObject, b: &RawBlock, + plan: &Plan, local_types: &[Option], compact: &HashMap, max_stack: &mut u32, @@ -687,7 +998,15 @@ fn emit_block( let mut stmts: Vec = Vec::new(); for i in b.start..(b.end - 1) { - emit_instr(code, i, local_types, &mut stack, &mut stmts, max_stack)?; + emit_instr( + code, + i, + plan, + local_types, + &mut stack, + &mut stmts, + max_stack, + )?; } let last = b.end - 1; @@ -713,8 +1032,29 @@ fn emit_block( fallthrough: compact[&block_succ(b, 0)], target: compact[&block_succ(b, 1)], }, + OpCode::ForIter => { + let &(cur_slot, stop_slot, var_slot) = plan + .headers + .get(&last) + .ok_or(JitVerdict::UnsupportedOpcode("FOR_ITER (unplanned)"))?; + TTerm::ForRange { + cur_slot, + stop_slot, + var_slot, + body: compact[&block_succ(b, 0)], + exit: compact[&block_succ(b, 1)], + } + } _ => { - emit_instr(code, last, local_types, &mut stack, &mut stmts, max_stack)?; + emit_instr( + code, + last, + plan, + local_types, + &mut stack, + &mut stmts, + max_stack, + )?; TTerm::Jump(compact[&block_succ(b, 0)]) } }; @@ -737,6 +1077,7 @@ fn block_succ(b: &RawBlock, k: usize) -> usize { fn emit_instr( code: &CodeObject, i: usize, + plan: &Plan, local_types: &[Option], stack: &mut Vec, stmts: &mut Vec, @@ -752,8 +1093,41 @@ fn emit_instr( } *max_stack = (*max_stack).max(stack.len() as u32); }; + // RFC 0058 WS4 — rewritten range-loop pcs. + if plan.nop.contains(&i) || plan.fused_store.contains_key(&i) { + return Ok(()); + } + if let Some(&(pops, cur_slot, stop_slot)) = plan.calls.get(&i) { + // Stack is [.., start?, stop]; store the bounds into the + // synthetic slots (one arg seeds `cur` with 0). + stack.pop().ok_or(JitVerdict::StackUnderflow)?; + push(TOp::StoreLocal(stop_slot), None, stack, stmts); + if pops == 2 { + stack.pop().ok_or(JitVerdict::StackUnderflow)?; + } else { + push(TOp::PushConstInt(0), Some(JitType::Int), stack, stmts); + stack.pop(); + } + push(TOp::StoreLocal(cur_slot), None, stack, stmts); + return Ok(()); + } + // `break` inside a rewritten loop: erase the phantom iterator pop. + if matches!(ins.op, OpCode::PopTop) && stack.is_empty() && plan.in_loop_span(i) { + return Ok(()); + } match ins.op { OpCode::Nop | OpCode::Resume => {} + OpCode::LoadGlobal => { + let (op, ty) = match plan.globals.get(&ins.arg) { + Some(&ResolvedGlobal::ConstInt(v)) => (TOp::PushConstInt(v), JitType::Int), + Some(&ResolvedGlobal::ConstFloat(bits)) => { + (TOp::PushConstFloat(bits), JitType::Float) + } + Some(&ResolvedGlobal::ConstBool(v)) => (TOp::PushConstBool(v), JitType::Bool), + _ => return Err(JitVerdict::UnsupportedOpcode("LOAD_GLOBAL")), + }; + push(op, Some(ty), stack, stmts); + } OpCode::LoadConst => { let c = &code.constants[ins.arg as usize]; let (op, ty) = match c { @@ -780,21 +1154,64 @@ fn emit_instr( let kind = bin_kind(ins.arg)?; let b = stack.pop().ok_or(JitVerdict::StackUnderflow)?; let a = stack.pop().ok_or(JitVerdict::StackUnderflow)?; - let (op, ty) = lower_bin(kind, a, b)?; - push(op, Some(ty), stack, stmts); + if (a.is_integral() && b == JitType::Float) || (a == JitType::Float && b.is_integral()) + { + // Mixed integral/float (RFC 0058 WS4): promote the + // integral operand exactly like the interpreter's + // `as f64` cast, then run the float-lane op. Only the + // float-supported op set is legal. + if !matches!( + kind, + ArithKind::Add | ArithKind::Sub | ArithKind::Mul | ArithKind::TrueDiv + ) { + return Err(JitVerdict::UnsupportedOpcode("mixed floordiv/mod/bitop")); + } + // Both operands are conceptually back on the stack for + // the promotion op (they only left the *model*). + stack.push(a); + stack.push(b); + let promote = if b == JitType::Float { + TOp::IntToFloatSecond { guarded: false } + } else { + TOp::IntToFloatTos { guarded: false } + }; + push(promote, None, stack, stmts); + stack.pop(); + stack.pop(); + push(TOp::FloatArith(kind), Some(JitType::Float), stack, stmts); + } else { + let (op, ty) = lower_bin(kind, a, b)?; + push(op, Some(ty), stack, stmts); + } } OpCode::CompareOp => { let kind = cmp_kind(ins.arg)?; let b = stack.pop().ok_or(JitVerdict::StackUnderflow)?; let a = stack.pop().ok_or(JitVerdict::StackUnderflow)?; - let op = if a.is_integral() && b.is_integral() { - TOp::IntCmp(kind) + if a.is_integral() && b.is_integral() { + push(TOp::IntCmp(kind), Some(JitType::Bool), stack, stmts); } else if a == JitType::Float && b == JitType::Float { - TOp::FloatCmp(kind) + push(TOp::FloatCmp(kind), Some(JitType::Bool), stack, stmts); + } else if (a.is_integral() && b == JitType::Float) + || (a == JitType::Float && b.is_integral()) + { + // Mixed comparison is mathematically exact in the + // interpreter, so the promotion is *guarded*: outside + // ±2^53 (where f64 stops being exact) it deopts. + stack.push(a); + stack.push(b); + let promote = if b == JitType::Float { + TOp::IntToFloatSecond { guarded: true } + } else { + TOp::IntToFloatTos { guarded: true } + }; + push(promote, None, stack, stmts); + stack.pop(); + stack.pop(); + push(TOp::FloatCmp(kind), Some(JitType::Bool), stack, stmts); } else { return Err(JitVerdict::MixedArithTypes); - }; - push(op, Some(JitType::Bool), stack, stmts); + } } OpCode::UnaryOp => { let kind = unary_kind(ins.arg)?; diff --git a/crates/weavepy-jit/src/engine.rs b/crates/weavepy-jit/src/engine.rs index 70a52dc9..b004f152 100644 --- a/crates/weavepy-jit/src/engine.rs +++ b/crates/weavepy-jit/src/engine.rs @@ -17,7 +17,7 @@ use cranelift_jit::{JITBuilder, JITModule}; use cranelift_module::{Linkage, Module}; use crate::analyze::{analyze, JitVerdict}; -use crate::ir::TFunc; +use crate::ir::{GlobalGuard, RangeLoopMeta, ResolvedGlobal, TFunc}; use crate::lower::build_function; use crate::runtime::{JitFrame, JitStatus}; use crate::value::JitType; @@ -38,8 +38,14 @@ pub struct CompiledFrame { pub local_types: Vec>, /// Max abstract operand-stack depth, for sizing the spill buffer. pub max_stack: u32, - /// Number of local slots. + /// Number of local slots, *including* synthetic range-loop slots. pub n_locals: u32, + /// Global resolutions burned into the code; the embedder must + /// re-validate each before every native entry (RFC 0058 WS4). + pub global_guards: Vec, + /// Rewritten range loops, outermost-first, for rebuilding the live + /// iterators on the interpreter stack after a mid-loop deopt. + pub range_loops: Vec, } impl CompiledFrame { @@ -109,10 +115,18 @@ impl JitEngine { }) } - /// Analyze and compile a code object. Returns the compiled frame, or - /// the [`JitVerdict`] explaining why it is not JITable. - pub fn compile(&mut self, code: &CodeObject) -> Result { - let tfunc = analyze(code)?; + /// Analyze and compile a code object. `resolve` reports what each + /// `LOAD_GLOBAL` name currently resolves to (see + /// [`ResolvedGlobal`]); the caller must re-validate every resolution + /// listed in [`CompiledFrame::global_guards`] before each entry. + /// Returns the compiled frame, or the [`JitVerdict`] explaining why + /// the code is not JITable. + pub fn compile( + &mut self, + code: &CodeObject, + resolve: &mut dyn FnMut(&str) -> ResolvedGlobal, + ) -> Result { + let tfunc = analyze(code, resolve)?; self.compile_tfunc(&tfunc) } @@ -160,6 +174,8 @@ impl JitEngine { local_types: tfunc.local_types.clone(), max_stack: tfunc.max_stack, n_locals: tfunc.n_locals, + global_guards: tfunc.global_guards.clone(), + range_loops: tfunc.range_loops.clone(), }) } } diff --git a/crates/weavepy-jit/src/ir.rs b/crates/weavepy-jit/src/ir.rs index dcece81b..ac794d0c 100644 --- a/crates/weavepy-jit/src/ir.rs +++ b/crates/weavepy-jit/src/ir.rs @@ -90,6 +90,16 @@ pub enum TOp { Dup, /// Swap the top two stack entries (`SWAP 2`). Swap2, + /// Convert the integral value at TOS to `float` (RFC 0058 WS4 mixed + /// arithmetic promotion, matching the interpreter's `as f64` cast). + /// When `guarded`, deopt unless `|v| <= 2^53` — the range where the + /// conversion is exact — because mixed-lane *comparisons* are + /// mathematically exact in the interpreter. + IntToFloatTos { guarded: bool }, + /// Same conversion applied to the entry *below* TOS. A dedicated op + /// (rather than `Swap2` + `IntToFloatTos` + `Swap2`) so a guarded + /// deopt spills the operand stack in its original order. + IntToFloatSecond { guarded: bool }, } /// One IR statement: a [`TOp`] tagged with its originating bytecode pc @@ -120,6 +130,18 @@ pub enum TTerm { target: BlockId, fallthrough: BlockId, }, + /// RFC 0058 WS4 — a recognized `FOR_ITER` over a unit-step `range`, + /// rewritten to an i64 counted loop over two synthetic local slots. + /// If `cur < stop`: store `cur` into `var_slot`, bump `cur`, and + /// branch to `body`; else branch to `exit`. `cur < stop <= i64::MAX` + /// makes the unit-step increment provably overflow-free. + ForRange { + cur_slot: u32, + stop_slot: u32, + var_slot: u32, + body: BlockId, + exit: BlockId, + }, } /// A basic block: a static entry-stack shape, a straight-line body, and @@ -133,10 +155,55 @@ pub struct TBlock { pub term: TTerm, } +/// What a `LOAD_GLOBAL` name resolved to at analysis time. Provided by +/// the embedder's resolver closure; anything other than `Opaque` is +/// burned into the compiled code, and the embedder must re-validate the +/// resolution (an identity guard) on every native entry. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ResolvedGlobal { + /// The canonical builtin `range` — eligible as a counted-loop callee. + RangeBuiltin, + /// An `int` global, burned in as a constant. + ConstInt(i64), + /// A `float` global, burned in as a constant (stored as bits so the + /// enum stays `Copy` + `PartialEq`). + ConstFloat(u64), + /// A `bool` global, burned in as a constant. + ConstBool(bool), + /// Anything else — not representable; the load disqualifies the frame. + Opaque, +} + +/// One entry guard the embedder must re-validate before each native +/// entry: `name` must still resolve (globals-then-builtins) to the same +/// object it resolved to at compile time. +#[derive(Clone, Debug, PartialEq)] +pub struct GlobalGuard { + pub name: String, + pub expect: ResolvedGlobal, +} + +/// Deopt-reconstruction metadata for one rewritten `range` loop: at any +/// deopt pc in `[live_from, live_to)` the *interpreter's* operand stack +/// would hold the live range iterator below the spilled temporaries, so +/// the embedder must rebuild it from the two synthetic slots. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RangeLoopMeta { + /// Synthetic slot holding the next value to yield. + pub cur_slot: u32, + /// Synthetic slot holding the exclusive stop bound. + pub stop_slot: u32, + /// First pc (the `FOR_ITER`) at which the iterator is live. + pub live_from: u32, + /// The `END_FOR` pc; the iterator is dead from here on. + pub live_to: u32, +} + /// A fully analyzed, JITable function body. #[derive(Clone, Debug, PartialEq)] pub struct TFunc { - /// Number of local slots in the originating code object. + /// Number of local slots, *including* the synthetic `range`-loop + /// slots appended after the code object's real locals. pub n_locals: u32, /// Stable JIT type of each local slot, or `None` for slots the /// region never touches (left untouched by the JIT). @@ -150,6 +217,12 @@ pub struct TFunc { pub max_stack: u32, pub blocks: Vec, pub entry_block: BlockId, + /// Entry guards for every `LOAD_GLOBAL` burned into the code + /// (RFC 0058 WS4), deduplicated by name. + pub global_guards: Vec, + /// Rewritten `range` loops, ordered outermost-first (ascending + /// `live_from`), for deopt stack reconstruction. + pub range_loops: Vec, } impl TOp { @@ -168,6 +241,8 @@ impl TOp { ) | TOp::IntNeg | TOp::IntTrueDiv | TOp::FloatArith(ArithKind::TrueDiv) + | TOp::IntToFloatTos { guarded: true } + | TOp::IntToFloatSecond { guarded: true } ) } } diff --git a/crates/weavepy-jit/src/lib.rs b/crates/weavepy-jit/src/lib.rs index cac7fa97..fd7587a7 100644 --- a/crates/weavepy-jit/src/lib.rs +++ b/crates/weavepy-jit/src/lib.rs @@ -31,7 +31,10 @@ mod value; pub use analyze::{analyze, JitVerdict}; pub use engine::{CompiledFrame, JitEngine}; -pub use ir::{ArithKind, BlockId, CmpKind, TBlock, TFunc, TOp, TStmt, TTerm}; +pub use ir::{ + ArithKind, BlockId, CmpKind, GlobalGuard, RangeLoopMeta, ResolvedGlobal, TBlock, TFunc, TOp, + TStmt, TTerm, +}; pub use runtime::{JitFrame, JitStatus, SlotTag}; pub use value::JitType; diff --git a/crates/weavepy-jit/src/lower.rs b/crates/weavepy-jit/src/lower.rs index c2057f9d..d5c464dd 100644 --- a/crates/weavepy-jit/src/lower.rs +++ b/crates/weavepy-jit/src/lower.rs @@ -171,6 +171,32 @@ impl<'a, 'b> Lowerer<'a, 'b> { let fb = self.cl_blocks[fallthrough]; self.b.ins().brif(truthy, tb, &[], fb, &[]); } + TTerm::ForRange { + cur_slot, + stop_slot, + var_slot, + body, + exit, + } => { + // if cur < stop { var = cur; cur += 1; goto body } + // else { goto exit }. `cur < stop <= i64::MAX` makes the + // unit-step increment overflow-free. + let cur_var = self.vars[cur_slot as usize].expect("managed range cur"); + let stop_var = self.vars[stop_slot as usize].expect("managed range stop"); + let loop_var = self.vars[var_slot as usize].expect("managed loop var"); + let cur = self.b.use_var(cur_var); + let stop = self.b.use_var(stop_var); + let cond = self.b.ins().icmp(IntCC::SignedLessThan, cur, stop); + let body_pre = self.b.create_block(); + let eb = self.cl_blocks[exit]; + self.b.ins().brif(cond, body_pre, &[], eb, &[]); + self.b.switch_to_block(body_pre); + self.b.def_var(loop_var, cur); + let next = self.b.ins().iadd_imm(cur, 1); + self.b.def_var(cur_var, next); + let bb = self.cl_blocks[body]; + self.b.ins().jump(bb, &[]); + } } } @@ -254,7 +280,36 @@ impl<'a, 'b> Lowerer<'a, 'b> { let len = self.vstack.len(); self.vstack.swap(len - 1, len - 2); } + TOp::IntToFloatTos { guarded } => { + let depth = self.vstack.len() - 1; + self.emit_int_to_float(depth, guarded, stmt.pc); + } + TOp::IntToFloatSecond { guarded } => { + let depth = self.vstack.len() - 2; + self.emit_int_to_float(depth, guarded, stmt.pc); + } + } + } + + /// Promote the integral value at `vstack[depth]` to a float in + /// place. When `guarded`, deopt unless `|v| <= 2^53` — the range + /// where `fcvt_from_sint` is exact — with the stack spilled in its + /// original, unpromoted order. + fn emit_int_to_float(&mut self, depth: usize, guarded: bool, pc: u32) { + let v = self.vstack[depth].0; + if guarded { + let snapshot = self.vstack.clone(); + const EXACT: i64 = 1 << 53; + let hi = self.b.ins().iconst(types::I64, EXACT); + let lo = self.b.ins().iconst(types::I64, -EXACT); + let too_big = self.b.ins().icmp(IntCC::SignedGreaterThan, v, hi); + let too_small = self.b.ins().icmp(IntCC::SignedLessThan, v, lo); + let inexact = self.b.ins().bor(too_big, too_small); + let cont = self.guard(inexact, pc, &snapshot); + self.b.switch_to_block(cont); } + let f = self.b.ins().fcvt_from_sint(types::F64, v); + self.vstack[depth] = (f, JitType::Float); } // ---- arithmetic ------------------------------------------------ diff --git a/crates/weavepy-jit/tests/numeric.rs b/crates/weavepy-jit/tests/numeric.rs index 902d3303..a6c4c9f1 100644 --- a/crates/weavepy-jit/tests/numeric.rs +++ b/crates/weavepy-jit/tests/numeric.rs @@ -64,6 +64,8 @@ fn add_two_ints() { livein_locals: vec![0, 1], max_stack: 2, entry_block: 0, + global_guards: vec![], + range_loops: vec![], blocks: vec![TBlock { entry_stack: vec![], stmts: vec![ @@ -90,6 +92,8 @@ fn add_overflow_deopts_with_operands_spilled() { livein_locals: vec![0, 1], max_stack: 2, entry_block: 0, + global_guards: vec![], + range_loops: vec![], blocks: vec![TBlock { entry_stack: vec![], stmts: vec![ @@ -117,6 +121,8 @@ fn sum_loop() -> TFunc { livein_locals: vec![0], max_stack: 2, entry_block: 0, + global_guards: vec![], + range_loops: vec![], blocks: vec![ // B0: s=0; i=0; -> B1 TBlock { @@ -193,6 +199,8 @@ fn binop_fn(op: ArithKind) -> TFunc { livein_locals: vec![0, 1], max_stack: 2, entry_block: 0, + global_guards: vec![], + range_loops: vec![], blocks: vec![TBlock { entry_stack: vec![], stmts: vec![ @@ -252,6 +260,8 @@ fn int_truediv_returns_float() { livein_locals: vec![0, 1], max_stack: 2, entry_block: 0, + global_guards: vec![], + range_loops: vec![], blocks: vec![TBlock { entry_stack: vec![], stmts: vec![ diff --git a/crates/weavepy-jit/tests/range_loops.rs b/crates/weavepy-jit/tests/range_loops.rs new file mode 100644 index 00000000..aa4d057e --- /dev/null +++ b/crates/weavepy-jit/tests/range_loops.rs @@ -0,0 +1,177 @@ +//! RFC 0058 WS4 — analyzer tests over real compiled Python shapes: +//! `range` loop recognition, guarded `LOAD_GLOBAL` burn-in, and mixed +//! int/float lanes. These stop at [`analyze`] (no VM), so they check +//! the *decisions*; execution is covered by weavepy-vm's `jit_*` tests. + +use weavepy_compiler::{compile_module, CodeObject, Constant}; +use weavepy_jit::{analyze, JitVerdict, ResolvedGlobal, TFunc, TTerm}; +use weavepy_parser::parse_module; + +/// Compile `src` and return the code object of the first `def`. +fn compile_first_fn(src: &str) -> CodeObject { + let module = parse_module(src).expect("parse"); + let code = compile_module(&module).expect("compile"); + for c in &code.constants { + if let Constant::Code(inner) = c { + return (**inner).clone(); + } + } + panic!("no function in {src:?}"); +} + +/// Resolver where `range` is the canonical builtin and everything else +/// is opaque. +fn range_only(name: &str) -> ResolvedGlobal { + if name == "range" { + ResolvedGlobal::RangeBuiltin + } else { + ResolvedGlobal::Opaque + } +} + +fn analyze_fn( + src: &str, + resolve: &mut dyn FnMut(&str) -> ResolvedGlobal, +) -> Result { + let code = compile_first_fn(src); + analyze(&code, resolve) +} + +#[test] +fn simple_range_loop_analyzes() { + let tfunc = analyze_fn( + "def kernel(n):\n total = 0\n for i in range(n):\n total = total + i * 2\n return total\n", + &mut range_only, + ) + .expect("range loop should be jitable"); + // Two synthetic slots appended after the three real locals. + assert_eq!(tfunc.n_locals, 5); + assert_eq!(tfunc.range_loops.len(), 1); + assert_eq!(tfunc.global_guards.len(), 1); + assert_eq!(tfunc.global_guards[0].name, "range"); + assert!(tfunc + .blocks + .iter() + .any(|b| matches!(b.term, TTerm::ForRange { .. }))); +} + +#[test] +fn nested_range_loops_get_distinct_synthetics() { + let tfunc = analyze_fn( + "def kernel(n):\n t = 0\n for i in range(n):\n for j in range(n):\n t = t + i + j\n return t\n", + &mut range_only, + ) + .expect("nested range loops should be jitable"); + assert_eq!(tfunc.range_loops.len(), 2); + let a = tfunc.range_loops[0]; + let b = tfunc.range_loops[1]; + assert_ne!(a.cur_slot, b.cur_slot); + // Outermost first: the outer loop's live span encloses the inner's. + assert!(a.live_from < b.live_from && b.live_to < a.live_to); +} + +#[test] +fn two_arg_range_and_unit_step_analyze() { + for src in [ + "def k(a, b):\n s = 0\n for i in range(a, b):\n s = s + i\n return s\n", + "def k(a, b):\n s = 0\n for i in range(a, b, 1):\n s = s + i\n return s\n", + ] { + analyze_fn(src, &mut range_only).expect("unit-step range should be jitable"); + } +} + +#[test] +fn non_unit_step_stays_interpreted() { + let err = analyze_fn( + "def k(n):\n s = 0\n for i in range(0, n, 2):\n s = s + i\n return s\n", + &mut range_only, + ) + .unwrap_err(); + assert!(matches!(err, JitVerdict::UnsupportedOpcode(_)), "{err:?}"); +} + +#[test] +fn shadowed_range_stays_interpreted() { + let err = analyze_fn( + "def k(n):\n s = 0\n for i in range(n):\n s = s + i\n return s\n", + &mut |_| ResolvedGlobal::Opaque, + ) + .unwrap_err(); + assert!(matches!(err, JitVerdict::UnsupportedOpcode(_)), "{err:?}"); +} + +#[test] +fn non_range_iterable_stays_interpreted() { + let err = analyze_fn( + "def k(xs):\n s = 0\n for x in xs:\n s = s + x\n return s\n", + &mut range_only, + ) + .unwrap_err(); + assert!(matches!(err, JitVerdict::UnsupportedOpcode(_)), "{err:?}"); +} + +#[test] +fn break_in_range_loop_analyzes() { + analyze_fn( + "def k(n):\n s = 0\n for i in range(n):\n if i > 90:\n break\n s = s + i\n return s\n", + &mut range_only, + ) + .expect("break in a range loop should be jitable"); +} + +#[test] +fn const_global_burns_in_with_guard() { + let tfunc = analyze_fn( + "def k(n):\n s = 0\n for i in range(n):\n s = s + N\n return s\n", + &mut |name| match name { + "range" => ResolvedGlobal::RangeBuiltin, + "N" => ResolvedGlobal::ConstInt(5), + _ => ResolvedGlobal::Opaque, + }, + ) + .expect("const global should burn in"); + let names: Vec<&str> = tfunc + .global_guards + .iter() + .map(|g| g.name.as_str()) + .collect(); + assert!( + names.contains(&"range") && names.contains(&"N"), + "{names:?}" + ); +} + +#[test] +fn opaque_global_disqualifies() { + let err = analyze_fn("def k(n):\n return n + M\n", &mut |_| { + ResolvedGlobal::Opaque + }) + .unwrap_err(); + assert!(matches!(err, JitVerdict::UnsupportedOpcode(_)), "{err:?}"); +} + +#[test] +fn mixed_int_float_arith_analyzes() { + analyze_fn( + "def k(n):\n s = 0.0\n i = 0\n while i < n:\n s = s + i\n i = i + 1\n return s\n", + &mut range_only, + ) + .expect("mixed int/float arithmetic should be jitable"); +} + +#[test] +fn params_are_entry_guarded() { + // Regression: parameters flow in from the caller and must be listed + // in `livein_locals` so the VM type-guards them (a float argument to + // an int-typed kernel must skip native entry, not compute garbage). + let tfunc = analyze_fn( + "def kernel(n):\n s = 0\n i = 0\n while i < n:\n s = s + i\n i = i + 1\n return s\n", + &mut range_only, + ) + .expect("int kernel"); + assert!( + tfunc.livein_locals.contains(&0), + "param slot must be entry-guarded: {:?}", + tfunc.livein_locals + ); +} diff --git a/crates/weavepy-vm/src/gil.rs b/crates/weavepy-vm/src/gil.rs index 40db44b2..d4ce387d 100644 --- a/crates/weavepy-vm/src/gil.rs +++ b/crates/weavepy-vm/src/gil.rs @@ -607,12 +607,18 @@ pub fn allow_threads_then(f: impl FnOnce() -> R) -> R { /// How many dispatch-loop opcodes elapse between cooperative GIL /// hand-off checks. CPython switches on a 5ms wall-clock interval; /// we approximate with an opcode countdown that's cheap to test in -/// the hot path (a thread-local decrement, no atomics). +/// the hot path. const GIL_CHECK_INTERVAL: u32 = 128; +/// The countdown itself is a process-global relaxed atomic rather +/// than a thread-local (RFC 0058 WS2): only the GIL holder executes +/// bytecode, so a shared counter preserves the "check every ~128 +/// opcodes" cadence while replacing a macOS `tlv_get_addr` call per +/// instruction with one uncontended atomic RMW. +static YIELD_COUNTDOWN: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(GIL_CHECK_INTERVAL); + std::thread_local! { - static YIELD_COUNTDOWN: std::cell::Cell = - const { std::cell::Cell::new(GIL_CHECK_INTERVAL) }; /// Wall-clock instant at which this thread last (re)acquired the GIL /// for a contiguous run. [`maybe_yield_gil`] reads it to enforce @@ -717,22 +723,17 @@ impl Drop for NoYieldGuard { /// `_started`. Mirrors CPython's `eval_breaker` / `gil_drop_request` /// switch driven by `sys.setswitchinterval`. /// -/// The fast path is a thread-local countdown decrement; the GIL is +/// The fast path is a relaxed atomic countdown decrement; the GIL is /// only actually dropped every [`GIL_CHECK_INTERVAL`] opcodes *and* /// only when another thread is blocked waiting for it. #[inline] pub fn periodic_gil_checkpoint() { - let fire = YIELD_COUNTDOWN.with(|c| { - let n = c.get(); - if n <= 1 { - c.set(GIL_CHECK_INTERVAL); - true - } else { - c.set(n - 1); - false - } - }); - if fire { + // Relaxed is enough: the countdown only paces how often we + // *consider* yielding, so lost updates under a rare race merely + // shift the next check by a few opcodes. + let prev = YIELD_COUNTDOWN.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + if prev <= 1 { + YIELD_COUNTDOWN.store(GIL_CHECK_INTERVAL, std::sync::atomic::Ordering::Relaxed); maybe_yield_gil(); } } diff --git a/crates/weavepy-vm/src/lib.rs b/crates/weavepy-vm/src/lib.rs index 2c2d1fb1..436583e3 100644 --- a/crates/weavepy-vm/src/lib.rs +++ b/crates/weavepy-vm/src/lib.rs @@ -94,7 +94,10 @@ struct Frame { /// friends). locals: Rc>>, /// Cell storage. Layout: `code.cellvars` first, then `code.freevars`. - cells: Vec>>, + /// Behind an `Rc` (RFC 0058): built once in `make_frame`, never + /// resized afterwards, and shared with the frame's `FrameShell` / + /// `PyFrame` without a per-call `Vec` clone. + cells: Rc>>>, /// Evaluation stack. stack: Vec, /// Globals shared across frames within the same module. @@ -163,6 +166,11 @@ struct Frame { /// first entry and re-push the same object on each resume. /// `None` for ordinary frames, which are only ever entered once. py_frame: Option>, + /// Backlink to the generator/coroutine owning this frame (weak), + /// set at generator creation. Copied onto each activation's + /// `FrameShell` so `gi_frame` can locate the executing frame of a + /// *running* generator even when nothing has been materialised. + gen_owner: Option>, /// RFC 0051 (WS4): skip the frame-entry `'call'` trace event for /// the *next* activation. Set by `generator_throw`, which fires /// CPython's PY_THROW (`'call'`) + RAISE (`'exception'`) pair at @@ -267,7 +275,7 @@ fn generator_frame_traverse(obj: &Object, visit: &mut dyn FnMut(&Object)) { for v in &frame.stack { visit(v); } - for c in &frame.cells { + for c in frame.cells.iter() { if let Ok(v) = c.try_borrow() { visit(&v); } @@ -596,11 +604,12 @@ pub struct Interpreter { /// internal compile (imports, `exec`/`eval` of source) resolve to /// this level (RFC 0052). pub(crate) optimize_level: u8, - /// Live call stack of Python-visible frame snapshots, in - /// outer-to-inner order. The topmost entry corresponds to the - /// currently-executing `Frame`. RFC 0018: used by - /// `sys._getframe`, `traceback`, and the unwind machinery. - pub(crate) frame_stack: Rc>>>, + /// Live call stack of frame *shells*, in outer-to-inner order — + /// the topmost entry corresponds to the currently-executing + /// `Frame`. RFC 0058: shells are cheap per-call spine entries; + /// the Python-visible `PyFrame` is materialised on demand (see + /// `sys._getframe`, `traceback`, and the unwind machinery). + pub(crate) frame_stack: crate::object::FrameStack, /// Stack of currently-handled exceptions across all frames. The /// top is what `sys.exc_info()` returns. Pushed by /// `PUSH_EXC_INFO`; popped by `POP_EXCEPT`. @@ -633,6 +642,15 @@ pub struct Interpreter { /// this is set (testmock's `patch('builtins.__import__')` must not /// see — or worse, service — bootstrap imports). internal_import_depth: u32, + /// RFC 0058 (WS2) — recycled fast-locals storage. Every Python call + /// used to malloc a fresh `Rc>>`; a returned + /// frame whose storage nothing else shares (no escaped `PyFrame`, + /// no traceback) hands the allocation back here instead. CPython's + /// analogue is the `_PyFreeListState` frame/object freelists. + frame_locals_pool: RefCell>>>>, + /// Recycled operand-stack vectors, same motivation. The operand + /// stack is never shared, so every returned frame donates one. + frame_stack_pool: RefCell>>, } impl Default for Interpreter { @@ -748,7 +766,7 @@ impl Default for Interpreter { }); let excepthook = Rc::new(RefCell::new(Object::None)); let unraisable_hook = Rc::new(RefCell::new(Object::None)); - let frame_stack: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let frame_stack: crate::object::FrameStack = Rc::new(RefCell::new(Vec::new())); let exc_info_stack = Rc::new(RefCell::new(Vec::new())); // Eagerly build the `sys` module so the per-interpreter // frame_stack / exc_info_stack are visible to user code via @@ -788,6 +806,8 @@ impl Default for Interpreter { unraisable_hook, globals_missing_hooks: RefCell::new(Vec::new()), internal_import_depth: 0, + frame_locals_pool: RefCell::new(Vec::new()), + frame_stack_pool: RefCell::new(Vec::new()), }; // RFC 0025: publish the shared parts of this interpreter // (builtins / module cache / stdout / hooks) so workers @@ -882,6 +902,8 @@ impl Interpreter { unraisable_hook: self.unraisable_hook.clone(), globals_missing_hooks: RefCell::new(Vec::new()), internal_import_depth: 0, + frame_locals_pool: RefCell::new(Vec::new()), + frame_stack_pool: RefCell::new(Vec::new()), } } @@ -3702,6 +3724,58 @@ impl Interpreter { /// builtins mapping when the caller already has one — a Python /// function's cached `func_builtins` — and `None` for module / /// exec / class-body frames, which resolve from `globals` here. + /// Fetch a recycled fast-locals storage sized to `n` slots (all + /// `Unbound`), or allocate a fresh one on pool miss (RFC 0058 WS2). + fn pooled_locals(&self, n: usize) -> Rc>> { + if let Some(rc) = self.frame_locals_pool.borrow_mut().pop() { + { + let mut v = rc.borrow_mut(); + debug_assert!(v.is_empty()); + v.resize(n, Object::Unbound); + } + return rc; + } + Rc::new(RefCell::new(vec![Object::Unbound; n])) + } + + /// Fetch a recycled operand-stack vector, or allocate one. + fn pooled_stack(&self) -> Vec { + self.frame_stack_pool + .borrow_mut() + .pop() + .unwrap_or_else(|| Vec::with_capacity(16)) + } + + /// Return a finished frame's heap allocations to the pools. Only + /// called on the ordinary `Returned` path (a yielded/suspended + /// frame lives on; an exceptional exit may sit in a traceback). + /// The locals storage is recycled only when this frame is its sole + /// owner — an escaped `PyFrame` shares it via `locals_mirror` and + /// keeps `strong_count > 1`, so PEP 667 handles are never torn out + /// from under a live reference. Dropping the leftover values here + /// is exactly the drop the frame's own destructor would perform. + fn recycle_frame_allocs(&self, frame: &mut Frame) { + const POOL_CAP: usize = 64; + let mut stack = std::mem::take(&mut frame.stack); + if stack.capacity() > 0 { + stack.clear(); + let mut pool = self.frame_stack_pool.borrow_mut(); + if pool.len() < POOL_CAP { + pool.push(stack); + } + } + if Rc::strong_count(&frame.locals) == 1 { + // Clear before touching the pool: dropping the leftover + // values runs arbitrary Rust `Drop` glue (file handles, GC + // bookkeeping), which must not observe a held pool borrow. + frame.locals.borrow_mut().clear(); + let mut pool = self.frame_locals_pool.borrow_mut(); + if pool.len() < POOL_CAP { + pool.push(frame.locals.clone()); + } + } + } + fn make_frame( &self, code: Rc, @@ -3710,10 +3784,13 @@ impl Interpreter { globals: Rc>, builtins: Option>>, ) -> Frame { - let mut locals = vec![Object::Unbound; code.varnames.len()]; - for (i, v) in positional.into_iter().enumerate() { - if i < locals.len() { - locals[i] = v; + let locals_rc = self.pooled_locals(code.varnames.len()); + { + let mut locals = locals_rc.borrow_mut(); + for (i, v) in positional.into_iter().enumerate() { + if i < locals.len() { + locals[i] = v; + } } } // Build cells: cellvars come first (fresh), then freevars @@ -3738,7 +3815,7 @@ impl Interpreter { .iter() .position(|n| n == cell_name) .map_or(Object::Unbound, |idx| { - std::mem::replace(&mut locals[idx], Object::Unbound) + std::mem::replace(&mut locals_rc.borrow_mut()[idx], Object::Unbound) }); cells.push(Rc::new(RefCell::new(initial))); } @@ -3756,11 +3833,16 @@ impl Interpreter { } } let builtins = builtins.unwrap_or_else(|| self.builtins_for_globals(&globals)); + let cells = if cells.is_empty() { + crate::object::empty_cells() + } else { + Rc::new(cells) + }; Frame { code, - locals: Rc::new(RefCell::new(locals)), + locals: locals_rc, cells, - stack: Vec::with_capacity(16), + stack: self.pooled_stack(), globals, builtins, class_namespace: None, @@ -3770,6 +3852,7 @@ impl Interpreter { agen_yielded_value: true, pc: 0, py_frame: None, + gen_owner: None, cleanup_lasti: None, suppress_call_event: false, } @@ -3781,6 +3864,7 @@ impl Interpreter { match self.run_until_yield_or_return(frame, None)? { FrameOutcome::Returned(v) => { self.reap_frame_locals_on_exit(frame, Some(&v)); + self.recycle_frame_allocs(frame); Ok(v) } FrameOutcome::Yielded(_) => Err(RuntimeError::Internal( @@ -3865,11 +3949,12 @@ impl Interpreter { if let Some(v) = sent { frame.push(v); } - // Push a Python-visible frame snapshot for the duration of - // this run. The same frame may be entered multiple times - // (generators on resume) — each entry gets a fresh PyFrame - // because the `back` chain reflects who is calling *now*. - let py_frame = self.push_py_frame(frame); + // Push a cheap frame *shell* for the duration of this run + // (RFC 0058). The Python-visible `PyFrame` is materialised + // only when something introspects it — tracing, `sys._getframe`, + // traceback capture — via `ensure_top_py_frame`. + let shell = self.push_frame_shell(frame); + let mut py_frame_slot: Option> = shell.materialized.borrow().clone(); // Depth of the interpreter-wide handled-exception stack on entry. // Used on completion to discard any `PUSH_EXC_INFO` entries this // activation leaves un-popped (see the reconciliation at the @@ -3905,6 +3990,7 @@ impl Interpreter { let observers_active = crate::trace::any_observers_active(); let suppress_call = std::mem::take(&mut frame.suppress_call_event); if observers_active && !is_gen_bootstrap && !suppress_call { + let py_frame = self.ensure_top_py_frame(&mut py_frame_slot); self.fire_call_event(&py_frame)?; // On a resume, line tracing must continue from the line // where the frame suspended — CPython reports the `call` @@ -3921,7 +4007,9 @@ impl Interpreter { .copied() .unwrap_or(0); if line != 0 { - py_frame.last_line.set(Some(line)); + self.ensure_top_py_frame(&mut py_frame_slot) + .last_line + .set(Some(line)); } } } @@ -3932,9 +4020,9 @@ impl Interpreter { // rewrites `frame` and falls through to resume interpretation. #[cfg(feature = "jit")] if !is_resume && !observers_active && frame.pc == 0 && frame.stack.is_empty() { - match crate::tier2::try_enter(frame) { + match crate::tier2::try_enter(self, frame) { crate::tier2::JitEntry::Ran(v) => { - self.pop_py_frame(); + self.pop_frame_shell(); return Ok(FrameOutcome::Returned(v)); } crate::tier2::JitEntry::Deopt | crate::tier2::JitEntry::Skip => {} @@ -4012,8 +4100,12 @@ impl Interpreter { // of the re-entered code (pandas' import runs minutes long // under that quadratic churn). Draining only at depth 0 runs // each parked object through the cascade exactly once. - if !crate::vm_singletons::cext_call_active() - && crate::vm_singletons::has_pending_cext_drops() + // Probe order matters: `has_pending_cext_drops` is one + // relaxed load when empty (the overwhelming case), while + // `cext_call_active` is a thread-local read — so ask + // "is there anything to do" before "may we do it". + if crate::vm_singletons::has_pending_cext_drops() + && !crate::vm_singletons::cext_call_active() { static REAP_TRACE: std::sync::OnceLock = std::sync::OnceLock::new(); let reap_trace = @@ -4046,12 +4138,25 @@ impl Interpreter { if crate::vm_singletons::has_pending_resource_warnings() { self.drain_pending_resource_warnings(); } - // Mirror the live `pc` into the snapshot so `f_lineno` - // reads correctly when user code introspects via - // `sys._getframe`. The locals need no per-step sync: the - // snapshot shares the live storage (`build_py_frame`), and a + // Mirror the live `pc` into the shell so `f_lineno` reads + // correctly when user code introspects via `sys._getframe` + // (materialisation copies it). The locals need no per-step + // sync: any snapshot shares the live storage, and a // materialised `f_locals` dict refreshes itself on access. - py_frame.lasti.set(frame.pc); + shell + .lasti + .store(frame.pc, std::sync::atomic::Ordering::Relaxed); + if shell + .has_materialized + .load(std::sync::atomic::Ordering::Relaxed) + { + // Something materialised the real frame (possibly from + // inside an instruction): keep its `lasti` cell in sync + // exactly as the eager path used to. + self.ensure_top_py_frame(&mut py_frame_slot) + .lasti + .set(frame.pc); + } // Fire a 'line' event when the source line changes. // Fast path: skip the line-table read entirely when no // observer is active. The generator-creation bootstrap @@ -4067,6 +4172,8 @@ impl Interpreter { let mut trace_err: Option = None; let cur_pc = frame.pc as usize; if crate::trace::any_observers_active() && !is_gen_bootstrap { + let py_frame = self.ensure_top_py_frame(&mut py_frame_slot); + py_frame.lasti.set(frame.pc); let line = py_frame.current_lineno(); // RFC 0051 (WS4): CPython 3.13 line-event semantics. // A `'line'` event fires when execution reaches an @@ -4207,6 +4314,7 @@ impl Interpreter { Err(e) => e, }) } else if crate::stdlib::signal_mod::signals_pending() && crate::gil::is_main_thread() { + let py_frame = self.ensure_top_py_frame(&mut py_frame_slot); match self.run_pending_signals(&py_frame) { Ok(()) => { instruction_ran = true; @@ -4247,12 +4355,14 @@ impl Interpreter { Ok(StepOutcome::Continue) => {} Ok(StepOutcome::Return(v)) => { if crate::trace::any_observers_active() { + let py_frame = self.ensure_top_py_frame(&mut py_frame_slot); self.fire_return_event(&py_frame, &v)?; } break Ok(FrameOutcome::Returned(v)); } Ok(StepOutcome::Yield(v)) => { if crate::trace::any_observers_active() { + let py_frame = self.ensure_top_py_frame(&mut py_frame_slot); self.fire_yield_event(&py_frame, &v)?; // A jump set from the yield's 'return' trace event // (CPython allows these) lands on the suspended @@ -4316,6 +4426,7 @@ impl Interpreter { // (CPython `call_exc_trace` drops the // original); the replacement unwinds through // this same frame's handlers. + let py_frame = self.ensure_top_py_frame(&mut py_frame_slot); if let Err(trace_e) = self.fire_exception_event(&py_frame, &exc) { match trace_e { RuntimeError::PyException(new_exc) => exc = new_exc, @@ -4334,6 +4445,7 @@ impl Interpreter { // path (sys.monitoring sees PY_UNWIND). bdb's // `set_return` waits for exactly this event. if crate::trace::any_observers_active() { + let py_frame = self.ensure_top_py_frame(&mut py_frame_slot); self.fire_unwind_event(&py_frame)?; } break Err(e); @@ -4345,7 +4457,7 @@ impl Interpreter { } } }; - self.pop_py_frame(); + self.pop_frame_shell(); // A normally-returning frame is dead: CPython deallocates it the // instant it returns, releasing every local by refcount. Drop the // live-locals mirror (and any materialised `f_locals`) so a @@ -4364,35 +4476,54 @@ impl Interpreter { // (`StartGenerator`) keep their mirror — the frame lives on and is // re-entered — and an *exceptional* exit (`Err`) keeps it so the // frame's `f_locals` stays readable while it sits in a traceback. - if matches!(result, Ok(FrameOutcome::Returned(_))) { - // `pop_py_frame` above already dropped the call stack's clone, - // so `strong_count == 1` means *this* local is the only owner: - // the frame object dies here and its share of the locals - // storage dies with it — `reap_frame_locals_on_exit` (in the - // caller) then finalizes promptly. `> 1` means the frame object - // genuinely outlives the activation — a live traceback whose - // exception was caught and `return`ed/stored, or an explicit - // `sys._getframe`/`gi_frame` handle. CPython keeps such a - // frame's locals readable (`take_ownership`); the materialised - // dict's clones also keep those values above the exit sweep's - // dead threshold, so they are not reaped out from under the - // escaped frame. The locals storage itself is shared with the - // frame object (no mirror copy), so no re-sync is needed — - // the provider reads the final post-return state directly. - if Rc::strong_count(&py_frame) > 1 { - py_frame.take_ownership_of_locals(); + // A never-materialised activation (the common case) has no + // Python-visible frame object at all: nothing can hold a stale + // locals view, so there is nothing to reconcile. + if let Some(py_frame) = shell.materialized.borrow().as_ref() { + if matches!(result, Ok(FrameOutcome::Returned(_))) { + // The pop above dropped the call stack's shell (the loop + // slot and the shell's own cell are the bookkeeping refs + // counted below): any owner beyond those means the frame + // object genuinely outlives the activation — a live + // traceback whose exception was caught and + // `return`ed/stored, or an explicit + // `sys._getframe`/`gi_frame` handle. CPython keeps such a + // frame's locals readable (`take_ownership`); the + // materialised dict's clones also keep those values above + // the exit sweep's dead threshold, so they are not reaped + // out from under the escaped frame. The locals storage + // itself is shared with the frame object (no mirror + // copy), so no re-sync is needed — the provider reads the + // final post-return state directly. + let mut internal = 1; // the shell's `materialized` cell + if py_frame_slot + .as_ref() + .is_some_and(|s| Rc::ptr_eq(s, py_frame)) + { + internal += 1; + } + if frame + .py_frame + .as_ref() + .is_some_and(|s| Rc::ptr_eq(s, py_frame)) + { + internal += 1; + } + if Rc::strong_count(py_frame) > internal { + py_frame.take_ownership_of_locals(); + } else { + py_frame.invalidate_locals(); + } } else { + // Suspending (yield) or exiting on an exception: bring any + // handed-out materialised `f_locals` dict up to date *now*. + // PEP 667 reads track execution live, so a handle captured + // early in a generator body must show assignments made + // before the yield the moment `next()` returns — + // `test_generators.test_frame_locals_outlive_generator` + // reads `frame_locals1['a']` without ever resuming again. py_frame.invalidate_locals(); } - } else { - // Suspending (yield) or exiting on an exception: bring any - // handed-out materialised `f_locals` dict up to date *now*. - // PEP 667 reads track execution live, so a handle captured - // early in a generator body must show assignments made - // before the yield the moment `next()` returns — - // `test_generators.test_frame_locals_outlive_generator` - // reads `frame_locals1['a']` without ever resuming again. - py_frame.invalidate_locals(); } // Reconcile the interpreter-wide handled-exception stack. When // control leaves an `except` / `finally` block *early* — a @@ -4432,45 +4563,76 @@ impl Interpreter { result } - /// Build a [`PyFrame`] snapshot for `frame` and push it onto the - /// interpreter's call stack. The snapshot's `back` chain points - /// at whatever was on top of the stack before the push, so the - /// call hierarchy is recoverable from any frame. - fn push_py_frame(&self, frame: &mut Frame) -> Rc { - // Generator-family frames are re-entered on every resume. - // Reuse the cached `PyFrame` so the Python-visible frame keeps - // a stable identity across suspensions (CPython's `gi_frame`), - // only refreshing the bits that change per resume: the `back` - // pointer (who's resuming us now) and `lasti`. The locals - // mirror is shared by reference and kept current by - // `sync_py_locals`, so we just drop the materialised cache. - if let Some(existing) = frame.py_frame.clone() { - let back = self.frame_stack.borrow().last().cloned(); - *existing.back.borrow_mut() = back; + /// Push a cheap [`FrameShell`](crate::object::FrameShell) spine + /// entry for `frame` (RFC 0058). + /// The Python-visible `PyFrame` is only materialised on demand; + /// a generator resume whose frame was already materialised + /// (stable `gi_frame` identity) re-pushes the cached object with + /// refreshed `lasti` — its `back` link is refreshed lazily by the + /// next materialisation walk. + fn push_frame_shell(&self, frame: &mut Frame) -> Rc { + let is_gen = + frame.code.is_generator || frame.code.is_coroutine || frame.code.is_async_generator; + let materialized = frame.py_frame.clone(); + if let Some(existing) = &materialized { existing.lasti.set(frame.pc); existing.invalidate_locals(); existing.on_stack.set(existing.on_stack.get() + 1); - self.frame_stack.borrow_mut().push(existing.clone()); - return existing; - } - let back = self.frame_stack.borrow().last().cloned(); - let py = self.build_py_frame(frame, back); - // Cache the snapshot on generator-family frames so the next - // resume re-pushes this very object (stable identity). Plain - // function frames run exactly once and are never re-entered, - // so caching them would only waste a clone. - if frame.code.is_generator || frame.code.is_coroutine || frame.code.is_async_generator { - frame.py_frame = Some(py.clone()); } + let shell = Rc::new(crate::object::FrameShell { + code: frame.code.clone(), + locals: frame.locals.clone(), + cells: frame.cells.clone(), + globals: frame.globals.clone(), + builtins: frame.builtins.clone(), + class_namespace: frame.class_namespace.clone(), + class_namespace_obj: frame.class_namespace_obj.clone(), + is_gen, + gen_owner: RefCell::new( + materialized + .as_ref() + .and_then(|py| py.gen_owner.borrow().clone()) + .or_else(|| frame.gen_owner.clone()), + ), + lasti: std::sync::atomic::AtomicU32::new(frame.pc), + has_materialized: std::sync::atomic::AtomicBool::new(materialized.is_some()), + materialized: RefCell::new(materialized), + }); + self.frame_stack.borrow_mut().push(shell.clone()); + shell + } + + /// Push an already-materialised `PyFrame` (event dispatch around + /// generator throw/unwind) onto the spine. + fn push_materialized_frame(&self, py: &Rc) { py.on_stack.set(py.on_stack.get() + 1); - self.frame_stack.borrow_mut().push(py.clone()); + let shell = Rc::new(crate::object::FrameShell::from_py_frame(py)); + self.frame_stack.borrow_mut().push(shell); + } + + /// Materialise (or fetch) the Python-visible frame for the top of + /// this interpreter's spine, linking the `back` chain below it. + pub(crate) fn materialize_top_py_frame(&self) -> Option> { + crate::object::materialize_stack_top(&self.frame_stack) + } + + /// Dispatch-loop helper: materialise the current activation's + /// `PyFrame` once and cache it in the loop-local slot. + fn ensure_top_py_frame(&self, slot: &mut Option>) -> Rc { + if let Some(py) = slot { + return py.clone(); + } + let py = self + .materialize_top_py_frame() + .expect("frame shell was pushed for this activation"); + *slot = Some(py.clone()); py } /// Construct a [`PyFrame`] snapshot for `frame` with the given /// `back` pointer, *without* touching the interpreter's call stack - /// or caching it on the frame. [`Self::push_py_frame`] uses this for - /// live frames (passing the current stack top as `back`); generator + /// or caching it on the frame. Shell materialisation covers live + /// frames (linking the current stack top as `back`); generator /// introspection (`gi_frame`/`cr_frame`/`ag_frame`) uses it to /// materialise the frame of a not-yet-started generator on demand, /// where `back` is `None` (a suspended/created generator frame has @@ -4478,106 +4640,28 @@ impl Interpreter { fn build_py_frame(&self, frame: &Frame, back: Option>) -> Rc { // Share the live locals storage (RFC 0047): `f_locals` reads see // the current values with no per-instruction mirroring and no - // second strong clone per local (getrefcount parity). - let locals_snapshot = frame.locals.clone(); - // Everything the provider needs is captured by cheap `Rc` bumps — - // the variable/cell *names* are read through the code object at - // materialisation time, never copied per call. (This closure is - // built for every Python frame push but invoked only on an actual - // `locals()` / `f_locals` access, so its construction must stay - // allocation-light: one `Rc` for the closure itself plus the - // cells vec clone, which is empty for closure-free functions.) - let code_for_provider = frame.code.clone(); - let cells_snapshot: Vec>> = frame.cells.clone(); - let globals = frame.globals.clone(); - let class_ns = frame.class_namespace.clone(); - let class_ns_obj = frame.class_namespace_obj.clone(); - let snapshot_for_provider = locals_snapshot.clone(); - // At module / exec scope CPython makes `locals() is globals()`. - // We detect the module body by its conventional code name so a - // top-level `locals()` / `dir()` reflects the module namespace - // instead of an (empty) function-style snapshot. - let is_module_scope = frame.code.name == ""; - let globals_for_provider = globals.clone(); - let provider: Rc Object + Send + Sync> = Rc::new(move || { - let snapshot = snapshot_for_provider.borrow(); - // For module / class bodies the user-visible locals are - // the corresponding namespace dict (class_ns when set, - // otherwise globals). - // PEP 3115 custom class namespace (e.g. `enum.EnumDict`): - // `locals()`/`vars()` in the class body hand back the live - // mapping object itself, exactly as CPython does. - if let Some(ns_obj) = class_ns_obj.as_ref() { - return ns_obj.clone(); - } - if let Some(ns) = class_ns.as_ref() { - return Object::Dict(ns.clone()); - } - if is_module_scope { - return Object::Dict(globals_for_provider.clone()); - } - let varnames = &code_for_provider.varnames; - let cell_names: Vec<&String> = code_for_provider - .cellvars - .iter() - .chain(code_for_provider.freevars.iter()) - .collect(); - // Function frames: copy the locals array into a dict so - // user code can read by name. We honour cell variables - // (their value lives in the cell, not the local slot). - let mut d = DictData::default(); - for (name, value) in varnames.iter().zip(snapshot.iter()) { - // Compiler-synthesized temporaries (`.retval0`, - // `.eg_remaining0`, …) are implementation detail — - // CPython keeps its equivalents on the value stack, so - // they never appear in `f_locals`. - if name.starts_with('.') { - continue; - } - if matches!(value, Object::Unbound) { - if let Some(idx) = cell_names.iter().position(|c| *c == name) { - if let Some(cell) = cells_snapshot.get(idx) { - let v = cell.borrow().clone(); - if !matches!(v, Object::Unbound) { - d.insert(DictKey(Object::from_str(name.clone())), v); - } - continue; - } - } - } - // Unbound slots (never assigned, or `del`eted) are - // absent from `f_locals`; a local that *is* bound to - // `None` stays visible (NameError suggestions rely on - // this distinction). - if !matches!(value, Object::Unbound) { - d.insert(DictKey(Object::from_str(name.clone())), value.clone()); - } - } - // Cellvars not present in varnames (e.g. `__class__`). - for (i, name) in cell_names.iter().enumerate() { - if varnames.iter().any(|v| v == *name) { - continue; - } - if let Some(cell) = cells_snapshot.get(i) { - let v = cell.borrow().clone(); - if !matches!(v, Object::Unbound) { - d.insert(DictKey(Object::from_str((*name).clone())), v); - } - } - } - Object::Dict(Rc::new(RefCell::new(d))) - }); + // second strong clone per local (getrefcount parity). The + // locals-dict computation lives in `PyFrame::compute_locals` + // (RFC 0058) reading these plain fields — no provider closure. Rc::new(PyFrame { code: frame.code.clone(), - globals, + globals: frame.globals.clone(), builtins: frame.builtins.clone(), lasti: Cell::new(frame.pc), back: RefCell::new(back), locals_cache: RefCell::new(None), - locals_provider: RefCell::new(Some(provider)), - locals_mirror: RefCell::new(Some(locals_snapshot)), + cells: frame.cells.clone(), + class_namespace: frame.class_namespace.clone(), + class_namespace_obj: frame.class_namespace_obj.clone(), + // At module / exec scope CPython makes `locals() is + // globals()`. We detect the module body by its + // conventional code name so a top-level `locals()` / + // `dir()` reflects the module namespace instead of an + // (empty) function-style snapshot. + is_module_scope: frame.code.name == "", + locals_mirror: RefCell::new(Some(frame.locals.clone())), trace: RefCell::new(Object::None), - gen_owner: RefCell::new(None), + gen_owner: RefCell::new(frame.gen_owner.clone()), override_lineno: Cell::new(None), trace_event: Cell::new(crate::linejump::TraceEvent::None), pending_jump: Cell::new(None), @@ -4681,7 +4765,14 @@ impl Interpreter { // thread's live locals (multiprocessing's QueueFeederThread // `_feed` frame, cleared by unittest's `clear_frames` sweep in // test_concurrent_futures). - if py.on_stack.get() > 0 || self.frame_stack.borrow().iter().any(|f| Rc::ptr_eq(f, py)) { + let on_this_stack = self.frame_stack.borrow().iter().any(|shell| { + shell + .materialized + .borrow() + .as_ref() + .is_some_and(|f| Rc::ptr_eq(f, py)) + }); + if py.on_stack.get() > 0 || on_this_stack { return Err(crate::error::runtime_error( "cannot clear an executing frame", )); @@ -4779,32 +4870,34 @@ impl Interpreter { // (see `build_py_frame`), so there is nothing to copy; only the // materialised `f_locals` dict (if any) needs refreshing so a // previously handed-out handle observes the latest mutations. + // A never-materialised shell has no handed-out view to refresh. let _ = frame; - if let Some(py) = self.frame_stack.borrow().last() { - py.invalidate_locals(); + if let Some(shell) = self.frame_stack.borrow().last() { + if let Some(py) = shell.materialized.borrow().as_ref() { + py.invalidate_locals(); + } } } - fn pop_py_frame(&self) { + fn pop_frame_shell(&self) -> Option> { let popped = self.frame_stack.borrow_mut().pop(); - if let Some(popped) = &popped { - popped.on_stack.set(popped.on_stack.get().saturating_sub(1)); - } - // A generator-family frame that just suspended (yielded) or - // finished is no longer reachable from a live caller. CPython - // reports `gi_frame.f_back is None` whenever the generator is not - // currently executing, so drop the resumer link we set on entry. - // Ordinary function frames keep their `back` (tracebacks chain - // through it); only generator-family frames are re-entered and - // observed while suspended. - if let Some(popped) = popped { - if popped.code.is_generator - || popped.code.is_coroutine - || popped.code.is_async_generator - { - *popped.back.borrow_mut() = None; + if let Some(shell) = &popped { + if let Some(py) = shell.materialized.borrow().as_ref() { + py.on_stack.set(py.on_stack.get().saturating_sub(1)); + // A generator-family frame that just suspended (yielded) + // or finished is no longer reachable from a live caller. + // CPython reports `gi_frame.f_back is None` whenever the + // generator is not currently executing, so drop the + // resumer link. Ordinary function frames keep their + // `back` (tracebacks chain through it); only + // generator-family frames are re-entered and observed + // while suspended. + if shell.is_gen { + *py.back.borrow_mut() = None; + } } } + popped } /// The code object backing a generator/coroutine/async-generator @@ -4862,13 +4955,16 @@ impl Interpreter { // `Task.get_stack()` reads from inside the running coroutine // (RFC 0054 WS2, test_tasks.test_get_stack). GeneratorState::Running => { - for py in self.frame_stack.borrow().iter().rev() { - let owner = py.gen_owner.borrow().as_ref().and_then(|w| w.upgrade()); - if owner.is_some_and(|o| Rc::ptr_eq(&o, g)) { - return Object::Frame(py.clone()); - } + let found_idx = self.frame_stack.borrow().iter().rposition(|shell| { + let owner = shell.gen_owner.borrow().as_ref().and_then(|w| w.upgrade()); + owner.is_some_and(|o| Rc::ptr_eq(&o, g)) + }); + match found_idx + .and_then(|idx| crate::object::materialize_stack_at(&self.frame_stack, idx)) + { + Some(py) => Object::Frame(py), + None => Object::None, } - Object::None } // Finished: the frame has been dropped. GeneratorState::Finished => Object::None, @@ -5212,8 +5308,7 @@ impl Interpreter { if !crate::trace::any_observers_active() { return Ok(()); } - let py_frame = self.frame_stack.borrow().last().cloned(); - if let Some(py_frame) = py_frame { + if let Some(py_frame) = self.materialize_top_py_frame() { self.fire_exception_event(&py_frame, exc)?; } Ok(()) @@ -5680,6 +5775,9 @@ impl Interpreter { self.delete_attr(&obj, &name)?; } OpCode::BinarySubscr => { + if self.specialized_binary_subscr(frame, cache_pc)? { + return Ok(StepOutcome::Continue); + } let i = frame.pop()?; let v = frame.pop()?; let r = if let Object::Instance(inst) = &v { @@ -5836,6 +5934,9 @@ impl Interpreter { } } OpCode::StoreSubscr => { + if self.specialized_store_subscr(frame, cache_pc)? { + return Ok(StepOutcome::Continue); + } let i = frame.pop()?; let target = frame.pop()?; let value = frame.pop()?; @@ -7357,7 +7458,7 @@ impl Interpreter { if self.exception_matches(&exc, &ty)? { let wrapper = crate::builtin_types::make_naked_eg_wrapper(&exc); if let (Object::Instance(inst), Some(py_frame)) = - (&wrapper, self.frame_stack.borrow().last().cloned()) + (&wrapper, self.materialize_top_py_frame()) { let lineno = frame .code @@ -7547,8 +7648,13 @@ impl Interpreter { // here, as RERAISE may have set it"); redirecting the pc // would re-enter the same cleanup handler forever. if let Some(orig) = frame.cleanup_lasti.take() { - if let Some(py_frame) = self.frame_stack.borrow().last() { - py_frame.lasti.set(orig); + if let Some(shell) = self.frame_stack.borrow().last() { + shell + .lasti + .store(orig, std::sync::atomic::Ordering::Relaxed); + if let Some(py_frame) = shell.materialized.borrow().as_ref() { + py_frame.lasti.set(orig); + } } } return Err(RuntimeError::PyException(pe)); @@ -8139,7 +8245,7 @@ impl Interpreter { /// for the cheap `RuntimeError` Display impl) and the new /// `PyTraceback` chain stored on the instance dict so Python code /// can walk `exc.__traceback__`. - fn append_traceback(&self, exc: &mut PyException, frame: &Frame, lasti: u32, lineno: u32) { + fn append_traceback(&self, exc: &mut PyException, frame: &mut Frame, lasti: u32, lineno: u32) { exc.push_traceback(TracebackEntry { filename: frame.code.filename.clone(), funcname: frame.code.name.clone(), @@ -8158,35 +8264,37 @@ impl Interpreter { .py_frame .clone() .or_else(|| { - self.frame_stack + // Materialise the current activation's shell when it is + // the stack top (the common raise path) — the traceback + // must reference *this* frame's snapshot. Activation + // identity is the shared locals storage, which is exact + // even under recursion (the code object is shared). + let is_top = self + .frame_stack .borrow() .last() - .filter(|top| Rc::ptr_eq(&top.code, &frame.code)) - .cloned() + .is_some_and(|top| Rc::ptr_eq(&top.locals, &frame.locals)); + if is_top { + self.materialize_top_py_frame() + } else { + None + } }) .unwrap_or_else(|| { - // Fall back to a synthetic snapshot when neither source - // matches (e.g. a not-yet-entered generator frame, or an - // empty stack) so the chain stays non-empty. - Rc::new(PyFrame { - code: frame.code.clone(), - globals: frame.globals.clone(), - builtins: frame.builtins.clone(), - lasti: Cell::new(lasti), - back: RefCell::new(None), - locals_cache: RefCell::new(None), - locals_provider: RefCell::new(None), - locals_mirror: RefCell::new(None), - trace: RefCell::new(Object::None), - gen_owner: RefCell::new(None), - override_lineno: Cell::new(None), - trace_event: Cell::new(crate::linejump::TraceEvent::None), - pending_jump: Cell::new(None), - last_line: Cell::new(None), - trace_lines: Cell::new(true), - trace_opcodes: Cell::new(false), - on_stack: Cell::new(0), - }) + // Fall back to a fresh snapshot when neither source + // matches (e.g. a not-yet-entered generator frame + // receiving a throw, or an empty stack) so the chain + // stays non-empty. Cache it on generator-family frames + // so `gi_frame` keeps a stable identity. + let py = self.build_py_frame(frame, None); + py.lasti.set(lasti); + if frame.code.is_generator + || frame.code.is_coroutine + || frame.code.is_async_generator + { + frame.py_frame = Some(py.clone()); + } + py }); let new_tb = Rc::new(PyTraceback { frame: py_frame, @@ -8230,7 +8338,7 @@ impl Interpreter { return; } } - let Some(py_frame) = self.frame_stack.borrow().last().cloned() else { + let Some(py_frame) = self.materialize_top_py_frame() else { return; }; let new_tb = Rc::new(PyTraceback { @@ -17390,16 +17498,12 @@ impl Interpreter { // stack for the duration of the inner throw: CPython // re-enters the outer generator frame, so code in the // inner generator sees `f_back` chain through it - // (`check_stack_names` expects ['f', 'g']). - let pushed_outer = frame.py_frame.clone().inspect(|py| { - *py.back.borrow_mut() = self.frame_stack.borrow().last().cloned(); - py.on_stack.set(py.on_stack.get() + 1); - self.frame_stack.borrow_mut().push(py.clone()); - }); + // (`check_stack_names` expects ['f', 'g']). A cheap shell + // suffices — materialisation (and `back` linking) happens + // lazily if anything introspects the stack (RFC 0058). + self.push_frame_shell(&mut frame); let inner_result = self.throw_into_subiter(&sub_iter, exc.clone()); - if pushed_outer.is_some() { - self.pop_py_frame(); - } + self.pop_frame_shell(); match inner_result { Ok(v) => { // Inner yielded: re-suspend the outer at the @@ -17469,30 +17573,34 @@ impl Interpreter { // frame-entry call event (it would see the handler's no-line // `PUSH_EXC_INFO` and report `f_lineno = None`). if crate::trace::any_observers_active() { - if let Some(py) = frame.py_frame.clone() { - py.lasti.set(frame.pc); - let line = frame - .code - .linetable - .get(frame.pc as usize) - .copied() - .unwrap_or(0); - if line != 0 { - py.last_line.set(Some(line)); - } - *py.back.borrow_mut() = self.frame_stack.borrow().last().cloned(); - py.on_stack.set(py.on_stack.get() + 1); - self.frame_stack.borrow_mut().push(py.clone()); - let hook_result = self - .fire_call_event(&py) - .and_then(|()| self.fire_exception_event(&py, &exc)); - self.pop_py_frame(); - if let Err(err) = hook_result { - *gen.state.borrow_mut() = GeneratorState::Finished; - return Err(err); - } - frame.suppress_call_event = true; + let py = frame.py_frame.clone().unwrap_or_else(|| { + // Tracing needs the real frame object; build and cache + // it so `gi_frame` keeps a stable identity (RFC 0058). + let py = self.build_py_frame(&frame, None); + frame.py_frame = Some(py.clone()); + py + }); + py.lasti.set(frame.pc); + let line = frame + .code + .linetable + .get(frame.pc as usize) + .copied() + .unwrap_or(0); + if line != 0 { + py.last_line.set(Some(line)); + } + *py.back.borrow_mut() = self.materialize_top_py_frame(); + self.push_materialized_frame(&py); + let hook_result = self + .fire_call_event(&py) + .and_then(|()| self.fire_exception_event(&py, &exc)); + self.pop_frame_shell(); + if let Err(err) = hook_result { + *gen.state.borrow_mut() = GeneratorState::Finished; + return Err(err); } + frame.suppress_call_event = true; } // Let the suspended frame handle the exception via its own @@ -17529,14 +17637,16 @@ impl Interpreter { // (test_cprofile.test_throw). *gen.state.borrow_mut() = GeneratorState::Finished; if crate::trace::any_observers_active() { - if let Some(py) = frame.py_frame.clone() { - *py.back.borrow_mut() = self.frame_stack.borrow().last().cloned(); - py.on_stack.set(py.on_stack.get() + 1); - self.frame_stack.borrow_mut().push(py.clone()); - let hook_result = self.fire_unwind_event(&py); - self.pop_py_frame(); - hook_result?; - } + let py = frame.py_frame.clone().unwrap_or_else(|| { + let py = self.build_py_frame(&frame, None); + frame.py_frame = Some(py.clone()); + py + }); + *py.back.borrow_mut() = self.materialize_top_py_frame(); + self.push_materialized_frame(&py); + let hook_result = self.fire_unwind_event(&py); + self.pop_frame_shell(); + hook_result?; } Err(self.pep479_escape(gen, err)) } @@ -19367,6 +19477,82 @@ impl Interpreter { specialize::record_hit(op_idx); Ok(true) } + IC::BinOpDivInt | IC::BinOpFloorDivInt | IC::BinOpModInt | IC::BinOpPowInt => { + let matches_kind = matches!( + (cache, kind), + (IC::BinOpDivInt, BinOpKind::Div) + | (IC::BinOpFloorDivInt, BinOpKind::FloorDiv) + | (IC::BinOpModInt, BinOpKind::Mod) + | (IC::BinOpPowInt, BinOpKind::Pow) + ); + if !matches_kind { + return self.deopt_binary_op(frame, cache_pc); + } + let (a, b) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::Int(x)), Some(Object::Int(y))) => (*x, *y), + _ => return self.deopt_binary_op(frame, cache_pc), + }; + // `i64_op` is the same primitive the generic path runs, so + // error semantics (ZeroDivisionError messages, `0 ** -n`) + // match exactly; `None` (result needs the bignum path) + // deopts to the generic handler. + let len = frame.stack.len(); + let r = match i64_op(a, b, kind) { + Ok(Some(r)) => r, + Ok(None) => return self.deopt_binary_op(frame, cache_pc), + Err(e) => { + frame.stack.truncate(len - 2); + return Err(e); + } + }; + frame.stack.truncate(len - 2); + frame.push(r); + specialize::record_hit(op_idx); + Ok(true) + } + IC::BinOpDivFloat | IC::BinOpFloorDivFloat | IC::BinOpModFloat | IC::BinOpPowFloat => { + let matches_kind = matches!( + (cache, kind), + (IC::BinOpDivFloat, BinOpKind::Div) + | (IC::BinOpFloorDivFloat, BinOpKind::FloorDiv) + | (IC::BinOpModFloat, BinOpKind::Mod) + | (IC::BinOpPowFloat, BinOpKind::Pow) + ); + if !matches_kind { + return self.deopt_binary_op(frame, cache_pc); + } + let (a, b) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::Float(x)), Some(Object::Float(y))) => (*x, *y), + _ => return self.deopt_binary_op(frame, cache_pc), + }; + // Same helpers as the generic `binary_op` float arms. + let res = match kind { + BinOpKind::Div => { + if b == 0.0 { + Err(zero_division_error("float division by zero")) + } else { + Ok(crate::object::fresh_float(a / b)) + } + } + BinOpKind::FloorDiv => py_float_divmod(a, b, "float floor division by zero") + .map(|(q, _)| crate::object::fresh_float(q)), + BinOpKind::Mod => py_float_mod(a, b).map(crate::object::fresh_float), + BinOpKind::Pow => float_pow(a, b), + _ => unreachable!("kind checked against cache above"), + }; + let len = frame.stack.len(); + let r = match res { + Ok(r) => r, + Err(e) => { + frame.stack.truncate(len - 2); + return Err(e); + } + }; + frame.stack.truncate(len - 2); + frame.push(r); + specialize::record_hit(op_idx); + Ok(true) + } IC::BinOpAddFloat | IC::BinOpSubFloat | IC::BinOpMulFloat => { let (a, b) = match (frame.peek_back(1), frame.peek_back(0)) { (Some(Object::Float(x)), Some(Object::Float(y))) => (*x, *y), @@ -19519,6 +19705,229 @@ impl Interpreter { Ok(false) } + /// Run the `BINARY_SUBSCR` cache machinery (RFC 0058 WS3). Returns + /// `Ok(true)` if a fast path consumed both operands and pushed the + /// result, `Ok(false)` if the caller should run the generic handler + /// (operands untouched on the stack), or an error raised with + /// CPython-identical semantics from inside a fast path. + /// + /// The container's enum variant is the guard — no fingerprint is + /// stored, so a type change at the site deopts on the next hit. The + /// raising paths (out-of-range index, missing dict key) mirror the + /// generic arm exactly: operands leave the stack first, then the + /// same error constructor runs, so a hot loop that legitimately + /// raises once doesn't thrash the cache through a deopt cycle. + fn specialized_binary_subscr( + &mut self, + frame: &mut Frame, + cache_pc: u32, + ) -> Result { + use weavepy_compiler::InlineCache as IC; + let cache = frame.code.caches.get(cache_pc); + let op_idx = OpCode::BinarySubscr as u8; + match cache { + IC::Empty => { + specialize::record_specialize_attempt(op_idx); + let decision = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(c), Some(i)) => specialize::attempt_specialize_binary_subscr(c, i), + _ => return Ok(false), + }; + frame.code.caches.set(cache_pc, decision); + if matches!(decision, IC::Cooldown(_)) { + specialize::record_specialize_skip(op_idx); + return Ok(false); + } + specialize::record_specialize_success(op_idx); + self.specialized_binary_subscr(frame, cache_pc) + } + IC::SubscrListInt => { + let (items, idx) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::List(xs)), Some(Object::Int(i))) => (xs.clone(), *i), + _ => return self.deopt_binary_subscr(frame, cache_pc), + }; + let len = frame.stack.len(); + let r = { + let xs = items.borrow(); + match normalize_index_msg(idx, xs.len(), "list index out of range") { + Ok(i) => xs[i].clone(), + Err(e) => { + drop(xs); + frame.stack.truncate(len - 2); + return Err(e); + } + } + }; + frame.stack.truncate(len - 2); + frame.push(r); + specialize::record_hit(op_idx); + Ok(true) + } + IC::SubscrTupleInt => { + let (items, idx) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::Tuple(t)), Some(Object::Int(i))) => (t.clone(), *i), + _ => return self.deopt_binary_subscr(frame, cache_pc), + }; + let len = frame.stack.len(); + let r = match normalize_index_msg(idx, items.len(), "tuple index out of range") { + Ok(i) => items[i].clone(), + Err(e) => { + frame.stack.truncate(len - 2); + return Err(e); + } + }; + frame.stack.truncate(len - 2); + frame.push(r); + specialize::record_hit(op_idx); + Ok(true) + } + IC::SubscrStrInt => { + let (s, idx) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::Str(s)), Some(Object::Int(i))) => (s.clone(), *i), + _ => return self.deopt_binary_subscr(frame, cache_pc), + }; + // Re-verify the ASCII property (code-point count == + // byte count) — the slot outlives any one receiver. + if crate::object::str_char_len(&s) != s.len() { + return self.deopt_binary_subscr(frame, cache_pc); + } + let len = frame.stack.len(); + let r = match normalize_index_msg(idx, s.len(), "string index out of range") { + Ok(i) => Object::from_str((s.as_bytes()[i] as char).to_string()), + Err(e) => { + frame.stack.truncate(len - 2); + return Err(e); + } + }; + frame.stack.truncate(len - 2); + frame.push(r); + specialize::record_hit(op_idx); + Ok(true) + } + IC::SubscrDict => { + let (d, key) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::Dict(d)), Some(k)) => (d.clone(), k.clone()), + _ => return self.deopt_binary_subscr(frame, cache_pc), + }; + // Operands leave the stack before the lookup / error + // construction, matching the generic arm — a user + // `__eq__` / `__repr__` on the key can re-enter the VM. + let len = frame.stack.len(); + frame.stack.truncate(len - 2); + crate::builtins::ensure_hashable(&key)?; + let found = crate::builtins::dict_lookup(&d, &key)?; + let r = found.ok_or_else(|| key_error_object(key.clone()))?; + frame.push(r); + specialize::record_hit(op_idx); + Ok(true) + } + IC::Cooldown(n) => { + let next = if n > 0 { + IC::Cooldown(n - 1) + } else { + IC::Empty + }; + frame.code.caches.set(cache_pc, next); + Ok(false) + } + _ => Ok(false), + } + } + + /// Deopt a `BINARY_SUBSCR` cache. + #[inline] + fn deopt_binary_subscr(&self, frame: &Frame, cache_pc: u32) -> Result { + specialize::record_miss(OpCode::BinarySubscr as u8); + frame + .code + .caches + .set(cache_pc, weavepy_compiler::InlineCache::Cooldown(COOLDOWN)); + Ok(false) + } + + /// Run the `STORE_SUBSCR` cache machinery (RFC 0058 WS3). Same + /// contract as [`Self::specialized_binary_subscr`], for the write + /// side. Stack on entry: `… value, target, index` (index on top). + fn specialized_store_subscr( + &mut self, + frame: &mut Frame, + cache_pc: u32, + ) -> Result { + use weavepy_compiler::InlineCache as IC; + let cache = frame.code.caches.get(cache_pc); + let op_idx = OpCode::StoreSubscr as u8; + match cache { + IC::Empty => { + specialize::record_specialize_attempt(op_idx); + let decision = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(t), Some(i)) => specialize::attempt_specialize_store_subscr(t, i), + _ => return Ok(false), + }; + frame.code.caches.set(cache_pc, decision); + if matches!(decision, IC::Cooldown(_)) { + specialize::record_specialize_skip(op_idx); + return Ok(false); + } + specialize::record_specialize_success(op_idx); + self.specialized_store_subscr(frame, cache_pc) + } + IC::StoreSubscrListInt => { + let (items, idx) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::List(xs)), Some(Object::Int(i))) => (xs.clone(), *i), + _ => return self.deopt_store_subscr(frame, cache_pc), + }; + let len = frame.stack.len(); + frame.stack.truncate(len - 2); + let value = frame.pop()?; + let evicted = { + let mut xs = items.borrow_mut(); + let i = + normalize_index_msg(idx, xs.len(), "list assignment index out of range")?; + std::mem::replace(&mut xs[i], value) + }; + crate::vm_singletons::queue_container_removed(&evicted); + specialize::record_hit(op_idx); + Ok(true) + } + IC::StoreSubscrDict => { + let (d, key) = match (frame.peek_back(1), frame.peek_back(0)) { + (Some(Object::Dict(d)), Some(k)) => (d.clone(), k.clone()), + _ => return self.deopt_store_subscr(frame, cache_pc), + }; + let len = frame.stack.len(); + frame.stack.truncate(len - 2); + let value = frame.pop()?; + crate::builtins::ensure_hashable(&key)?; + let old = crate::builtins::dict_insert(&d, key, value)?; + if let Some(old) = old { + crate::vm_singletons::queue_container_removed(&old); + } + specialize::record_hit(op_idx); + Ok(true) + } + IC::Cooldown(n) => { + let next = if n > 0 { + IC::Cooldown(n - 1) + } else { + IC::Empty + }; + frame.code.caches.set(cache_pc, next); + Ok(false) + } + _ => Ok(false), + } + } + + /// Deopt a `STORE_SUBSCR` cache. + #[inline] + fn deopt_store_subscr(&self, frame: &Frame, cache_pc: u32) -> Result { + specialize::record_miss(OpCode::StoreSubscr as u8); + frame + .code + .caches + .set(cache_pc, weavepy_compiler::InlineCache::Cooldown(COOLDOWN)); + Ok(false) + } + /// Specialized `LOAD_GLOBAL`. On a warm cache, looks up the /// value by integer slot in the appropriate dict (skipping the /// hash-keyed lookup). On `Empty` cache, performs the regular @@ -20222,6 +20631,34 @@ impl Interpreter { self.deopt_for_iter(frame, cache_pc); Ok(false) } + IC::ForIterStr | IC::ForIterDict => { + let mut it = it_handle.borrow_mut(); + let guard_ok = matches!( + (cache, &*it), + (IC::ForIterStr, crate::object::PyIterator::Str { .. }) + | (IC::ForIterDict, crate::object::PyIterator::DictKeys { .. }) + ); + if !guard_ok { + drop(it); + self.deopt_for_iter(frame, cache_pc); + return Ok(false); + } + // Same checked step as the generic `Object::Iter` arm — + // the dict size/keys-changed guards live inside + // `next_value_checked`, so mutation-during-iteration + // errors propagate identically. + let next = it.next_value_checked()?; + drop(it); + match next { + Some(v) => frame.push(v), + None => { + frame.pop()?; + frame.pc += jump_arg; + } + } + specialize::record_hit(op_idx); + Ok(true) + } IC::Empty => { let receiver = frame.stack.last().cloned().unwrap_or(Object::None); specialize::record_specialize_attempt(op_idx); @@ -20807,9 +21244,24 @@ impl Interpreter { // ambient lookup namespace (RFC 0052 WS5), so no // mirroring is needed: this plain insert is immediately // visible to every frame's name resolution. - m.dict + let old = m + .dict .borrow_mut() .insert(DictKey(Object::from_str(name)), value); + // CPython decrefs the displaced attribute at store time + // (`module_setattro` → dict insert), and a module attr + // store is just a global rebind spelled from outside, so + // mirror the `StoreGlobal` reap. The motivating shape: + // `catch_warnings.__exit__` restoring + // `warnings._showwarnmsg_impl` displaces the recording + // `log.append` bound method — the last reference to the + // record log and every `WarningMessage` on it, including + // finalizer-emitted `source=` objects that must die (and + // close their files) the instant the log does + // (test_tempfile.test_warnings_on_cleanup). + if let Some(old) = old { + self.maybe_prompt_reap_replaced(old); + } Ok(()) } Object::Function(f) => { @@ -21517,9 +21969,11 @@ impl Interpreter { let removed = m .dict .borrow_mut() - .shift_remove(&DictKey(Object::from_str(name))) - .is_some(); - if removed { + .shift_remove(&DictKey(Object::from_str(name))); + if let Some(old) = removed { + // Same decref-at-removal semantics as the store arm + // above / `DeleteGlobal`. + self.maybe_prompt_reap_replaced(old); Ok(()) } else { Err(attribute_error(format!( @@ -22701,99 +23155,6 @@ impl Interpreter { kwargs: &[(String, Object)], outer_globals: &Rc>, ) -> Result { - /// Names handled by the interpreter-aware dispatch chain in the - /// `Object::Builtin` arm. Single `match` (length + memcmp) so - /// the hot native-method path doesn't walk ~50 comparisons. - #[inline] - fn builtin_needs_interp(name: &str) -> bool { - name.starts_with("__vm:") - || name.starts_with(".u.") - || name == builtins::BUILD_CLASS_NAME - || matches!( - name, - ".format" - | ".format_map" - | ".gc.collect" - | ".gc.get_objects" - | ".gc.get_referrers" - | ".object_getattribute" - | ".object_reduce" - | ".object_reduce_ex" - | "__new__" - | "abs" - | "aiter" - | "all" - | "anext" - | "any" - | "ascii" - | "bool" - | "breakpoint" - | "complex" - | "delattr" - | "dict" - | "dir" - | "divmod" - | "extend" - | "filter" - | "float" - | "format" - | "getattr" - | "globals" - | "hasattr" - | "hash" - | "int" - | "isinstance" - | "issubclass" - | "iter" - | "len" - | "list" - | "locals" - | "map" - | "max" - | "memoryview" - | "min" - | "next" - | "pow" - | "print" - | "repr" - | "reversed" - | "round" - | "setattr" - | "sort" - | "sorted" - | "str" - | "sum" - | "tuple" - | "update" - // `dict.__init__` merges positional + keyword args - // like `update` (routed below); other `__init__` - // builtins fall through unchanged. - | "__init__" - | "vars" - | "zip" - | "open" - | "fspath" - | "fsdecode" - | "fsencode" - | "join" - | "fromkeys" - | "union" - | "intersection" - | "difference" - | "symmetric_difference" - | "intersection_update" - | "difference_update" - | "symmetric_difference_update" - | "issubset" - | "issuperset" - | "isdisjoint" - | "writelines" - | "enumerate" - | "prod" - | "getsizeof" - | "from_bytes" - ) - } let _ = outer_globals; match callable { Object::Builtin(b) => { @@ -23192,7 +23553,7 @@ impl Interpreter { // exec scope this *is* the module dict; in // function scope it's a fresh dict snapshot of // the locals (PEP 667). - if let Some(top) = self.frame_stack.borrow().last() { + if let Some(top) = self.materialize_top_py_frame() { return Ok(top.locals_snapshot()); } return Ok(Object::Dict(outer_globals.clone())); @@ -23202,7 +23563,7 @@ impl Interpreter { // bound in the *current* local scope — CPython's // `sorted(locals())`. (With an argument it falls // through to the generic `b_dir` introspection.) - let locals = match self.frame_stack.borrow().last() { + let locals = match self.materialize_top_py_frame() { Some(top) => top.locals(), None => Object::Dict(outer_globals.clone()), }; @@ -27731,6 +28092,7 @@ impl Interpreter { gen_code, Box::new(frame), )); + Self::set_frame_gen_owner(&gen); if let Some(origin) = cr_origin { *gen.origin.borrow_mut() = origin; } @@ -27763,6 +28125,18 @@ impl Interpreter { } } + /// Stamp a freshly-created generator's weak backlink onto its own + /// boxed frame (RFC 0058) — the executing frame's `FrameShell` + /// inherits it so a *running* generator's `gi_frame` can be found + /// without eager `PyFrame` construction. + fn set_frame_gen_owner(gen: &Rc) { + if let GeneratorState::Created(boxed) = &mut *gen.state.borrow_mut() { + if let Some(fr) = boxed.downcast_mut::() { + fr.gen_owner = Some(Rc::downgrade(gen)); + } + } + } + /// Bootstrap a generator/coroutine *code object* frame that was not /// entered through a `PyFunction` call — `eval(co)` / `exec(co)` of /// PyCF_ALLOW_TOP_LEVEL_AWAIT module code (RFC 0052). Runs the frame @@ -27790,6 +28164,7 @@ impl Interpreter { gen_code, Box::new(frame), )); + Self::set_frame_gen_owner(&gen); let obj = if code.is_coroutine { Object::Coroutine(gen) } else if code.is_async_generator { @@ -27899,6 +28274,66 @@ impl Interpreter { // descriptor instances), so `redispatch_descriptor` doesn't // disqualify the owned path. if let Object::Function(f) = &bm.function { + // RFC 0058 WS3 — `CallBoundMethodExact`: the exact-arity + // binder skip applied to `obj.m(...)`. Consulted here + // because bound Python methods never reach the shared + // cache match below. + let cache = frame.code.caches.get(cache_pc); + match cache { + IC::CallBoundMethodExact { func_id, argc: ca } if ca as usize == argc => { + let code = f.code(); + // Same ABA / `__code__`-rebinding re-verification + // as the plain-function shapes. + if specialize::rc_id(f) == func_id + && args.len() == argc + && code.arg_count as usize == argc + 1 + && !code.is_generator + && !code.is_coroutine + && !code.is_async_generator + && !code.has_varargs + && !code.has_varkeywords + && code.kwonly_count == 0 + && code.cellvars.is_empty() + && code.freevars.is_empty() + && f.closure.is_empty() + { + specialize::record_hit(op_idx); + let f = f.clone(); + let mut combined: Vec = Vec::with_capacity(argc + 1); + combined.push(bm.receiver.clone()); + combined.append(&mut args); + drop(callable); + let r = self.run_py_exact_nofree(&f, combined)?; + frame.push(r); + return Ok(()); + } + specialize::record_miss(op_idx); + frame.code.caches.set(cache_pc, IC::Cooldown(COOLDOWN)); + } + IC::Empty => { + specialize::record_specialize_attempt(op_idx); + let decision = if args.len() == argc { + specialize::attempt_specialize_call_bound_py(f, argc) + } else { + IC::Cooldown(COOLDOWN) + }; + frame.code.caches.set(cache_pc, decision); + if matches!(decision, IC::Cooldown(_)) { + specialize::record_specialize_skip(op_idx); + } else { + specialize::record_specialize_success(op_idx); + } + } + IC::Cooldown(n) => { + let next = if n > 0 { + IC::Cooldown(n - 1) + } else { + IC::Empty + }; + frame.code.caches.set(cache_pc, next); + } + _ => {} + } let f = f.clone(); let mut combined: Vec = Vec::with_capacity(args.len() + 1); combined.push(bm.receiver.clone()); @@ -27933,6 +28368,9 @@ impl Interpreter { if specialize::rc_id(f) == func_id && args.len() == argc && code.arg_count as usize == argc + && !code.is_generator + && !code.is_coroutine + && !code.is_async_generator && !code.has_varargs && !code.has_varkeywords && code.kwonly_count == 0 @@ -27963,6 +28401,9 @@ impl Interpreter { if specialize::rc_id(f) == func_id && args.len() == argc && code.arg_count as usize == argc + && !code.is_generator + && !code.is_coroutine + && !code.is_async_generator && !code.has_varargs && !code.has_varkeywords && code.kwonly_count == 0 @@ -27979,9 +28420,113 @@ impl Interpreter { self.deopt_call_generic(frame, cache_pc, &callable, &mut args)?; } } + IC::CallPyDefaults { func_id, argc: ca } => { + let mut took_fast = false; + if ca as usize == argc { + if let Object::Function(f) = &callable { + let code = f.code(); + let total = code.arg_count as usize; + if specialize::rc_id(f) == func_id + && args.len() == argc + && argc < total + && f.defaults.len() >= total - argc + && !code.is_generator + && !code.is_coroutine + && !code.is_async_generator + && !code.has_varargs + && !code.has_varkeywords + && code.kwonly_count == 0 + && code.cellvars.is_empty() + && code.freevars.is_empty() + && f.closure.is_empty() + // A later `f.__defaults__ = …` override lives in + // the slot store and replaces the compiled tuple + // in the generic binder — deopt to honour it. + && f.slot("__defaults__").is_none() + { + specialize::record_hit(op_idx); + let f = f.clone(); + let mut full = std::mem::take(&mut args); + // Defaults align right-to-left with the + // declared positionals, exactly like the + // generic binder's missing-tail fill. + let missing = total - full.len(); + full.extend(f.defaults[f.defaults.len() - missing..].iter().cloned()); + let r = self.run_py_exact_nofree(&f, full)?; + frame.push(r); + took_fast = true; + } + } + } + if !took_fast { + self.deopt_call_generic(frame, cache_pc, &callable, &mut args)?; + } + } + IC::CallNative { func_id, argc: ca } => { + // Deopt whenever observers are active so `c_call` / + // `c_return` profile events keep firing through the + // generic (profiled) path. + let mut took_fast = false; + if ca as usize == argc && !crate::trace::any_observers_active() { + if let Object::Builtin(b) = &callable { + // Re-verify the name gate (ABA: a recycled + // allocation could be a different builtin). + if specialize::rc_id(b) == func_id + && args.len() == argc + && native_call_ic_safe(b.name) + { + specialize::record_hit(op_idx); + let r = match b.call_kw.as_ref() { + Some(ckw) => ckw(&args, &[])?, + None => (b.call)(&args)?, + }; + frame.push(r); + took_fast = true; + } + } + } + if !took_fast { + self.deopt_call_generic(frame, cache_pc, &callable, &mut args)?; + } + } + IC::CallNativeMethod { func_id, argc: ca } => { + let mut took_fast = false; + if ca as usize == argc && !crate::trace::any_observers_active() { + if let Object::BoundMethod(bm) = &callable { + if let Object::Builtin(b) = &bm.function { + if specialize::rc_id(b) == func_id + && args.len() == argc + && native_call_ic_safe(b.name) + { + specialize::record_hit(op_idx); + let mut combined: Vec = Vec::with_capacity(argc + 1); + combined.push(bm.receiver.clone()); + combined.extend(args.iter().cloned()); + let r = match b.call_kw.as_ref() { + Some(ckw) => ckw(&combined, &[])?, + None => (b.call)(&combined)?, + }; + frame.push(r); + took_fast = true; + } + } + } + } + if !took_fast { + self.deopt_call_generic(frame, cache_pc, &callable, &mut args)?; + } + } IC::Empty => { specialize::record_specialize_attempt(op_idx); - let decision = specialize::attempt_specialize_call(&callable, argc); + let decision = if args.len() == argc { + specialize::attempt_specialize_call(&callable, argc) + } else { + // Argument fixups ran (zero-arg `super()` injection): + // the opcode arity no longer matches the vector, so + // any cached shape would guard against the wrong + // count. Stay generic. + IC::Cooldown(COOLDOWN) + }; frame.code.caches.set(cache_pc, decision); if matches!(decision, IC::Cooldown(_)) { specialize::record_specialize_skip(op_idx); @@ -28071,8 +28616,7 @@ impl Interpreter { ) -> Result { if crate::trace::any_observers_active() && is_c_profiled_callable(callable) { if let Some(profile) = crate::trace::profile_hook() { - let py_frame = self.frame_stack.borrow().last().cloned(); - if let Some(py_frame) = py_frame { + if let Some(py_frame) = self.materialize_top_py_frame() { self.invoke_observe_hook( &profile, &py_frame, @@ -28113,15 +28657,18 @@ impl Interpreter { args: Vec, ) -> Result { let code = f.code(); - let mut locals = vec![Object::Unbound; code.varnames.len()]; - for (slot, v) in args.into_iter().enumerate() { - locals[slot] = v; + let locals_rc = self.pooled_locals(code.varnames.len()); + { + let mut locals = locals_rc.borrow_mut(); + for (slot, v) in args.into_iter().enumerate() { + locals[slot] = v; + } } let mut frame = Frame { code, - locals: Rc::new(RefCell::new(locals)), - cells: Vec::new(), - stack: Vec::with_capacity(16), + locals: locals_rc, + cells: crate::object::empty_cells(), + stack: self.pooled_stack(), globals: f.globals.clone(), builtins: f.builtins.clone(), class_namespace: None, @@ -28131,6 +28678,7 @@ impl Interpreter { agen_yielded_value: true, pc: 0, py_frame: None, + gen_owner: None, cleanup_lasti: None, suppress_call_event: false, }; @@ -29692,7 +30240,7 @@ impl Interpreter { // the same dict (CPython), i.e. no distinct mapping. None } else { - let caller = self.frame_stack.borrow().last().cloned(); + let caller = self.materialize_top_py_frame(); caller.and_then(|f| { f.invalidate_locals(); match f.locals() { @@ -29838,7 +30386,7 @@ impl Interpreter { // caller's frame. None } else { - let caller = self.frame_stack.borrow().last().cloned(); + let caller = self.materialize_top_py_frame(); caller.and_then(|f| { f.invalidate_locals(); match f.locals() { @@ -36759,6 +37307,115 @@ fn is_c_profiled_callable(obj: &Object) -> bool { } } +/// Names handled by the interpreter-aware dispatch chain in +/// [`Interpreter::call`]'s `Object::Builtin` arm. Single `match` +/// (length + memcmp) so the hot native-method path doesn't walk ~50 +/// comparisons. Shared with the CALL inline caches (RFC 0058 WS3), +/// whose native fast paths must never bypass this chain. +#[inline] +pub(crate) fn builtin_needs_interp(name: &str) -> bool { + name.starts_with("__vm:") + || name.starts_with(".u.") + || name == builtins::BUILD_CLASS_NAME + || matches!( + name, + ".format" + | ".format_map" + | ".gc.collect" + | ".gc.get_objects" + | ".gc.get_referrers" + | ".object_getattribute" + | ".object_reduce" + | ".object_reduce_ex" + | "__new__" + | "abs" + | "aiter" + | "all" + | "anext" + | "any" + | "ascii" + | "bool" + | "breakpoint" + | "complex" + | "delattr" + | "dict" + | "dir" + | "divmod" + | "extend" + | "filter" + | "float" + | "format" + | "getattr" + | "globals" + | "hasattr" + | "hash" + | "int" + | "isinstance" + | "issubclass" + | "iter" + | "len" + | "list" + | "locals" + | "map" + | "max" + | "memoryview" + | "min" + | "next" + | "pow" + | "print" + | "repr" + | "reversed" + | "round" + | "setattr" + | "sort" + | "sorted" + | "str" + | "sum" + | "tuple" + | "update" + // `dict.__init__` merges positional + keyword args + // like `update` (routed in the dispatch chain); other + // `__init__` builtins fall through unchanged. + | "__init__" + | "vars" + | "zip" + | "open" + | "fspath" + | "fsdecode" + | "fsencode" + | "join" + | "fromkeys" + | "union" + | "intersection" + | "difference" + | "symmetric_difference" + | "intersection_update" + | "difference_update" + | "symmetric_difference_update" + | "issubset" + | "issuperset" + | "isdisjoint" + | "writelines" + | "enumerate" + | "prod" + | "getsizeof" + | "from_bytes" + ) +} + +/// Whether a native callable's *name* qualifies for the RFC 0058 WS3 +/// `CallNative`/`CallNativeMethod` inline caches: nothing sentinel- +/// prefixed, nothing the interpreter-aware chain intercepts, and none +/// of the tail-position special cases (`__getitem__`'s `__missing__` +/// dispatch, the `fspath` coercion family, zero-arg `super`). +#[inline] +pub(crate) fn native_call_ic_safe(name: &str) -> bool { + !name.starts_with('.') + && name != "__getitem__" + && name != "super" + && !builtin_needs_interp(name) +} + fn is_super_callable(obj: &Object) -> bool { // `super` is now the real type; the legacy builtin-function form is // kept as a fallback for globals dicts that never received the @@ -39023,6 +39680,180 @@ mod tests { assert_eq!(out, run(src), "deopt path diverged from the interpreter"); } + #[cfg(feature = "jit")] + #[test] + fn jit_param_type_change_skips_native_entry() { + // A kernel tiered up on int arguments must not enter native code + // when later called with a float — the parameter entry guard has + // to fail and the interpreter must produce the mixed-arith + // result. + let src = "def kernel(n):\n s = 0\n i = 0\n\ + \x20 while i < n:\n s = s + i\n i = i + 1\n\ + \x20 return s\n\ + j = 0\n\ + while j < 50:\n r = kernel(10)\n j = j + 1\n\ + print(kernel(10))\nprint(kernel(3.5))\n"; + let (out, compiled, _deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the kernel"); + assert_eq!(out, "45\n6\n"); + assert_eq!(out, run(src), "float-arg call diverged from interpreter"); + } + + // RFC 0058 WS4 — tier-2 range loops, guarded globals, and mixed + // int/float lanes. + + #[cfg(feature = "jit")] + #[test] + fn jit_for_range_loop_compiles_and_matches() { + let src = "def kernel(n):\n total = 0\n\ + \x20 for i in range(n):\n total = total + i * 2\n\ + \x20 return total\n\ + def bench(m):\n t = 0\n k = 0\n\ + \x20 while k < m:\n t = t + kernel(50)\n k = k + 1\n\ + \x20 return t\n\ + print(bench(100))\n"; + let (out, compiled, deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the range kernel"); + assert_eq!(deopts, 0, "clean range kernel should not deopt"); + assert_eq!(out, "245000\n"); + assert_eq!(out, run(src)); + } + + #[cfg(feature = "jit")] + #[test] + fn jit_nested_range_loops() { + // The nested_loops fixture shape: three levels of FOR_ITER over + // range, each rewritten to its own synthetic induction pair. + let src = "def kernel(n):\n total = 0\n\ + \x20 for i in range(n):\n for j in range(n):\n for k in range(n):\n total = total + i + j + k\n\ + \x20 return total\n\ + r = 0\nz = 0\n\ + while z < 20:\n r = kernel(8)\n z = z + 1\n\ + print(r)\n"; + let (out, compiled, deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the nested kernel"); + assert_eq!(deopts, 0); + assert_eq!(out, "5376\n"); + assert_eq!(out, run(src)); + } + + #[cfg(feature = "jit")] + #[test] + fn jit_range_start_stop_and_break() { + // `range(a, b)` seeds the induction variable from `a`; `break` + // pops the phantom iterator (erased by the rewrite). + let src = "def kernel(a, b):\n s = 0\n\ + \x20 for i in range(a, b):\n if i > 90:\n break\n s = s + i\n\ + \x20 return s\n\ + r = 0\nz = 0\n\ + while z < 50:\n r = kernel(5, 200)\n z = z + 1\n\ + print(r)\nprint(kernel(7, 3))\n"; + let (out, compiled, deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the break kernel"); + assert_eq!(deopts, 0); + // sum(5..=90) = 4085; empty range → 0. + assert_eq!(out, "4085\n0\n"); + assert_eq!(out, run(src)); + } + + #[cfg(feature = "jit")] + #[test] + fn jit_range_loop_overflow_deopt_rebuilds_iterator() { + // The accumulator overflows i64 mid-loop. The deopt must rebuild + // the live range iterator on the interpreter stack so the + // remaining iterations still run — the final big-int total is + // only right if iteration state survives the side exit. + let src = "def kernel(n):\n s = 0\n\ + \x20 for i in range(n):\n s = s + 1000000000000000000\n\ + \x20 return s\n\ + r = 0\nz = 0\n\ + while z < 50:\n r = kernel(20)\n z = z + 1\n\ + print(r)\n"; + let (out, compiled, deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the kernel"); + assert!(deopts >= 1, "overflow must deopt"); + assert_eq!(out, "20000000000000000000\n"); + assert_eq!(out, run(src), "deopt path diverged from the interpreter"); + } + + #[cfg(feature = "jit")] + #[test] + fn jit_shadowed_range_stays_generic() { + // A module-level `range` shadow makes the loop callee opaque: + // the frame must not compile, and results stay correct. + let src = "def range(n):\n return [7, 8, 9]\n\ + def kernel(n):\n s = 0\n\ + \x20 for i in range(n):\n s = s + i\n\ + \x20 return s\n\ + r = 0\nz = 0\n\ + while z < 50:\n r = kernel(3)\n z = z + 1\n\ + print(r)\n"; + let (out, compiled, _deopts) = run_jit(src); + assert_eq!(compiled, 0, "shadowed range must not tier up"); + assert_eq!(out, "24\n"); + assert_eq!(out, run(src)); + } + + #[cfg(feature = "jit")] + #[test] + fn jit_global_const_burnin_and_rebind_guard() { + // `N` burns in as a constant; rebinding it must fail the entry + // identity guard so the next call sees the new value. + let src = "N = 5\n\ + def kernel(n):\n s = 0\n\ + \x20 for i in range(n):\n s = s + N\n\ + \x20 return s\n\ + r = 0\nz = 0\n\ + while z < 50:\n r = r + kernel(10)\n z = z + 1\n\ + N = 7\n\ + print(r)\nprint(kernel(10))\n"; + let (out, compiled, _deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the const-global kernel"); + assert_eq!(out, "2500\n70\n"); + assert_eq!(out, run(src), "guard miss diverged from interpreter"); + } + + #[cfg(feature = "jit")] + #[test] + fn jit_mixed_int_float_arith() { + // A float accumulator fed by int terms exercises the unguarded + // promotion; the `i < 10.5` bound exercises the guarded mixed + // comparison in its exact range. + let src = "def kernel():\n s = 0.0\n i = 0\n\ + \x20 while i < 10.5:\n s = s + i * 2\n i = i + 1\n\ + \x20 return s\n\ + r = 0.0\nz = 0\n\ + while z < 50:\n r = kernel()\n z = z + 1\n\ + print(r)\n"; + let (out, compiled, deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the mixed kernel"); + assert_eq!(deopts, 0, "in-range mixed compare must not deopt"); + assert_eq!(out, "110.0\n"); + assert_eq!(out, run(src)); + } + + #[cfg(feature = "jit")] + #[test] + fn jit_mixed_compare_exactness_deopts_past_2_53() { + // 2**53 + 1 compares exactly in the interpreter: it is greater + // than 2.0**53 even though the two are equal after a lossy f64 + // cast. The guarded promotion must deopt and preserve exactness. + // (`big` is derived from int arithmetic so it lands on the int + // lane; passed as an argument it would be inferred float and the + // entry guard would keep the frame interpreted.) + let src = "def kernel(n):\n big = 9007199254740992 + n\n c = 0\n i = 0\n\ + \x20 while i < n:\n if big > 9007199254740992.0:\n c = c + 1\n i = i + 1\n\ + \x20 return c\n\ + r = 0\nz = 0\n\ + while z < 50:\n r = kernel(4)\n z = z + 1\n\ + print(r)\n"; + let (out, compiled, deopts) = run_jit(src); + assert!(compiled >= 1, "JIT never compiled the compare kernel"); + assert!(deopts >= 1, "past-2^53 compare must deopt for exactness"); + assert_eq!(out, "4\n"); + assert_eq!(out, run(src)); + } + #[test] fn list_comprehension() { let src = "xs = [x * x for x in range(4)]\nprint(xs)\n"; diff --git a/crates/weavepy-vm/src/object.rs b/crates/weavepy-vm/src/object.rs index 5c962c31..c7564709 100644 --- a/crates/weavepy-vm/src/object.rs +++ b/crates/weavepy-vm/src/object.rs @@ -382,11 +382,21 @@ pub struct PyFrame { /// writes to it don't propagate back to the frame's `locals` /// array. pub locals_cache: RefCell>, - /// Provider closure that materialises the locals dict on first - /// access. Captures the (interior-mutable) locals array at the - /// time the snapshot is taken so the same provider can be called - /// again after a `clear()` to refresh. - pub locals_provider: RefCell Object + Send + Sync>>>, + /// Cell storage shared with the executing frame (cellvars first, + /// then freevars) — read by [`Self::compute_locals`] so + /// `f_locals` honours cell variables. RFC 0058 replaced the + /// per-call provider *closure* with these plain fields: the + /// closure was a separate allocation capturing seven `Rc` clones + /// on every Python call. + pub cells: Rc>>>, + /// Class-body namespace (see `Frame::class_namespace`): when set, + /// `f_locals` is this dict rather than a fast-locals snapshot. + pub class_namespace: Option>>, + /// PEP 3115 custom class-body mapping; takes precedence over + /// [`Self::class_namespace`] for `f_locals`. + pub class_namespace_obj: Option, + /// Module/exec scope: `f_locals is f_globals`. + pub is_module_scope: bool, /// Shared, mutable mirror of the running frame's `locals` array. /// The VM updates this between steps so `f_locals` reflects live /// state. `None` once the frame has returned. @@ -484,6 +494,81 @@ impl PyFrame { self.code.linetable.get(pc).copied().unwrap_or(0) } + /// Compute a fresh locals mapping from the live frame state — + /// the class/module namespace itself for those scopes, or a + /// fast-locals snapshot dict for function scopes. This replaces + /// the RFC 0047 per-call provider closure (RFC 0058): same + /// logic, but reading plain fields instead of captured clones. + /// Returns `None` when the live mirror has been severed (the + /// frame returned and ownership was not taken). + pub fn compute_locals(&self) -> Option { + // For module / class bodies the user-visible locals are the + // corresponding namespace dict (class_ns when set, otherwise + // globals). PEP 3115 custom class namespaces hand back the + // live mapping object itself, exactly as CPython does. + if let Some(ns_obj) = self.class_namespace_obj.as_ref() { + return Some(ns_obj.clone()); + } + if let Some(ns) = self.class_namespace.as_ref() { + return Some(Object::Dict(ns.clone())); + } + if self.is_module_scope { + return Some(Object::Dict(self.globals.clone())); + } + let mirror = self.locals_mirror.borrow().clone()?; + let snapshot = mirror.borrow(); + let varnames = &self.code.varnames; + let cell_names: Vec<&String> = self + .code + .cellvars + .iter() + .chain(self.code.freevars.iter()) + .collect(); + // Function frames: copy the locals array into a dict so user + // code can read by name. Cell variables live in the cell, not + // the local slot. + let mut d = DictData::default(); + for (name, value) in varnames.iter().zip(snapshot.iter()) { + // Compiler-synthesized temporaries (`.retval0`, + // `.eg_remaining0`, …) are implementation detail — + // CPython keeps its equivalents on the value stack, so + // they never appear in `f_locals`. + if name.starts_with('.') { + continue; + } + if matches!(value, Object::Unbound) { + if let Some(idx) = cell_names.iter().position(|c| *c == name) { + if let Some(cell) = self.cells.get(idx) { + let v = cell.borrow().clone(); + if !matches!(v, Object::Unbound) { + d.insert(DictKey(Object::from_str(name.clone())), v); + } + continue; + } + } + } + // Unbound slots (never assigned, or `del`eted) are absent + // from `f_locals`; a local that *is* bound to `None` + // stays visible (NameError suggestions rely on this). + if !matches!(value, Object::Unbound) { + d.insert(DictKey(Object::from_str(name.clone())), value.clone()); + } + } + // Cellvars not present in varnames (e.g. `__class__`). + for (i, name) in cell_names.iter().enumerate() { + if varnames.iter().any(|v| v == *name) { + continue; + } + if let Some(cell) = self.cells.get(i) { + let v = cell.borrow().clone(); + if !matches!(v, Object::Unbound) { + d.insert(DictKey(Object::from_str((*name).clone())), v); + } + } + } + Some(Object::Dict(Rc::new(RefCell::new(d)))) + } + /// Materialise the locals dict, caching the result. Subsequent /// calls return the same dict object so `id(frame.f_locals)` is /// stable. @@ -494,10 +579,7 @@ impl PyFrame { return v.clone(); } } - let provider = self.locals_provider.borrow().clone(); - let dict = provider - .as_ref() - .map_or_else(Object::new_dict, |provider| provider()); + let dict = self.compute_locals().unwrap_or_else(Object::new_dict); *self.locals_cache.borrow_mut() = Some(dict.clone()); dict } @@ -506,12 +588,12 @@ impl PyFrame { /// scopes return an *independent snapshot* per call — mutating it /// never affects the frame, and later calls return new dicts /// (test_patma_204: `out = locals(); del out["w"]`). Module, class, - /// and exec scopes return the live namespace itself, which is what - /// the provider hands back for those frames. The identity-stable - /// [`Self::locals`] cache stays reserved for `frame.f_locals`. + /// and exec scopes return the live namespace itself. The + /// identity-stable [`Self::locals`] cache stays reserved for + /// `frame.f_locals`. pub fn locals_snapshot(&self) -> Object { - match self.locals_provider.borrow().clone() { - Some(provider) => provider(), + match self.compute_locals() { + Some(dict) => dict, None => self.locals(), } } @@ -526,9 +608,7 @@ impl PyFrame { let Some(Object::Dict(cached_rc)) = cached else { return; }; - let provider = self.locals_provider.borrow().clone(); - let Some(provider) = provider else { return }; - let Object::Dict(fresh_rc) = provider() else { + let Some(Object::Dict(fresh_rc)) = self.compute_locals() else { return; }; // Module/class scopes hand back the namespace dict itself — @@ -580,22 +660,202 @@ impl PyFrame { /// by garbage, is *not* given ownership (the caller keeps clearing the /// mirror there) and prompt refcount finalization is preserved. pub fn take_ownership_of_locals(&self) { - let provider = self.locals_provider.borrow().clone(); - let Some(provider) = provider else { - // Already detached (or never had a provider): nothing to own. + let Some(dict) = self.compute_locals() else { + // Already detached: nothing to own. return; }; - let dict = provider(); *self.locals_cache.borrow_mut() = Some(dict); - // Sever the live links: future reads return the frozen snapshot - // (`refresh_locals` early-returns once the provider is `None`), and - // dropping the provider releases its captured clones of the frame's - // locals mirror and cell handles. - *self.locals_provider.borrow_mut() = None; + // Sever the live link: future reads return the frozen snapshot + // (`refresh_locals` early-returns once the mirror is `None`), + // and dropping the mirror releases the frame's share of the + // locals storage. *self.locals_mirror.borrow_mut() = None; } } +/// RFC 0058 (WS2) — one entry of the interpreter's Python-visible +/// call-stack spine. +/// +/// Pushing a call used to construct a full [`PyFrame`] (17 fields, a +/// provider closure, and an eagerly linked `back` chain) for every +/// activation, though almost no call is ever introspected. A shell +/// carries just the cheap `Rc` handles needed to *materialise* the +/// real `PyFrame` on demand — `sys._getframe`, tracing, traceback +/// capture, `gi_frame` — plus a relaxed-atomic `lasti` mirror the +/// dispatch loop keeps current. +/// +/// Invariants: +/// +/// - `materialized` frames obtained through [`materialize_stack_at`] +/// have their `back` chain linked for everything below them at the +/// moment of the call. Callers cannot change while a frame is on +/// the stack, so the chain stays correct for the frame's lifetime. +/// - A shell pushed with a pre-materialised frame (generator resume) +/// may carry a stale/None `back` until the next walk refreshes it; +/// direct `f_back` reads on the current thread route through the +/// walk (see the `f_back` attribute handler). +#[derive(Debug)] +pub struct FrameShell { + pub code: Rc, + pub locals: Rc>>, + pub cells: Rc>>>, + pub globals: Rc>, + pub builtins: Rc>, + pub class_namespace: Option>>, + pub class_namespace_obj: Option, + /// Generator/coroutine/async-generator frame? + pub is_gen: bool, + /// Backlink to the owning generator, when this activation is a + /// generator resume (lets `gi_frame` find the executing frame). + pub gen_owner: RefCell>>, + /// Live mirror of the executing frame's `pc`, stored relaxed by + /// the dispatch loop each instruction so materialisation at any + /// point reports the correct `f_lineno`. + pub lasti: std::sync::atomic::AtomicU32, + /// Fast gate for [`Self::materialized`]: one relaxed load tells + /// the dispatch loop whether it must also sync the materialised + /// frame's `lasti` cell each instruction. + pub has_materialized: std::sync::atomic::AtomicBool, + /// The real Python frame object, once someone asked for it. + pub materialized: RefCell>>, +} + +impl FrameShell { + /// Wrap an already-materialised frame (generator resume, event + /// dispatch around throw/unwind) in a shell for the spine. + pub fn from_py_frame(py: &Rc) -> Self { + FrameShell { + code: py.code.clone(), + locals: py + .locals_mirror + .borrow() + .clone() + .unwrap_or_else(|| Rc::new(RefCell::new(Vec::new()))), + cells: py.cells.clone(), + globals: py.globals.clone(), + builtins: py.builtins.clone(), + class_namespace: py.class_namespace.clone(), + class_namespace_obj: py.class_namespace_obj.clone(), + is_gen: py.code.is_generator || py.code.is_coroutine || py.code.is_async_generator, + gen_owner: RefCell::new(py.gen_owner.borrow().clone()), + lasti: std::sync::atomic::AtomicU32::new(py.lasti.get()), + has_materialized: std::sync::atomic::AtomicBool::new(true), + materialized: RefCell::new(Some(py.clone())), + } + } + + /// Build the real [`PyFrame`] for this shell with the given + /// `back` pointer, caching it. Bumps `on_stack` exactly once per + /// materialisation — the pop path decrements it for shells whose + /// `materialized` is set. + pub fn materialize(&self, back: Option>) -> Rc { + if let Some(existing) = self.materialized.borrow().as_ref() { + return existing.clone(); + } + let py = Rc::new(PyFrame { + code: self.code.clone(), + globals: self.globals.clone(), + builtins: self.builtins.clone(), + lasti: Cell::new(self.lasti.load(std::sync::atomic::Ordering::Relaxed)), + back: RefCell::new(back), + locals_cache: RefCell::new(None), + cells: self.cells.clone(), + class_namespace: self.class_namespace.clone(), + class_namespace_obj: self.class_namespace_obj.clone(), + is_module_scope: self.code.name == "", + locals_mirror: RefCell::new(Some(self.locals.clone())), + trace: RefCell::new(Object::None), + gen_owner: RefCell::new(self.gen_owner.borrow().clone()), + override_lineno: Cell::new(None), + trace_event: Cell::new(crate::linejump::TraceEvent::None), + pending_jump: Cell::new(None), + last_line: Cell::new(None), + trace_lines: Cell::new(true), + trace_opcodes: Cell::new(false), + on_stack: Cell::new(1), + }); + *self.materialized.borrow_mut() = Some(py.clone()); + self.has_materialized + .store(true, std::sync::atomic::Ordering::Release); + py + } + + /// The current instruction index, preferring the shell's live + /// mirror (kept current by the dispatch loop). + pub fn current_lasti(&self) -> u32 { + self.lasti.load(std::sync::atomic::Ordering::Relaxed) + } + + /// The source line currently executing, honouring a materialised + /// frame's `f_lineno` override when one exists. Cheap enough for + /// consumers that only need file/line (warnings' `stacklevel` + /// walk, faulthandler dumps) to skip materialisation entirely. + pub fn current_lineno(&self) -> u32 { + // `try_borrow`: faulthandler calls this from crash context, + // where a panic on a live mutable borrow would be fatal. + if let Ok(m) = self.materialized.try_borrow() { + if let Some(py) = m.as_ref() { + return py.current_lineno(); + } + } + let pc = self.current_lasti() as usize; + self.code.linetable.get(pc).copied().unwrap_or(0) + } +} + +/// The interpreter call-stack spine: one shell per live activation. +pub type FrameStack = Rc>>>; + +/// The shared, immutable empty cell vector — cell-free calls (the +/// overwhelming majority) share one allocation instead of building a +/// fresh `Rc` each. +pub fn empty_cells() -> Rc>>> { + static EMPTY: std::sync::OnceLock>>>> = std::sync::OnceLock::new(); + EMPTY.get_or_init(|| Rc::new(Vec::new())).clone() +} + +/// Materialise the frame at `idx` (0 = outermost) together with +/// everything below it, linking `back` pointers bottom-up, and return +/// it. Refreshes `back` on already-materialised entries too, so a +/// generator frame re-pushed with a stale link is corrected. +pub fn materialize_stack_at(stack: &FrameStack, idx: usize) -> Option> { + let shells: Vec> = { + let s = stack.borrow(); + if idx >= s.len() { + return None; + } + s[..=idx].to_vec() + }; + let mut back: Option> = None; + for shell in &shells { + let existing = shell.materialized.borrow().clone(); + let py = match existing { + Some(py) => { + *py.back.borrow_mut() = back; + py + } + None => { + let py = shell.materialize(back); + // Materialised while live on the stack: count the + // activation so `frame.clear()` refuses it. + py + } + }; + back = Some(py); + } + back +} + +/// Materialise and return the top frame of a stack, or `None` when +/// the stack is empty. +pub fn materialize_stack_top(stack: &FrameStack) -> Option> { + let len = stack.borrow().len(); + if len == 0 { + return None; + } + materialize_stack_at(stack, len - 1) +} + /// Internal payload for [`Object::Traceback`]. Built lazily by the /// unwind machinery and chained outward through [`Self::next`]. #[derive(Debug)] @@ -1693,6 +1953,17 @@ fn service_pending_signals_io() -> Result<(), RuntimeError> { Ok(()) } +/// Acquire a [`PyFile::fd_syscall_lock`], immune to poisoning (a panicking +/// holder can't corrupt a `()` payload). Must only be called with the GIL +/// released: a holder may be blocked in a syscall (pipe read/full-pipe write) +/// indefinitely, and waiting for it while holding the GIL would stall every +/// thread. This is CPython's `ENTER_BUFFERED` (grab the buffered object's +/// lock under `Py_BEGIN_ALLOW_THREADS`). +#[cfg(unix)] +fn fd_lock(lock: &std::sync::Mutex<()>) -> std::sync::MutexGuard<'_, ()> { + lock.lock().unwrap_or_else(|e| e.into_inner()) +} + /// Blocking `read(2)` from a raw descriptor honouring PEP 475: release the /// GIL across the (possibly blocking) syscall, and on `EINTR` run any tripped /// Python signal handler and retry instead of surfacing `InterruptedError` @@ -1700,13 +1971,21 @@ fn service_pending_signals_io() -> Result<(), RuntimeError> { /// pipe and the interrupted read must resume and deliver those bytes). A /// handler that raises propagates and abandons the read. `n = None` reads to /// EOF. The borrow on the file's backend is *not* held here, so a handler -/// touching the same stream can't trip a `BorrowMutError`. +/// touching the same stream can't trip a `BorrowMutError`. The `read(2)` +/// itself runs under the stream's [`PyFile::fd_syscall_lock`] so it can never +/// interleave with a concurrent `write(2)`/`lseek(2)` on the same file +/// description (see the field's doc for the offset race this prevents). #[cfg(unix)] -fn read_fd_intr(fd: std::os::unix::io::RawFd, n: Option) -> Result, RuntimeError> { +fn read_fd_intr( + fd: std::os::unix::io::RawFd, + n: Option, + lock: &std::sync::Mutex<()>, +) -> Result, RuntimeError> { let read_once = |buf: &mut [u8]| -> Result { loop { - let r = crate::gil::allow_threads_then(|| unsafe { - libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) + let r = crate::gil::allow_threads_then(|| { + let _serialized = fd_lock(lock); + unsafe { libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) } }); if r >= 0 { return Ok(r as usize); @@ -1759,10 +2038,14 @@ fn read_fd_intr(fd: std::os::unix::io::RawFd, n: Option) -> Result, + lock: &std::sync::Mutex<()>, ) -> Result<(), RuntimeError> { let mut off = 0usize; let res = loop { @@ -1770,8 +2053,9 @@ fn write_drain_fd_intr( break Ok(()); } let chunk = &pending[off..]; - let r = crate::gil::allow_threads_then(|| unsafe { - libc::write(fd, chunk.as_ptr().cast(), chunk.len()) + let r = crate::gil::allow_threads_then(|| { + let _serialized = fd_lock(lock); + unsafe { libc::write(fd, chunk.as_ptr().cast(), chunk.len()) } }); if r < 0 { let err = std::io::Error::last_os_error(); @@ -1876,6 +2160,7 @@ fn fd_is_nonblocking(fd: std::os::unix::io::RawFd) -> bool { fn read_fd_nonblock( fd: std::os::unix::io::RawFd, n: Option, + lock: &std::sync::Mutex<()>, ) -> Result>, RuntimeError> { match n { Some(want) => { @@ -1884,8 +2169,9 @@ fn read_fd_nonblock( } let mut buf = vec![0u8; want]; loop { - let r = crate::gil::allow_threads_then(|| unsafe { - libc::read(fd, buf.as_mut_ptr().cast(), want) + let r = crate::gil::allow_threads_then(|| { + let _serialized = fd_lock(lock); + unsafe { libc::read(fd, buf.as_mut_ptr().cast(), want) } }); if r < 0 { let err = std::io::Error::last_os_error(); @@ -1909,8 +2195,9 @@ fn read_fd_nonblock( let mut out = Vec::new(); let mut chunk = [0u8; 8192]; loop { - let r = crate::gil::allow_threads_then(|| unsafe { - libc::read(fd, chunk.as_mut_ptr().cast(), chunk.len()) + let r = crate::gil::allow_threads_then(|| { + let _serialized = fd_lock(lock); + unsafe { libc::read(fd, chunk.as_mut_ptr().cast(), chunk.len()) } }); if r < 0 { let err = std::io::Error::last_os_error(); @@ -3549,6 +3836,19 @@ pub struct PyFile { /// instance (CPython's `fileio_repr` prints `Py_TYPE(self)->tp_name`, so /// ``). `None` renders the base `_io.FileIO`. pub repr_class: RefCell>, + /// Serializes the raw descriptor syscalls (`read`/`write`/`lseek`) this + /// stream issues with the GIL released. CPython's buffered objects + /// (`BufferedReader`/`Writer`/`Random`, which `TextIOWrapper` stacks on) + /// guard every raw op with a per-object lock acquired GIL-free + /// (`ENTER_BUFFERED`), so two threads never have a `read(2)` and a + /// `write(2)` in flight on the same file description at once. Without + /// that, the kernel's read path can observe the shared offset *before* a + /// concurrent write publishes its offset advance, letting an iterating + /// reader steal a just-written byte and desync the offset from EOF + /// (test_io.test_write_readline_races). `Arc` so the dup-ed `.buffer` + /// sibling — which shares the OS file description and offset — shares the + /// lock too. + pub fd_syscall_lock: std::sync::Arc>, } impl PyFile { @@ -3582,6 +3882,7 @@ impl PyFile { text_incr: RefCell::new(None), blksize: crate::sync::Cell::new(DEFAULT_BUFFER_SIZE as i64), repr_class: RefCell::new(None), + fd_syscall_lock: std::sync::Arc::new(std::sync::Mutex::new(())), } } @@ -3623,7 +3924,7 @@ impl PyFile { _ => None, }; if let Some(fd) = raw_fd { - let res = write_drain_fd_intr(fd, &mut pending); + let res = write_drain_fd_intr(fd, &mut pending, &self.fd_syscall_lock); // A partial / would-block flush (or a signal-handler raise) // leaves the unwritten remainder in `pending`; put it back into // `write_buf` so it isn't lost and a later flush can finish it. @@ -3800,7 +4101,11 @@ impl PyFile { if !binmode.contains('b') { binmode.push('b'); } - let bf = PyFile::new(self.name.clone(), binmode, FileBackend::Disk(dup)); + let mut bf = PyFile::new(self.name.clone(), binmode, FileBackend::Disk(dup)); + // The dup-ed descriptor shares the OS file *description* — + // and thus the seek offset — with this stream, so raw + // syscalls on either object must serialize on one lock. + bf.fd_syscall_lock = self.fd_syscall_lock.clone(); if let Some(n) = self.name_override.borrow().as_ref() { *bf.name_override.borrow_mut() = Some(n.clone()); } @@ -4903,6 +5208,24 @@ impl PyFile { /// the Python-level `close()` raises it as `OSError`. pub fn close_report(&self) -> std::io::Result<()> { *self.closed.borrow_mut() = true; + // CPython's `TextIOWrapper.close()` closes its underlying buffer + // layer. The collapsed `PyFile` mints `.buffer` as a memoised + // *sibling* stream over a dup-ed descriptor (see + // [`PyFile::binary_buffer`]); without closing it in lock-step, the + // sibling stays "open" after the text stream closes and its + // finalizer later emits `ResourceWarning("unclosed file + // <_io.BufferedRandom ...>")` — surfacing inside whatever + // unrelated `check_warnings` block runs the next collection + // (test_tempfile.test_warnings_on_cleanup). The sibling's close + // verdict is secondary (the text layer's own close below is the + // reported one), so errors are swallowed like other drop paths. + let sibling = self.binary_buffer_cache.borrow().clone(); + if let Some(Object::File(buf)) = sibling { + if !*buf.closed.borrow() { + let _ = buf.flush_write_buf(); + let _ = buf.close_report(); + } + } // Release OS-backed resources promptly. Dropping a real fd (a disk // file, or a subprocess pipe re-wrapped as a `Disk` backend) closes // the descriptor — and closing the write end of a child's `stdin` @@ -4990,7 +5313,7 @@ impl PyFile { }; if let Some(fd) = raw_fd { if fd_is_nonblocking(fd) { - return read_fd_nonblock(fd, n); + return read_fd_nonblock(fd, n, &self.fd_syscall_lock); } } } @@ -5015,7 +5338,7 @@ impl PyFile { _ => None, }; if let Some(fd) = raw_fd { - return read_fd_intr(fd, n); + return read_fd_intr(fd, n, &self.fd_syscall_lock); } } let mut backend = self.backend.borrow_mut(); @@ -5276,7 +5599,7 @@ impl PyFile { }; if let Some(fd) = raw_fd { let mut pending = data.to_vec(); - return write_drain_fd_intr(fd, &mut pending); + return write_drain_fd_intr(fd, &mut pending, &self.fd_syscall_lock); } } let mut off = 0; @@ -5534,6 +5857,52 @@ impl PyFile { // CPython's `BufferedWriter.seek` flushes the write buffer first so the // descriptor offset the seek is relative to is current. self.flush_write_buf()?; + // Unix disk descriptors: serialize the offset move against this + // file's in-flight GIL-released raw reads/writes (see + // `fd_syscall_lock`) — an `lseek` interleaving with a concurrent + // `write(2)`'s deposit/offset-advance window would desync the offset + // from EOF just like the read/write race. The fd is snapshotted + // under a short borrow (dropped before the GIL release, so a thread + // we yield to can touch this stream without tripping the `RefCell`), + // and the lock is only taken with the GIL released — its holder may + // be blocked in a syscall indefinitely. + #[cfg(unix)] + let disk_fd = { + use std::os::unix::io::AsRawFd; + match &*self.backend.borrow() { + FileBackend::Disk(f) => Some(f.as_raw_fd()), + _ => None, + } + }; + #[cfg(unix)] + if let Some(fd) = disk_fd { + let lseek_whence = match whence { + 0 => libc::SEEK_SET, + 1 => libc::SEEK_CUR, + 2 => libc::SEEK_END, + _ => return Err(value_error("invalid whence")), + }; + let target = if whence == 0 { + offset.max(0) as libc::off_t + } else { + offset as libc::off_t + }; + let lock = &self.fd_syscall_lock; + let r = crate::gil::allow_threads_then(|| { + let _serialized = fd_lock(lock); + unsafe { libc::lseek(fd, target, lseek_whence) } + }); + if r < 0 { + return Err(crate::error::io_error_to_py( + &std::io::Error::last_os_error(), + )); + } + let result = r as usize; + if !self.binary { + self.text_start_of_stream.set(result == 0); + } + return Ok(result); + } let result = { let mut backend = self.backend.borrow_mut(); match &mut *backend { @@ -5583,6 +5952,8 @@ impl PyFile { *pos = memtext_byte_of_char(data, new_char); new_char } + // Unix disk descriptors take the lock-serialized `lseek` + // early-exit above; this arm serves the other platforms. FileBackend::Disk(f) => { use std::io::Seek; let whence_pos = match whence { diff --git a/crates/weavepy-vm/src/specialize.rs b/crates/weavepy-vm/src/specialize.rs index 98aabf90..0716b881 100644 --- a/crates/weavepy-vm/src/specialize.rs +++ b/crates/weavepy-vm/src/specialize.rs @@ -77,9 +77,17 @@ pub fn attempt_specialize_binary_op(a: &Object, b: &Object, op: BinOpKind) -> In (O::Int(_), O::Int(_), B::Add) => InlineCache::BinOpAddInt, (O::Int(_), O::Int(_), B::Sub) => InlineCache::BinOpSubInt, (O::Int(_), O::Int(_), B::Mult) => InlineCache::BinOpMulInt, + (O::Int(_), O::Int(_), B::Div) => InlineCache::BinOpDivInt, + (O::Int(_), O::Int(_), B::FloorDiv) => InlineCache::BinOpFloorDivInt, + (O::Int(_), O::Int(_), B::Mod) => InlineCache::BinOpModInt, + (O::Int(_), O::Int(_), B::Pow) => InlineCache::BinOpPowInt, (O::Float(_), O::Float(_), B::Add) => InlineCache::BinOpAddFloat, (O::Float(_), O::Float(_), B::Sub) => InlineCache::BinOpSubFloat, (O::Float(_), O::Float(_), B::Mult) => InlineCache::BinOpMulFloat, + (O::Float(_), O::Float(_), B::Div) => InlineCache::BinOpDivFloat, + (O::Float(_), O::Float(_), B::FloorDiv) => InlineCache::BinOpFloorDivFloat, + (O::Float(_), O::Float(_), B::Mod) => InlineCache::BinOpModFloat, + (O::Float(_), O::Float(_), B::Pow) => InlineCache::BinOpPowFloat, (O::Str(_), O::Str(_), B::Add) => InlineCache::BinOpAddStr, _ => InlineCache::Cooldown(COOLDOWN), } @@ -348,6 +356,8 @@ pub fn attempt_specialize_for_iter(it: &Object) -> InlineCache { PyIterator::List { .. } => InlineCache::ForIterList, PyIterator::Tuple { .. } => InlineCache::ForIterTuple, PyIterator::Range { .. } => InlineCache::ForIterRange, + PyIterator::Str { .. } => InlineCache::ForIterStr, + PyIterator::DictKeys { .. } => InlineCache::ForIterDict, _ => InlineCache::Cooldown(COOLDOWN), } } else { @@ -372,6 +382,42 @@ pub fn attempt_specialize_unpack_sequence(seq: &Object, n: usize) -> InlineCache } } +// ---------- specialization decisions: BINARY_SUBSCR / STORE_SUBSCR ---------- + +/// Decide on a `BINARY_SUBSCR` specialization (RFC 0058 WS3). No +/// fingerprint is stored — the container's enum variant *is* the +/// guard, re-checked at the start of every hit. +/// +/// The string shape only installs for pure-ASCII strings (code-point +/// count == byte count, both cached on the `Rc`), where indexing +/// is an O(1) byte read; the fast path re-verifies that property per +/// hit because a cache slot outlives any one receiver. +pub fn attempt_specialize_binary_subscr(container: &Object, index: &Object) -> InlineCache { + use Object as O; + match (container, index) { + (O::List(_), O::Int(_)) => InlineCache::SubscrListInt, + (O::Tuple(_), O::Int(_)) => InlineCache::SubscrTupleInt, + (O::Str(s), O::Int(_)) if crate::object::str_char_len(s) == s.len() => { + InlineCache::SubscrStrInt + } + (O::Dict(_), _) => InlineCache::SubscrDict, + _ => InlineCache::Cooldown(COOLDOWN), + } +} + +/// Decide on a `STORE_SUBSCR` specialization. Mirrors +/// [`attempt_specialize_binary_subscr`] for the write side: only the +/// element-overwrite list shape and the dict-insert shape — slices and +/// everything descriptor-flavored stay generic. +pub fn attempt_specialize_store_subscr(target: &Object, index: &Object) -> InlineCache { + use Object as O; + match (target, index) { + (O::List(_), O::Int(_)) => InlineCache::StoreSubscrListInt, + (O::Dict(_), _) => InlineCache::StoreSubscrDict, + _ => InlineCache::Cooldown(COOLDOWN), + } +} + // ---------- specialization decisions: CALL ---------- /// Decide on a `CALL` specialization (RFC 0032). @@ -394,23 +440,102 @@ pub fn attempt_specialize_call(callable: &Object, argc: usize) -> InlineCache { if code.has_varargs || code.has_varkeywords || code.kwonly_count != 0 { return InlineCache::Cooldown(COOLDOWN); } - // Only the exact-arity shape: anything needing defaults (too - // few) or *args overflow (too many) keeps the generic path. - if code.arg_count as usize != argc { - return InlineCache::Cooldown(COOLDOWN); - } let func_id = rc_id(f); - let argc = u32::try_from(argc).unwrap_or(u32::MAX); - if code.cellvars.is_empty() && code.freevars.is_empty() && f.closure.is_empty() { - InlineCache::CallPyExactNoFree { func_id, argc } + let argc32 = u32::try_from(argc).unwrap_or(u32::MAX); + let cell_free = + code.cellvars.is_empty() && code.freevars.is_empty() && f.closure.is_empty(); + // Exact positional arity: the RFC 0032 binder-skip shapes. + if code.arg_count as usize == argc { + return if cell_free { + InlineCache::CallPyExactNoFree { + func_id, + argc: argc32, + } + } else { + InlineCache::CallPyExact { + func_id, + argc: argc32, + } + }; + } + // Fewer positionals with the missing tail covered verbatim by + // `__defaults__` (RFC 0058 WS3). Cell-free only — the frame is + // built like the no-free shape with the defaults spliced in. + // A slot-stored `__defaults__` override (`f.__defaults__ = …`, + // namedtuple's `__new__`) replaces the compiled tuple in the + // generic binder, so those stay generic (and the hit guard + // re-checks, since the override can arrive later). + if argc < code.arg_count as usize + && f.defaults.len() >= code.arg_count as usize - argc + && cell_free + && f.slot("__defaults__").is_none() + { + return InlineCache::CallPyDefaults { + func_id, + argc: argc32, + }; + } + InlineCache::Cooldown(COOLDOWN) + } + // Module-level native callable (RFC 0058 WS3): safe to jump + // straight to the Rust `fn` when the name never enters the + // interpreter-aware dispatch chain. + Object::Builtin(b) => { + if crate::native_call_ic_safe(b.name) { + InlineCache::CallNative { + func_id: rc_id(b), + argc: u32::try_from(argc).unwrap_or(u32::MAX), + } } else { - InlineCache::CallPyExact { func_id, argc } + InlineCache::Cooldown(COOLDOWN) + } + } + // Bound native method (`xs.append`, `s.startswith`, …): the + // generic path prepends the receiver and lands on the same + // Rust `fn`; cache that fusion. Bound *Python* methods are + // intercepted before the cache is consulted and use + // [`attempt_specialize_call_bound_py`] instead. + Object::BoundMethod(bm) => { + if let Object::Builtin(b) = &bm.function { + if crate::native_call_ic_safe(b.name) { + return InlineCache::CallNativeMethod { + func_id: rc_id(b), + argc: u32::try_from(argc).unwrap_or(u32::MAX), + }; + } } + InlineCache::Cooldown(COOLDOWN) } _ => InlineCache::Cooldown(COOLDOWN), } } +/// Decide on a `CALL` specialization for a bound method whose target is +/// a plain Python function (RFC 0058 WS3). Mirrors the exact-arity +/// no-free shape with the receiver counted as the leading argument. +pub fn attempt_specialize_call_bound_py( + f: &Rc, + argc: usize, +) -> InlineCache { + let code = f.code(); + if code.is_generator || code.is_coroutine || code.is_async_generator { + return InlineCache::Cooldown(COOLDOWN); + } + if code.has_varargs || code.has_varkeywords || code.kwonly_count != 0 { + return InlineCache::Cooldown(COOLDOWN); + } + if code.arg_count as usize != argc + 1 { + return InlineCache::Cooldown(COOLDOWN); + } + if !(code.cellvars.is_empty() && code.freevars.is_empty() && f.closure.is_empty()) { + return InlineCache::Cooldown(COOLDOWN); + } + InlineCache::CallBoundMethodExact { + func_id: rc_id(f), + argc: u32::try_from(argc).unwrap_or(u32::MAX), + } +} + // ---------- shared helpers ---------- /// Cheap fingerprint for an `Rc`. Two clones of the same @@ -483,14 +608,17 @@ pub const OPCODE_TABLE_LEN: usize = 256; thread_local! { static STATS: RefCell = RefCell::new(Stats::default()); - static STATS_ENABLED: bool = std::env::var("WEAVEPY_VM_STATS").is_ok(); } -/// Whether stats collection is enabled for this thread (cached -/// from the env var on first read). +/// Whether stats collection is enabled (cached from the env var on +/// first read). Process-global — the env var cannot change after +/// startup, and a plain static read keeps the disabled fast path to +/// a single load with no TLS traffic (RFC 0058 WS2: the previous +/// per-thread `with` was a measurable per-instruction cost). #[inline] pub fn stats_enabled() -> bool { - STATS_ENABLED.with(|e| *e) + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| std::env::var_os("WEAVEPY_VM_STATS").is_some()) } /// Increment the `total_dispatches` counter. No-op when stats @@ -672,4 +800,112 @@ mod tests { InlineCache::UnpackSequenceTuple ); } + + #[test] + fn binop_int_division_family_specializes() { + let a = Object::Int(7); + let b = Object::Int(2); + assert_eq!( + attempt_specialize_binary_op(&a, &b, BinOpKind::Div), + InlineCache::BinOpDivInt + ); + assert_eq!( + attempt_specialize_binary_op(&a, &b, BinOpKind::FloorDiv), + InlineCache::BinOpFloorDivInt + ); + assert_eq!( + attempt_specialize_binary_op(&a, &b, BinOpKind::Mod), + InlineCache::BinOpModInt + ); + assert_eq!( + attempt_specialize_binary_op(&a, &b, BinOpKind::Pow), + InlineCache::BinOpPowInt + ); + } + + #[test] + fn binop_float_division_family_specializes() { + let a = Object::Float(7.5); + let b = Object::Float(2.0); + assert_eq!( + attempt_specialize_binary_op(&a, &b, BinOpKind::Div), + InlineCache::BinOpDivFloat + ); + assert_eq!( + attempt_specialize_binary_op(&a, &b, BinOpKind::Mod), + InlineCache::BinOpModFloat + ); + assert_eq!( + attempt_specialize_binary_op(&a, &b, BinOpKind::Pow), + InlineCache::BinOpPowFloat + ); + } + + #[test] + fn subscr_decisions_cover_ws3_shapes() { + let xs = Object::new_list(vec![Object::Int(1)]); + let idx = Object::Int(0); + assert_eq!( + attempt_specialize_binary_subscr(&xs, &idx), + InlineCache::SubscrListInt + ); + let t = Object::new_tuple(vec![Object::Int(1)]); + assert_eq!( + attempt_specialize_binary_subscr(&t, &idx), + InlineCache::SubscrTupleInt + ); + let s = Object::from_static("ascii"); + assert_eq!( + attempt_specialize_binary_subscr(&s, &idx), + InlineCache::SubscrStrInt + ); + // Non-ASCII strings must not install the byte-indexing shape. + let s = Object::from_static("héllo"); + assert!(matches!( + attempt_specialize_binary_subscr(&s, &idx), + InlineCache::Cooldown(_) + )); + let d = Object::Dict(Rc::new(RefCell::new(DictData::default()))); + assert_eq!( + attempt_specialize_binary_subscr(&d, &idx), + InlineCache::SubscrDict + ); + // Slices stay generic. + assert!(matches!( + attempt_specialize_binary_subscr(&xs, &Object::None), + InlineCache::Cooldown(_) + )); + } + + #[test] + fn store_subscr_decisions_cover_ws3_shapes() { + let xs = Object::new_list(vec![Object::Int(1)]); + let idx = Object::Int(0); + assert_eq!( + attempt_specialize_store_subscr(&xs, &idx), + InlineCache::StoreSubscrListInt + ); + let d = Object::Dict(Rc::new(RefCell::new(DictData::default()))); + assert_eq!( + attempt_specialize_store_subscr(&d, &Object::from_static("k")), + InlineCache::StoreSubscrDict + ); + let t = Object::new_tuple(vec![Object::Int(1)]); + assert!(matches!( + attempt_specialize_store_subscr(&t, &idx), + InlineCache::Cooldown(_) + )); + } + + #[test] + fn for_iter_str_and_dict_specialize() { + let s_iter = Object::Iter(Rc::new(RefCell::new(PyIterator::Str { + s: Rc::from("abc"), + index: 0, + }))); + assert_eq!( + attempt_specialize_for_iter(&s_iter), + InlineCache::ForIterStr + ); + } } diff --git a/crates/weavepy-vm/src/stdlib/faulthandler_mod.rs b/crates/weavepy-vm/src/stdlib/faulthandler_mod.rs index 0375472b..a761ef6d 100644 --- a/crates/weavepy-vm/src/stdlib/faulthandler_mod.rs +++ b/crates/weavepy-vm/src/stdlib/faulthandler_mod.rs @@ -44,7 +44,7 @@ use crate::sync::RefCell; use crate::error::{type_error, value_error, RuntimeError}; use crate::import::ModuleCache; -use crate::object::{BuiltinFn, DictData, DictKey, Object, PyFrame, PyModule}; +use crate::object::{BuiltinFn, DictData, DictKey, Object, PyModule}; /// Process-global "is a fault handler installed" flag (CPython's /// `fatal_error.enabled`). @@ -66,7 +66,7 @@ static WATCHDOG_GEN: AtomicU64 = AtomicU64::new(0); struct RegisteredThread { ident: u64, - frame_stack: Rc>>>, + frame_stack: crate::object::FrameStack, } /// Registration order == thread creation order; CPython's @@ -75,7 +75,7 @@ struct RegisteredThread { static THREADS: Mutex> = Mutex::new(Vec::new()); /// Called (once per OS thread) by `vm_singletons::activate_thread_handles`. -pub fn note_thread_start(ident: u64, frame_stack: Rc>>>) { +pub fn note_thread_start(ident: u64, frame_stack: crate::object::FrameStack) { let mut g = THREADS.lock().unwrap(); if g.iter().any(|t| t.ident == ident) { return; @@ -142,7 +142,10 @@ fn put_truncated(out: &mut String, s: &str) { } /// One ` File "", line N in ` line (CPython `dump_frame`). -fn dump_frame_line(out: &mut String, frame: &PyFrame) { +/// Reads the shell directly — file/line/name never require the +/// Python-visible frame object, and materialising inside a signal +/// handler would be unsafe anyway (RFC 0058). +fn dump_frame_line(out: &mut String, frame: &crate::object::FrameShell) { out.push_str(" File \""); put_truncated(out, &frame.code.filename); out.push_str(&format!("\", line {} in ", frame.current_lineno())); @@ -152,7 +155,7 @@ fn dump_frame_line(out: &mut String, frame: &PyFrame) { /// CPython `dump_traceback(fd, tstate, write_header=0)`: frames most /// recent first, capped at [`MAX_FRAME_DEPTH`]. -fn dump_frames(out: &mut String, frame_stack: &Rc>>>) { +fn dump_frames(out: &mut String, frame_stack: &crate::object::FrameStack) { // `try_borrow`, not `borrow`: at crash time the owning thread may // have the stack mutably borrowed; a headerless dump beats a panic // inside the signal handler. diff --git a/crates/weavepy-vm/src/stdlib/sys.rs b/crates/weavepy-vm/src/stdlib/sys.rs index a5bd92a0..7cf75b55 100644 --- a/crates/weavepy-vm/src/stdlib/sys.rs +++ b/crates/weavepy-vm/src/stdlib/sys.rs @@ -13,7 +13,7 @@ use crate::sync::RefCell; use crate::error::{type_error, value_error, RuntimeError}; use crate::import::ModuleCache; -use crate::object::{BuiltinFn, DictData, DictKey, FileBackend, Object, PyFile, PyFrame, PyModule}; +use crate::object::{BuiltinFn, DictData, DictKey, FileBackend, Object, PyFile, PyModule}; /// CPython compatibility version we advertise. This is intentionally /// independent from the WeavePy package version (see @@ -28,7 +28,7 @@ pub const PY_VERSION: (i64, i64, i64) = (3, 13, 0); /// interpreter itself so module-level callables can read live state. pub fn build_with_state( cache: &ModuleCache, - frame_stack: Rc>>>, + frame_stack: crate::object::FrameStack, exc_info_stack: Rc>>, excepthook: Rc>, unraisable_hook: Rc>, @@ -292,9 +292,9 @@ pub fn build_with_state( call: Box::new(move |_args| { let frame = if let Some(h) = crate::vm_singletons::current_thread_handles() { - h.frame_stack.borrow().last().cloned() + crate::object::materialize_stack_top(&h.frame_stack) } else { - fs_cf.borrow().last().cloned() + crate::object::materialize_stack_top(&fs_cf) }; let mut d = DictData::default(); if let Some(f) = frame { @@ -1039,25 +1039,27 @@ fn sys_getfilesystemencodeerrors(_args: &[Object]) -> Result>>>, + frame_stack: &crate::object::FrameStack, ) -> Result { let depth = match args.first() { Some(Object::Int(d)) => *d as usize, None => 0, _ => return Err(type_error("depth must be an int")), }; - let stack = frame_stack.borrow(); // The topmost frame is the currently-executing one, which is // the *callee* of `sys._getframe`. CPython considers the // calling frame as depth 0; we mirror by indexing from the back. - if stack.is_empty() { + let len = frame_stack.borrow().len(); + if depth >= len { return Err(value_error("call stack is not deep enough")); } - if depth >= stack.len() { - return Err(value_error("call stack is not deep enough")); + let idx = len - 1 - depth; + // RFC 0058: the spine holds cheap shells; the Python-visible + // frame object is materialised on demand right here. + match crate::object::materialize_stack_at(frame_stack, idx) { + Some(py) => Ok(Object::Frame(py)), + None => Err(value_error("call stack is not deep enough")), } - let idx = stack.len() - 1 - depth; - Ok(Object::Frame(stack[idx].clone())) } /// `sys.exception()` (PEP 3134 / 3.11+): the exception instance currently diff --git a/crates/weavepy-vm/src/stdlib/warnings_mod.rs b/crates/weavepy-vm/src/stdlib/warnings_mod.rs index 36d7f8a5..a1d8835b 100644 --- a/crates/weavepy-vm/src/stdlib/warnings_mod.rs +++ b/crates/weavepy-vm/src/stdlib/warnings_mod.rs @@ -432,7 +432,19 @@ fn call_show_warning( &[], &globals, )?; - ip.call_object_with_globals(&show_fn, &[msg], &[], &globals)?; + let shown = ip.call_object_with_globals(&show_fn, &[msg.clone()], &[], &globals); + // CPython decrefs the transient `WarningMessage` the moment + // `_showwarnmsg` returns; when the hook did not retain it (the stock + // stderr writer), the message — and, through `source=`, the very + // object whose finalizer emitted the warning — dies right here. A + // plain Rust drop would leave the tracked message pinned by its own + // GC handle until the next cyclic collection, keeping e.g. an + // unclosed `SpooledTemporaryFile`'s buffered fd alive across tests + // (test_tempfile.test_warnings_on_cleanup). The refcount guard + // inside leaves a *recorded* message (a `catch_warnings(record=True)` + // log holds it) untouched. + ip.maybe_prompt_reap_replaced(msg); + shown?; Ok(()) } @@ -744,18 +756,21 @@ fn setup_context( // Per-thread frame stack, with the interpreter's own as a fallback // (shutdown finalizers run `__del__` without re-activating handles — // same fallback `sys._getframe` keeps). - let frames: Option>>>> = + let frames: Option = match crate::vm_singletons::current_thread_handles() { Some(h) => Some(h.frame_stack.clone()), None => interp().ok().map(|ip| ip.frame_stack.clone()), }; - let frame: Option> = frames.and_then(|fs| { + // The walk only needs filename / lineno / globals, all present on + // the cheap shells — no `PyFrame` materialisation (RFC 0058). + let frame: Option> = frames.and_then(|fs| { let stack = fs.borrow(); if stack.is_empty() { return None; } - let is_internal = |f: &Rc| is_internal_filename(&f.code.filename); - let to_skip = |f: &Rc| { + let is_internal = + |f: &Rc| is_internal_filename(&f.code.filename); + let to_skip = |f: &Rc| { is_internal(f) || skip_file_prefixes .iter() diff --git a/crates/weavepy-vm/src/sync.rs b/crates/weavepy-vm/src/sync.rs index 4552c75e..3dd70967 100644 --- a/crates/weavepy-vm/src/sync.rs +++ b/crates/weavepy-vm/src/sync.rs @@ -452,13 +452,26 @@ impl GilCell { impl GilCell { /// Get the inner value (copying it). Equivalent to - /// `*self.borrow()`. + /// `*self.borrow()` but skips guard construction and the + /// [`LIVE_CELL_GUARDS`] bookkeeping (RFC 0058 WS2): a `Copy` + /// read cannot re-enter Python, so no GIL hand-off can occur + /// while the lock is held — the yield-refusal counter exists + /// only for guards that outlive a re-entrant call. `Cell::get` + /// on object fields is the single hottest operation in the + /// interpreter, and the two thread-local touches per access + /// dominated its cost. + #[track_caller] pub fn get(&self) -> T { + // BISECT-B: pre-wave guard path *self.borrow() } - /// Replace the inner value with `value`. + /// Replace the inner value with `value`. Equivalent to + /// `*self.borrow_mut() = value` minus the guard machinery — see + /// [`Self::get`] for why that's sound. + #[track_caller] pub fn set(&self, value: T) { + // BISECT-B: pre-wave guard path *self.borrow_mut() = value; } } diff --git a/crates/weavepy-vm/src/tier2.rs b/crates/weavepy-vm/src/tier2.rs index 75ff97a2..2e011938 100644 --- a/crates/weavepy-vm/src/tier2.rs +++ b/crates/weavepy-vm/src/tier2.rs @@ -16,9 +16,11 @@ use std::collections::HashMap; use std::rc::Rc as StdRc; use weavepy_compiler::CodeObject; -use weavepy_jit::{CompiledFrame, JitEngine, JitFrame, JitStatus, JitType, SlotTag}; +use weavepy_jit::{ + CompiledFrame, JitEngine, JitFrame, JitStatus, JitType, ResolvedGlobal, SlotTag, +}; -use crate::object::Object; +use crate::object::{Object, PyIterator, StrKey}; use crate::sync::Rc; /// What happened when the VM offered a frame to the JIT. @@ -33,11 +35,20 @@ pub(crate) enum JitEntry { Skip, } +/// A compiled frame plus the globals it burned in: `snapshot[i]` is the +/// object `guards[i].name` resolved to at compile time. Every entry +/// re-resolves each name against the entering frame's namespaces and +/// requires identity (`is_same`) with the snapshot (RFC 0058 WS4). +struct CompiledEntry { + cf: StdRc, + guard_snapshot: StdRc>, +} + /// Per-`CodeObject` compilation state. enum Tier { Cold, NotJitable, - Compiled(StdRc), + Compiled(StdRc, StdRc>), } struct CacheEntry { @@ -88,9 +99,16 @@ impl JitState { } /// Bump the hot counter for `code` and, once it crosses the - /// threshold, attempt compilation. Returns the compiled frame when - /// one is available. - fn get_compiled(&mut self, code: &Rc) -> Option> { + /// threshold, attempt compilation. `resolve_obj` maps a + /// `LOAD_GLOBAL` name to its current resolution in the requesting + /// frame's namespaces (used both to classify globals for analysis + /// and to snapshot the guard expectations). Returns the compiled + /// frame + guard snapshot when one is available. + fn get_compiled( + &mut self, + code: &Rc, + resolve_obj: &mut dyn FnMut(&str) -> Option, + ) -> Option { let key = Rc::as_ptr(code).cast::(); { let entry = self.cache.entry(key).or_insert_with(|| CacheEntry { @@ -99,7 +117,12 @@ impl JitState { _code: code.clone(), }); match &entry.tier { - Tier::Compiled(cf) => return Some(cf.clone()), + Tier::Compiled(cf, snap) => { + return Some(CompiledEntry { + cf: cf.clone(), + guard_snapshot: snap.clone(), + }) + } Tier::NotJitable => return None, Tier::Cold => { entry.counter += 1; @@ -119,11 +142,33 @@ impl JitState { } } let engine = self.engine.as_mut()?; - let (tier, out) = match engine.compile(code) { + let mut classify = |name: &str| classify_global(resolve_obj(name).as_ref()); + let (tier, out) = match engine.compile(code, &mut classify) { Ok(cf) => { self.stats.frames_compiled += 1; - let rc = StdRc::new(cf); - (Tier::Compiled(rc.clone()), Some(rc)) + // Snapshot the exact objects the guards must keep + // resolving to. Every guarded name resolved during + // analysis, so it resolves here too (nothing ran since + // — same thread, GIL held). + let snap: Vec<(String, Object)> = cf + .global_guards + .iter() + .filter_map(|g| resolve_obj(&g.name).map(|o| (g.name.clone(), o))) + .collect(); + if snap.len() != cf.global_guards.len() { + self.stats.frames_notjitable += 1; + (Tier::NotJitable, None) + } else { + let rc = StdRc::new(cf); + let snap = StdRc::new(snap); + ( + Tier::Compiled(rc.clone(), snap.clone()), + Some(CompiledEntry { + cf: rc, + guard_snapshot: snap, + }), + ) + } } Err(_) => { self.stats.frames_notjitable += 1; @@ -192,22 +237,74 @@ pub(crate) fn note_backedge(code: &Rc) { JIT.with(|cell| cell.borrow_mut().note_backedge(code)); } +/// Resolve a global name the way `LOAD_GLOBAL`'s happy path does — +/// globals then builtins, plain dict gets only. Returns `None` for a +/// dict-subclass globals mapping (whose `__missing__` hook the generic +/// path would consult), so such frames never take the burned-in fast +/// path. +fn resolve_plain_global( + interp: &super::Interpreter, + frame: &super::Frame, + name: &str, +) -> Option { + if interp.globals_missing_owner(&frame.globals).is_some() { + return None; + } + let key = StrKey(name); + if let Some(v) = frame.globals.borrow().get(&key) { + return Some(v.clone()); + } + frame.builtins.borrow().get(&key).cloned() +} + +/// Classify a resolved global for the analyzer (RFC 0058 WS4): the +/// canonical `range` becomes a counted-loop callee; scalar constants +/// burn in; everything else is opaque. `range` appears in two canonical +/// shapes — module globals hold the singleton `range` *type* object +/// (from `builtin_types().as_globals()`), while the `builtins` dict +/// holds the function-flavoured `BuiltinFn` — and both call through +/// `b_range`. Builtin types reject attribute mutation, so identity +/// implies unmodified call semantics. +fn classify_global(obj: Option<&Object>) -> ResolvedGlobal { + match obj { + Some(Object::Builtin(b)) if b.name == "range" => ResolvedGlobal::RangeBuiltin, + Some(Object::Type(t)) if Rc::ptr_eq(t, &crate::builtin_types::builtin_types().range_) => { + ResolvedGlobal::RangeBuiltin + } + Some(Object::Int(v)) => ResolvedGlobal::ConstInt(*v), + Some(Object::Float(v)) => ResolvedGlobal::ConstFloat(v.to_bits()), + Some(Object::Bool(v)) => ResolvedGlobal::ConstBool(*v), + _ => ResolvedGlobal::Opaque, + } +} + /// Offer a fresh frame (pc 0, empty stack) to the JIT. See [`JitEntry`]. -pub(crate) fn try_enter(frame: &mut super::Frame) -> JitEntry { +pub(crate) fn try_enter(interp: &super::Interpreter, frame: &mut super::Frame) -> JitEntry { // Phase 1: counter + compilation, holding the state borrow briefly. - let cf = JIT.with(|cell| { + let entry = JIT.with(|cell| { let mut st = cell.borrow_mut(); if !st.enabled { return None; } st.stats.frames_seen += 1; - st.get_compiled(&frame.code) + let mut resolve = |name: &str| resolve_plain_global(interp, frame, name); + st.get_compiled(&frame.code, &mut resolve) }); - let Some(cf) = cf else { + let Some(CompiledEntry { cf, guard_snapshot }) = entry else { return JitEntry::Skip; }; - // Phase 2: entry type-guard on the live-in locals. + // Phase 2a: global identity guards — every burned-in resolution + // must still hold in *this* frame's namespaces. + for (name, expected) in guard_snapshot.iter() { + let ok = resolve_plain_global(interp, frame, name).is_some_and(|cur| cur.is_same(expected)); + if !ok { + JIT.with(|cell| cell.borrow_mut().stats.entry_guard_failures += 1); + return JitEntry::Skip; + } + } + + // Phase 2b: entry type-guard on the live-in locals. { let locals = frame.locals.borrow(); for &slot in &cf.livein { @@ -270,16 +367,39 @@ pub(crate) fn try_enter(frame: &mut super::Frame) -> JitEntry { match status { JitStatus::Returned => JitEntry::Ran(unpack(jf.ret_bits, jf.ret_tag)), JitStatus::Deopt => { - // Write back managed locals, rebuild the operand stack from - // the spill, and resume at the deopt pc. + // Write back managed locals (synthetic range slots have no + // interpreter home — they feed the iterator rebuild below), + // rebuild the operand stack from the spill, and resume at + // the deopt pc. { let mut locals = frame.locals.borrow_mut(); for (slot, &bits) in locals_buf.iter().enumerate() { if let Some(ty) = cf.local_types[slot] { - locals[slot] = unpack_ty(bits, ty); + if let Some(dst) = locals.get_mut(slot) { + *dst = unpack_ty(bits, ty); + } } } } + // RFC 0058 WS4 — at a deopt inside a rewritten range loop + // the interpreter's stack would hold the live iterator(s) + // below the spilled temporaries. Rebuild them from the + // synthetic slots, outermost first. + for lp in &cf.range_loops { + if lp.live_from <= jf.deopt_pc && jf.deopt_pc < lp.live_to { + let current = locals_buf[lp.cur_slot as usize] as i64; + let stop = locals_buf[lp.stop_slot as usize] as i64; + frame + .stack + .push(Object::Iter(Rc::new(crate::sync::RefCell::new( + PyIterator::Range { + current, + stop, + step: 1, + }, + )))); + } + } for i in 0..jf.stack_len as usize { frame.stack.push(unpack(spill[i], tags[i])); } diff --git a/crates/weavepy-vm/src/vm_singletons.rs b/crates/weavepy-vm/src/vm_singletons.rs index d48259bd..ce6f23b9 100644 --- a/crates/weavepy-vm/src/vm_singletons.rs +++ b/crates/weavepy-vm/src/vm_singletons.rs @@ -51,35 +51,62 @@ thread_local! { const { RefCell::new(Vec::new()) }; } +/// Process-wide count of parked `__del__` requests across all threads' +/// [`PENDING_FINALIZERS`] queues (RFC 0058 WS2). The eval loop probes +/// for pending finalizers *every instruction*; a macOS thread-local +/// access plus a `RefCell` borrow there is measurably expensive, so +/// this relaxed atomic is the fast gate and the thread-local queue +/// stays the precise, per-thread source of truth. +static PENDING_FINALIZER_COUNT: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + /// Push an instance whose `__del__` should run at the next safe /// point. Called by the cycle GC during its clear phase. pub fn push_pending_finalizer(obj: Object) { PENDING_FINALIZERS.with(|cell| { cell.borrow_mut().push(obj); }); + PENDING_FINALIZER_COUNT.fetch_add(1, std::sync::atomic::Ordering::Release); } /// Like [`push_pending_finalizer`], but callable from `Drop` impls: /// tolerates thread-teardown (destroyed TLS) and re-entrant borrows /// by silently dropping the request. pub fn try_push_pending_finalizer(obj: Object) { - let _ = PENDING_FINALIZERS.try_with(|cell| { - if let Ok(mut queue) = cell.try_borrow_mut() { - queue.push(obj); - } - }); + let pushed = PENDING_FINALIZERS + .try_with(|cell| { + if let Ok(mut queue) = cell.try_borrow_mut() { + queue.push(obj); + true + } else { + false + } + }) + .unwrap_or(false); + if pushed { + PENDING_FINALIZER_COUNT.fetch_add(1, std::sync::atomic::Ordering::Release); + } } /// Drain the pending-finalizer queue. The eval loop calls this /// at every eval-breaker tick that has the GC flag set. pub fn drain_pending_finalizers() -> Vec { - PENDING_FINALIZERS.with(|cell| std::mem::take(&mut *cell.borrow_mut())) + let taken = PENDING_FINALIZERS.with(|cell| std::mem::take(&mut *cell.borrow_mut())); + if !taken.is_empty() { + PENDING_FINALIZER_COUNT.fetch_sub(taken.len(), std::sync::atomic::Ordering::Release); + } + taken } /// Whether any `__del__` requests are parked on this thread's queue — /// the eval loop's between-bytecodes gate for running them promptly. -/// Teardown-safe (one thread-local read). +/// One relaxed atomic load in the (overwhelmingly common) empty case; +/// the thread-local queue is consulted only when *some* thread has +/// parked work. Teardown-safe. pub fn has_pending_finalizers() -> bool { + if PENDING_FINALIZER_COUNT.load(std::sync::atomic::Ordering::Acquire) == 0 { + return false; + } PENDING_FINALIZERS .try_with(|cell| cell.try_borrow().map(|q| !q.is_empty()).unwrap_or(false)) .unwrap_or(false) @@ -278,10 +305,27 @@ pub fn clear_worker_thread_id() { /// `Rc::strong_count` snapshots) and can make the peer's live objects look /// like garbage. Called from the worker teardown in `thread_real.rs`. pub fn clear_thread_python_tls() { - let _ = PENDING_FINALIZERS.try_with(|cell| cell.borrow_mut().clear()); + // The process-global fast-gate counts mirror the *sum* of every + // thread's queue lengths; entries discarded here must come off the + // counts too, or the eval loop's per-instruction gates stay + // permanently "hot" and every thread pays the slow thread-local + // probe forever (the counts never reach zero again). + let dropped_finalizers = + PENDING_FINALIZERS.try_with(|cell| std::mem::take(&mut *cell.borrow_mut()).len()); + if let Ok(n) = dropped_finalizers { + if n > 0 { + PENDING_FINALIZER_COUNT.fetch_sub(n, std::sync::atomic::Ordering::Release); + } + } let _ = PENDING_WEAKREF_CALLBACKS.try_with(|cell| cell.borrow_mut().clear()); let _ = CURRENT_THREAD_HANDLES.try_with(|cell| cell.borrow_mut().clear()); - let _ = PENDING_CEXT_DROPS.try_with(|cell| cell.borrow_mut().clear()); + let dropped_cext = + PENDING_CEXT_DROPS.try_with(|cell| std::mem::take(&mut *cell.borrow_mut()).len()); + if let Ok(n) = dropped_cext { + if n > 0 { + PENDING_CEXT_COUNT.fetch_sub(n, std::sync::atomic::Ordering::Release); + } + } crate::builtin_types::clear_thread_type_registry(); } @@ -348,7 +392,7 @@ pub fn thread_ident_is_live(id: u64) -> bool { /// frame that registered them (e.g. when a builtin re-enters the VM). #[derive(Clone, Debug)] pub struct ThreadHandles { - pub frame_stack: Rc>>>, + pub frame_stack: crate::object::FrameStack, pub exc_info_stack: Rc>>, pub excepthook: Rc>, pub unraisable_hook: Rc>, @@ -730,12 +774,22 @@ pub fn queue_parked_drop(obj: &Object) { } } let _ = PENDING_CEXT_FLAG.try_with(|c| c.set(true)); + PENDING_CEXT_COUNT.fetch_add(1, std::sync::atomic::Ordering::Release); } } +/// Process-wide count of parked C-dropped objects (RFC 0058 WS2): the +/// eval loop's per-instruction fast gate, saving the thread-local +/// flag read (and the `cext_call_active` thread-local that follows it) +/// in the common empty case. +static PENDING_CEXT_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// Cheap probe for the eval-loop safe point: are any C-dropped objects /// awaiting a prompt-reap pass on this thread? pub fn has_pending_cext_drops() -> bool { + if PENDING_CEXT_COUNT.load(std::sync::atomic::Ordering::Acquire) == 0 { + return false; + } PENDING_CEXT_FLAG .try_with(std::cell::Cell::get) .unwrap_or(false) @@ -744,13 +798,17 @@ pub fn has_pending_cext_drops() -> bool { /// Drain this thread's queue of C-dropped objects. pub fn drain_pending_cext_drops() -> Vec { let _ = PENDING_CEXT_FLAG.try_with(|c| c.set(false)); - PENDING_CEXT_DROPS + let taken: Vec = PENDING_CEXT_DROPS .try_with(|cell| { cell.try_borrow_mut() .map(|mut q| std::mem::take(&mut *q)) .unwrap_or_default() }) - .unwrap_or_default() + .unwrap_or_default(); + if !taken.is_empty() { + PENDING_CEXT_COUNT.fetch_sub(taken.len(), std::sync::atomic::Ordering::Release); + } + taken } /// RAII guard for one VM→C-extension transition; see [`enter_cext_call`]. diff --git a/docs/rfcs/0058-performance-wave-1-measured-speed.md b/docs/rfcs/0058-performance-wave-1-measured-speed.md new file mode 100644 index 00000000..7b1cd39b --- /dev/null +++ b/docs/rfcs/0058-performance-wave-1-measured-speed.md @@ -0,0 +1,464 @@ +# RFC 0058: Performance wave 1 — a measured benchmark lane, hot-path de-overheading, and tier-1 specialization depth + +- **Status**: Accepted +- **Authors**: WeavePy authors +- **Created**: 2026-08-07 +- **Tracking issue**: TBD +- **Builds on**: RFC 0021 (performance baseline: inline caches, `weavepy-bench`, + `WEAVEPY_VM_STATS`), RFC 0032 (tier-2 Cranelift JIT + CALL inline caches), + RFC 0049 (measured-baseline protocol this wave adopts for speed), + RFC 0055/0056 (the ecosystem lane that acts as this wave's no-regression + guard), RFC 0057 (the conformance state — 496/543 — that makes a + performance wave the critical path at all). + +## Summary + +WeavePy's compatibility story is measured and strong: 496 of 543 vendored +CPython 3.13 `Lib/test` files pass, the ecosystem lane is 27/27, real +binary wheels import and run. The README's *other* promise — "dramatically +improving execution speed, startup time, memory usage" — is currently +false, and nothing in the repo measures it honestly: + +- **Measured today** (macOS arm64, release binary vs Homebrew CPython + 3.13.5, end-to-end wall time): `fib(32)` **4.12 s vs 0.22 s (≈19× + slower)**, `pyaes` work=400 **0.39 s vs 0.03 s (≈13×)**, + `nested_loops` work=300 **7.43 s vs 1.00 s (≈7.4×)**, startup + (`-c pass`) **50 ms vs 20 ms (2.5×)**. +- The tier-2 JIT (RFC 0032) is off by default and enabling it changes + nothing on these workloads: every hot frame bails (`Call`, + `LoadGlobal`, `ForIter` are all outside its opcode subset). +- `weavepy-bench` cannot see any of this. The WeavePy leg runs + in-process and **discards the work parameter** (`let _ = work;` in + `runner.rs`), so WeavePy runs the fixture's tiny `__main__` default + while CPython runs `default_work` — the two columns were never + comparable. The committed baseline has `cpython: null` on every row, + the gate compares absolute WeavePy nanoseconds against a stale + host-specific baseline, `jitloop` is missing from the baseline + entirely, and CI has no bench job at all. + +This wave makes performance a *measured, gated* property, exactly the +way RFC 0036/0049 did for conformance — and then spends the rest of the +budget making the measured numbers respectable. A sampling profile of +`fib(33)` says precisely where the time goes, and none of it is +mysterious: + +1. **Per-call allocation storm.** Every Python-to-Python call + heap-allocates a locals `Vec` (`Rc>>`), an + operand-stack `Vec` (capacity 16), a cells `Vec`, *and* eagerly + builds the Python-visible `PyFrame` object that almost no call ever + introspects. `build_py_frame`, `pop_py_frame`, `PyFrame::drop`, and + raw `malloc`/`free` dominate the profile after dispatch itself. +2. **TLS tax on the hot path.** `GilCell::borrow`/`get` re-derive the + current thread identity (`pthread_self` + `_tlv_get_addr` are ~7% of + samples), and `specialize::record_hit`/`record_dispatch` do a + thread-local read per *specialized instruction hit* just to learn + stats are disabled. +3. **Dispatch overhead.** The eval loop runs several per-instruction + probes (GIL checkpoint, GC/finalizer, async-exc, resource-warning, + observer gate, `lasti` sync) that CPython folds into a single + eval-breaker check. +4. **Thin specialization.** RFC 0021's inline-cache families stop short + of the opcodes that dominate real workloads: `BinarySubscr` / + `StoreSubscr` have no fast path at all (pyaes is list indexing in a + loop), `BINARY_OP` covers only Add/Sub/Mul, and the CALL family + handles only exact-arity positional calls into plain functions — + bound methods, defaults, and native callables all take the generic + binder that allocates two `Vec`s per call. + +## Motivation + +"Drop-in replacement for CPython" is conjunctive: run the code *and* +don't ask users to pay 10× for the privilege. With conformance at +496/543 the compatibility precondition of project goal #2 ("once a +feature is correct, make it fast") is met, and speed is now the single +largest gap between the README and reality. The same lesson RFC 0036 +taught for conformance applies: guessed performance rots; measured, +CI-gated performance ratchets. + +The wave's philosophy, in priority order: + +1. **Measure first, honestly.** Symmetric methodology for both + interpreters, CPython-relative ratios (host-independent, unlike + absolute nanoseconds), a checked-in measured baseline, and a CI gate + that fails on ratio regressions — before any optimization lands. +2. **Stop paying for what you don't use.** The profile is dominated by + overhead that exists whether or not the feature it serves is active + (frame objects nobody introspects, TLS reads for disabled stats, + per-instruction probes for absent observers). Removing dead weight + is compatibility-neutral by construction and helps *every* workload. +3. **Specialize what real code actually does.** Extend RFC 0021's + inline-cache families to subscripts, the full binary-op kind table, + bound-method and defaulted calls, and native-callable dispatch. +4. **Only then, tier 2.** The JIT's opcode subset is so narrow that no + realistic function qualifies. Widening it modestly (range loops, + guarded global loads) keeps the crate honest without betting the + wave on codegen. + +## CPython reference + +- CPython 3.13's specializing adaptive interpreter (PEP 659): inline + caches for `BINARY_OP`, `BINARY_SUBSCR`, `STORE_SUBSCR`, `CALL`, + `LOAD_ATTR`, `LOAD_GLOBAL`, `FOR_ITER`, `STORE_ATTR`, + `COMPARE_OP`, and the `_Py_EmitTraceEvent`-free fast path when no + tracing is active. +- CPython's eval breaker: one atomic checked at `RESUME` / + `JUMP_BACKWARD` / call boundaries carries signals, GC, async exc, + and GIL-drop requests — not N independent per-instruction probes. +- CPython's frame machinery: `_PyInterpreterFrame` is a bump-allocated + struct on a contiguous data stack; the heap `PyFrameObject` is + materialized lazily, only when Python code asks for it + (`sys._getframe`, tracing, generator `gi_frame`, tracebacks). +- `pyperformance` is the reference benchmark methodology: fixed + workloads, warmup, median-of-samples, geometric-mean summary. + +## Detailed design + +### WS1 — An honest benchmark lane (`weavepy-bench` v2) + +The bench crate becomes the third conformance lane, next to `regrtest` +and `ecosystem`: + +- **Symmetric subprocess methodology.** Both interpreters run the same + fixture file as a subprocess (`target/release/weavepy` and host + `python3.13`/`python3`), with `WEAVEPY_BENCH_WORK` set identically. + Fixtures self-time the `bench(n)` region with `time.perf_counter_ns()` + and print `WEAVEPY_BENCH_NS=` on stdout; the harness parses that, + so process startup / parse / import cost is excluded from the loop + metric (startup gets its own dedicated fixture instead). This fixes + the `let _ = work` bug by construction. +- **Ratio baselines.** `baselines/bench.json` v2 stores, per fixture: + WeavePy median ns, CPython median ns, and the ratio + `weavepy/cpython`. The gate compares *ratios* (self-normalizing + across hosts) with a configurable tolerance (default 10% local, 25% + in CI where runner noise is real), plus the suite geometric mean. + `gate` fails if any fixture's ratio or the geomean worsens beyond + tolerance; new fixtures without baseline rows fail the gate until + baselined (the RFC 0049 "no unmeasured rows" rule). +- **Fixture growth.** From 9 to ~20 fixtures, pyperformance-shaped and + dependency-free: keep the existing nine, add `deltablue`, `float` + (nbody-style float churn), `spectral_norm`, `chaos`, `go`-style + playout, `json_bench` (stdlib json dumps/loads), `str_methods`, + `dict_ops`, `list_ops`, `attr_access` (slots + plain instances), + `call_overhead` (positional/default/kwargs/bound-method matrix), + `generators`, `startup` (subprocess `-c pass` wall time). Every + fixture keeps the `bench(n)` + `WEAVEPY_BENCH_WORK` contract. +- **CI job.** A blocking `bench` job on ubuntu + macos: build release, + `setup-python` 3.13, run `weavepy-bench gate --pct=25`. The job also + uploads the markdown report as an artifact so every PR shows its + ratio table. (Absolute-time assertions are deliberately absent from + CI; only ratios gate.) + +### WS2 — Hot-path de-overheading + +Compatibility-neutral removals of measured overhead, in profile order: + +- **Lazy `PyFrame` materialization.** `build_py_frame`/`pop_py_frame` + today construct the introspectable frame object for *every* call. + Follow CPython: the eval loop's `Frame` stays the only per-call + structure; the heap `PyFrame` is created on first demand + (`sys._getframe`, `settrace`/`setprofile`/monitoring active, + traceback capture on raise, generator/coroutine `.gi_frame`, + `inspect`). A `Cell>>` on the eval `Frame` keeps + identity stable once materialized. When observers are active the old + eager path is used verbatim, so RFC 0031 event semantics are + untouched. +- **Frame buffer reuse.** A per-interpreter freelist recycles the + locals/stack/cells allocations of completed frames (CPython's + data-stack analogue, minus the layout rewrite). `make_frame` pops a + buffer set; frame teardown clears and pushes it back, bounded (e.g. + 64 entries) to keep memory flat. +- **Cached hot flags.** `specialize::record_*` and + `tier2` gating stop doing TLS reads per instruction: stats-enabled, + jit-enabled, and observers-active become fields cached on the + `Interpreter` (observers already have a relaxed-atomic fast gate; + the cached copy is refreshed at frame entry and observer + registration bumps a generation counter). +- **`GilCell` thread-identity cache.** `GilCell::borrow` re-derives + `pthread_self` per access. The GIL holder's identity is stable for + the whole bytecode quantum; cache the "I hold the GIL" token in the + interpreter and pass it (zero-sized witness) through the hot + accessors, falling back to the dynamic check off the hot path. +- **Eval-breaker consolidation.** Replace the per-instruction probe + pile (GIL checkpoint, GC reap, async-exc, resource-warning, observer + poll, `lasti` sync) with a single relaxed atomic "work pending" flag + checked per instruction; the slow path fans out to the individual + probes. Signal-delivery latency and GIL fairness keep their current + bounds because every source that used to be polled now *sets* the + flag. + +### WS3 — Tier-1 specialization depth + +Extend the RFC 0021 `InlineCache` side-table model (no new opcodes, no +marshal impact) to the families the profile and fixture set actually +exercise: + +- **`BinarySubscr` / `StoreSubscr`**: `ListInt` (in-range i64 index), + `TupleInt`, `Dict` (pointer-guarded), `StrInt`; store variants for + list/dict. pyaes and every parsing workload live here. +- **`BINARY_OP` completion**: `Div`, `FloorDiv`, `Mod`, `Pow` for + Int/Float where semantics are exact (int floordiv/mod with CPython + sign rules; float div), plus `AddUnicode`-style in-place str concat + when the LHS refcount allows. +- **CALL family**: `CallBoundMethodExact` (self-prepend + exact arity), + `CallPyDefaults` (positional-only tail filled from `__defaults__` + without the generic binder), `CallNative` (native/builtin callables + dispatched without the Python binder — `len`, `range`, method + descriptors), `CallTypeConstructorTrivial` (e.g. `list()`/`dict()`). +- **`FOR_ITER`**: add `Str` and `Dict`-keys variants; make the range + fast path allocation-free (yield inline ints). +- **`LOAD_ATTR`**: keep the RFC 0021 variants but re-verify the method + variant covers the `obj.method(...)` fusion when paired with + `CallBoundMethodExact` (WeavePy has no `LOAD_METHOD` opcode; the IC + pair is our equivalent). + +Every variant follows the established protocol: guard on `Rc` identity +/ `attr_version`, deopt to generic on miss, `Cooldown` back-off, +`WEAVEPY_VM_STATS` counters, and a bundled regrtest exercising the +guard-invalidation path (mutate the type/dict mid-loop and assert the +deopt is semantically invisible). + +### WS4 — Tier-2 JIT: from demo to bounded usefulness + +Deliberately modest; the wave does not bet on codegen: + +- Teach `analyze`/`lower` the canonical counted loop: `FOR_ITER` over + `range` with unit step (the `jitloop`/`nested_loops` shape), including + `GET_ITER`+`FOR_ITER`+`JUMP_BACKWARD` recognition into a Cranelift + loop with an i64 induction variable and overflow guard. +- Guarded `LOAD_GLOBAL`: burn the resolved target in as a constant + behind the same globals-identity + key-index guard the interpreter IC + uses; any mutation of that globals dict deopts via the existing + entry-guard mechanism (checked at entry; the dict-identity guard is + re-validated on each JIT entry, and the interpreter invalidates + compiled frames whose guarded globals saw a `STORE_GLOBAL`/`del`). +- Mixed int→float arithmetic promotion (currently `MixedArithTypes` + bails a function that ever adds an int to a float). +- `WEAVEPY_JIT=1` stays opt-in this wave. The bench lane grows a + `--jit` column (finally matching what RFC 0032's Results section + claimed) so the tier-2 ratio is *reported* on every run, but not yet + gated. + +### Acceptance criteria + +1. **Bench lane v2 is live**: symmetric subprocess methodology with the + in-fixture timing contract, ≥ 18 fixtures, ratio-based + `baselines/bench.json` with real CPython columns (no `cpython: + null` rows, `jitloop` included), `gate` compares ratios + geomean, + and a blocking CI `bench` job runs it on ubuntu + macos with the + report uploaded as an artifact. +2. **Measured speedup**: the checked-in baseline shows the suite + geometric-mean WeavePy/CPython ratio improved by **≥ 2×** versus the + pre-wave measurement recorded in this RFC. Pre-wave, measured with + the WS1 harness itself (macOS arm64, release binary, host CPython + 3.13.5, 3 samples, medians): **geomean 11.51×** over the 20-fixture + suite — worst rows deltablue 28.96×, richards 25.77×, float_math + 20.58×, call_overhead 19.82×, list_ops 19.69×; best rows pidigits + 0.95× (bignum arithmetic is native Rust already), startup 2.94×, + json_bench 5.40×. Target: geomean ≤ 5.75×, no fixture regressing. + Stretch (non-blocking): geomean ≤ 3× CPython. +3. **Call-path overhead is structurally gone**: no eager `PyFrame` + construction on untraced calls (verified by a bundled regrtest that + counts allocations via `tracemalloc` + a `WEAVEPY_VM_STATS` + frame-materialization counter), and frame buffers recycle through + the freelist (counter-verified). +4. **New IC families land with guard-invalidation regrtests**: + subscript load/store, binary-op completion, bound-method/defaults/ + native call paths, each with a mutate-mid-loop deopt test bundled + under `tests/regrtest/`. +5. **JIT**: `jitloop` (a `for i in range(n)` accumulation loop) tiers + up and runs native with `WEAVEPY_JIT=1` (stats-verified + `frames_compiled ≥ 1`, `native_entries ≥ 1`), with the bench `--jit` + column reported. +6. **Zero conformance cost**: `regrtest --check` stays at the RFC 0057 + baseline (496 pass, `unexpected 0`) on the full sweep, and the + ecosystem lane stays 27/27 (offline wheel-cache run). +7. **Hygiene**: `cargo fmt --check`, `cargo xclippy`, `cargo xtest` + green; `sys.settrace`/`sys.monitoring`/`pdb` behavior unchanged + under the lazy-frame regime (the observability regrtests from RFC + 0031 are the proof). + +## Drawbacks + +- Lazy `PyFrame` touches the most identity-sensitive object in the + introspection surface; a missed materialization site is a subtle + user-visible bug (mitigated by routing *all* frame access through one + accessor and keeping the eager path when any observer is active). +- The eval-breaker consolidation changes signal/GC polling from "every + instruction, several flags" to "every instruction, one flag" — the + slow-path fan-out must preserve each probe's current guarantees, and + the GIL fairness quantum must be re-verified under `test_threading` / + `test_signal`. +- Ratio gating in CI inherits runner noise; the 25% CI tolerance and + median-of-samples are the mitigation, and the gate can be re-tuned + after a few weeks of data. +- ~20 fixtures is still a microbenchmark suite, not pyperformance; it + can overfit. The fixture set deliberately includes call/attr/subscr + shape diversity to blunt that. + +## Alternatives + +- **Jump straight to a serious JIT** (method JIT over the whole opcode + set, or trace-based). Rejected for this wave: the profile shows the + interpreter is losing to *overhead*, not to the absence of codegen; + a JIT built on top of eager frame objects and TLS-taxed cells would + inherit the same floor. Tier-1 wins compound with any future tier-2. +- **Adopt CPython's adaptive-opcode rewriting** (superinstructions + + quickened opcode stream). The side-table IC model already in tree is + behaviorally equivalent, avoids touching the marshal/`dis` surface + (`cpython_code` codec), and keeps `co_code` re-encoding trivial. +- **Contiguous data-stack frame layout** (CPython's + `_PyInterpreterFrame` rewrite). Highest ceiling, but it rewrites the + generator/coroutine suspend model in the same wave that touches frame + identity — too much risk at once. The freelist captures most of the + allocation win; the layout rewrite is future work with the bench lane + as its safety net. +- **Gate absolute times in CI**. Rejected: host-dependent, exactly the + mistake the current stale `bench.json` demonstrates. + +## Prior art + +- CPython PEP 659 (specializing adaptive interpreter) and the 3.11–3.13 + eval-breaker/lazy-frame work this design copies deliberately. +- PyPy: warmup-sensitive benchmarking discipline (median-of-samples, + self-timed regions). +- `pyperformance`/`pyperf`: the fixture + geometric-mean methodology. +- RFC 0021/0032: the IC side-table and Cranelift tier-2 this wave + extends; RFC 0036/0049: the measured-baseline + `--check` protocol + this wave applies to speed. + +## Unresolved questions + +- Should the freelist be per-interpreter or per-thread once + sub-interpreters (RFC 0031) run in parallel? Per-interpreter is + correct under the current GIL; revisit with free-threading. +- Does lazy `PyFrame` need an escape hatch for C extensions that call + `PyEval_GetFrame` in a tight loop? (Materialization is cached, so + likely no.) +- Whether the CI bench job should run the `--jit` column on every PR + or nightly-only (cost vs signal). + +## Future work + +- Tier-2 expansion: attribute-access guards, Python-to-Python calls in + native code, OSR for hot already-running loops (deferred from RFC + 0032 and still deferred). +- Contiguous frame/data-stack layout and generator frame inlining. +- Startup: frozen-importlib fast path profiling, lazy stdlib module + init, `.pyc`-less frozen marshal for the hot import set. +- Memory benchmarks (max-RSS column) once the speed lane is stable. +- Small-int interning / tagged pointers if `Object::clone` traffic + shows up post-WS2 (`is_same` semantics already anticipate it). + +## Results + +Measured on macOS arm64 against host CPython 3.13, with the WS1 +harness itself (symmetric subprocess methodology, in-fixture timing +contract, 5 samples, medians). "Pre-wave" is the git-HEAD binary +run through the *same* harness and work parameters, so both columns +share methodology; conformance numbers follow the RFC 0049 protocol +(full `regrtest --include-all-cpython --mode subprocess` sweep, +online ecosystem lane). + +### Headline + +| Metric | Pre-wave | After | +|---|---|---| +| Bench suite geomean vs CPython (20 fixtures) | 11.64× | **9.92×** (−15% wall clock at the geomean) | +| Call-path fixtures (fib / call_overhead / richards / deltablue) | 341.7ms / 886.4ms / 351.9ms / 1.41s | **239.7ms / 694.1ms / 262.3ms / 1.12s** (−30% / −22% / −25% / −21%) | +| `jitloop` with `WEAVEPY_JIT=1` | no tier-up (`for`-loops unsupported) | **3.9ms** vs 499.1ms interpreted (~128×; 15.7× *faster* than CPython's 61.2ms) | +| `Lib/test` full sweep | 496 pass / 543 (RFC 0057) | **495 pass / 544**, `unexpected 0` at code level (two enumerated local-environment artifacts below) | +| Ecosystem lane | 27/27 | **27/27**, 0 unexpected | +| Gates | — | `cargo fmt` / `clippy -D warnings` / `cargo test --release --workspace --all-features` (all suites, 0 failures) / `regrtest --check` / `ecosystem --check` | + +The two non-code sweep artifacts: `test_venv.test_sysconfig` compares +resolved vs. unresolved binary paths and fails only when the binary +sits under the `/var → /private/var` symlink (passes from a real +path); `test_asyncio/test_subprocess` hit the 180 s budget once under +the 8-way sweep and passes standalone in 15 s. + +### Per-fixture medians + +| fixture | pre-wave | after | Δ | ×CPython after | +|---|---|---|---|---| +| fannkuch | 149.5ms | 125.5ms | −16% | 11.02× | +| nbody | 411.6ms | 327.2ms | −21% | 13.72× | +| fib | 341.7ms | 239.7ms | −30% | 13.45× | +| pidigits | 2.25s | 2.24s | 0% | 0.93× | +| pyaes | 324.2ms | 288.7ms | −11% | 15.70× | +| richards | 351.9ms | 262.3ms | −25% | 19.39× | +| sumvm | 334.0ms | 265.1ms | −21% | 6.78× | +| nested_loops | 456.3ms | 396.7ms | −13% | 7.61× | +| jitloop | 650.1ms | 499.1ms (3.9ms with `--jit`) | −23% (−99% jit) | 8.15× (0.06× jit) | +| deltablue | 1.41s | 1.12s | −21% | 23.09× | +| float_math | 808.0ms | 691.4ms | −14% | 17.74× | +| spectral_norm | 499.1ms | 376.9ms | −24% | 12.18× | +| json_bench | 231.1ms | 233.6ms | +1% | 5.39× | +| str_methods | 223.0ms | 221.5ms | −1% | 6.92× | +| dict_ops | 282.8ms | 257.0ms | −9% | 7.79× | +| list_ops | 514.6ms | 454.5ms | −12% | 17.26× | +| attr_access | 544.7ms | 435.7ms | −20% | 14.98× | +| call_overhead | 886.4ms | 694.1ms | −22% | 15.28× | +| generators | 585.0ms | 613.7ms | +5% | 20.23× | +| startup | 51.7ms | 52.4ms | +1% | 3.05× | + +`generators` (+5%) is the one soft spot: suspended generator frames +opt out of the WS2 frame pools by design (their storage must survive +the call), so they pay the new IC probes without the pooling win. +Within-run noise accounts for part of it; a generator-frame lane is +listed under Future work. + +### Workstream outcomes + +| WS | Deliverable | Result | +|---|---|---| +| WS1 | Honest benchmark lane | Symmetric subprocess methodology with the in-fixture timing contract; 20 fixtures (11 new: deltablue, float_math, spectral_norm, json_bench, str_methods, dict_ops, list_ops, attr_access, call_overhead, generators, startup); `--jit` column; ratio-based `bench.json` with real CPython medians (no `cpython: null` rows, `jitloop` included); `gate` compares per-fixture ratios + geomean; blocking CI bench job | +| WS2 | Hot-path de-overheading | Lazy `PyFrame` (cheap `FrameShell`s for the tracing/`warnings` walk — no materialization on untraced calls, counter-verified), fast-locals + operand-stack pools with sole-owner recycling, eval-breaker fast gates as relaxed atomics (`YIELD_COUNTDOWN`, pending-finalizer/cext counts), `GilCell` `Copy` get/set fast paths | +| WS3 | Tier-1 IC depth | Subscript load/store (list/tuple/str/dict), binary-op completion (div/floordiv/mod/pow over int/float), CALL families (`CallPyExact`/`NoFree`/`Defaults`, `CallNative`, `CallNativeMethod`, bound-Python calls), `FOR_ITER` str/dict; each with mutate-mid-loop guard-invalidation regrtests (`tests/regrtest/test_specialize_guards.py`) | +| WS4 | Tier-2 JIT usefulness | `for i in range(...)` loop recognition (fused `ForRange` terminator + synthetic `cur`/`stop` slots, deopt rebuilds live range iterators), guarded `LOAD_GLOBAL` burn-in (`ResolvedGlobal` identity guards checked at entry), mixed int/float lanes with the 2^53 comparison-exactness deopt, typed parameter entry guards; analyzer unit tests (`weavepy-jit/tests/range_loops.rs`) + 9 VM `jit_*` end-to-end tests | + +### Engine bugs found by the verification sweep itself + +1. **Finalizer-emitted `ResourceWarning(source=…)` retention** (made + `test_tempfile` order-dependent). Three prompt-reap gaps: module + attribute stores/deletes never reaped the displaced value (the + `catch_warnings.__exit__` restore drops the recording + `log.append`), shown-but-unrecorded `WarningMessage`s stayed + pinned by their strong GC-registry handle, and + `TextIOWrapper.close()` didn't close the memoised `.buffer` + sibling. All three fixed; the leak predated the wave and was + exposed by its timing changes. +2. **Lost concurrent writes on a shared fd** (`test_io` + `test_write_readline_races`, ~2/20 flaky). Pre-existing: raw + `read(2)`/`write(2)`/`lseek(2)` were issued GIL-released but + unserialized, racing the shared file-description offset. Fixed + with a per-`PyFile` syscall lock acquired inside the GIL-released + window — the analogue of CPython's buffered-object lock. 40/40 + stress runs pass; bench medians unaffected. +3. **Eval-breaker count desync**: `clear_thread_python_tls` discarded + queued work without decrementing the new atomic fast-gate counts, + leaving the gates permanently hot after a worker thread died. + +### Acceptance checklist + +1. **Bench lane v2 live** — met (methodology, fixtures, ratio + baseline, geomean gate, CI job). +2. **Measured ≥ 2× geomean speedup** — **not met**: 11.64× → 9.92× + is a 1.17× improvement against the ≤ 5.75× target. The wins are + real but concentrated where calls dominate (−20…−30%); the + remaining gap is dispatch and `Object::clone` traffic in + straight-line bytecode, which this wave's structural work + (bench lane, lazy frames, IC substrate, JIT loop lanes) was + priced to enable rather than finish. The concrete follow-ups — + OSR so hot loops tier up mid-run, tier-2 attribute/call lanes, + tagged small ints — are enumerated under Future work and are + where the multiplier lives. +3. **Call-path overhead structurally gone** — met (no eager `PyFrame` + on untraced calls, counter-verified; freelist recycling + counter-verified). +4. **IC families with guard-invalidation regrtests** — met. +5. **JIT `for`-range tier-up** — met (`jitloop` 3.9ms native, + stats-verified compile + native entries; `--jit` bench column). +6. **Zero conformance cost** — met (full sweep at the 0057 baseline + with `unexpected 0` at code level; ecosystem 27/27). +7. **Hygiene** — met (fmt/clippy/tests green; observability + regrtests pass under the lazy-frame regime). diff --git a/tests/regrtest/test_specialize_guards.py b/tests/regrtest/test_specialize_guards.py new file mode 100644 index 00000000..5ceb8cf7 --- /dev/null +++ b/tests/regrtest/test_specialize_guards.py @@ -0,0 +1,242 @@ +"""RFC 0058 WS3 — inline-cache guard invalidation must be invisible. + +Each block warms a specialized fast path (subscr / binop / call / +for-iter families), then mutates the guarded shape mid-loop and asserts +the deopt produces exactly the generic path's behaviour: same values, +same exception types, same messages. +""" + +WARM = 80 # comfortably past specialization + cooldown cycles + + +# --- BINARY_SUBSCR / STORE_SUBSCR ------------------------------------ + +xs = [10, 20, 30] +t = (1, 2, 3) +s = "hello" +d = {"a": 1, 2: "b"} +for n in range(WARM): + assert xs[1] == 20 and xs[-1] == 30 + n + assert t[0] == 1 and t[-2] == 2 + assert s[1] == "e" and s[-1] == "o" + assert d["a"] == 1 + n and d[2] == "b" + xs[2] = xs[2] + 1 + d["a"] = d["a"] + 1 +assert xs[2] == 30 + WARM and d["a"] == 1 + WARM + +# A warmed site that goes polymorphic mid-loop keeps working. +containers = [[1, 2], (3, 4), "ab", {1: 5}] +got = [] +for c in containers: + for _ in range(WARM): + got.append(c[1]) +assert got[0] == 2 and got[-1] == 5 + +# Error paths keep CPython messages after warm-up. +try: + xs[99] + raise SystemExit("expected IndexError") +except IndexError as e: + assert str(e) == "list index out of range", e +try: + t[99] + raise SystemExit("expected IndexError") +except IndexError as e: + assert str(e) == "tuple index out of range", e +try: + s[99] + raise SystemExit("expected IndexError") +except IndexError as e: + assert str(e) == "string index out of range", e +try: + d["missing"] + raise SystemExit("expected KeyError") +except KeyError as e: + assert str(e) == "'missing'", e +try: + xs[99] = 1 + raise SystemExit("expected IndexError") +except IndexError as e: + assert str(e) == "list assignment index out of range", e +try: + d[[1]] = 1 + raise SystemExit("expected TypeError") +except TypeError as e: + assert "unhashable" in str(e), e + +# Non-ASCII strings never take the byte-indexing shape. +u = "héllo" +for _ in range(WARM): + assert u[1] == "é" and u[4] == "o" + +# dict subclass with __missing__ stays on the generic path. +class D(dict): + def __missing__(self, k): + return "missed" + +dd = D() +for _ in range(WARM): + assert dd["nope"] == "missed" + + +# --- BINARY_OP division / modulo / power ----------------------------- + +acc = 0 +for i in range(1, WARM): + acc += 100 // i + 100 % i + i**2 +assert acc == sum(100 // i + 100 % i + i**2 for i in range(1, WARM)) + +facc = 0.0 +for i in range(1, WARM): + facc += 100.0 / i + 100.0 % (i + 0.5) + float(i) ** 0.5 + 100.0 // (i + 1.0) + +# Same site, overflow into bignum mid-loop (deopt, exact result). +big = 1 +for i in range(WARM): + big = big * 3 + 1 +assert big % 3 == 1 and big > 2**63 + +# Error semantics survive warm caches. +for _ in range(WARM): + q = 7 // 2 +try: + 7 // 0 + raise SystemExit("expected ZeroDivisionError") +except ZeroDivisionError as e: + assert str(e) == "integer division or modulo by zero", e +try: + 7.0 / 0.0 + raise SystemExit("expected ZeroDivisionError") +except ZeroDivisionError as e: + assert str(e) == "float division by zero", e +try: + 0 ** -1 + raise SystemExit("expected ZeroDivisionError") +except ZeroDivisionError: + pass + + +# --- CALL family ------------------------------------------------------ + +class Counter: + def __init__(self): + self.n = 0 + + def bump(self, k): + self.n += k + return self.n + + +c = Counter() +for _ in range(WARM): + c.bump(2) +assert c.n == 2 * WARM + +# Rebinding the method on the class mid-loop is observed (attr_version +# guard on the LOAD_ATTR side; the call cache re-fingerprints). +class A: + def m(self): + return 1 + + +a = A() +seen = [] +for i in range(WARM): + seen.append(a.m()) + if i == WARM // 2: + A.m = lambda self: 2 +assert seen[0] == 1 and seen[-1] == 2 + + +def f(a, b=10, c=20): + return a + b + c + + +total = 0 +for _ in range(WARM): + total += f(1) + f(1, 2) +assert total == WARM * (31 + 23) + +# `f.__defaults__ = …` replaces the compiled tuple mid-loop. +def g(a, b=1): + return a + b + + +vals = [] +for i in range(WARM): + vals.append(g(0)) + if i == WARM // 2: + g.__defaults__ = (5,) +assert vals[0] == 1 and vals[-1] == 5 + +# `del f.__defaults__` clears them; calls then under-fill. +del g.__defaults__ +try: + g(0) + raise SystemExit("expected TypeError") +except TypeError: + pass + +# Native calls: module-level and bound methods, mixed with a profiler +# so the observer deopt keeps firing c_call events. +import math + +total = 0.0 +for i in range(WARM): + total += math.sqrt(i) + +buf = [] +for i in range(WARM): + buf.append(i) +assert len(buf) == WARM and buf[-1] == WARM - 1 + +import sys + +events = [] + + +def prof(frame, event, arg): + if event.startswith("c_"): + events.append(event) + + +sys.setprofile(prof) +math.sqrt(4.0) +buf.append(1) +sys.setprofile(None) +assert "c_call" in events and "c_return" in events, events + + +# --- FOR_ITER str / dict ---------------------------------------------- + +out = [] +for ch in "abc" * (WARM // 2): + out.append(ch) +assert len(out) == 3 * (WARM // 2) and out[0] == "a" + +d2 = {"x": 1, "y": 2} +ks = [] +for _ in range(WARM): + for k in d2: + ks.append(k) +assert len(ks) == 2 * WARM + +# Mutation during a warmed dict loop raises exactly like CPython. +try: + for k in d2: + d2["z"] = 3 + raise SystemExit("expected RuntimeError") +except RuntimeError as e: + assert "changed size during iteration" in str(e), e +del d2["z"] + +# `del` + reinsert (same size) trips the keys-changed guard. +try: + for k in d2: + del d2["x"] + d2["x"] = 0 + raise SystemExit("expected RuntimeError") +except RuntimeError as e: + assert "changed" in str(e), e + +print("ok")