diff --git a/CLAUDE.md b/CLAUDE.md index 30a57dd50e..a26a9bd656 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1432 +**Current Version:** 0.5.1433 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index f1406f4a65..d6931f6a61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1432" +version = "0.5.1433" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1432" +version = "0.5.1433" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1432" +version = "0.5.1433" [[package]] name = "perry-ui-tvos" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1432" +version = "0.5.1433" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 319169ecdb..af641b50d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1432" +version = "0.5.1433" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7729-gc-zeal-allocation-pacing.md b/changelog.d/7729-gc-zeal-allocation-pacing.md index b7726166d2..ed5843b05d 100644 --- a/changelog.d/7729-gc-zeal-allocation-pacing.md +++ b/changelog.d/7729-gc-zeal-allocation-pacing.md @@ -8,7 +8,7 @@ The earlier build that ran "instantly with the correct answer" was **vacuous**. `PERRY_GC_MOVING_LOOP_POLLS` was default-OFF there (#7161), so a compute-only program reached no loop safepoint and zeal forced nothing; that build also predates the #7604 exit verdict, so it exited 0 in silence. #7721 flipped the poll default ON — correctly, it is a large collector win — and in the same commit turned zeal from free-and-vacuous into correct-but-unusable. Isolated rather than assumed: the *old* compiler with `PERRY_GC_MOVING_LOOP_POLLS=1` forced at compile and run time already costs 35.8 s for one round under zeal against 0.62 s without, so no commit broke zeal — zeal was never paced, and the poll default is what exposed it. #7254 had already logged "a striking concentration of multi-minute-plus runs" under this pairing and left the population untriaged; this is that triage. - Zeal now forces a collection at the first poll at which `PERRY_GC_ZEAL_ALLOC_KB` (default 4) of new nursery material has accumulated — the model V8 (`--gc-interval`) and SpiderMonkey (`gcZeal(mode, frequency)`) both use, and for the same reason. The stride is a **monotone high-water mark**, not a "bytes since" delta: each forced collection rearms to `from_space_after + stride`, so a collection that reclaims nothing (an escalation to a non-moving full mark-sweep, which #7592 and #7682 both produced in the field) still demands another full stride of genuinely new allocation. Total forced collections are bounded by `bytes_allocated / stride` whatever the collector does with them, which makes this a bound rather than a hope. + Zeal now forces a collection at the first poll at which `PERRY_GC_ZEAL_ALLOC_KB` (default 4) of new nursery material has accumulated — the model V8 (`--gc-interval`) and SpiderMonkey (`gcZeal(mode, frequency)`) both use, and for the same reason. The stride is a **monotone high-water mark**, not a "bytes since" delta: each forced collection rearms to `from_space_after + stride`, so a collection that reclaims nothing (an escalation to a non-moving full mark-sweep, which #7592 and #7682 both produced in the field) still demands another full stride of genuinely new allocation. For any positive stride, total forced collections are bounded by `bytes_allocated / stride` whatever the collector does with them, which makes this a bound rather than a hope. (`PERRY_GC_ZEAL_ALLOC_KB=0` is a supported mode and deliberately outside that bound — it *is* the unpaced every-poll semantics, kept for a bug window that executes exactly once.) **Measured on the pinned quiet host.** The full workload under zeal goes from a 240 s timeout (unpaced cost ~1,426 s) to **98.8 s with the correct answer**, forcing 193,087 collections out of 2,838,560 polls — all of them copying minors, relocating 3,115,719 objects. The stride sweep below is one binary and one env var, at a quarter scale, and `loop_polls` is **283,852 in every row**, so the knob changes only the decision to collect: @@ -20,7 +20,7 @@ | 16 | 5,070 | 129,959 | 3.0 s | | 64 | 1,291 | 52,357 | 1.1 s | - Row 0 reproduces the pre-fix 1:1 behaviour exactly on the shipped binary. Every row keeps `copying_minors == forced_collections` and `moved > 0`, so no stride degrades the instrument into non-moving sweeps. 4 KB rather than the faster 16/64 is deliberate — this is a correctness instrument, so the default errs toward sensitivity, still collecting once per ~15 loop iterations while being 14x cheaper than unpaced. The zeal-OFF path is untouched: the same workload without zeal is 4.49 s before and after. + Row 0 reproduces the pre-fix behaviour on the shipped binary: 283,857 forced collections for 283,852 polls — one per back-edge poll, plus a handful from the other safepoint zeal forces at (the outermost microtask-pump boundary, which the poll counter does not count). Near 1:1, not exactly. Every row keeps `copying_minors == forced_collections` and `moved > 0`, so no stride degrades the instrument into non-moving sweeps. 4 KB rather than the faster 16/64 is deliberate — this is a correctness instrument, so the default errs toward sensitivity, still collecting once per ~15 loop iterations while being 14x cheaper than unpaced. The zeal-OFF path is untouched: the same workload without zeal is 4.49 s before and after. `PERRY_GC_ZEAL_ALLOC_KB=0` restores the literal every-poll semantics — the right setting for a small fixture or a bug window executed exactly once. What pacing gives up, stated rather than buried: a window crossed a single time may now fall between two forced collections; a window that *recurs* (every shape in the #7154 family, which is why the reproducers are loops) is still caught, after N KB of allocation instead of on the first iteration. diff --git a/changelog.d/7739-gc-pacing-snapshot-boundary.md b/changelog.d/7739-gc-pacing-snapshot-boundary.md new file mode 100644 index 0000000000..77a1891577 --- /dev/null +++ b/changelog.d/7739-gc-pacing-snapshot-boundary.md @@ -0,0 +1,32 @@ +### Fixed + +- **The GC trace's `major_pacing` block reports the escalation boundary the collector actually decides on (#7733 review follow-up).** + + `major_pacing_snapshot` recomputed the boundary as `baseline × growth` and dropped the floor on the floor of the function — `let (_floor, growth_num) = major_pacing_config();` — while the predicate it mirrors, `arena_growth_full_escalation_due`, **also** rejects every reading below that same floor. Wherever the floor dominated, the trace named a boundary the collector does not use: + + | state | predicate escalates at | old snapshot reported | + |---|--:|--:| + | no full yet (`baseline == 0`) | 32 MB (the floor) | `0` — "escalates at any size" | + | `baseline = 4 MB`, growth 2 | 32 MB (the floor) | 8 MB | + | `baseline = 64 MB`, growth 2 | 128 MB + 1 | 128 MB | + + That matters more than the size of the diff because of *why* the snapshot exists. #7733 added it so the pacing subject could be asserted **live** in the trace rather than a gate merely proving nothing threw — which is the one job it could not do while misreporting the quantity. This repo has paid for that shape repeatedly (`PERRY_GC_FORCE_EVACUATE` inert for every `gc()`-driven test, #6942/#6946; the matrix's `--pressure` knob disabling the path it was measuring, #7024; `moved=` summing two different collectors, #7025). + + The fix is structural rather than a second correct formula. There is now **one** definition of the boundary, `major_pacing_escalation_threshold_bytes`: `arena_growth_full_escalation_due_inner` is literally `in_use >= it`, and the snapshot reports it verbatim, floor included. `None` means "no arena reading escalates" — either pacing is disabled (`PERRY_GC_MAJOR_PACING_FLOOR_MB=0`) or the growth term overflowed `usize`, which is the same statement about the world; the helper uses `checked_*` rather than `saturating_*` because saturating would report `usize::MAX` and then claim an arena of `usize::MAX` escalates, which the strict `>` clause never would. + + The trace key follows the semantics: **`escalate_at_or_above_bytes`**, replacing `escalate_above_bytes`. The predicate's floor clause is a `>=`, and the old name was half of why the reported figure and the decision could disagree. `null` now means pacing is off. Nothing in `scripts/`, `.github/` or `docs/` consumed the old key. + +- **`zeal_holds_the_poll_word_armed_with_nothing_pending` asserts the release, not just the acquire.** + + The test checked `PERRY_GC_POLL_ARMED > 0` inside the guard scope and then only *narrated* the release in a trailing comment. If `ZealGuard`'s `Drop` ever stopped giving the arm back, the process-global word would stay non-zero for the life of the test binary, every later test would silently take the poll's slow path — and this test would still have passed. It now captures the baseline before the guard and asserts `base + 1` inside and `base` after the drop, the shape `a_deferral_arms_the_poll_word_and_draining_disarms_it` already uses a few lines above. + +### Docs + +- `docs/src/internals/memory-model.md` no longer contradicts itself about back-edge polls: one line said they became default-on in #7721, and a caveat twenty lines below still said "default off since #7161". The caveat now states the current default and its kill switch, the two gaps that survive it (an alloc-free loop body emits no poll by design, `loop_purity::loop_may_allocate`; the specialized `for` / `for-of` / `for-in` lowerings emit none by omission), and the #7604 exit-70 verdict that makes a vacuous zeal run red instead of green. +- `changelog.d/7729-gc-zeal-allocation-pacing.md` and the matching `gc/zeal.rs` doc drop two overclaims. The `bytes_allocated / stride` bound is qualified to a **positive** stride, since `PERRY_GC_ZEAL_ALLOC_KB=0` is a supported every-poll mode and deliberately outside it. And "reproduces the pre-fix 1:1 behaviour exactly", against a table showing 283,857 collections for 283,852 polls, becomes what the numbers say: one per back-edge poll, plus a handful from the other safepoint zeal forces at — the outermost microtask-pump boundary, which calls `gc_safepoint_moving_minor` without `note_loop_poll_reached`. Near 1:1, not exactly. + +### Tests + +- `the_reported_escalation_boundary_is_the_one_the_predicate_decides_on` — the named floor-dominates and growth-dominates cases the review called for, plus baseline-zero, the backoff shift and pacing-disabled, then exhaustive over `floor × growth × baseline × shift` probing each boundary's own ±1 neighbourhood. The oracle is a deliberate **independent transcription** of the four clauses the predicate used to spell out inline, not a call into the code under test, so collapsing both onto one helper cannot quietly redefine the rule. +- `the_shipped_predicate_and_the_shipped_snapshot_read_one_boundary` — drives the real `arena_growth_full_escalation_due` against the real `major_pacing_snapshot` on the live arena, so a future re-split fails here even if the pure helper stays correct. `baseline = 0` is the discriminating row and needs no particular heap size: the old snapshot reported `0` there, i.e. `in_use >= 0`, true of every arena including an empty one, while the predicate declines under the floor. +- Both are sabotage-checked against the pre-fix formula, and the `ZealGuard` assertion against a neutered `Drop`. diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 3b8bce56fc..d00d8dca9d 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -1593,18 +1593,25 @@ pub(super) fn major_pacing_backoff_shift() -> u32 { GC_MAJOR_PACING_BACKOFF_SHIFT.with(|shift| shift.get()) } -/// `(post-full baseline bytes, backoff shift, arena in-use bytes needed to -/// escalate the next minor to a full)` — emitted in the GC trace so a gate can -/// prove the backoff actually engaged rather than merely that nothing threw. -/// A zero baseline means no full has run yet, and the threshold is reported as -/// 0 because the `baseline == 0` clause escalates unconditionally. -#[cfg(feature = "diagnostics")] -pub(super) fn major_pacing_snapshot() -> (usize, u32, usize) { - let (_floor, growth_num) = major_pacing_config(); +/// `(post-full baseline bytes, backoff shift, arena in-use bytes at or above +/// which the next minor escalates to a full)` — emitted in the GC trace so a +/// gate can prove the backoff actually engaged rather than merely that nothing +/// threw. +/// +/// The third element is [`major_pacing_escalation_threshold_bytes`] verbatim, +/// i.e. the boundary `arena_growth_full_escalation_due` actually tests, **floor +/// included**. `None` means arena-growth pacing is disabled +/// (`PERRY_GC_MAJOR_PACING_FLOOR_MB=0`) and no reading escalates; a zero +/// baseline (no full yet) reports the floor, which is the reading that +/// escalates — not `0`, which is what it used to say. +// `test` as well as `diagnostics` (matching `major_pacing_backoff_shift`), so +// the test that pins snapshot-vs-predicate agreement still builds under +// `--no-default-features`, where the trace itself is compiled out. +#[cfg(any(feature = "diagnostics", test))] +pub(super) fn major_pacing_snapshot() -> (usize, u32, Option) { let baseline = GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|bytes| bytes.get()); let shift = major_pacing_backoff_shift(); - let threshold = baseline.saturating_mul(growth_num.saturating_mul(1usize << shift)); - (baseline, shift, threshold) + (baseline, shift, major_pacing_escalation_threshold_bytes()) } #[cfg(test)] @@ -2645,7 +2652,7 @@ pub(super) fn test_start_budgeted_minor_fallback_state_with_trace( /// parse / String alloc. The env vars are for tuning and measurement (read at /// process start); defaults chosen so churn oscillates ~baseline..2×baseline /// and stays below node's peak. -fn major_pacing_config() -> (usize, usize) { +pub(super) fn major_pacing_config() -> (usize, usize) { use std::sync::OnceLock; static CONFIG: OnceLock<(usize, usize)> = OnceLock::new(); let &(floor_bytes, growth_num) = CONFIG.get_or_init(|| { @@ -2685,25 +2692,70 @@ pub(super) fn arena_growth_full_escalation_due() -> bool { } fn arena_growth_full_escalation_due_inner() -> bool { - let (floor_bytes, growth_num) = major_pacing_config(); - if floor_bytes == 0 { - return false; // PERRY_GC_MAJOR_PACING_FLOOR_MB=0 disables the pacing - } - let in_use = crate::arena::arena_in_use_bytes(); - if in_use < floor_bytes { - return false; + match major_pacing_escalation_threshold_bytes() { + // `PERRY_GC_MAJOR_PACING_FLOOR_MB=0` disables the pacing: no reading + // escalates, so there is no boundary to compare against. + None => false, + Some(threshold) => crate::arena::arena_in_use_bytes() >= threshold, } +} + +/// The smallest `arena_in_use_bytes()` reading that escalates the next minor to +/// a full, or `None` when arena-growth pacing is disabled outright and no +/// reading escalates. +/// +/// **This is the only definition of that boundary.** +/// `arena_growth_full_escalation_due_inner` is now literally +/// `in_use >= this`, and `major_pacing_snapshot` reports exactly this — so the +/// decision and the diagnostic cannot drift apart, rather than merely agreeing +/// today. #7733 added the snapshot so the pacing subject could be asserted live +/// in the GC trace, and it recomputed the boundary as `baseline × growth` with +/// the floor dropped on the ground (`let (_floor, growth_num) = …`). Wherever +/// the floor dominates — the entire pre-first-full phase, where the old +/// snapshot reported `0`, and any baseline below `floor / growth` — the trace +/// named a boundary the collector does not use. A probe that misreports the +/// quantity it exists to prove is this repo's most expensive recurring bug, so +/// the fix is one source of truth, not two that match. +fn major_pacing_escalation_threshold_bytes() -> Option { + let (floor_bytes, growth_num) = major_pacing_config(); let baseline = GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|bytes| bytes.get()); - // No full yet (baseline 0): bound the initial growth once we clear the floor. - if baseline == 0 { - return true; - } // Yield-adaptive: a full that reclaimed almost nothing pushes the next // escalation out (`GC_MAJOR_PACING_BACKOFF_SHIFT`). Shift the multiplier, // not the baseline, so one productive full restores the original pacing. let shift = GC_MAJOR_PACING_BACKOFF_SHIFT.with(|shift| shift.get()); + major_pacing_escalation_threshold_for(floor_bytes, growth_num, baseline, shift) +} + +/// Pure `(config, state) → boundary`, factored out so the floor/growth +/// interaction is unit-testable without a 32 MB live heap (which is what left +/// the original divergence untested: every reachable unit test sat below the +/// floor, where the two formulas' disagreement is invisible to a `bool`). +/// +/// `None` means "no arena reading escalates" — either pacing is off, or the +/// growth term overflowed `usize`, which is the same statement about the world. +pub(super) fn major_pacing_escalation_threshold_for( + floor_bytes: usize, + growth_num: usize, + baseline: usize, + shift: u32, +) -> Option { + if floor_bytes == 0 { + return None; // PERRY_GC_MAJOR_PACING_FLOOR_MB=0 disables the pacing + } + // No full yet (baseline 0): bound the initial growth once we clear the + // floor, so the floor alone is the boundary. + if baseline == 0 { + return Some(floor_bytes); + } let growth = growth_num.saturating_mul(1usize << shift); - in_use > baseline.saturating_mul(growth) + // `+1` because the growth clause is a strict `>` while the floor clause is + // a `>=`; taking the max of the two is what the snapshot used to omit. + // `checked_*` rather than `saturating_*`: a growth term that does not fit + // in a `usize` is a boundary no arena reading can reach, which is exactly + // what `None` says — saturating would report `usize::MAX` and then claim an + // arena of `usize::MAX` escalates, which the `>` clause never would. + let growth_boundary = baseline.checked_mul(growth)?.checked_add(1)?; + Some(floor_bytes.max(growth_boundary)) } fn gc_start_budgeted_cycle_for_pressure(progress_kind: GcProgressKind) -> Option { diff --git a/crates/perry-runtime/src/gc/telemetry.rs b/crates/perry-runtime/src/gc/telemetry.rs index 4271d4e54e..525afcb6d4 100644 --- a/crates/perry-runtime/src/gc/telemetry.rs +++ b/crates/perry-runtime/src/gc/telemetry.rs @@ -1118,10 +1118,15 @@ impl GcCycleTrace { let steps_value = steps_json(self.steps_before, steps_after); let (pacing_baseline, pacing_shift, pacing_threshold) = super::policy::major_pacing_snapshot(); + // `escalate_at_or_above_bytes`, not the old `escalate_above_bytes`: the + // predicate this mirrors is `in_use >= threshold` (its floor clause is a + // `>=`), and the old name was half of why the reported figure and the + // decision could disagree. `null` = arena-growth pacing disabled + // (`PERRY_GC_MAJOR_PACING_FLOOR_MB=0`), i.e. no reading escalates. let major_pacing_json = serde_json::json!({ "baseline_bytes": pacing_baseline, "backoff_shift": pacing_shift, - "escalate_above_bytes": pacing_threshold, + "escalate_at_or_above_bytes": pacing_threshold, }); serde_json::json!({ "event": "gc_cycle", diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 7c4cec3270..4b4a9abb11 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -778,6 +778,158 @@ fn declining_to_escalate_records_no_pre_full_reading() { ); } +/// The escalation boundary the GC trace reports must be the boundary the +/// predicate takes its decision on. +/// +/// #7733 added `major_pacing_snapshot` for exactly one purpose: so a gate could +/// assert the pacing subject was LIVE in the trace rather than merely that +/// nothing threw. It then recomputed the boundary as `baseline × growth` with +/// the floor dropped on the floor of the function (`let (_floor, growth_num)`), +/// while `arena_growth_full_escalation_due` rejects everything under that same +/// floor. Wherever the floor dominates the two disagreed — most starkly before +/// the first full, where the trace reported `0` ("escalates at any size") for a +/// collector that escalates at 32 MB. A probe that misreports its own subject +/// is worse than no probe, because it reads green. +/// +/// `escalates_reference` below is a deliberate INDEPENDENT transcription of the +/// four clauses the predicate used to spell out inline — not a call into the +/// code under test. The point is to pin the boundary against a second statement +/// of the rule, so collapsing both onto one helper cannot quietly redefine it. +#[test] +fn the_reported_escalation_boundary_is_the_one_the_predicate_decides_on() { + use super::super::policy::major_pacing_escalation_threshold_for; + const MB: usize = 1024 * 1024; + + fn escalates_reference( + floor: usize, + growth_num: usize, + baseline: usize, + shift: u32, + in_use: usize, + ) -> bool { + if floor == 0 { + return false; // pacing disabled + } + if in_use < floor { + return false; // under the absolute floor: small heaps never pay + } + if baseline == 0 { + return true; // no full yet + } + in_use > baseline.saturating_mul(growth_num.saturating_mul(1usize << shift)) + } + + // The named cases first: each is a different bug if it breaks. + assert_eq!( + major_pacing_escalation_threshold_for(32 * MB, 2, 4 * MB, 0), + Some(32 * MB), + "FLOOR DOMINATES (4 MB baseline × 2 = 8 MB, under the 32 MB floor): the \ + boundary is the floor. Reporting 8 MB names a collection the collector \ + will decline to run." + ); + assert_eq!( + major_pacing_escalation_threshold_for(32 * MB, 2, 64 * MB, 0), + Some(128 * MB + 1), + "GROWTH DOMINATES: strictly ABOVE K× the baseline, per the `>` clause" + ); + assert_eq!( + major_pacing_escalation_threshold_for(32 * MB, 2, 0, 0), + Some(32 * MB), + "no full yet: the floor is the boundary — the old snapshot reported 0 \ + here, i.e. 'always', for a collector that escalates at 32 MB" + ); + assert_eq!( + major_pacing_escalation_threshold_for(32 * MB, 2, 64 * MB, 2), + Some(512 * MB + 1), + "the backoff shift multiplies the GROWTH term, not the floor" + ); + assert_eq!( + major_pacing_escalation_threshold_for(0, 2, 64 * MB, 0), + None, + "PERRY_GC_MAJOR_PACING_FLOOR_MB=0 disables the pacing outright: no \ + arena reading escalates, so there is no boundary to report" + ); + + // ...then exhaustively against the reference, probing each boundary's own + // ±1 neighbourhood so an off-by-one cannot hide between the named cases. + for &floor in &[0, 1, 8 * MB, 32 * MB] { + for &growth_num in &[1, 2, 3] { + for &baseline in &[0, 1, 4 * MB, 32 * MB, usize::MAX / 4, usize::MAX] { + for shift in 0..=2u32 { + let threshold = + major_pacing_escalation_threshold_for(floor, growth_num, baseline, shift); + let mut probes = + vec![0usize, 1, 8 * MB, 32 * MB, 64 * MB, 512 * MB, usize::MAX]; + if let Some(boundary) = threshold { + probes.extend([ + boundary.saturating_sub(1), + boundary, + boundary.saturating_add(1), + ]); + } + for in_use in probes { + assert_eq!( + threshold.is_some_and(|boundary| in_use >= boundary), + escalates_reference(floor, growth_num, baseline, shift, in_use), + "floor={floor} growth={growth_num} baseline={baseline} \ + shift={shift} in_use={in_use}: the reported boundary \ + disagrees with the escalation rule" + ); + } + } + } + } + } +} + +/// ...and the SHIPPED pair must agree, not just the pure helper. +/// +/// The pure test above proves the formula; this proves both production callers +/// are actually reading it. It drives the real `arena_growth_full_escalation_due` +/// against the real `major_pacing_snapshot` on this thread's real arena, so a +/// future re-split (a snapshot that recomputes "the same" boundary inline) +/// fails here even if the pure helper stays correct. +/// +/// `baseline = 0` is the discriminating row and it needs no particular heap +/// size: the pre-#7740 snapshot reported `0` there, i.e. `in_use >= 0`, which +/// is true of every arena including an empty one, while the predicate declines +/// under the floor. +#[test] +fn the_shipped_predicate_and_the_shipped_snapshot_read_one_boundary() { + use super::super::policy::{ + arena_growth_full_escalation_due, major_pacing_config, major_pacing_snapshot, + test_reset_major_pacing_backoff, test_set_major_pacing_baseline, + }; + let (floor_bytes, _growth_num) = major_pacing_config(); + test_reset_major_pacing_backoff(); + let saved = test_set_major_pacing_baseline(0); + + for baseline in [0usize, 1, floor_bytes / 4, usize::MAX / 4] { + test_set_major_pacing_baseline(baseline); + let (reported_baseline, _shift, threshold) = major_pacing_snapshot(); + let in_use = crate::arena::arena_in_use_bytes(); + let due = arena_growth_full_escalation_due(); + // `due == true` records a pre-full reading; drop it so the next row and + // every later test on this thread start from a clean pacing state. + test_reset_major_pacing_backoff(); + + assert_eq!( + reported_baseline, baseline, + "the snapshot must report the live baseline" + ); + assert_eq!( + due, + threshold.is_some_and(|boundary| in_use >= boundary), + "baseline={baseline}: the trace's escalate_at_or_above_bytes \ + ({threshold:?}) disagrees with the verdict the collector actually \ + took on this arena ({in_use} bytes)" + ); + } + + test_set_major_pacing_baseline(saved); + test_reset_major_pacing_backoff(); +} + // ---------------------------------------------- the poll's arming word ----- /// The deferral flag and `PERRY_GC_POLL_ARMED` are one piece of state with two @@ -864,18 +1016,34 @@ fn an_unarmed_poll_touches_nothing() { /// path: every back-edge would read zero, skip the call, and force nothing — /// and `zeal_liveness_report` would be left to report the vacuity after the /// fact instead of the instrument simply working. +/// +/// The release half is asserted, not narrated. `ZealGuard::set(true)` arms the +/// process-global word; if its `Drop` ever stopped giving that arm back, the +/// word would stay non-zero for the life of the test binary and every later +/// test would silently take the poll's slow path — with this test still green, +/// because it only ever looked INSIDE the scope. A test that cannot fail is not +/// a test. #[test] fn zeal_holds_the_poll_word_armed_with_nothing_pending() { let _isolation = GcTestIsolationGuard::new(); crate::gc::set_safepoint_pending(false); + let base = crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed); { let _zeal = super::super::zeal::ZealGuard::set(true); - assert!( - crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed) > 0, + assert_eq!( + crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed), + base + 1, "zeal must keep the poll reachable even with no deferral outstanding" ); } // And it gives the arm back, so one zeal test does not leave every later // test in this binary paying for the slow path. + assert_eq!( + crate::gc::PERRY_GC_POLL_ARMED.load(std::sync::atomic::Ordering::Relaxed), + base, + "dropping the ZealGuard must release the arm it took — a leaked arm \ + pins the poll on for the rest of the process, and nothing else in this \ + binary would notice" + ); crate::gc::set_safepoint_pending(false); } diff --git a/crates/perry-runtime/src/gc/zeal.rs b/crates/perry-runtime/src/gc/zeal.rs index 849de5176d..a408b74101 100644 --- a/crates/perry-runtime/src/gc/zeal.rs +++ b/crates/perry-runtime/src/gc/zeal.rs @@ -210,9 +210,12 @@ pub(crate) fn note_zeal_forced_collection() { /// | 16 | 5,070 | 129,959 | 3.0 s | /// | 64 | 1,291 | 52,357 | 1.1 s | /// -/// Row 0 is the pre-fix behaviour reproduced exactly — 283,857 collections for -/// 283,852 polls, i.e. 1:1 — and it is what made the full-scale workload take -/// ~24 minutes. +/// Row 0 is the pre-fix behaviour reproduced on the shipped binary: 283,857 +/// collections for 283,852 polls — one per back-edge poll, plus a handful from +/// the other safepoint zeal forces at (the outermost microtask-pump boundary, +/// which `note_loop_poll_reached` does not count). Near 1:1, not exactly; the +/// bound that matters is one per poll, and it is what made the full-scale +/// workload take ~24 minutes. /// /// 4 KB rather than the faster 16/64 is deliberate: this is a *correctness* /// instrument, so the default errs toward sensitivity. It still collects once diff --git a/docs/src/internals/memory-model.md b/docs/src/internals/memory-model.md index 1e602eb4da..deab13496c 100644 --- a/docs/src/internals/memory-model.md +++ b/docs/src/internals/memory-model.md @@ -150,10 +150,19 @@ investigation: publishes it: the default depth of 4 misses it silently, and `PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` faults on the first use. Rule of thumb: depth ≥ the number of safepoints the suspect value survives. -- `PERRY_GC_ZEAL` cannot emit loop back-edge polls that codegen never produced. - Those require the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` (default off - since #7161). Without it, zeal only fires at event-loop boundaries and a - compute-only loop never collects at all. Compile *and* run with the poll opt-in. +- `PERRY_GC_ZEAL` cannot force a collection at a loop back-edge poll codegen + never produced. Those come from the **compile-time** + `PERRY_GC_MOVING_LOOP_POLLS`, which is **default ON since #7721** (kill switch + `=0`); it was default off from #7161 until then, and that is why zeal used to + look free — it was collecting nothing. Two gaps survive the new default: + codegen emits no poll for a provably alloc-free loop body (by design, + `loop_purity::loop_may_allocate`), nor for the specialized `for` / `for-of` / + `for-in` lowerings (by omission). On a poll-free binary zeal fires only at + event-loop boundaries, so a compute-only loop never collects at all. You no + longer have to remember to check: since #7604 a zeal run prints + `[gc-zeal] forced_collections=… copying_minors=… moved_objects=… loop_polls=…` + at exit and **exits 70** if it forced or moved nothing, so a vacuous run is + red rather than green. - **Page protection is Unix-only.** `mprotect` / `sigaction` / `sysconf` are not exposed by the `libc` crate on `x86_64-pc-windows-msvc`, a target `perry-runtime` is genuinely built for. On non-Unix hosts `=1` degrades to