diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 887cfde..f9f47d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,15 @@ jobs: # documented in UNSAFE.md and re-baselined in the same commit. - name: unsafe census ratchet run: bash tools/unsafe-census.sh + # The same argument as `prove the rules are not vacuous`, applied to the + # TESTS: reintroduce each defect a load-bearing test guards and require the + # suite to go red. Four tests in this repo have passed under the exact bug + # they existed to catch; this is what stops the fifth. + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.97.1 + - name: prove the gates are not vacuous + run: bash tools/gate-selftest.sh # H-30: the proofs must keep proving. Kani is slow, so this runs on a # schedule and on demand rather than blocking every PR — the harnesses @@ -147,6 +156,114 @@ jobs: - name: check windows target from linux run: cargo check --workspace --target x86_64-pc-windows-msvc + # The EMBEDDED surface: `no_std`, the `ra_small_profile` geometry, and a + # bare-metal target with no 64-bit atomics. + # + # Everything the small-metal campaign added — the fixed-region prim backend, + # the second geometry, the `split64` shim, the reclamation fixes — was + # verified by hand and by NOTHING ELSE until this job existed. Five real + # defects were found in that surface in a single session; every one of them + # would have passed the jobs above, because none of them build `no_std`, none + # set `ra_small_profile`, and none target a chip. + # + # riscv32imac/imafc rather than Xtensa: they are stock rustup targets, and + # they exercise the properties that matter — `no_std`, a 32-bit `usize`, and + # `cfg(not(target_has_atomic = "64"))`, which is what selects `split64` over + # `portable-atomic`. The ESP32-S3 board runs are in + # `docs/plans/small-metal.md`; they need hardware and cannot gate a PR. + embedded: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.97.1 + components: clippy + targets: riscv32imac-unknown-none-elf, riscv32imafc-unknown-none-elf + # The second geometry is a full test run, not a build check: it changes + # segment/slice/page arithmetic that most of the suite depends on. + - name: test (small profile) + run: cargo test -p rusty_alloc + env: + RUSTFLAGS: --cfg ra_small_profile + - name: clippy (small profile) + run: cargo clippy -p rusty_alloc --all-targets -- -D warnings + env: + RUSTFLAGS: --cfg ra_small_profile + # The no_std build REFUSES to compile without `ra_single_threaded`: its + # soundness rests on there being exactly one thread, and that has to be + # opted into rather than inherited. The gate's negative case is asserted + # below, in the same spirit as `prove the rules are not vacuous`. + - name: clippy (no_std) + run: cargo clippy -p rusty_alloc --no-default-features -- -D warnings + env: + RUSTFLAGS: --cfg ra_single_threaded + - name: prove the single-thread gate is not vacuous + run: | + if cargo check -p rusty_alloc --no-default-features \ + --target riscv32imac-unknown-none-elf 2>/dev/null; then + echo "no_std built WITHOUT --cfg ra_single_threaded; the gate is gone" >&2 + exit 1 + fi + echo "gate fires: no_std refuses to build without the opt-in" + # BOTH geometries on BOTH bare-metal targets. The matrix is the point: + # `ra_small_profile` and `no_std` are independent axes, and a defect that + # needs both is exactly the kind this job exists to catch. + - name: build no_std bare metal (both targets, both geometries) + run: | + set -euo pipefail + for target in riscv32imac-unknown-none-elf riscv32imafc-unknown-none-elf; do + for flags in "" "--cfg ra_small_profile"; do + echo "::group::$target ${flags:-default geometry}" + RUSTFLAGS="--cfg ra_single_threaded $flags" cargo build -p rusty_alloc \ + --no-default-features --target "$target" + echo "::endgroup::" + done + done + # `rusty_alloc-api` is what a firmware actually depends on, so its + # `no_std` path is gated too rather than assumed from the core crate. + - name: build rusty_alloc-api no_std + run: cargo build -p rusty_alloc-api --no-default-features --target riscv32imac-unknown-none-elf + env: + RUSTFLAGS: --cfg ra_small_profile --cfg ra_single_threaded + + # The README quotes instruction-count ratios against mimalloc, jemalloc and + # glibc. `bench/icount-arms.sh` is what produces every column of them, but + # nothing RE-RAN it, so the published figures aged silently: P4e's reclamation + # fixes cost 2.2-7.9 % on hardware and the host table still predated them. + # + # Scheduled rather than per-PR because callgrind is slow — the same reasoning + # as `proofs`. It uploads the table so a release can be cut against a number + # that was measured rather than remembered. + icount: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.97.1 + - name: install valgrind and jemalloc + run: | + sudo apt-get update + sudo apt-get install -y valgrind libjemalloc2 + - name: build oracle arms + run: bash oracle/build.sh + - name: build the override shim + run: cargo build --release -p rusty_alloc-override + - name: instructions retired, all arms + run: | + set -o pipefail + RA_OVERRIDE_LIB="$PWD/target/release/librusty_alloc_override.so" \ + bash bench/icount-arms.sh 2>&1 | tee icount.txt + - name: publish the table + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: icount-${{ github.sha }} + path: icount.txt + # G4: the os-layer logic must stay UB-free under miri against the mock prim. miri: runs-on: ubuntu-latest @@ -179,6 +296,12 @@ jobs: run: cargo build -p rusty_alloc-wasm --target wasm32-unknown-unknown --release - name: run self-test inside a WebAssembly VM run: node bench/wasm-selftest.mjs target/wasm32-unknown-unknown/release/rusty_alloc_wasm.wasm + # SIZE is a shipped property of a wasm allocator: every byte is downloaded + # by every visitor to every page using it. Nothing measured it, so a + # 3,700-byte-gzipped regression sat in the crate for its whole life until + # an integrator reported it. See docs/plans/wasm-size.md. + - name: wasm size ratchet + run: bash tools/wasm-size.sh oracle: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index ff51431..5c97113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,6 +158,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -269,24 +275,25 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty_alloc" -version = "1.1.6" +version = "2.0.0" dependencies = [ "libc", "loom", + "portable-atomic", "proptest", "windows-sys", ] [[package]] name = "rusty_alloc-api" -version = "1.1.6" +version = "2.0.0" dependencies = [ "rusty_alloc", ] [[package]] name = "rusty_alloc-bench" -version = "1.1.6" +version = "2.0.0" dependencies = [ "libloading", "rusty_alloc", @@ -295,14 +302,14 @@ dependencies = [ [[package]] name = "rusty_alloc-ffi" -version = "1.1.6" +version = "2.0.0" dependencies = [ "rusty_alloc", ] [[package]] name = "rusty_alloc-override" -version = "1.1.6" +version = "2.0.0" dependencies = [ "rusty_alloc", "rusty_alloc-ffi", @@ -310,7 +317,7 @@ dependencies = [ [[package]] name = "rusty_alloc-wasm" -version = "1.1.6" +version = "2.0.0" dependencies = [ "rusty_alloc", ] diff --git a/Cargo.toml b/Cargo.toml index a03b34f..fa715fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,7 +132,7 @@ members = [ # different files that never got the section. Fixed here, in both, and # verified by extracting the packaged `.crate` before publishing rather than # checking the live page afterwards. -version = "1.1.6" +version = "2.0.0" edition = "2024" # MSRV. Declared for the first time in 1.0.1 because this release genuinely # needs it: `asm!` label blocks (`asm_goto`, stable 1.87) carry the free path's @@ -150,7 +150,12 @@ categories = ["memory-management", "no-std", "development-tools"] [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" -unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)", "cfg(kani)"] } +# `ra_small_profile` (P2, docs/plans/small-metal.md) selects the chip geometry. +# A --cfg rather than a cargo feature ON PURPOSE: features are additive and +# unify across the dependency graph, so two consumers wanting different +# geometries would silently get one of them. A cfg is set by the DELIVERABLE, +# the same way a Janus firmware picks its chip. +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)", "cfg(kani)", "cfg(ra_small_profile)", "cfg(ra_single_threaded)"] } # Lint policy (hardening gate H-15). `pedantic` and `nursery` are ENABLED at # workspace level and the build is clean under them, because every group diff --git a/README.md b/README.md index 52a1f3e..2ec8619 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,27 @@ does not offer. cross-thread path and abort. - **~150 of mimalloc's ~157 `mi_*` entry points**, gated against the C implementation as a differential oracle on every change. -- **Runs on WebAssembly** with no C toolchain and no emscripten. - -> **Status: `1.1.4` — the API is frozen; changes follow semver from here.** -> `1.1.0` is additive only (one new public item); every `1.0.x` user compiles -> unchanged. +- **Runs on WebAssembly** with no C toolchain and no emscripten, and **2.0.0 + halves what it adds to a gzipped bundle** (+7,760 -> +3,829 bytes on a minimal + module) — see [Shipping it to a browser](#shipping-it-to-a-browser). +- **Runs on a microcontroller, and is 2.0-3.7x faster than `esp-alloc` there** — + measured on a XIAO ESP32-S3 at 240 MHz, both allocators built from one source. + It costs more RAM to get that (68 KiB vs 8 KiB); both numbers are below. + +> **Status: `2.0.0`.** The API is frozen and changes follow semver. +> +> **What breaks, and why it is a major.** Two things, neither of which touches a +> consumer on default features: `default-features = false` now selects the +> `no_std` profile (it used to be identical to the default, because there were +> no default features), and `heap::Heap` gained a field, which a struct literal +> would notice. If you set `default-features = false` and want what you had, add +> `features = ["std"]`. `CHANGELOG.md` has the detail. +> +> **What you gain**: `no_std` on bare metal, a second geometry for parts with +> kilobytes instead of gigabytes, and three reclamation fixes — one of which +> could leave a long-running heap reporting OOM while holding memory it could +> have reclaimed. +> > Upgrading from 0.3.x or earlier is mandatory, not optional: 0.4.0 fixed > three platform-independent use-after-frees, so **treat 0.3.2 and earlier as > unsound on every target.** @@ -55,6 +71,15 @@ WHOLE-PROGRAM instructions: jemalloc is 5.3.0; all four arms are the same neutral binary under `LD_PRELOAD`, same callgrind method. +> **Provenance, and a caveat.** These were measured at `v1.1.5` (2026-08-28) and +> **predate the reclamation fixes on `main`** — `collect` reclaiming a bin's last +> page, the periodic `generic_collect`, and the reclaim-and-retry before the +> generic path returns null. Those cost **2.2-7.9 % of allocation throughput** +> measured on an ESP32-S3, so the ratios above are the floor of what the current +> `main` would report, not the number. `bench/icount-arms.sh` regenerates every +> column, and the `icount` CI job runs it on a schedule; **re-run it before +> quoting these for a release.** + Those are whole-program ratios, and they understate the allocator by design, because in a real program most instructions are not the allocator. The same runs, decomposed: @@ -139,6 +164,180 @@ than the cost. configuration with flat, measured RSS (a 6-minute thread-churn soak held 9.4 MiB, slope −0.02 MiB/min). The shipped default leaves purging opt-in. +## Embedded: bare metal, measured on silicon + +`rusty_alloc` builds `no_std` and runs as the `#[global_allocator]` on an +ESP32-S3. Everything below was measured on a **Seeed XIAO ESP32-S3 Sense at +240 MHz**, against **`esp-alloc` 0.11**, the standard allocator for the +`esp-hal` bare-metal track. + +### Throughput — 2.0x to 3.7x faster + +Nanoseconds per allocate/free pair, lower is better: + +| workload | `esp-alloc` | `rusty_alloc` | speedup | +|---|---:|---:|---:| +| 32 B alloc/free, one size | 1,638 | **647** | **2.53x** | +| 64 mixed blocks (8-512 B), batch out then back | 1,792 | **881** | **2.03x** | +| **churn: 64 live, random sizes 8-512 B, random replacement** | 3,987 | **1,087** | **3.67x** | +| 2048 B alloc/free | 1,638 | **1,200** | **1.37x** | + +Both arms measured in the same session, same floor (162 ns/op in each), with +matching checksums. These include the reclamation fixes a stress battery forced +(see below), and the 2 KiB row also carries the medium-band collect-and-retry +that took it from 1.19x to 1.37x. + +The churn row is the one to read. It is the shape real code has, and the shape +that fragments a first-fit free list — which is exactly what a size-class page +allocator with per-class free lists is built not to do. The 2048 B row is the +weakest because at this geometry 2 KiB is the top of the binned range and lands +in a medium page. + +**How the arms are kept honest:** + +- **One binary source.** Both arms are the same firmware; `--cfg` picks the + allocator, so the dependency graph and every other line are identical. +- **Equal budgets.** Both get the same 192 KiB for the speed run, so neither is + advantaged by having more (or less) memory to walk. +- **The harness measures itself.** A baseline arm runs the identical loop, + non-inlined touch and four volatile accesses with *no allocator call*. It cost + **162 ns/op in both arms** and is subtracted from every row above. Without + that subtraction the harness overhead sits inside both arms and compresses the + ratio. +- **Work parity is proven, not assumed.** Every block is written and read back + through `write_volatile`/`read_volatile` (so the optimiser cannot delete an + alloc/free pair and time an empty loop), folded into a checksum that is + printed. **Every checksum matches across the two arms**, so both allocators + provably did the same work. +- **A null arm.** The same benchmark twice within one arm reproduced to the + nanosecond (625 and 625; 1,638 and 1,638), so the resolution floor is below + any gap claimed here. Best-of-5, spread <= 1% on every row. + +### Footprint — this is the cost, not a win + +| | `esp-alloc` | `rusty_alloc` | +|---|---:|---:| +| smallest heap that runs the same workload | **8 KiB** | 68 KiB | +| peak live bytes (identical, the parity check) | 4,914 | 4,914 | +| app image | 116,032 B | 127,088 B (+9.5%) | + +**`esp-alloc` wins this by 8.5x, and the reason is structural rather than a +missing optimisation.** A linked-list heap's floor is `bytes live + per-block +header`. A size-class page allocator's floor is `(size classes touched) x (page +size)` — independent of how many bytes you actually asked for. The measured +workload touches 10 classes and holds 4,914 bytes; nine of its pages hold 1,412 +bytes between them. + +**Read 68 KiB as this workload's floor, not a general budget.** It is the least +memory that runs *this* sketch. A stress battery that touches 24 distinct size +classes holds only 5 of them at 68 KiB — because the floor scales with the +number of classes a program uses, which is the same sentence as above read from +the other end. + +That floor is roughly **fixed** for a given mix of sizes: the same pages serve a +5 KB working set or a 500 KB one. So the crossover is where live bytes approach +`classes x page size` — below it `esp-alloc` wins by construction, above it the +page allocator starts earning what it charges, and the throughput above is what +it buys. + +We got from 192 KiB to 68 KiB by fixing a placement bug in the fixed-region +backend and halving the slice; the full decomposition, the levers taken and the +one deliberately left on the table are in +[`docs/plans/small-metal.md`](docs/plans/small-metal.md). + +### Stress: what a hostile workload does to each + +Eight adversarial tests — every routing boundary, alignment up to a whole +segment, a realloc chain, `alloc_zeroed` over deliberately dirtied memory, a +fragmentation adversary, exhaustion and recovery, a 24-class sweep, and 50,000 +random-replacement churn ops: + +| | `esp-alloc` | `rusty_alloc` | +|---|---|---| +| routing boundaries, realloc chain, zeroing, fragmentation, exhaustion | PASS | PASS | +| 24 distinct size classes held at once | 24 | 21 | +| 64 KiB-aligned request | served | refused | +| NULLs in 50,000 churn allocations | 0 | 357 | +| 512 B capacity over the whole battery | flat 383 | flat 240 | + +**The battery found a real bug and we fixed it.** `collect` was borrowing +`mi_page_retire`'s keep-one-page-per-class rule, so no collect at any level +could return a class's page to a different class, and nothing collected +automatically. On a heap with 16 slices per segment that compounded: capacity +decayed 168 -> 8 blocks and **22,533 of 50,000** churn allocations returned null +while 61,440 bytes sat free. With the fixes, capacity no longer decays at all +and the same churn returns 357. + +The two remaining refusals are the size-class floor, not defects: a 64 KiB-aligned +request needs a whole free 64 KiB segment, and 21-of-24 classes is exactly what +30 slices hold once classes above 512 B cost four slices each. + +**Use it on a microcontroller when** allocation throughput or fragmentation +under churn matters and you have RAM to spare. **Use `esp-alloc` when** the +budget is tight — which on many parts it is. We would rather say that than sell +you the wrong one. + +## Shipping it to a browser + +An integrator reported `rusty_alloc` adding ~12 % to their gzipped wasm bundle. +It was measured, and most of it is gone in 2.0.0. + +| | raw | gzip | overhead vs the Rust default | +|---|---:|---:|---:| +| dlmalloc (Rust default for wasm32) | 15,536 | 6,706 | — | +| rusty_alloc 1.1.x | 34,285 | 14,466 | +7,760 | +| **rusty_alloc 2.0.0** | **25,734** | **10,535** | **+3,829** | + +Measured on a minimal consumer built the way you would ship it (`opt-level="z"`, +`lto="fat"`, `panic="abort"`, `strip`), attributed by a set difference against +the same module without the allocator. The cause was an option-environment pass +that ran on `wasm32-unknown-unknown`, where `std::env::var` is a stub that always +fails: 38 iterations formatting 76 strings every startup, to read an environment +that target does not have. `tools/wasm-size.sh` is now a CI gate so it cannot +come back. The method and what was ruled out are in +[`docs/plans/wasm-size.md`](docs/plans/wasm-size.md). + +### What the bytes buy + +Same module, run in node — nanoseconds per allocate/free pair, net of a measured +harness floor, with checksums proving both allocators did identical work: + +| workload | dlmalloc | rusty_alloc | | +|---|---:|---:|---| +| **churn: 64 live blocks, random 8-512 B** | ~71-78 ns | ~10-15 ns | **4.9-7.3x faster** | +| 2048 B tight alloc/free | ~9-14 ns | ~16-22 ns | 0.55-0.83x | +| 32 B tight alloc/free, and 64 mixed batched | — | — | within noise | + +Churn is the shape real code has, and the one that fragments a free list. The +2048 B row is a tight same-size loop, where a boundary-tag allocator's +free-then-alloc is a single list push and pop; `alloc.rs` carries a dated, +measured note explaining why the obvious fix for it is a regression elsewhere. +Only the two outer rows are claims — the middle two straddle 1.0 across repeats +and are reported as such. Ranges are five runs; reproduce with +[`bench/wasm-speed/`](bench/wasm-speed/). + +**To ship it small:** + +```toml +[profile.release] +opt-level = "z" # "s" if you would rather have the speed +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true +``` + +```sh +# Another ~16% off the RAW size (parse time and memory; gzip already +# captures most of what this does, so the download barely moves). +wasm-opt -Oz --enable-bulk-memory --strip-debug --strip-producers in.wasm -o out.wasm +``` + +**And check your own artifact for build paths.** Rust embeds panic locations as +absolute paths, so a published `.wasm` can carry your home directory and +username. `RUSTFLAGS="--remap-path-prefix=$PWD=."` fixes it on stable; Cargo's +`trim-paths` is still nightly. + ## Correctness evidence Every change runs Windows + Linux suites (all features), `clippy -D warnings`, diff --git a/bench/wasm-speed/Cargo.lock b/bench/wasm-speed/Cargo.lock new file mode 100644 index 0000000..9794f93 --- /dev/null +++ b/bench/wasm-speed/Cargo.lock @@ -0,0 +1,119 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "rusty_alloc" +version = "2.0.0" +dependencies = [ + "libc", + "portable-atomic", + "windows-sys", +] + +[[package]] +name = "rusty_alloc-api" +version = "2.0.0" +dependencies = [ + "rusty_alloc", +] + +[[package]] +name = "speedprobe" +version = "0.0.0" +dependencies = [ + "rusty_alloc", + "rusty_alloc-api", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" diff --git a/bench/wasm-speed/Cargo.toml b/bench/wasm-speed/Cargo.toml new file mode 100644 index 0000000..b1ffea5 --- /dev/null +++ b/bench/wasm-speed/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "speedprobe" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +rusty_alloc-api = { path = "../../crates/rusty_alloc_api", optional = true } +rusty_alloc = { path = "../../crates/rusty_alloc", optional = true } + +[features] +ra = ["dep:rusty_alloc-api", "dep:rusty_alloc"] + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" + +[workspace] diff --git a/bench/wasm-speed/README.md b/bench/wasm-speed/README.md new file mode 100644 index 0000000..415cbbc --- /dev/null +++ b/bench/wasm-speed/README.md @@ -0,0 +1,33 @@ +# wasm-speed — rusty_alloc vs the Rust default allocator, inside a real VM + +Size was measured twice before anyone asked the other half: **do the extra bytes +buy anything?** (`docs/plans/wasm-size.md` §8.) + +```sh +cd bench/wasm-speed +cargo build --release --target wasm32-unknown-unknown && cp target/wasm32-unknown-unknown/release/speedprobe.wasm dl.wasm +cargo build --release --target wasm32-unknown-unknown --features ra && cp target/wasm32-unknown-unknown/release/speedprobe.wasm ra.wasm +node run.mjs dl.wasm ra.wasm +``` + +One source; `--features ra` swaps the allocator and changes nothing else. + +**Read the floor line first.** Both modules run identical floor code, so their +floors must agree; the harness prints a warning when they differ by more than +25 %. The first version of this benchmark reported floors of 5.2 and 0.6 ns/op +for the same code, because V8 tiers wasm up per code path and only one branch +had been warmed. Every branch is warmed now. + +**Checksums must match across arms** — printed on every row. They are what +proves both allocators serviced the identical size sequence, and that the +optimiser did not delete an alloc/free pair and leave an empty loop being timed. + +**Arms are INTERLEAVED within each repeat**, not measured one after the other. +Two A/B attempts that measured arm A to completion and then arm B produced +orderings that disagreed in *sign*, because drift landed entirely on one arm. +Interleaving cancels anything slower than one repeat; a 7 % effect became +resolvable, and both orders then agreed on sign and magnitude. + +**Repeat before believing a row.** Between-process variance is ±25 %, so a 10 % +effect is not resolvable here. Only churn (~5x) and 2048 B (~0.7x) survive +repeats; the other two straddle 1.0 and are reported as ranges. diff --git a/bench/wasm-speed/after.wasm b/bench/wasm-speed/after.wasm new file mode 100644 index 0000000..5630084 Binary files /dev/null and b/bench/wasm-speed/after.wasm differ diff --git a/bench/wasm-speed/before.wasm b/bench/wasm-speed/before.wasm new file mode 100644 index 0000000..ac51e3b Binary files /dev/null and b/bench/wasm-speed/before.wasm differ diff --git a/bench/wasm-speed/dl.wasm b/bench/wasm-speed/dl.wasm new file mode 100644 index 0000000..85f4ce2 Binary files /dev/null and b/bench/wasm-speed/dl.wasm differ diff --git a/bench/wasm-speed/gated.wasm b/bench/wasm-speed/gated.wasm new file mode 100644 index 0000000..1ccf54e Binary files /dev/null and b/bench/wasm-speed/gated.wasm differ diff --git a/bench/wasm-speed/probe.wasm b/bench/wasm-speed/probe.wasm new file mode 100644 index 0000000..2b2a0f3 Binary files /dev/null and b/bench/wasm-speed/probe.wasm differ diff --git a/bench/wasm-speed/ra.wasm b/bench/wasm-speed/ra.wasm new file mode 100644 index 0000000..5630084 Binary files /dev/null and b/bench/wasm-speed/ra.wasm differ diff --git a/bench/wasm-speed/run.mjs b/bench/wasm-speed/run.mjs new file mode 100644 index 0000000..b900e1f --- /dev/null +++ b/bench/wasm-speed/run.mjs @@ -0,0 +1,73 @@ +// Best-of-N, net of a measured floor, with checksums proving work parity. +import { readFileSync } from 'node:fs'; + +const WORK = [ + { kind: 1, name: '32 B alloc/free', iters: 200_000, ops: 200_000 }, + { kind: 2, name: '64 mixed, batched', iters: 3_000, ops: 3_000 * 64 }, + { kind: 3, name: 'churn 64 live, 8-512 B', iters: 200_000, ops: 200_000 }, + { kind: 4, name: '2048 B alloc/free', iters: 200_000, ops: 200_000 }, +]; +const RUNS = 11; + +async function load(path) { + const bytes = readFileSync(path); + const { instance } = await WebAssembly.instantiate(bytes, {}); + return instance.exports.bench; +} + +function best(fn, kind, iters) { + let ns = Infinity, sum = 0; + for (let r = 0; r < RUNS; r++) { + const t0 = process.hrtime.bigint(); + sum = fn(kind, iters) >>> 0; + const t1 = process.hrtime.bigint(); + const d = Number(t1 - t0); + if (d < ns) ns = d; + } + return { ns, sum }; +} + +// INTERLEAVED, not arm-A-then-arm-B. Measuring one module to completion and +// then the other lets slow drift -- another process waking up, a thermal step, +// the OS scheduler -- land entirely on one arm. That produced two orderings +// that disagreed in SIGN on the same change. Alternating the arms within each +// repeat and taking the per-arm minimum cancels any drift slower than one +// repeat, which is what makes a 10% effect resolvable at all. +const names = ['dlmalloc', 'rusty_alloc']; +const fns = [await load(process.argv[2]), await load(process.argv[3])]; +// Warm EVERY branch of BOTH modules first. V8 tiers wasm up per code path, so +// timing a cold branch against a hot one produced a "floor" that differed 8x +// between two modules running IDENTICAL floor code -- the tell that the +// harness, not the allocator, was being measured. +for (const fn of fns) for (const k of [0, 1, 2, 3, 4]) fn(k, k === 2 ? 300 : 20_000); + +const arms = { dlmalloc: { floor: null, rows: {} }, rusty_alloc: { floor: null, rows: {} } }; +const acc = new Map(); // "arm|kind" -> {ns, sum} +for (let r = 0; r < RUNS; r++) { + for (const w of [{ kind: 0, name: '__floor', iters: 200_000 }, ...WORK]) { + for (let a = 0; a < 2; a++) { + const t0 = process.hrtime.bigint(); + const sum = fns[a](w.kind, w.iters) >>> 0; + const d = Number(process.hrtime.bigint() - t0); + const key = `${a}|${w.name}`; + const cur = acc.get(key); + if (!cur || d < cur.ns) acc.set(key, { ns: d, sum }); + } + } +} +for (let a = 0; a < 2; a++) { + arms[names[a]].floor = acc.get(`${a}|__floor`); + for (const w of WORK) arms[names[a]].rows[w.name] = acc.get(`${a}|${w.name}`); +} + +const f0 = arms.dlmalloc.floor.ns / 200_000; +const f1 = arms.rusty_alloc.floor.ns / 200_000; +console.log(`\nharness floor (no allocator call): dlmalloc ${f0.toFixed(1)} ns/op, rusty_alloc ${f1.toFixed(1)} ns/op`); +console.log(`${'workload'.padEnd(24)}${'dlmalloc'.padStart(12)}${'rusty_alloc'.padStart(13)}${'speedup'.padStart(10)} checksums`); +for (const w of WORK) { + const a = arms.dlmalloc.rows[w.name], b = arms.rusty_alloc.rows[w.name]; + const an = a.ns / w.ops - f0, bn = b.ns / w.ops - f1; + const ok = a.sum === b.sum ? `match (${a.sum})` : `MISMATCH ${a.sum} vs ${b.sum}`; + const ar = a.ns / w.ops, br = b.ns / w.ops; + console.log(`${w.name.padEnd(24)}${an.toFixed(1).padStart(10)} ns${bn.toFixed(1).padStart(11)} ns${(an / bn).toFixed(2).padStart(9)}x raw ${ar.toFixed(1)}/${br.toFixed(1)} ${ok}`); +} diff --git a/bench/wasm-speed/src/lib.rs b/bench/wasm-speed/src/lib.rs new file mode 100644 index 0000000..accb07d --- /dev/null +++ b/bench/wasm-speed/src/lib.rs @@ -0,0 +1,138 @@ +// Allocator speed inside a real WebAssembly VM. One source, both arms; `--cfg` +// feature `ra` swaps the allocator and changes nothing else. +// +// Same discipline as the ESP32 harness: a FLOOR arm that allocates nothing, a +// checksum that the optimiser cannot elide the work past, and identical size +// sequences from a seeded PRNG so both arms provably do the same work. +use std::alloc::{alloc, dealloc, Layout}; + +#[cfg(feature = "ra")] +#[global_allocator] +static A: rusty_alloc_api::RustyAlloc = rusty_alloc_api::RustyAlloc; + +static mut SCRATCH: [u8; 4096] = [0; 4096]; + +struct Rng(u32); +impl Rng { + fn next(&mut self) -> u32 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.0 = x; + x + } +} + +#[inline(never)] +fn touch(p: *mut u8, size: usize, tag: u8) -> u32 { + if p.is_null() { + return 0; + } + unsafe { + p.write_volatile(tag); + let last = p.add(size - 1); + last.write_volatile(tag ^ 0x5A); + u32::from(p.read_volatile()) + u32::from(last.read_volatile()) + } +} + +fn take(size: usize) -> *mut u8 { + unsafe { alloc(Layout::from_size_align_unchecked(size, 8)) } +} +fn give(p: *mut u8, size: usize) { + if !p.is_null() { + unsafe { dealloc(p, Layout::from_size_align_unchecked(size, 8)) } + } +} + +/// 0 floor, 1 pingpong-32B, 2 batch-64-mixed, 3 churn, 4 large-2048 +#[no_mangle] +pub extern "C" fn bench(kind: u32, iters: u32) -> u32 { + let mut sum = 0u32; + match kind { + 0 => { + let s = &raw mut SCRATCH as *mut u8; + for i in 0..iters { + sum = sum.wrapping_add(touch(s, 32, i as u8)); + } + } + 1 => { + for i in 0..iters { + let p = take(32); + sum = sum.wrapping_add(touch(p, 32, i as u8)); + give(p, 32); + } + } + 2 => { + const SIZES: [usize; 8] = [8, 24, 48, 96, 160, 256, 384, 512]; + let mut live: Vec<(*mut u8, usize)> = Vec::with_capacity(64); + for r in 0..iters { + for i in 0..64usize { + let sz = SIZES[i & 7]; + let p = take(sz); + sum = sum.wrapping_add(touch(p, sz, r as u8)); + live.push((p, sz)); + } + while let Some((p, sz)) = live.pop() { + give(p, sz); + } + } + } + 3 => { + let mut rng = Rng(0x1234_5678); + let mut live: Vec<(*mut u8, usize)> = Vec::with_capacity(64); + for _ in 0..64 { + let sz = 8 + (rng.next() % 504) as usize; + let p = take(sz); + sum = sum.wrapping_add(touch(p, sz, 1)); + live.push((p, sz)); + } + for _ in 0..iters { + let slot = (rng.next() % 64) as usize; + let (op, osz) = live[slot]; + give(op, osz); + let sz = 8 + (rng.next() % 504) as usize; + let p = take(sz); + sum = sum.wrapping_add(touch(p, sz, 2)); + live[slot] = (p, sz); + } + for (p, sz) in live.drain(..) { + give(p, sz); + } + } + _ => { + for i in 0..iters { + let p = take(2048); + sum = sum.wrapping_add(touch(p, 2048, i as u8)); + give(p, 2048); + } + } + } + sum +} + +/// Slow-path trips, so a speed difference can be attributed instead of guessed. +#[cfg(feature = "ra")] +#[no_mangle] +pub extern "C" fn generic_trips() -> u32 { + rusty_alloc::alloc::stats().generic as u32 +} + +/// Page carves, so "why does this size re-enter the slow path" can be answered +/// with the counter rather than a story about it. +#[cfg(feature = "ra")] +#[no_mangle] +pub extern "C" fn extends() -> u32 { + rusty_alloc::alloc::stats().extends as u32 +} +#[cfg(feature = "ra")] +#[no_mangle] +pub extern "C" fn pages_fresh() -> u32 { + rusty_alloc::alloc::stats().pages_fresh as u32 +} +#[cfg(not(feature = "ra"))] +#[no_mangle] +pub extern "C" fn generic_trips() -> u32 { + 0 +} diff --git a/bench/wasm-speed/target/.rustc_info.json b/bench/wasm-speed/target/.rustc_info.json new file mode 100644 index 0000000..6f6f186 --- /dev/null +++ b/bench/wasm-speed/target/.rustc_info.json @@ -0,0 +1 @@ +{"rustc_fingerprint":10247008043229276228,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\talmo\\.rustup\\toolchains\\1.97.1-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"128\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\nC:\\Users\\talmo\\.rustup\\toolchains\\1.97.1-x86_64-pc-windows-msvc\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"8480363167076041836":{"success":true,"status":"","code":0,"stdout":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.97.1\nLLVM version: 22.1.6\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/bench/wasm-speed/target/CACHEDIR.TAG b/bench/wasm-speed/target/CACHEDIR.TAG new file mode 100644 index 0000000..20d7c31 --- /dev/null +++ b/bench/wasm-speed/target/CACHEDIR.TAG @@ -0,0 +1,3 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by cargo. +# For information about cache directory tags see https://bford.info/cachedir/ diff --git a/bench/wasm-speed/target/release/.cargo-artifact-lock b/bench/wasm-speed/target/release/.cargo-artifact-lock new file mode 100644 index 0000000..e69de29 diff --git a/bench/wasm-speed/target/release/.cargo-build-lock b/bench/wasm-speed/target/release/.cargo-build-lock new file mode 100644 index 0000000..e69de29 diff --git a/bench/wasm-speed/target/release/.cargo-lock b/bench/wasm-speed/target/release/.cargo-lock new file mode 100644 index 0000000..e69de29 diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/CACHEDIR.TAG b/bench/wasm-speed/target/wasm32-unknown-unknown/CACHEDIR.TAG new file mode 100644 index 0000000..20d7c31 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/CACHEDIR.TAG @@ -0,0 +1,3 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by cargo. +# For information about cache directory tags see https://bford.info/cachedir/ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.cargo-artifact-lock b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.cargo-artifact-lock new file mode 100644 index 0000000..e69de29 diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.cargo-build-lock b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.cargo-build-lock new file mode 100644 index 0000000..e69de29 diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.cargo-lock b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.cargo-lock new file mode 100644 index 0000000..e69de29 diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/dep-lib-rusty_alloc b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/dep-lib-rusty_alloc new file mode 100644 index 0000000..c35a78f Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/dep-lib-rusty_alloc differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/invoked.timestamp b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/invoked.timestamp new file mode 100644 index 0000000..e00328d --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/invoked.timestamp @@ -0,0 +1 @@ +This file has an mtime of when this was started. \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/lib-rusty_alloc b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/lib-rusty_alloc new file mode 100644 index 0000000..628d8de --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/lib-rusty_alloc @@ -0,0 +1 @@ +9fde96e39c204a3d \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/lib-rusty_alloc.json b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/lib-rusty_alloc.json new file mode 100644 index 0000000..cf67230 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/lib-rusty_alloc.json @@ -0,0 +1 @@ +{"rustc":3720210673988096810,"features":"[\"default\", \"std\"]","declared_features":"[\"blockmap\", \"debug_checks\", \"default\", \"linkcheck\", \"profile\", \"secure\", \"std\"]","target":10085266625313816817,"profile":12927086336690686898,"path":2916297298608968487,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"wasm32-unknown-unknown\\release\\.fingerprint\\rusty_alloc-30203c3fd9d3b00b\\dep-lib-rusty_alloc","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":14682669768258224367} \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/output-lib-rusty_alloc b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/output-lib-rusty_alloc new file mode 100644 index 0000000..ef30ac7 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-30203c3fd9d3b00b/output-lib-rusty_alloc @@ -0,0 +1,2 @@ +{"$message_type":"diagnostic","message":"function `parse_value` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"F:\\coding\\rusty_alloc\\crates\\rusty_alloc\\src\\options.rs","byte_start":13654,"byte_end":13665,"line_start":333,"line_end":333,"column_start":4,"column_end":15,"is_primary":true,"text":[{"text":"fn parse_value(s: &str) -> Option {","highlight_start":4,"highlight_end":15}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"`#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[93mwarning\u001b[0m\u001b[1m\u001b[97m: function `parse_value` is never used\u001b[0m\n \u001b[1m\u001b[96m--> \u001b[0mF:\\coding\\rusty_alloc\\crates\\rusty_alloc\\src\\options.rs:333:4\n \u001b[1m\u001b[96m|\u001b[0m\n\u001b[1m\u001b[96m333\u001b[0m \u001b[1m\u001b[96m|\u001b[0m fn parse_value(s: &str) -> Option {\n \u001b[1m\u001b[96m|\u001b[0m \u001b[1m\u001b[93m^^^^^^^^^^^\u001b[0m\n \u001b[1m\u001b[96m|\u001b[0m\n \u001b[1m\u001b[96m= \u001b[0m\u001b[1m\u001b[97mnote\u001b[0m: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default\n\n"} +{"$message_type":"diagnostic","message":"1 warning emitted","code":null,"level":"warning","spans":[],"children":[],"rendered":"\u001b[1m\u001b[93mwarning\u001b[0m\u001b[1m\u001b[97m: 1 warning emitted\u001b[0m\n\n"} diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/dep-lib-rusty_alloc_api b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/dep-lib-rusty_alloc_api new file mode 100644 index 0000000..02bca30 Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/dep-lib-rusty_alloc_api differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/invoked.timestamp b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/invoked.timestamp new file mode 100644 index 0000000..e00328d --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/invoked.timestamp @@ -0,0 +1 @@ +This file has an mtime of when this was started. \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/lib-rusty_alloc_api b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/lib-rusty_alloc_api new file mode 100644 index 0000000..eccdbee --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/lib-rusty_alloc_api @@ -0,0 +1 @@ +1e99924ea3cbaf62 \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/lib-rusty_alloc_api.json b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/lib-rusty_alloc_api.json new file mode 100644 index 0000000..b5c8202 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/rusty_alloc-api-65fdac25e68ac577/lib-rusty_alloc_api.json @@ -0,0 +1 @@ +{"rustc":3720210673988096810,"features":"[\"default\", \"std\"]","declared_features":"[\"debug_checks\", \"default\", \"profile\", \"secure\", \"std\"]","target":10444181462946702350,"profile":12927086336690686898,"path":17481420428904646363,"deps":[[2387993517738183206,"rusty_alloc",false,4416378242795495071]],"local":[{"CheckDepInfo":{"dep_info":"wasm32-unknown-unknown\\release\\.fingerprint\\rusty_alloc-api-65fdac25e68ac577\\dep-lib-rusty_alloc_api","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":14682669768258224367} \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/dep-lib-speedprobe b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/dep-lib-speedprobe new file mode 100644 index 0000000..02bca30 Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/dep-lib-speedprobe differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/invoked.timestamp b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/invoked.timestamp new file mode 100644 index 0000000..e00328d --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/invoked.timestamp @@ -0,0 +1 @@ +This file has an mtime of when this was started. \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/lib-speedprobe b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/lib-speedprobe new file mode 100644 index 0000000..dfcf9b3 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/lib-speedprobe @@ -0,0 +1 @@ +3fa7fc3be998846a \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/lib-speedprobe.json b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/lib-speedprobe.json new file mode 100644 index 0000000..3570d03 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/.fingerprint/speedprobe-f92ee3d991fb435d/lib-speedprobe.json @@ -0,0 +1 @@ +{"rustc":3720210673988096810,"features":"[\"ra\"]","declared_features":"[\"ra\"]","target":16610879620610110527,"profile":5652530863572545030,"path":10763286916239946207,"deps":[[2387993517738183206,"rusty_alloc",false,4416378242795495071],[17777520122732431131,"rusty_alloc_api",false,7111126238899640606]],"local":[{"CheckDepInfo":{"dep_info":"wasm32-unknown-unknown\\release\\.fingerprint\\speedprobe-f92ee3d991fb435d\\dep-lib-speedprobe","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":14682669768258224367} \ No newline at end of file diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc-30203c3fd9d3b00b.rlib b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc-30203c3fd9d3b00b.rlib new file mode 100644 index 0000000..6064302 Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc-30203c3fd9d3b00b.rlib differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc-30203c3fd9d3b00b.rmeta b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc-30203c3fd9d3b00b.rmeta new file mode 100644 index 0000000..2e62cbf Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc-30203c3fd9d3b00b.rmeta differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc_api-65fdac25e68ac577.rlib b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc_api-65fdac25e68ac577.rlib new file mode 100644 index 0000000..73c71fd Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc_api-65fdac25e68ac577.rlib differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc_api-65fdac25e68ac577.rmeta b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc_api-65fdac25e68ac577.rmeta new file mode 100644 index 0000000..fa7a7d0 Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/librusty_alloc_api-65fdac25e68ac577.rmeta differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/rusty_alloc-30203c3fd9d3b00b.d b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/rusty_alloc-30203c3fd9d3b00b.d new file mode 100644 index 0000000..0633985 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/rusty_alloc-30203c3fd9d3b00b.d @@ -0,0 +1,26 @@ +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\rusty_alloc-30203c3fd9d3b00b.d: F:\coding\rusty_alloc\crates\rusty_alloc\src\lib.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\alloc.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\arena.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\bins.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\heap.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\init.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\options.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\os.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\page.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\mod.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\wasm.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\fixed.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\random.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment_map.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\slice_pool.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\stats.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\types.rs + +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\librusty_alloc-30203c3fd9d3b00b.rlib: F:\coding\rusty_alloc\crates\rusty_alloc\src\lib.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\alloc.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\arena.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\bins.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\heap.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\init.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\options.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\os.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\page.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\mod.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\wasm.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\fixed.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\random.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment_map.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\slice_pool.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\stats.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\types.rs + +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\librusty_alloc-30203c3fd9d3b00b.rmeta: F:\coding\rusty_alloc\crates\rusty_alloc\src\lib.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\alloc.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\arena.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\bins.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\heap.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\init.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\options.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\os.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\page.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\mod.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\wasm.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\fixed.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\random.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment_map.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\slice_pool.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\stats.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\types.rs + +F:\coding\rusty_alloc\crates\rusty_alloc\src\lib.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\alloc.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\arena.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\bins.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\heap.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\init.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\options.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\os.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\page.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\mod.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\wasm.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\fixed.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\random.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\segment.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\segment_map.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\slice_pool.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\stats.rs: +F:\coding\rusty_alloc\crates\rusty_alloc\src\types.rs: + +# env-dep:CARGO_PKG_VERSION=2.0.0 diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/rusty_alloc_api-65fdac25e68ac577.d b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/rusty_alloc_api-65fdac25e68ac577.d new file mode 100644 index 0000000..dca9d55 --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/rusty_alloc_api-65fdac25e68ac577.d @@ -0,0 +1,7 @@ +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\rusty_alloc_api-65fdac25e68ac577.d: F:\coding\rusty_alloc\crates\rusty_alloc_api\src\lib.rs + +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\librusty_alloc_api-65fdac25e68ac577.rlib: F:\coding\rusty_alloc\crates\rusty_alloc_api\src\lib.rs + +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\librusty_alloc_api-65fdac25e68ac577.rmeta: F:\coding\rusty_alloc\crates\rusty_alloc_api\src\lib.rs + +F:\coding\rusty_alloc\crates\rusty_alloc_api\src\lib.rs: diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/speedprobe.d b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/speedprobe.d new file mode 100644 index 0000000..eaf0fbf --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/speedprobe.d @@ -0,0 +1,5 @@ +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\speedprobe.d: src\lib.rs + +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\deps\speedprobe.wasm: src\lib.rs + +src\lib.rs: diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/speedprobe.wasm b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/speedprobe.wasm new file mode 100644 index 0000000..5630084 Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/deps/speedprobe.wasm differ diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/speedprobe.d b/bench/wasm-speed/target/wasm32-unknown-unknown/release/speedprobe.d new file mode 100644 index 0000000..635fd0c --- /dev/null +++ b/bench/wasm-speed/target/wasm32-unknown-unknown/release/speedprobe.d @@ -0,0 +1 @@ +F:\coding\rusty_alloc\bench\wasm-speed\target\wasm32-unknown-unknown\release\speedprobe.wasm: F:\coding\rusty_alloc\bench\wasm-speed\src\lib.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\alloc.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\arena.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\bins.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\heap.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\init.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\lib.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\options.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\os.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\page.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\fixed.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\mod.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\prim\wasm.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\random.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\segment_map.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\slice_pool.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\stats.rs F:\coding\rusty_alloc\crates\rusty_alloc\src\types.rs F:\coding\rusty_alloc\crates\rusty_alloc_api\src\lib.rs diff --git a/bench/wasm-speed/target/wasm32-unknown-unknown/release/speedprobe.wasm b/bench/wasm-speed/target/wasm32-unknown-unknown/release/speedprobe.wasm new file mode 100644 index 0000000..5630084 Binary files /dev/null and b/bench/wasm-speed/target/wasm32-unknown-unknown/release/speedprobe.wasm differ diff --git a/crates/rusty_alloc/CHANGELOG.md b/crates/rusty_alloc/CHANGELOG.md index 9152e65..87d89c7 100644 --- a/crates/rusty_alloc/CHANGELOG.md +++ b/crates/rusty_alloc/CHANGELOG.md @@ -7,6 +7,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v1.1.6...rusty_alloc-v2.0.0) - 2026-09-07 + +### Breaking + +Two changes require a major bump. Neither affects a consumer using default +features, which is the overwhelming majority. + +- **`default-features = false` now selects `no_std`.** Before this release the + crate had `default = []`, so `default-features = false` was identical to the + default and got the full crate. It now selects the single-heap `no_std` + profile, which additionally refuses to compile without + `--cfg ra_single_threaded`. If you set `default-features = false` and want + what you had, set `features = ["std"]`. **Verified against the downstream + corpus** (`tools/corpus/`): `spacedb-sdk` (plain and `secure`), + `rusty_alloc_default` and `rusty_zstd` compile unchanged; `rusty_maplibre` is + broken by this and is fixed by adding `features = ["std"]` to both of its + `rusty_alloc` dependencies -- tested by applying it, not assumed. +- **`heap::Heap` gained a field** (`generic_countdown`). Every field on it is + `pub` and it is not `#[non_exhaustive]`, so a struct literal naming all + fields no longer compiles. Nothing constructs a `Heap` that way in practice — + it needs raw pointers and a `PageQueue` array — but semver is semver. + +### Fixed + +- *(heap)* **`collect` could not reclaim a size class's last empty page.** The + keep-one-page-per-bin reuse cache is `mi_page_retire`'s policy on the free + path; `collect` had borrowed it, so no collect at any level could return a + class's page to a different class. Upstream's `mi_heap_page_collect` frees an + all-free page unconditionally ("this will free retired pages as well"). + Harmless at the default 32 MiB geometry, where 512 slices per segment absorb + a cached page per class; severe where slices are scarce. +- *(heap)* **nothing ever collected automatically.** `generic_collect` was + declared as an option with a default of 10,000 and read nowhere. It is now a + per-heap countdown on the generic path, as upstream. +- *(heap)* **the generic path reported OOM while holding reclaimable memory.** + It now reclaims once and retries before returning null. Free on the happy + path — it runs only when the allocation was about to fail. +- *(segment)* an exclusive arena's huge-path allocations could escape to the OS + instead of failing, diverging from `mi_segment_huge_page_alloc`. +- *(arena)* the chunk bitmap scan stopped at the first word: an arena of more + than 32 chunks could not allocate past chunk 31. +- *(heap)* `stats.segments` was not incremented on the huge path, so it did not + balance `segments_freed`. +- *(prim)* the fixed-region backend placed every allocation bottom-up, so one + page-sized block below a segment boundary cost a whole segment of reach. + +### Added + +- `no_std` support behind a default-on `std` feature: the core allocator builds + and runs on bare metal (`riscv32imac`/`imafc` gated in CI, ESP32-S3 measured + on hardware). +- `ra_small_profile`: a second geometry (4 KiB slices, 64 KiB segments) for + parts with kilobytes rather than gigabytes. Non-additive, so it is a `--cfg` + rather than a feature. +- `prim::fixed`, a fixed-region backend for targets with no OS — + `init_region`, `region_stats`. +- `options::GENERIC_COLLECT`, the index of the `generic_collect` option. +- `segment_map::range_table_overflowed`, an observable latch for the + small-profile segment map. + +### Changed + +- 64-bit atomics on targets without them: `no_std` builds use two `AtomicU32` + halves instead of `portable-atomic`'s lock-based fallback (sound under the + single-threaded assumption `no_std` already carries), removing the dependency + from that path. + +### Performance + +- Medium allocations (above `SMALL_SIZE_MAX`, up to `MEDIUM_OBJ_SIZE_MAX`) + collect-and-retry the bin queue front before the slow-path heartbeat. Through + `GlobalAlloc` those sizes reach `malloc_generic` on **1.000** of their calls, + so the saving lands on every one: **+14-16 % on a 2 KiB tight alloc/free + loop**, and it survives multithreading (+12-26 % with two threads each freeing + their own blocks). The retry **turns itself off per heap** once that heap is + seen receiving cross-thread frees: `free`, `local_free` and `xthread_free` are + adjacent in a `#[repr(C)]` `Page`, so peeking a page another core is freeing + into costs 20-30 %, and no variant of the peek avoids it. Measured on all + three targets that can run it: **+14-16 % native x86-64, +1-16 % wasm32 in + V8, +15 % on an ESP32-S3** (`no_std`, small profile), with every other + workload unchanged. Host INSTRUCTION counts under callgrind still pending. +- **On wasm, 4.9-7.3x faster than the Rust default allocator on a churn workload** + (64 live blocks, random 8-512 B), measured in node with a subtracted harness + floor and checksums proving work parity. A tight 2 KiB same-size loop is + ~0.7x, for a reason `alloc.rs` already documents as measured-and-refuted. +- **wasm modules are 8,551 bytes smaller (3,931 gzipped)** — the allocator's + gzipped overhead on a minimal consumer falls from +7,760 to +3,829 bytes, + roughly halving what it adds to a bundle. Two causes, both measured + (`docs/plans/wasm-size.md`), plus a `tools/wasm-size.sh` CI ratchet so it + cannot regress unnoticed. +- `ra_thread_local!` takes the single-`static` arm on `wasm32-unknown-unknown` + without the atomics proposal, which `prim/wasm.rs` has assumed single-threaded + since it was written. A `std::thread_local!` there linked lazy init, + destructor registration and an "accessed during or after destruction" panic + that can never run. +- **wasm modules are 8,180 bytes smaller (3,700 gzipped).** The option + environment pass ran on `wasm32-unknown-unknown`, where `std::env::var` is a + stub that always fails: 38 iterations formatting 76 strings and allocating 76 + `String`s at startup, to read an environment that cannot exist. It also made + `options::get` the largest function in a wasm build at 3,708 bytes and dragged + `str::to_uppercase`, `alloc::fmt::format` and the `OPTION_NAMES` table in with + it. Measured against the Rust default allocator on a minimal consumer, the + allocator's gzipped overhead falls from +7,760 to +4,060 bytes. +- `options::error` renders its code into a stack buffer instead of `format!`, + and `out_fmt` writes bytes instead of `eprint!`. Worth ~0 on wasm (measured), + but it removes an allocation from an error path and gives `no_std` back the + message it used to lose. +- **On an ESP32-S3, 2.06-3.73x faster than `esp-alloc`** across four + allocate/free workloads, measured with a subtracted harness floor and + checksums proving work parity (`docs/plans/small-metal.md` §2.15). +- The reclamation fixes above cost 2.2-7.9 % of allocation throughput on that + board. **The host instruction counts in the README predate them** and are + pending re-measurement; a scheduled `icount` CI job now regenerates them. +- `generic_collect`'s default is geometry-aware: upstream's 10,000 at the + shipped geometry, 512 under `ra_small_profile`, chosen from a measured sweep + rather than picked. + ## [1.1.6](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v1.1.5...rusty_alloc-v1.1.6) - 2026-08-28 ### Other diff --git a/crates/rusty_alloc/Cargo.toml b/crates/rusty_alloc/Cargo.toml index 1738bd8..7106eba 100644 --- a/crates/rusty_alloc/Cargo.toml +++ b/crates/rusty_alloc/Cargo.toml @@ -11,8 +11,31 @@ keywords.workspace = true categories.workspace = true readme = "README.md" +# The crate has NO dependencies on any target it currently ships to — this +# entry is compiled ONLY for targets that lack a 64-bit atomic (32-bit RISC-V +# and Xtensa: the Janus ESP32-C3/C6/S3 parts), so the x86-64, aarch64, wasm32 +# and Windows builds remain dependency-free. +# +# Why a shim here and NARROWING everywhere else (P3 of docs/plans/small-metal.md): +# the crate's other 64-bit atomics are BITMAPS, whose word width is a free +# choice, and those were narrowed to `u32` — no dependency, still lock-free. +# These two are different: `options::{get,set}` are `i64` in an API frozen at +# v2.0.0, and `DeferredFreeFun`'s heartbeat is a `u64` in the C ABI shared with +# mimalloc's `mi_deferred_free_fun`. Narrowing either breaks a contract, and +# hand-rolling a 64-bit atomic out of two 32-bit halves inside an allocator is +# exactly the kind of thing that produces a subtle bug. +[target.'cfg(not(target_has_atomic = "64"))'.dependencies] +portable-atomic = { version = "1", default-features = false, features = ["fallback"] } + [features] -default = [] +# `std` is DEFAULT and additive — unlike the geometry `--cfg` (P2), which is +# deliberately not a feature. A feature is right here precisely because it IS +# additive: if any consumer in a graph needs `std`, enabling it for all of them +# is harmless. Turning it OFF is what a firmware does, and it selects the +# single-heap profile: no `thread_local!`, no environment, no `process::abort` +# (see `lib.rs`). P3 of docs/plans/small-metal.md. +default = ["std"] +std = [] # Full invariant checking: list walks, canaries, double-free detection (our `dmi`). debug_checks = [] # Feature-gated rdtsc path profiler (§7.5 of the plan). OFF = byte-identical build. diff --git a/crates/rusty_alloc/README.md b/crates/rusty_alloc/README.md index f748fed..10db459 100644 --- a/crates/rusty_alloc/README.md +++ b/crates/rusty_alloc/README.md @@ -81,6 +81,39 @@ perl produce **byte-identical output** under rusty_alloc, mimalloc and glibc; the full mimalloc-bench corpus (19 configurations, including the 8–16-thread storms) runs clean; Miri is clean over the whole target. +## Embedded: 2.0-3.7x faster than `esp-alloc` on an ESP32-S3 + +Builds `no_std` and runs as the `#[global_allocator]` on bare metal. Measured on +a Seeed XIAO ESP32-S3 Sense at 240 MHz against `esp-alloc` 0.11 — nanoseconds +per allocate/free pair, lower is better: + +| workload | `esp-alloc` | `rusty_alloc` | speedup | +|---|---:|---:|---:| +| 32 B alloc/free | 1,638 | **647** | **2.53x** | +| 64 mixed blocks (8-512 B), batched | 1,792 | **881** | **2.03x** | +| **churn: 64 live, random 8-512 B** | 3,987 | **1,087** | **3.67x** | +| 2048 B alloc/free | 1,638 | **1,200** | **1.37x** | + +Both arms are one firmware source with `--cfg` picking the allocator, given +equal budgets. A baseline arm with no allocator call measured 162 ns/op in both +and is subtracted from every row. Blocks are touched through volatile +reads/writes and folded into a checksum that **matches across both arms**, so +the work is provably identical; the same benchmark run twice in one arm +reproduced to the nanosecond. + +An eight-test adversarial battery runs clean on both (boundaries, alignment, +realloc chains, zeroing over dirtied memory, fragmentation, exhaustion); it +found and fixed a real reclamation bug on the way, after which 512 B capacity no +longer decays and churn NULLs fell from 22,533 to 357 per 50,000. + +**It costs RAM to get that.** The smallest heap that runs the same workload is +**68 KiB for `rusty_alloc` against 8 KiB for `esp-alloc`** — a linked-list +heap's floor is `bytes live + header`, while a size-class page allocator's is +`(classes touched) x (page size)`, independent of bytes requested. That floor is +roughly fixed, so it amortises as the working set grows. Reach for `esp-alloc` +when the budget is tight, and for this when throughput or fragmentation under +churn is what hurts. + ## Usage This crate is the allocator core. For the ergonomic Rust surface diff --git a/crates/rusty_alloc/UNSAFE.md b/crates/rusty_alloc/UNSAFE.md index 6750f95..9b719b3 100644 --- a/crates/rusty_alloc/UNSAFE.md +++ b/crates/rusty_alloc/UNSAFE.md @@ -21,7 +21,7 @@ unsafe in DEPENDENCIES, and ours are `libc` plus bindings-only `windows-sys`. | Module | Count | What the `unsafe` is for | Last audit | |---|---:|---|---| | `alloc.rs` | 94 | The public entry points: raw-pointer reads on the malloc fast path (must never form `&mut` on the shared empty-heap sentinel), pointer-derived metadata on the free path (`segment_of`/`page_of`), block-content copies in the realloc family. **+15 on 2026-08-22**: the free path's two `asm!` sites — the memory-destination `used--` whose flags drive the retire branch (its `label` block is a separate item and carries its own `unsafe`), and the fused `cmp {tid}, fs:0` in both `free_inline` and `free_general` — plus `malloc_or`/`malloc_or_slow`, which give `operator new` a fast path whose miss is a tail call. Each asm reads or writes exactly one field it already had a valid pointer to; none widens what the surrounding code could already touch | 2026-08-22 (free campaign; every new block reviewed at the site) | -| `heap.rs` | 67 | Owner-thread page/queue manipulation under raw pointers (no two `&mut Page` may coexist), the aligned-allocation peek, span carving. **+3 on 2026-08-19**: `try_unlink_huge_segment` split out of `remove_huge_segment`. **+15 on 2026-08-22**: `malloc_generic` split into a small entry plus `malloc_generic_walk`, with `grow_front`, `try_guarded` and `drain_delayed` as cold arms — each split adds an `unsafe fn` signature and its block while dereferencing nothing the single function did not — and the immortal `EMPTY_DELAYED` sentinel that lets the heartbeat read its list without a null test | 2026-08-22 (slow-path and free campaigns) | +| `heap.rs` | 71 | Owner-thread page/queue manipulation under raw pointers (no two `&mut Page` may coexist), the aligned-allocation peek, span carving. **+3 on 2026-08-19**: `try_unlink_huge_segment` split out of `remove_huge_segment`. **+15 on 2026-08-22**: `malloc_generic` split into a small entry plus `malloc_generic_walk`, with `grow_front`, `try_guarded` and `drain_delayed` as cold arms — each split adds an `unsafe fn` signature and its block while dereferencing nothing the single function did not — and the immortal `EMPTY_DELAYED` sentinel that lets the heartbeat read its list without a null test. **+2 on 2026-09-07 (P4e, §2.15 of `docs/plans/small-metal.md`)**: the reclamation fixes — one `unsafe` for the periodic `generic_collect` sweep and one for the reclaim-and-retry that runs before `malloc_generic` returns null. Both call `collect_inner`, which allocates nothing and touches only this heap’s own pages on the owner thread; each carries its SAFETY line. `malloc_generic` itself became a safe wrapper over the renamed `malloc_generic_once`, so the split added no signature. **+2 on 2026-09-08:** the medium collect-and-retry ahead of the heartbeat -- one `unsafe` around `page_collect` + `page_pop` on the bin queue front, one reading `free_is_zero` off the page just popped. Both are the SAME operations `malloc_generic_walk` performs on the same page a few lines later, on the owner thread under the heap lock: the block moves earlier, nothing new is dereferenced. Each carries its SAFETY line | 2026-09-08 | | `init.rs` | 36 | Thread/heap lifecycle: the initial-exec TLS slot (`global_asm!` + fs-relative asm reads), thread-pointer register reads (`fs:0`/`gs:0x30`/`tpidrro_el0`), heap-box creation/teardown, the abandonment path run inside platform TLS destructors | 2026-08-19 (`.tdata` sentinel redesign) | | `segment.rs` | 35 | Segment/page metadata addressing: the mask trick (`segment_of`), `page_of`'s contract-based indexing — **the bound is now PROVED for every in-segment offset by `proofs.rs` (Kani), not merely asserted** — span tiling, purge/recommit | 2026-08-19 | | `prim/windows.rs` | 30 | OS FFI: VirtualAlloc family, FLS destructors, QPC, BCryptGenRandom | 2026-08-08 (0.4.0) | @@ -32,11 +32,13 @@ unsafe in DEPENDENCIES, and ours are `libc` plus bindings-only `windows-sys`. | `os.rs` | 12 | The prim-layer wrapper: commit/decommit/protect plumbing | 2026-08-08 | | `prim/mock.rs` | 8 | Miri-only mock OS backend (never shipped; `cfg(miri)`) | 2026-08-06 | | `arena.rs` | 8 | Lock-free chunk bitmap claim/verify, recycled-chunk scrubbing (the 0.1.0-alpha.2 UAF fix lives here: `wait_no_remote_in_flight` on every recycle path) | 2026-08-08 | +| `prim/fixed.rs` | 25 | **New 2026-09-07 (P1 of `docs/plans/small-metal.md`).** The fixed-region backend for a target with no OS: memory is a `&'static mut [u8]` handed over once. **6 of the 18 are `unsafe fn` signatures the prim seam requires** (`alloc`/`free`/`commit`/`decommit`/`reset`/`protect`) whose *bodies contain no unsafe operation at all* — the free list is `AtomicUsize` arrays under a spin lock and the pointers are built with the safe `with_exposed_provenance_mut`, so this backend adds **zero** unsafe dereferences to the shipped crate. The other 12 are in `#[cfg(test)]`: two `&raw mut` static-region handoffs and ten calls through the `unsafe fn` seam, each with its SAFETY line. **+1 on 2026-09-07 (P2):** the region test became two-sided — where the shipped geometry refuses a segment-sized request from a 512 KiB region, the small profile SERVES one, so the test now frees it too. Audited at the site; the module is `allow(dead_code)` and unreachable on every platform that has an arm above it. **+7 on 2026-09-07 (P4b, §2.9/§2.10 of the same plan):** the two-ended `place` rule added ZERO unsafe to shipped code — `place` is a pure arithmetic `fn` and the scan around it is unchanged — and all seven are in `#[cfg(test)]`: one `slice::from_raw_parts_mut` carving the `REGION_ALIGN`-aligned window out of `BACKING` (replacing a `&mut *ptr` that a `repr(align(65536))` static would have needed, which rustc 1.97.1 on MSVC cannot compile), one `ptr::add` to reach that window, and five calls through the `unsafe fn` seam in the placement assertions and in `greedy_segments`, which allocates segments until refusal and frees every one before returning. Each carries its SAFETY line | 2026-09-07 (P4b) | | `prim/wasm.rs` | 6 | `memory.grow` linear-memory backend | 2026-08-06 | | `options.rs` | 6 | Env parsing at init, registered-hook invocation | 2026-08-06 | | `stats.rs` | 3 | Volatile whole-struct snapshot of racy-by-design counters | 2026-08-06 | | `random.rs` | 2 | OS entropy seeding via the prim layer | 2026-08-08 | -| `types.rs`, `bins.rs`, `segment_map.rs`, `lib.rs` | 0 | safe | — | +| `lib.rs` | 2 | **New 2026-09-07 (P3).** One `unsafe impl Sync for SingleThreadCell` — the `no_std` half of `ra_thread_local!`. With `std` the macro is `std::thread_local!` verbatim and this type does not exist; without it, a "thread-local" is a plain `static`, sound because the crate serves `no_std` only on single-threaded targets (the same standing assumption as `prim::fixed`: constant thread id, TLS destructors that never fire, a spin lock that never contends). A `no_std` build on a target that grows threads must revisit this type first — the SAFETY comment says so at the impl. **+1 on 2026-09-07 (P5): PROSE, not code.** The census is a text search, and the `compile_error!` that now forces `--cfg ra_single_threaded` on a `no_std` build names `unsafe impl Sync` in its message so the person who hits it knows what they are opting into. Counted, and left counted rather than reworded: the ratchet is allowed to be conservative, and a message that names the thing is worth one line of baseline | 2026-09-07 (P5) | +| `types.rs`, `bins.rs`, `segment_map.rs` | 0 | safe | — | ### The `publish = false` crates diff --git a/crates/rusty_alloc/src/arena.rs b/crates/rusty_alloc/src/arena.rs index 6e2a2a5..6d263dc 100644 --- a/crates/rusty_alloc/src/arena.rs +++ b/crates/rusty_alloc/src/arena.rs @@ -9,7 +9,20 @@ //! (ever-used → its memory is NOT zero — feeds the segment zero-tracking). use core::ptr; -use core::sync::atomic::{AtomicPtr, AtomicU64, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicPtr, AtomicU32, AtomicUsize, Ordering}; + +/// Word type of the chunk bitmaps. +/// +/// `u32`, not `u64`: a bitmap's word width is a FREE CHOICE — the same total +/// bits either way — and 32-bit RISC-V (ESP32-C3/C6) and Xtensa (ESP32-S3) +/// have no 64-bit atomic at all. This is the one CORRECTNESS-path 64-bit +/// atomic the crate had (P3 of `docs/plans/small-metal.md`, §2.2), and +/// narrowing it keeps the claim/verify loop lock-free rather than routing it +/// through `portable-atomic`'s fallback lock. +type BitWord = u32; +type AtomicBitWord = AtomicU32; +/// Bits per bitmap word. +const WORD_BITS: usize = BitWord::BITS as usize; use crate::os; use crate::types::SEGMENT_SIZE; @@ -48,8 +61,8 @@ pub struct Arena { pub owned: bool, /// Advisory NUMA node (recorded; placement lands post-v1). pub numa_node: i32, - used: [AtomicU64; MAX_CHUNKS / 64], - dirty: [AtomicU64; MAX_CHUNKS / 64], + used: [AtomicBitWord; MAX_CHUNKS / WORD_BITS], + dirty: [AtomicBitWord; MAX_CHUNKS / WORD_BITS], } static ARENAS: [AtomicPtr; MAX_ARENAS] = @@ -73,7 +86,7 @@ fn arena_register( let desc = os::alloc_aligned(core::mem::size_of::(), os::page_size(), true, false) .map_err(|_| ())?; let a: *mut Arena = desc.ptr.cast(); - // SAFETY: fresh zeroed mapping; AtomicU64 zero bit-pattern is valid, so + // SAFETY: fresh zeroed mapping; the bitmap words' zero bit-pattern is valid, so // only the scalar fields need writing. unsafe { (*a).base = base; @@ -140,11 +153,11 @@ pub fn manage_os_memory_ex( // SAFETY: freshly registered arena descriptor. unsafe { let chunks = (*a).chunks_live.load(Ordering::Acquire); - for w in 0..chunks.div_ceil(64) { - let bits = if (w + 1) * 64 <= chunks { - u64::MAX + for w in 0..chunks.div_ceil(WORD_BITS) { + let bits = if (w + 1) * WORD_BITS <= chunks { + BitWord::MAX } else { - (1u64 << (chunks % 64)) - 1 + ((1 as BitWord) << (chunks % WORD_BITS)) - 1 }; (*a).dirty[w].store(bits, Ordering::Relaxed); } @@ -255,20 +268,20 @@ fn chunk_alloc_inner(restrict_id: i32) -> Option<(*mut u8, bool)> { continue; } let chunks = (*a).chunks_live.load(Ordering::Acquire); - let words = chunks.div_ceil(64); + let words = chunks.div_ceil(WORD_BITS); for w in 0..words { loop { let cur = (*a).used[w].load(Ordering::Acquire); - let limit = if (w + 1) * 64 <= chunks { - 64 + let limit = if (w + 1) * WORD_BITS <= chunks { + WORD_BITS } else { - chunks % 64 + chunks % WORD_BITS }; let free_bits = !cur - & if limit == 64 { - u64::MAX + & if limit == WORD_BITS { + BitWord::MAX } else { - (1u64 << limit) - 1 + ((1 as BitWord) << limit) - 1 }; if free_bits == 0 { break; @@ -285,7 +298,7 @@ fn chunk_alloc_inner(restrict_id: i32) -> Option<(*mut u8, bool)> { { continue; } - let idx = w * 64 + bit; + let idx = w * WORD_BITS + bit; let was_dirty = (*a).dirty[w].fetch_or(1 << bit, Ordering::AcqRel) & (1 << bit) != 0; let p = (*a).base.add(idx * SEGMENT_SIZE); @@ -348,7 +361,8 @@ fn chunk_alloc_n_inner(restrict_id: i32, n: usize) -> Option<(*mut u8, bool)> { let mut run = 0usize; let mut idx = 0usize; while idx < chunks { - let bit = (*a).used[idx / 64].load(Ordering::Acquire) & (1 << (idx % 64)); + let bit = (*a).used[idx / WORD_BITS].load(Ordering::Acquire) + & (1 << (idx % WORD_BITS)); run = if bit == 0 { run + 1 } else { 0 }; if run == n { let start = idx + 1 - n; @@ -362,15 +376,17 @@ fn chunk_alloc_n_inner(restrict_id: i32, n: usize) -> Option<(*mut u8, bool)> { // the parallel-test corruption found in M8. let mut conflict = None; for j in start..=idx { - let prev = (*a).used[j / 64].fetch_or(1 << (j % 64), Ordering::AcqRel); - if prev & (1 << (j % 64)) != 0 { + let prev = (*a).used[j / WORD_BITS] + .fetch_or(1 << (j % WORD_BITS), Ordering::AcqRel); + if prev & (1 << (j % WORD_BITS)) != 0 { conflict = Some(j); break; } } if let Some(c) = conflict { for j in start..c { - (*a).used[j / 64].fetch_and(!(1 << (j % 64)), Ordering::AcqRel); + (*a).used[j / WORD_BITS] + .fetch_and(!(1 << (j % WORD_BITS)), Ordering::AcqRel); } run = 0; idx = c + 1; @@ -378,9 +394,9 @@ fn chunk_alloc_n_inner(restrict_id: i32, n: usize) -> Option<(*mut u8, bool)> { } let mut any_dirty = false; for j in start..=idx { - any_dirty |= (*a).dirty[j / 64] - .fetch_or(1 << (j % 64), Ordering::AcqRel) - & (1 << (j % 64)) + any_dirty |= (*a).dirty[j / WORD_BITS] + .fetch_or(1 << (j % WORD_BITS), Ordering::AcqRel) + & (1 << (j % WORD_BITS)) != 0; } let p = (*a).base.add(start * SEGMENT_SIZE); @@ -413,7 +429,7 @@ pub fn chunk_free_n(p: *mut u8, n: usize) -> bool { { let start = (addr - (*a).base.addr()) / SEGMENT_SIZE; for j in start..start + n { - (*a).used[j / 64].fetch_and(!(1 << (j % 64)), Ordering::AcqRel); + (*a).used[j / WORD_BITS].fetch_and(!(1 << (j % WORD_BITS)), Ordering::AcqRel); } return true; } @@ -438,7 +454,7 @@ pub fn chunk_free(p: *mut u8) -> bool { && addr < (*a).base.addr() + (*a).chunks_live.load(Ordering::Acquire) * SEGMENT_SIZE { let idx = (addr - (*a).base.addr()) / SEGMENT_SIZE; - (*a).used[idx / 64].fetch_and(!(1 << (idx % 64)), Ordering::AcqRel); + (*a).used[idx / WORD_BITS].fetch_and(!(1 << (idx % WORD_BITS)), Ordering::AcqRel); return true; } } @@ -515,8 +531,8 @@ pub(crate) fn adopt_os_block(ptr: *mut u8, size: usize) -> Option { // Dirty bits FIRST, counts after: a reader that observes the // new count must observe the new chunks as dirty. for j in chunks..chunks + n { - (*a).dirty[j / 64].fetch_or(1 << (j % 64), Ordering::AcqRel); - (*a).used[j / 64].fetch_and(!(1 << (j % 64)), Ordering::AcqRel); + (*a).dirty[j / WORD_BITS].fetch_or(1 << (j % WORD_BITS), Ordering::AcqRel); + (*a).used[j / WORD_BITS].fetch_and(!(1 << (j % WORD_BITS)), Ordering::AcqRel); } (*a).chunks_live.store(chunks + n, Ordering::Release); return Some(id as i32); @@ -535,7 +551,7 @@ pub(crate) fn adopt_os_block(ptr: *mut u8, size: usize) -> Option { // SAFETY: freshly registered live descriptor. unsafe { for j in 0..n { - (*a).dirty[j / 64].fetch_or(1 << (j % 64), Ordering::AcqRel); + (*a).dirty[j / WORD_BITS].fetch_or(1 << (j % WORD_BITS), Ordering::AcqRel); } } Some(id) @@ -560,6 +576,11 @@ pub fn arena_area(id: i32) -> (*mut u8, usize) { } /// Debug print of arena occupancy (mi_debug_show_arenas / mi_arenas_print). +/// +/// std-only: each line is built as an owned string (P3, §2.5). The occupancy +/// itself is readable without it — `arena_area` and the chunk counters are not +/// gated; only the human formatting is. +#[cfg(feature = "std")] #[allow(clippy::needless_range_loop)] // indexed scan over a fixed atomic table pub fn arenas_print(out: &mut dyn FnMut(&str)) { let n = ARENA_COUNT.load(Ordering::Acquire).min(MAX_ARENAS); @@ -576,7 +597,7 @@ pub fn arenas_print(out: &mut dyn FnMut(&str)) { unsafe { let chunks = (*a).chunks_live.load(Ordering::Acquire); let mut used = 0usize; - for w in 0..chunks.div_ceil(64) { + for w in 0..chunks.div_ceil(WORD_BITS) { used += (*a).used[w].load(Ordering::Relaxed).count_ones() as usize; } let mut line = heapless_fmt( @@ -595,6 +616,7 @@ pub fn arenas_print(out: &mut dyn FnMut(&str)) { // Tiny fixed formatting helper (no allocation inside the allocator's own // diagnostics). +#[cfg(feature = "std")] fn heapless_fmt( id: usize, base: usize, @@ -602,8 +624,8 @@ fn heapless_fmt( used: usize, chunks: usize, excl: bool, -) -> String { - format!( +) -> std::string::String { + std::format!( "arena {id}: base {base:#x} size {} MiB, {used}/{chunks} chunks used{}\n", size / (1024 * 1024), if excl { " (exclusive)" } else { "" } diff --git a/crates/rusty_alloc/src/bins.rs b/crates/rusty_alloc/src/bins.rs index d02b5d5..6fbefb3 100644 --- a/crates/rusty_alloc/src/bins.rs +++ b/crates/rusty_alloc/src/bins.rs @@ -231,6 +231,19 @@ mod tests { (4097, 5120), (65536, 65536), // last binned size ] { + // These are pinned against the mimalloc v2.4.5 oracle, which + // exists only at the shipped geometry. `good_size` bins up to + // MEDIUM_OBJ_SIZE_MAX and PAGE-ROUNDS above it, so under a + // different geometry (P2, `docs/plans/small-metal.md`) the + // largest rows here move out of the binned range and stop being + // oracle facts. Filtering keeps every row the two geometries + // share instead of disabling the whole fixture — and the + // page-rounded half is already covered, as a PROPERTY rather + // than a literal, by `good_size_above_binned_range_is_page_rounded` + // below. + if size > crate::types::MEDIUM_OBJ_SIZE_MAX { + continue; + } assert_eq!(good_size(size), good, "good_size({size})"); } } diff --git a/crates/rusty_alloc/src/heap.rs b/crates/rusty_alloc/src/heap.rs index f288462..1db72cb 100644 --- a/crates/rusty_alloc/src/heap.rs +++ b/crates/rusty_alloc/src/heap.rs @@ -167,6 +167,18 @@ pub struct Heap { pub tag: i32, /// Per-heap CSPRNG: free-list keys, guarded sampling (M8). pub rng: crate::random::Random, + /// Has any page of this heap ever received a CROSS-THREAD free? + /// + /// Owner-thread only, so a plain `bool`. Sticky: once a remote free has + /// been seen the medium retry stays off for this heap's life, which is the + /// conservative direction (it falls back to the behaviour that has always + /// shipped). + pub saw_remote_free: bool, + /// Trips of the generic path left before the next automatic collect. + /// + /// `mi_option_generic_collect`. Counts DOWN so the hot check is a compare + /// against zero rather than a modulo by a runtime value. + pub generic_countdown: usize, /// Guarded-object sampling: 1-in-N (0 = off), and the size window. pub guarded_rate: usize, /// Countdown to the next guarded object. @@ -221,6 +233,8 @@ impl Heap { arena_id: -1, tag: 0, rng: crate::random::Random::new(), + saw_remote_free: false, + generic_countdown: 0, guarded_rate: 0, guarded_count: 0, guarded_min: 0, @@ -397,7 +411,34 @@ impl Heap { // compare every generic call pays, including the small ones that never had // a bin to pass. The const-generic form that would fold the check away // duplicates this whole function; not worth it for 0.35% of one benchmark. + /// The generic (slow) path, with ONE reclaim-and-retry before it gives up. + /// + /// A page allocator keeps a page per size class as a reuse cache, so a heap + /// can be simultaneously "full" and holding many empty pages that belong to + /// classes the caller is not asking for. Returning null in that state + /// reports OOM while still hoarding reclaimable memory. + /// + /// P4d measured exactly that on a XIAO ESP32-S3: 22,533 of 50,000 churn + /// allocations returned null from a heap that a single `collect` restored + /// from 8 to 240 blocks of capacity (docs/plans/small-metal.md §2.14). The + /// periodic `generic_collect` sweep does not cover it — that battery makes + /// only ~649 generic trips in total, far under the 10,000 default, so the + /// timer never fires. This trigger is failure, not a clock. + /// + /// **Costs nothing on the happy path**: it runs only when the allocation + /// was about to fail. `collect_inner(true, true)` is `mi_collect(true)` — + /// reclaim orphans too, since this is the last resort before null. pub(crate) fn malloc_generic(&mut self, size: usize) -> (*mut u8, bool) { + let r = self.malloc_generic_once(size); + if !r.0.is_null() { + return r; + } + // SAFETY: owner thread; `collect_inner` allocates nothing. + unsafe { self.collect_inner(true, true) }; + self.malloc_generic_once(size) + } + + fn malloc_generic_once(&mut self, size: usize) -> (*mut u8, bool) { self.stats.generic += 1; // Guarded objects (secure/guarded builds): sampled allocations get a // dedicated segment whose trailing page is PROT_NONE, so an overflow @@ -407,12 +448,79 @@ impl Heap { { return r; } + // COLLECT-AND-RETRY for the medium band, and it TURNS ITSELF OFF. + // + // Through `GlobalAlloc` a medium allocation reaches this function on + // every call: `alloc::malloc` serves `size <= SMALL_SIZE_MAX` from the + // direct table and tail-calls `malloc_slow`, which comes straight here, + // while `Heap::malloc`'s medium branch is on a different entry point. + // Measured at 1.000 generic trips per op for 2 KiB against 0.008 for + // 32 B -- routing, not list state. Collecting the queue front and + // retrying before the heartbeat is worth +15 % on wasm and +15-19 % on + // native for a 2 KiB tight alloc/free loop. + // + // It is worth **-20 to -30 %** the moment another thread is freeing into + // these pages. `free`, `local_free` and `xthread_free` are adjacent + // fields of a `#[repr(C)]` `Page`, so touching the queue front at all + // pulls a line the remote thread is invalidating -- doing only the + // local half of the collect measured WORSE, not better, which is how + // that was established. + // + // So the predicate is not "how many threads exist" but "does THIS heap + // receive remote frees", and the retry answers it itself: `page_collect` + // reports whether it stole a cross-thread chain, and the first steal + // latches the retry off for this heap. A thread that owns its + // allocations keeps the win however many threads the process has; a + // producer whose pages a consumer frees pays one detection and then + // behaves exactly as before. + if !self.saw_remote_free && size > SMALL_SIZE_MAX && size <= MEDIUM_OBJ_SIZE_MAX { + let bin = bins::bin(size); + let p = self.pages[bin].first; + if !p.is_null() { + // SAFETY: queue members are live pages of this heap, we are the + // owner thread, and `page_collect` is the same operation the + // walk below performs on this page. + let (stole, b) = unsafe { + let stole = crate::page::page_collect(p); + (stole, page_pop(p)) + }; + if stole { + self.saw_remote_free = true; + } + if !b.is_null() { + self.stat_alloc(); + // SAFETY: p live per above. + return (b, unsafe { (*p).free_is_zero }); + } + } + } // Heartbeat: process cross-thread delayed frees at slow-path cadence // (this is what un-parks full pages whose blocks died remotely), and // fire the registered deferred-free hook (mi_register_deferred_free). // SAFETY: we are the owner thread. unsafe { self.process_delayed() }; crate::options::deferred_free(false); + // Periodic collect (`mi_option_generic_collect`, default 10,000). + // + // Upstream runs an UNFORCED collect every N trips of this path. Ours + // declared the option and read it nowhere, so nothing ever collected on + // its own: a heap that had touched many size classes kept a page per + // class forever and could not give the slices back, even though a + // manual `collect` would have. P4d measured that on hardware — 512 B + // capacity decaying 168 -> 8 blocks and 22,533 of 50,000 churn + // allocations returning null with 61,440 bytes of the region free + // (docs/plans/small-metal.md §2.14). + // + // `reclaim = false`: this is a routine sweep of our own pages, not the + // orphan adoption a forced `mi_collect(true)` performs. + if self.generic_countdown == 0 { + self.generic_countdown = + crate::options::get_clamp(crate::options::GENERIC_COLLECT, 1, 1_000_000) as usize; + // SAFETY: owner thread, and `collect_inner` allocates nothing. + unsafe { self.collect_inner(false, false) }; + } else { + self.generic_countdown -= 1; + } if size > MEDIUM_OBJ_SIZE_MAX { return if size <= LARGE_OBJ_SIZE_MAX { self.large_alloc(size) @@ -533,7 +641,15 @@ impl Heap { let mut p = (*q).first; while !p.is_null() { if (*p).free.is_null() { - page_collect(p); + // A steal ANYWHERE in this heap's queue means this heap + // receives cross-thread frees, which is what the medium + // retry must not run into. Latching only when the retry + // itself steals was not enough: the frees that hurt land on + // OTHER pages of the same heap, so the retry's own page + // never sees them and it never turned itself off. + if page_collect(p) { + self.saw_remote_free = true; + } } if (*p).free.is_null() && (*p).capacity < (*p).reserved { // `(*p).area` is the cached payload start (see `Page::area`); @@ -826,7 +942,7 @@ impl Heap { } fn huge_alloc(&mut self, size: usize, align: usize, offset: usize) -> (*mut u8, bool) { - match segment::huge_alloc(size, align, offset) { + match segment::huge_alloc(size, align, offset, self.arena_id) { Ok((seg, block)) => { // SAFETY: fresh segment we own; page slot 1 is its block's // metadata. DELAYED + xheap route remote frees through our @@ -844,6 +960,14 @@ impl Heap { } self.stat_alloc(); self.stats.huge_allocs += 1; + // A Huge segment IS a segment, and the release path already + // counts it as one (`segments_freed` next to `huge_free`, + // heap.rs:1243). Without this the pair is asymmetric and a + // workload that cycles huge blocks ends with more segments + // freed than allocated — an impossible reading from the + // counters this project uses as its work-parity instrument. + // Found by P2 of docs/plans/small-metal.md. + self.stats.segments += 1; // SAFETY: seg live; recycled arena chunks are NOT zero. (block, unsafe { (*seg).mem_is_zero }) } @@ -1144,7 +1268,7 @@ impl Heap { /// # Safety /// Owner thread; `reclaim` only when this heap will REMAIN live. - unsafe fn collect_inner(&mut self, force: bool, reclaim: bool) { + unsafe fn collect_inner(&mut self, _force: bool, reclaim: bool) { // SAFETY: owner thread per contract. unsafe { // `force` RECLAIMS ABANDONED SEGMENTS. It used to be ignored @@ -1177,7 +1301,6 @@ impl Heap { let _ = self.adopt_segment(aseg); // nosemgrep: discarded-lifecycle-result -- terminal, see comment above } } - let _ = force; self.process_delayed(); let mut bin = 1; // Running queue pointer: `self.pages[bin]` re-indexed the array @@ -1189,8 +1312,26 @@ impl Heap { let mut p = (*q).first; while !p.is_null() { let next = (*p).next; - page_collect(p); - if page_all_free(p) && !((*q).first == p && (*q).last == p) { + if page_collect(p) { + self.saw_remote_free = true; + } + // An all-free page is freed HERE regardless of whether it + // is its bin's only one. That is upstream: + // `mi_heap_page_collect` calls `_mi_page_free` whenever + // `mi_page_all_free(page)`, at every collect level, with the + // comment "this will free retired pages as well". The + // keep-one-page-per-bin reuse cache is `mi_page_retire`'s + // policy, on the FREE path — not collect's. + // + // Ours borrowed that exemption into collect, so no collect + // at any level could return a size class's slice to a + // different class. Invisible at the shipped 32 MiB geometry + // (512 slices per segment absorb a cached page per class); + // fatal at the small profile's 16, where P4d watched 512 B + // capacity decay 168 -> 8 blocks and 22,533 of 50,000 churn + // allocations return null with 61,440 bytes of the region + // still free. docs/plans/small-metal.md §2.14. + if page_all_free(p) { queue_remove(q, p); self.update_direct(bin); let seg = segment_of(p.cast::()); diff --git a/crates/rusty_alloc/src/init.rs b/crates/rusty_alloc/src/init.rs index b09c198..46d82a2 100644 --- a/crates/rusty_alloc/src/init.rs +++ b/crates/rusty_alloc/src/init.rs @@ -289,7 +289,7 @@ mod heap_tls { use super::HeapBox; use core::cell::Cell; - std::thread_local! { + ra_thread_local! { /// Fast-path heap pointer. Const-init + !Drop ⇒ plain TLS access, no /// lazy-init branch, no allocation ever. Initialised to the /// empty-heap SENTINEL (never null) so the malloc fast path can read @@ -308,7 +308,7 @@ mod heap_tls { } } -std::thread_local! { +ra_thread_local! { /// Cached OS thread id. `free` needs the calling thread's id on EVERY /// call to route local-vs-remote; the raw `prim::thread_id()` is a libc /// call (`pthread_self` through the PLT from a cdylib) measured at @@ -576,7 +576,7 @@ fn init_thread_heap() -> *mut HeapBox { hb } -std::thread_local! { +ra_thread_local! { /// The thread's original (backing) heap — what `mi_heap_get_backing` /// returns regardless of `mi_heap_set_default` swaps. static BACKING_PTR: Cell<*mut HeapBox> = const { Cell::new(ptr::null_mut()) }; @@ -625,7 +625,7 @@ fn done_slot() -> prim::TlsSlot { // resource failure into a silent process-wide hang. Fail loudly and // deterministically instead, identically in debug and release. let Some(slot) = prim::TlsSlot::new(Some(thread_done_cb)) else { - std::process::abort(); + crate::abort(); }; RAW.store(slot.into_raw() + 1, Ordering::Release); // +1: 0 is the unset sentinel } @@ -918,7 +918,7 @@ static SUBPROC_NEXT: AtomicUsize = AtomicUsize::new(1); /// Diagnostic: segments currently abandoned (all subprocs). pub static ABANDONED_COUNT: AtomicUsize = AtomicUsize::new(0); -std::thread_local! { +ra_thread_local! { static SUBPROC: Cell = const { Cell::new(0) }; } diff --git a/crates/rusty_alloc/src/lib.rs b/crates/rusty_alloc/src/lib.rs index c177f2b..f8a0b56 100644 --- a/crates/rusty_alloc/src/lib.rs +++ b/crates/rusty_alloc/src/lib.rs @@ -11,8 +11,152 @@ //! R1 spike measured it at atomic-load parity). A no_std profile returns //! post-v1 with the nightly `#[thread_local]` or a platform TLS shim. +#![cfg_attr(not(feature = "std"), no_std)] #![deny(missing_docs)] +// --------------------------------------------------------------------------- +// P3 of `docs/plans/small-metal.md`: the three things the crate used `std` FOR. +// +// These live here, above the `mod` lines, because `macro_rules!` is TEXTUALLY +// scoped — a macro defined after a module is invisible inside it. +// --------------------------------------------------------------------------- + +/// End the process immediately, without unwinding. +/// +/// A double free, a corrupted free list and a failed TLS slot all reach this: +/// the allocator's contract is that it aborts rather than continues, and +/// unwinding out of `free` into a C caller is not an option (which is why the +/// release profile is `panic = "abort"`). +/// +/// Without `std` there is no `process::abort`, so this panics and relies on the +/// deliverable's panic strategy. **A `no_std` consumer MUST build with +/// `panic = "abort"`** — every Janus firmware profile already does — or an +/// abort becomes an unwind and the guarantee is gone. +#[cold] +#[inline(never)] +pub(crate) fn abort() -> ! { + #[cfg(feature = "std")] + { + std::process::abort() + } + #[cfg(not(feature = "std"))] + { + panic!("rusty_alloc: abort (build a no_std consumer with panic = \"abort\")") + } +} + +/// A `thread_local!` that survives `no_std` — the single-heap profile. +/// +/// With `std` this expands to `std::thread_local!` unchanged, so the shipped +/// build keeps the const-init, `!Drop`, initial-exec fast path M10c measured. +/// +/// Without it there is no thread-local storage and, on the targets this crate +/// serves without `std`, no second thread either: `prim::fixed::thread_id` +/// returns a constant and its TLS is a fixed table whose destructors never run, +/// because there is no thread exit. So a "thread-local" becomes a plain +/// `static` — which is not a compromise but the point of the profile: one heap, +/// no TLS lookup at all, a SHORTER fast path than the threaded one. +/// **`wasm32-unknown-unknown` takes the single-`static` arm too, not just +/// `no_std`.** That target has exactly one thread unless the atomics+threads +/// proposal is on, which `prim/wasm.rs` has assumed since it was written. A +/// `std::thread_local!` there still links lazy initialisation, destructor +/// registration and the "accessed during or after destruction" panic — none of +/// which can ever run — and the strings for it ship in every module. +/// +/// `target_feature = "atomics"` is the precise switch: it is what +/// `-C target-feature=+atomics` sets to build wasm WITH threads, and such a +/// build keeps real TLS. +macro_rules! ra_thread_local { + ($($(#[$m:meta])* static $N:ident: $T:ty = const $init:block;)*) => { + #[cfg(all(feature = "std", not(all(target_arch = "wasm32", target_os = "unknown", not(target_feature = "atomics")))))] + std::thread_local! { + $($(#[$m])* static $N: $T = const $init;)* + } + $( + #[cfg(any(not(feature = "std"), all(target_arch = "wasm32", target_os = "unknown", not(target_feature = "atomics"))))] + $(#[$m])* + static $N: $crate::SingleThreadCell<$T> = + $crate::SingleThreadCell::new($init); + )* + }; +} + +// The `no_std` build asserts single-threadedness, so it must be OPTED INTO. +// +// Three things in a `no_std` build are sound only because there is exactly one +// thread: [`SingleThreadCell`]'s `unsafe impl Sync`, `prim::fixed`'s constant +// thread id and never-contended spin lock, and `options`' 64-bit atomics split +// into `AtomicU32` halves. None of them is checkable at compile time, and none +// of them fails loudly if the assumption breaks — they corrupt quietly. +// +// A doc comment is not a guard. `no_std` here therefore requires +// `--cfg ra_single_threaded`, so that using this allocator on a bare-metal +// target is a decision somebody wrote down rather than a default they +// inherited. There is no cost to it and no way around it: +// +// ```text +// RUSTFLAGS="--cfg ra_single_threaded" cargo build --no-default-features +// ``` +// +// If your target has more than one thread touching the allocator, do not set +// it — enable the `std` feature instead, or the port is not done. +#[cfg(all(not(feature = "std"), not(ra_single_threaded), not(doc)))] +compile_error!( + "rusty_alloc's no_std build assumes a SINGLE THREAD (SingleThreadCell's \ + `unsafe impl Sync`, prim::fixed's constant thread id and spin lock, and \ + options' split 64-bit atomics all depend on it). Confirm that is true of \ + your target and opt in with `--cfg ra_single_threaded`, or enable the \ + `std` feature. See the crate docs on SingleThreadCell." +); + +/// The single-thread half of [`ra_thread_local!`]: a `static` with a `.with()`. +#[cfg(any( + not(feature = "std"), + all( + target_arch = "wasm32", + target_os = "unknown", + not(target_feature = "atomics") + ) +))] +pub(crate) struct SingleThreadCell(T); + +#[cfg(any( + not(feature = "std"), + all( + target_arch = "wasm32", + target_os = "unknown", + not(target_feature = "atomics") + ) +))] +// SAFETY: only ever constructed by `ra_thread_local!`, and only on a target +// this crate serves single-threaded: a `no_std` build (which must opt in with +// `--cfg ra_single_threaded`), or `wasm32-unknown-unknown` without the atomics +// proposal, where `prim/wasm.rs` has assumed one thread since it was written. +// The same standing assumption as `prim::fixed` (constant thread id, TLS +// destructors that never fire, a spin lock that never contends). With one +// thread there is no other referent, so shared access cannot race. A build on a +// target that grows threads must revisit this type FIRST — which is what the +// `target_feature = "atomics"` half of the condition above is there to catch. +unsafe impl Sync for SingleThreadCell {} + +#[cfg(any( + not(feature = "std"), + all( + target_arch = "wasm32", + target_os = "unknown", + not(target_feature = "atomics") + ) +))] +impl SingleThreadCell { + pub(crate) const fn new(v: T) -> Self { + Self(v) + } + /// Mirrors `LocalKey::with`, which is the only accessor the crate uses. + pub(crate) fn with(&self, f: impl FnOnce(&T) -> R) -> R { + f(&self.0) + } +} + pub mod alloc; pub mod arena; pub mod bins; diff --git a/crates/rusty_alloc/src/options.rs b/crates/rusty_alloc/src/options.rs index 8e9fbd2..fb624b5 100644 --- a/crates/rusty_alloc/src/options.rs +++ b/crates/rusty_alloc/src/options.rs @@ -8,11 +8,199 @@ //! yes/no/on/off; sizes are plain integers (`_size` options are KiB). use core::ffi::c_void; -use core::sync::atomic::{AtomicBool, AtomicI64, AtomicPtr, AtomicU64, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; + +// The only two 64-bit atomics left in the crate, and the only two whose width +// is a CONTRACT rather than a choice (P3 of `docs/plans/small-metal.md`): +// `VALUES` backs `options::{get,set}`, which are `i64` in an API frozen at +// v2.0.0, and `HEARTBEAT` is handed to a registered `DeferredFreeFun` whose C +// ABI declares it `u64`. Every other 64-bit atomic in the crate was a bitmap +// and was narrowed to `u32` instead. On a target with the real thing this is +// `core`; only 32-bit RISC-V / Xtensa pull the shim, and only there. +#[cfg(target_has_atomic = "64")] +use core::sync::atomic::{AtomicI64, AtomicU64}; +// With `std` on a 32-bit target, threads are real and the shim's lock table is +// the honest answer. Without it, the crate already serves `no_std` only on +// single-threaded targets — that is what `lib.rs`'s `SingleThreadCell` rests on +// — so paying `portable_atomic`'s lock table (measured at **4,288 bytes of +// BSS** in a shipped XIAO ESP32-S3 firmware, larger than the heap descriptor +// the allocator starts with) buys atomicity nothing can observe. Two `u32` +// halves cost 8 bytes and no lock. See `split64` below. +#[cfg(all(not(target_has_atomic = "64"), feature = "std"))] +use portable_atomic::{AtomicI64, AtomicU64}; +#[cfg(all(not(target_has_atomic = "64"), not(feature = "std")))] +use split64::{AtomicI64, AtomicU64}; + +/// A 64-bit atomic as two `AtomicU32` halves, for single-threaded `no_std`. +/// +/// **Sound only because the crate is single-threaded wherever it is used** — +/// the same standing assumption as `lib.rs`'s `SingleThreadCell`, `prim::fixed`'s +/// constant thread id, and its never-contended spin lock. A `no_std` build on a +/// target that grows threads must revisit all four together. Nothing here is +/// `unsafe`: a struct of `AtomicU32` is `Sync` already, so this adds no unsafe +/// to the crate. +/// +/// Only the four operations `options.rs` actually performs are provided; a +/// fifth would need its own thought about which half moves first. +/// +/// **Orderings are normalised, not forwarded.** A caller's `Ordering` describes +/// one 64-bit access; this performs two 32-bit ones, so there is nothing +/// faithful to forward it to. Forwarding is also a panic: `AtomicU32::load` +/// rejects `Release`/`AcqRel` and `store` rejects `Acquire`/`AcqRel`, so the +/// `compare_exchange(.., AcqRel, ..)` that `set_default` performs would abort +/// the firmware. Loads use `Acquire` and stores `Release` — valid for every +/// caller, and stronger than a single-threaded target can observe. +// `test` in the cfg so the module COMPILES AND ITS TEST RUNS on the host. Gated +// only on the target that uses it, the test below would never execute anywhere +// CI or a developer runs — and a test that cannot run is worse than no test, +// because it looks like coverage. +#[cfg(any(all(not(target_has_atomic = "64"), not(feature = "std")), test))] +mod split64 { + use core::sync::atomic::{AtomicU32, Ordering}; + + /// Split a `u64` into `(lo, hi)` and back. Free-standing so both wrappers + /// share one definition of which half is which. + const fn split(v: u64) -> (u32, u32) { + (v as u32, (v >> 32) as u32) + } + const fn join(lo: u32, hi: u32) -> u64 { + ((hi as u64) << 32) | lo as u64 + } + + #[derive(Debug)] + pub struct AtomicU64 { + lo: AtomicU32, + hi: AtomicU32, + } + + impl AtomicU64 { + pub const fn new(v: u64) -> Self { + let (lo, hi) = split(v); + Self { + lo: AtomicU32::new(lo), + hi: AtomicU32::new(hi), + } + } + pub fn load(&self, _ord: Ordering) -> u64 { + join( + self.lo.load(Ordering::Acquire), + self.hi.load(Ordering::Acquire), + ) + } + pub fn store(&self, v: u64, _ord: Ordering) { + let (lo, hi) = split(v); + self.lo.store(lo, Ordering::Release); + self.hi.store(hi, Ordering::Release); + } + pub fn fetch_add(&self, v: u64, ord: Ordering) -> u64 { + let prev = self.load(ord); + self.store(prev.wrapping_add(v), ord); + prev + } + } + + /// The signed half of the same thing: options are `i64` in an API frozen at + /// v2.0.0, and the bit pattern round-trips exactly. + #[derive(Debug)] + pub struct AtomicI64(AtomicU64); + + impl AtomicI64 { + pub const fn new(v: i64) -> Self { + Self(AtomicU64::new(v as u64)) + } + pub fn load(&self, ord: Ordering) -> i64 { + self.0.load(ord) as i64 + } + pub fn store(&self, v: i64, ord: Ordering) { + self.0.store(v as u64, ord); + } + /// `Ordering` pair mirrors `core`'s signature; single-threaded, so the + /// read-compare-write cannot be interleaved. + pub fn compare_exchange( + &self, + current: i64, + new: i64, + success: Ordering, + _failure: Ordering, + ) -> Result { + let seen = self.load(success); + if seen == current { + self.store(new, success); + Ok(seen) + } else { + Err(seen) + } + } + } + + /// The orderings `options.rs` actually passes, exercised so a future caller + /// forwarding `AcqRel` cannot reintroduce the panic the module doc names. + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn every_ordering_options_uses_is_accepted() { + let v = AtomicI64::new(i64::MIN); + v.store(-1, Ordering::Release); + assert_eq!(v.load(Ordering::Acquire), -1); + // `set_default`'s ordering pair — the one that would abort. + assert_eq!( + v.compare_exchange(-1, 7, Ordering::AcqRel, Ordering::Acquire), + Ok(-1) + ); + assert_eq!(v.load(Ordering::Acquire), 7); + assert_eq!( + v.compare_exchange(-1, 9, Ordering::AcqRel, Ordering::Acquire), + Err(7) + ); + + // Halves join in the right order across the 32-bit boundary. + let u = AtomicU64::new(u64::from(u32::MAX)); + assert_eq!(u.fetch_add(1, Ordering::Relaxed), u64::from(u32::MAX)); + assert_eq!(u.load(Ordering::Relaxed), 1u64 << 32); + } + } +} /// Number of options (== `_mi_option_last` in v2.4.5). pub const OPTION_COUNT: usize = 38; +/// Index of `generic_collect` in [`OPTION_NAMES`] — how many trips of the +/// allocator's generic (slow) path go by between automatic collects. +/// +/// Named because it is the one option the allocator reads on its own hot-ish +/// path. It was declared with a default of 10,000 and **read by nothing** until +/// P4d of `docs/plans/small-metal.md`, which is why a small-profile heap could +/// starve on its own per-class page cache and never recover. +pub const GENERIC_COLLECT: usize = 36; +const _: () = assert!(GENERIC_COLLECT < OPTION_COUNT); + +/// Default trips of the generic path between automatic collects. +/// +/// Upstream's 10,000 is tuned for a segment of 512 slices, where one cached +/// page per size class is 14 % of the segment and waiting is free. At the small +/// profile a segment holds 16, so ~24 classes is every slice there is — and the +/// P4d stress battery makes only ~649 generic trips in TOTAL, so a 10,000-trip +/// timer never fires at all before the heap has starved. +/// +/// **This is the cheap half of upstream's `retire_expire`.** That mechanism +/// gives each retired page its own countdown, decremented on every generic +/// trip, so a sole empty page ages out after ~16 rather than waiting for a +/// sweep. Implementing it means tracking a retired-bin range on the heap, and +/// `alloc::retire_or_abort` is deliberately written to decide keep-one-warm +/// from the PAGE's own links precisely so it never has to resolve the heap — +/// a measured optimisation. Since `collect` now reclaims a bin's last page, +/// a short sweep period buys the same ageing without touching that path. +/// Upstream's per-page countdown stays unimplemented and is recorded in +/// `docs/plans/small-metal.md` §6. +/// Shipped geometry: upstream's 10,000. +#[cfg(not(ra_small_profile))] +pub const GENERIC_COLLECT_DEFAULT: i64 = 10_000; +/// Small profile: 512, for the reasons above. +#[cfg(ra_small_profile)] +pub const GENERIC_COLLECT_DEFAULT: i64 = 512; + /// Option names in ABI index order (also the env-var suffixes, uppercased). pub const OPTION_NAMES: [&str; OPTION_COUNT] = [ "show_errors", @@ -69,7 +257,10 @@ const DEFAULTS: [i64; OPTION_COUNT] = [ // abandoned_page_purge defaults ON (upstream does the same). An abandoned // segment has no owner to reuse its pages, so holding them resident buys // nothing and costs 32 MiB a time — the RSS tail measured against mimalloc. - 0, 0, 0, 1, // deprecated x3 / abandoned_page_purge(1) + 0, + 0, + 0, + 1, // deprecated x3 / abandoned_page_purge(1) 1, // eager_commit_delay -1, // purge_delay: v1 ships purging OPT-IN (see LEDGER M8 open defect) 0, // use_numa_nodes @@ -86,12 +277,14 @@ const DEFAULTS: [i64; OPTION_COUNT] = [ 0, // disallow_arena_alloc 400, // retry_on_oom (ms) 0, // visit_abandoned - 0, 0, 0, // guarded_min/max/precise - 1000, // guarded_sample_rate - 0, // guarded_sample_seed - 0, // target_segments_per_thread - 10000, // generic_collect - 1, // allow_thp + 0, + 0, + 0, // guarded_min/max/precise + 1000, // guarded_sample_rate + 0, // guarded_sample_seed + 0, // target_segments_per_thread + GENERIC_COLLECT_DEFAULT, // generic_collect + 1, // allow_thp ]; static VALUES: [AtomicI64; OPTION_COUNT] = [const { AtomicI64::new(i64::MIN) }; OPTION_COUNT]; @@ -101,17 +294,42 @@ fn ensure_init() { if ENV_PARSED.swap(true, Ordering::AcqRel) { return; } + // A firmware has no environment, no owned strings and no formatter, so + // without `std` every option keeps its compiled-in default — the whole of + // the no_std option story (P3 of `docs/plans/small-metal.md`, §2.5). This + // is deletion, not a port: there is nothing to read, so the environment + // pass does not exist rather than existing and returning nothing. + for i in 0..OPTION_COUNT { + VALUES[i].store(DEFAULTS[i], Ordering::Release); + } + // ...and neither has `wasm32-unknown-unknown`. `std::env::var` there is a + // stub that always fails, so this loop formatted 76 strings, allocated 76 + // `String`s and read an environment that cannot exist — on every startup, + // to find nothing. It also dragged `core::fmt`, `alloc::fmt::format` and + // `str::to_uppercase` into a module that otherwise needs none of them: + // `options::get` was the LARGEST function in a wasm build at 3,708 bytes, + // ahead of anything in the allocator proper. Same deletion as the `no_std` + // arm above, for the same reason — there is nothing to read. + // + // `target_os = "unknown"` and not `target_arch` alone: wasm32-wasip1 does + // have an environment and keeps the pass. + #[cfg(all( + feature = "std", + not(all(target_arch = "wasm32", target_os = "unknown")) + ))] for i in 0..OPTION_COUNT { let name = OPTION_NAMES[i].to_uppercase(); - let val = std::env::var(format!("RUSTY_ALLOC_{name}")) - .or_else(|_| std::env::var(format!("MIMALLOC_{name}"))) + let val = std::env::var(std::format!("RUSTY_ALLOC_{name}")) + .or_else(|_| std::env::var(std::format!("MIMALLOC_{name}"))) .ok() .and_then(|s| parse_value(&s)); - let v = val.unwrap_or(DEFAULTS[i]); - VALUES[i].store(v, Ordering::Release); + if let Some(v) = val { + VALUES[i].store(v, Ordering::Release); + } } } +#[cfg(feature = "std")] fn parse_value(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "" | "1" | "true" | "yes" | "on" => Some(1), @@ -171,6 +389,11 @@ pub fn get_size(option: usize) -> usize { } /// `mi_options_print` via the output hook. +/// +/// std-only: building the line needs an owned string. A `no_std` consumer that +/// wants this can format into a stack buffer and call [`out_fmt`], which is +/// the seam that survives. +#[cfg(feature = "std")] pub fn print() { ensure_init(); for (i, name) in OPTION_NAMES.iter().enumerate() { @@ -244,10 +467,23 @@ pub fn register_deferred_free(f: Option, arg: *mut c_void) { } /// Route a message to the registered output hook, else stderr. +/// +/// Takes `&str` and needs no allocation, so this SEAM survives `no_std` — a +/// firmware that registers an output hook still gets the allocator's messages +/// over its serial log. Only the *stderr fallback* and the `format!`-based +/// CALLERS are std-only (P3 of `docs/plans/small-metal.md`, §2.5). pub fn out_fmt(msg: &str) { let (f, a) = OUTPUT_FUN.load(); if f.is_null() { - eprint!("{msg}"); + // `write_all`, not `eprint!`. The macro formats, and formatting is not + // free: `core::fmt`, `Display for str` and `Display for u64` are ~2.5 KiB + // of wasm that this one interpolation of an ALREADY-`&str` argument + // pulled into every build. Bytes to a writer need none of it. + #[cfg(feature = "std")] + { + use std::io::Write; + let _ = std::io::stderr().write_all(msg.as_bytes()); + } return; } // NUL-terminate on the stack for the C hook (bounded copy). @@ -263,18 +499,47 @@ pub fn out_fmt(msg: &str) { } } -/// Report an error code through the hook (else stderr when show_errors). -pub fn error(err: i32) { - let (f, a) = ERROR_FUN.load(); - if !f.is_null() { - // SAFETY: registered with the documented signature. - unsafe { - let fun: ErrorFun = core::mem::transmute::<*mut c_void, ErrorFun>(f); - fun(err, a); +/// `"rusty_alloc: error \n"` into `buf`, without a formatter. +/// +/// Hand-rendered because `format!` on a single integer is what dragged +/// `core::fmt` into every build; see [`error`]. The buffer is sized for the +/// prefix plus the longest `i32` (`-2147483648`) plus the newline. +fn render_error(buf: &mut [u8; 32], err: i32) -> &str { + const PREFIX: &[u8] = b"rusty_alloc: error "; + buf[..PREFIX.len()].copy_from_slice(PREFIX); + let mut n = PREFIX.len(); + if err < 0 { + buf[n] = b'-'; + n += 1; + } + // `unsigned_abs`: negating `i32::MIN` overflows, and this path must not + // panic -- it is what runs when something has already gone wrong. + let mut v = err.unsigned_abs(); + let mut digits = [0u8; 10]; + let mut d = 0; + loop { + digits[d] = b'0' + (v % 10) as u8; + d += 1; + v /= 10; + if v == 0 { + break; } - } else if is_enabled(0) { - out_fmt(&format!("rusty_alloc: error {err}\n")); } + while d > 0 { + d -= 1; + buf[n] = digits[d]; + n += 1; + } + buf[n] = b'\n'; + n += 1; + // `from_utf8`, not `from_utf8_unchecked`: every byte above is ASCII by + // construction, so this cannot fail -- but validating 30 bytes on a path + // that only runs when something has already gone wrong is cheaper than + // adding an `unsafe` to the census for it. + core::str::from_utf8(&buf[..n]).unwrap_or( + "rusty_alloc: error +", + ) } /// Fire the deferred-free hook (called from the allocation heartbeat). @@ -309,3 +574,46 @@ fn fire_deferred(force: bool) { } } } + +/// Report an error code through the hook (else stderr when show_errors). +pub fn error(err: i32) { + let (f, a) = ERROR_FUN.load(); + if !f.is_null() { + // SAFETY: registered with the documented signature. + unsafe { + let fun: ErrorFun = core::mem::transmute::<*mut c_void, ErrorFun>(f); + fun(err, a); + } + } else if is_enabled(0) { + // Rendered into a stack buffer rather than `format!`. The error code is + // one integer; paying `core::fmt` plus an allocation for it linked the + // whole formatting machinery into a wasm module that never reports an + // error. It also means this fallback no longer needs `std`, so a + // firmware gets back the message P3 had to delete. + let mut buf = [0u8; 32]; + out_fmt(render_error(&mut buf, err)); + } +} + +#[cfg(test)] +mod render_error_tests { + use super::render_error; + + /// Including the value that makes a naive `-err` overflow. + #[test] + fn renders_every_shape_without_a_formatter() { + let mut b = [0u8; 32]; + assert_eq!(render_error(&mut b, 0), "rusty_alloc: error 0\n"); + assert_eq!(render_error(&mut b, 7), "rusty_alloc: error 7\n"); + assert_eq!(render_error(&mut b, 12345), "rusty_alloc: error 12345\n"); + assert_eq!(render_error(&mut b, -1), "rusty_alloc: error -1\n"); + assert_eq!( + render_error(&mut b, i32::MAX), + "rusty_alloc: error 2147483647\n" + ); + assert_eq!( + render_error(&mut b, i32::MIN), + "rusty_alloc: error -2147483648\n" + ); + } +} diff --git a/crates/rusty_alloc/src/page.rs b/crates/rusty_alloc/src/page.rs index 9103f49..219e4ff 100644 --- a/crates/rusty_alloc/src/page.rs +++ b/crates/rusty_alloc/src/page.rs @@ -131,7 +131,7 @@ pub(crate) const fn bitmap_bytes(blocks: usize) -> usize { #[inline(never)] #[cfg(feature = "blockmap")] pub(crate) fn blockmap_abort() -> ! { - std::process::abort() + crate::abort() } /// `blockmap`: flip `b`'s liveness bit, requiring it to currently be the @@ -712,7 +712,7 @@ pub unsafe fn page_push_local(page: *mut Page, block: *mut Block) -> u32 { #[cold] #[inline(never)] pub(crate) fn double_free_abort() -> ! { - std::process::abort() + crate::abort() } /// A corrupted free-list link was detected on decode (`secure` builds). @@ -729,7 +729,7 @@ pub(crate) fn double_free_abort() -> ! { #[inline(never)] #[cfg(any(feature = "secure", feature = "linkcheck"))] pub(crate) fn corrupt_free_list_abort() -> ! { - std::process::abort() + crate::abort() } /// Remote (non-owner) free — the loom-modeled protocol. @@ -864,7 +864,7 @@ pub unsafe fn page_set_flag(page: *mut Page, flag: usize) { /// /// # Safety /// `page` owned by the calling thread. -pub unsafe fn page_collect(page: *mut Page) { +pub unsafe fn page_collect(page: *mut Page) -> bool { // SAFETY: forwarded contract; PRESERVE the protocol flag. unsafe { page_collect_impl::(page, 0) } } @@ -884,7 +884,7 @@ pub unsafe fn page_collect(page: *mut Page) { /// As [`page_collect`]. pub unsafe fn page_collect_and_set_flag(page: *mut Page, flag: usize) { // SAFETY: forwarded contract. - unsafe { page_collect_impl::(page, flag) } + let _stole = unsafe { page_collect_impl::(page, flag) }; } /// The body of both. `SET_FLAG` is a const parameter so neither caller pays a @@ -893,7 +893,7 @@ pub unsafe fn page_collect_and_set_flag(page: *mut Page, flag: usize) { /// # Safety /// As [`page_collect`]. #[inline] -unsafe fn page_collect_impl(page: *mut Page, flag: usize) { +unsafe fn page_collect_impl(page: *mut Page, flag: usize) -> bool { // SAFETY: owner-only lists plus designed atomic steal. unsafe { if (*page).free.is_null() { @@ -913,7 +913,7 @@ unsafe fn page_collect_impl(page: *mut Page, flag: usize) // Steal the cross-thread chain, preserving the protocol flag — or, // when SET_FLAG, replacing it in the same CAS. if !SET_FLAG && ((*page).xthread_free.load(Ordering::Acquire) & !XMASK) == 0 { - return; + return false; } loop { let x = (*page).xthread_free.load(Ordering::Acquire); @@ -933,7 +933,7 @@ unsafe fn page_collect_impl(page: *mut Page, flag: usize) { continue; } - break; + return false; } let want = if SET_FLAG { flag } else { x & XMASK }; if (*page) @@ -988,6 +988,9 @@ unsafe fn page_collect_impl(page: *mut Page, flag: usize) (*page).used -= n; break; } + // Reached only by breaking out of the steal arm above, i.e. a + // cross-thread chain was actually taken. + true } } @@ -1246,8 +1249,18 @@ mod link_tests { /// precisely why it can be pinned this exactly. The end-to-end proof that /// a bad link actually ABORTS lives in `tests/corruption.rs`. const BASE: usize = 0x0000_4000_0000_0000; - /// A block sitting 1 MiB into that segment. - const B: usize = BASE + 0x10_0000; + /// An offset well inside a segment at ANY geometry. + /// + /// Derived rather than the literal 1 MiB it used to be: the predicate + /// under test is scoped to `SEGMENT_SIZE`, so a fixed offset silently + /// stops testing the inside of the segment the moment that constant + /// moves — under the small profile (P2, `docs/plans/small-metal.md`) a + /// 64 KiB segment does not contain a 1 MiB offset at all, and three of + /// these assertions inverted. `/8` keeps the +-4096 probes below inside + /// the segment at every geometry this crate builds. + const OFF: usize = SEGMENT_SIZE / 8; + /// A block sitting `OFF` bytes into that segment. + const B: usize = BASE + OFF; #[test] fn accepts_genuine_links_anywhere_in_the_same_segment() { @@ -1322,8 +1335,8 @@ mod link_tests { "the neighbouring block is reachable by design — see R-005" ); assert!( - link_is_plausible(B + 0x10_0000, B), - "1 MiB away, still the same segment" + link_is_plausible(B + OFF, B), + "far away in bytes, still the same segment" ); } } diff --git a/crates/rusty_alloc/src/prim/fixed.rs b/crates/rusty_alloc/src/prim/fixed.rs new file mode 100644 index 0000000..635ffa8 --- /dev/null +++ b/crates/rusty_alloc/src/prim/fixed.rs @@ -0,0 +1,746 @@ +//! Fixed-region prim backend: memory is a range someone hands us, once. +//! +//! P1 of `docs/plans/small-metal.md`. This is the backend for a target with no +//! OS at all — a microcontroller, where "memory" is a region the linker +//! reserved and there is no `mmap`, no `VirtualAlloc`, and nothing to give it +//! back to. It is the second implementation of the prim seam that P1 asks for; +//! the first is the four platform arms in [`super`]. +//! +//! It is **always compiled** so that it is type-checked and unit-tested on the +//! host, and **selected** only where no platform arm matches. Nothing about a +//! host build changes because this file exists. +//! +//! Every consequence of "the region is all there is", and what each one costs: +//! +//! - **The backend cannot allocate.** It *is* the allocator's memory source, so +//! its own bookkeeping must be a fixed static: [`MAX_EXTENTS`] free extents in +//! two `AtomicUsize` arrays, guarded by a spin lock. That bound is a real +//! limit — a fragmentation pattern needing more than [`MAX_EXTENTS`] holes +//! fails the free rather than corrupting anything (see [`free`]). +//! - **`free` genuinely frees**, unlike the wasm backend: an extent returns to +//! the list and coalesces with its neighbours. So +//! [`super::FREE_RETURNS_MEMORY`] is true here and the arena's adopt-on-free +//! path folds away, as it does on every platform with a working `free`. +//! - **There is no MMU**, so [`commit`] / [`decommit`] / [`reset`] are no-ops +//! over memory that is always backed, and [`protect`] returns an error rather +//! than pretending. That is the same call the wasm backend makes and for the +//! same reason: a guard page that cannot trap would let a `secure` build +//! claim a hardening it does not have. +//! - **There is no clock and one thread**, so [`clock_now`] is a monotonic +//! counter (purge *ordering* survives; duration does not) and [`thread_id`] is +//! a non-zero constant. TLS is a fixed static table whose destructors never +//! run, because there is no thread exit to run them at. +//! - **Memory is not known-zero.** A `.bss` region starts zeroed, but a range +//! handed back by [`free`] and re-served does not, and the backend cannot tell +//! the two apart. [`alloc`] therefore reports `is_zero: false` always, which +//! is the conservative direction: a caller that needs zeros writes them. +//! +//! **What this backend does NOT solve, and P1 does not claim it does:** the +//! allocator above it asks for `SEGMENT_SIZE`-aligned 32 MiB reservations, and +//! a chip-sized region can satisfy exactly none of them. That is §2.1 of the +//! plan, it is P2's work, and it shows up here as an honest `Err` from +//! [`alloc`] rather than as anything this file can fix. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; + +use super::{Alloc, MemConfig, PrimError, TlsDtor, align_up}; + +/// Synthetic error code. The backends surface no errno, so any non-zero +/// sentinel does; this one is distinct from wasm's `0xBEEF` and the mock's. +const FERR: PrimError = 0xF13D; + +/// Page granularity reported to the layers above. A chip has no paging +/// hardware, so this is a bookkeeping unit rather than a hardware fact; 4 KiB +/// matches the flash/RAM block size the ESP parts use and keeps `page_align_up` +/// rounding modest on a region measured in tens of kilobytes. +const FIXED_PAGE: usize = 4096; + +/// Free extents tracked at once. +/// +/// The bound exists because this backend cannot allocate its own bookkeeping. +/// 32 is far more than a chip needs — the layers above make a handful of large +/// reservations, not many small ones — and exceeding it is reported, never +/// papered over. +const MAX_EXTENTS: usize = 32; + +/// The region, published once by [`init_region`]. Zero length means "no region +/// yet", which every entry point checks. +static REGION_BASE: AtomicUsize = AtomicUsize::new(0); +static REGION_LEN: AtomicUsize = AtomicUsize::new(0); + +/// The free list: `EXT_BASE[i] .. EXT_BASE[i] + EXT_LEN[i]`, kept sorted by +/// base so that coalescing is a look at the two neighbours. Only ever touched +/// with [`LOCK`] held, so plain `Relaxed` access is correct. +static EXT_BASE: [AtomicUsize; MAX_EXTENTS] = [const { AtomicUsize::new(0) }; MAX_EXTENTS]; +static EXT_LEN: [AtomicUsize; MAX_EXTENTS] = [const { AtomicUsize::new(0) }; MAX_EXTENTS]; +static EXT_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// Spin lock over the free list. On the single-threaded target this backend is +/// for it never contends; it is here so the statics are sound under the +/// `Sync` the seam requires, not for throughput. +static LOCK: AtomicBool = AtomicBool::new(false); + +/// Guards one named lock. Not reentrant — hold at most one at a time, and +/// never call out to something that takes the same one. +struct Guard(&'static AtomicBool); + +impl Guard { + fn acquire(lock: &'static AtomicBool) -> Self { + while lock + .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + core::hint::spin_loop(); + } + Self(lock) + } +} + +impl Drop for Guard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +/// Hand the backend the region it will serve from, once. +/// +/// Takes `&'static mut [u8]` because that is exactly the claim being made: the +/// range lives forever and nobody else may touch it. On a chip this is the +/// linker-reserved heap symbol; in a test it is a `static mut` array or a +/// leaked box. +/// +/// Returns `Err` if a region is already registered, or if this one is too small +/// to hold anything after alignment. +/// +/// # Errors +/// [`FERR`] on a second call, or on a region below [`FIXED_PAGE`] bytes. +pub fn init_region(region: &'static mut [u8]) -> Result<(), PrimError> { + let len = region.len(); + if len < FIXED_PAGE { + return Err(FERR); + } + let base = region.as_mut_ptr().expose_provenance(); + + let _g = Guard::acquire(&LOCK); + if REGION_LEN.load(Ordering::Relaxed) != 0 { + return Err(FERR); + } + REGION_BASE.store(base, Ordering::Relaxed); + REGION_LEN.store(len, Ordering::Relaxed); + EXT_BASE[0].store(base, Ordering::Relaxed); + EXT_LEN[0].store(len, Ordering::Relaxed); + EXT_COUNT.store(1, Ordering::Relaxed); + Ok(()) +} + +/// The region's occupancy: `(used, free, total)` bytes. +/// +/// A fixed-region allocator that cannot report how much of its region is out +/// is unmeasurable on exactly the deployment it exists for — `esp_alloc::HEAP` +/// answers `used()`/`free()` and P4 of `docs/plans/small-metal.md` compares +/// against it. `used` is derived (`total - free`) rather than counted, so it +/// cannot drift from the free list. +/// +/// A snapshot: another thread could change it, though on the single-threaded +/// targets this backend serves there is no other thread. +#[must_use] +pub fn region_stats() -> (usize, usize, usize) { + let _g = Guard::acquire(&LOCK); + let total = REGION_LEN.load(Ordering::Relaxed); + let free: usize = (0..EXT_COUNT.load(Ordering::Relaxed)) + .map(|i| EXT_LEN[i].load(Ordering::Relaxed)) + .sum(); + (total - free, free, total) +} + +/// Remove the extent at `idx`, shifting the tail down to keep the list sorted. +fn remove_at(idx: usize) { + let n = EXT_COUNT.load(Ordering::Relaxed); + for i in idx..n - 1 { + EXT_BASE[i].store(EXT_BASE[i + 1].load(Ordering::Relaxed), Ordering::Relaxed); + EXT_LEN[i].store(EXT_LEN[i + 1].load(Ordering::Relaxed), Ordering::Relaxed); + } + EXT_COUNT.store(n - 1, Ordering::Relaxed); +} + +/// Insert `(base, len)` at `idx`, shifting the tail up. Caller has checked +/// there is room. +fn insert_at(idx: usize, base: usize, len: usize) { + let n = EXT_COUNT.load(Ordering::Relaxed); + let mut i = n; + while i > idx { + EXT_BASE[i].store(EXT_BASE[i - 1].load(Ordering::Relaxed), Ordering::Relaxed); + EXT_LEN[i].store(EXT_LEN[i - 1].load(Ordering::Relaxed), Ordering::Relaxed); + i -= 1; + } + EXT_BASE[idx].store(base, Ordering::Relaxed); + EXT_LEN[idx].store(len, Ordering::Relaxed); + EXT_COUNT.store(n + 1, Ordering::Relaxed); +} + +/// A slice must be at least a page. +/// +/// `bins::good_size` answers the large range with `os::page_align_up`, but the +/// large path allocates EXACT SLICES. `usable_size >= good_size` — an +/// ABI-visible promise, and a proptest — therefore holds only while a slice is +/// no smaller than a page. Every other backend gets this for free (a 64 KiB +/// slice over a 4 KiB page); this is the only one where the two can be tuned +/// into conflict, so this is where it is written down. Found by dropping the +/// small profile to a 2 KiB slice: `good_size(49_153)` promised 53,248 while +/// the 25-slice span delivered 51,200. +const _: () = assert!( + crate::types::SEGMENT_SLICE_SIZE >= FIXED_PAGE, + "SEGMENT_SLICE_SIZE must be >= FIXED_PAGE or good_size over-promises" +); + +pub(super) fn mem_init() -> MemConfig { + MemConfig { + page_size: FIXED_PAGE, + alloc_granularity: FIXED_PAGE, + large_page_size: 0, + has_overcommit: false, + // A sub-range can be returned independently: `free` takes any extent. + has_partial_free: true, + } +} + +/// Where inside `[base, base + len)` a `size`-byte `align`-aligned block goes: +/// the HIGHEST such address when `from_top`, the lowest otherwise. `None` when +/// it does not fit. `align` is a power of two (it comes from a `Layout` or from +/// [`FIXED_PAGE`]), so the top-down case is a mask. +fn place(base: usize, len: usize, size: usize, align: usize, from_top: bool) -> Option { + if size > len { + return None; + } + let at = if from_top { + (base + len - size) & !(align - 1) + } else { + align_up(base, align) + }; + // Top-down can mask below `base`; bottom-up can align past the end. Compare + // on the sum, not a subtraction that would wrap. + if at < base || at.saturating_add(size) > base + len { + return None; + } + Some(at) +} + +/// Two-ended first-fit over the free list, honouring `try_alignment`. +/// +/// **Coarsely-aligned requests take the bottom; merely page-aligned ones take +/// the top.** That split is the whole point on a chip-sized region. A +/// `SEGMENT_SIZE` reservation can only start on a `SEGMENT_SIZE` boundary, so +/// every byte handed out below one pushes it to the next — a single 4 KiB heap +/// block placed at the bottom of the region costs an entire segment of reach. +/// Measured on a XIAO ESP32-S3 (docs/plans/small-metal.md §2.9): bottom-only +/// placement needed a 192 KiB region for a workload whose segments and metadata +/// total 132 KiB, with 61,440 bytes sitting on the free list that no segment +/// request could ever use. Requests that do NOT care about coarse alignment are +/// the ones that can move, so they are the ones that move. +/// +/// Alignment slack around the chosen placement is not lost: head and tail stay +/// on the list as their own extents, which is what makes repeated aligned +/// requests on a small region survivable at all. +/// +/// # Errors +/// [`FERR`] when no region is registered, when no extent can hold +/// `size` at `try_alignment` — which is what a `SEGMENT_SIZE` request on a +/// chip-sized region does — or when splitting would need more than +/// [`MAX_EXTENTS`] entries. +pub(super) unsafe fn alloc( + size: usize, + try_alignment: usize, + _commit: bool, + _allow_large: bool, +) -> Result { + if size == 0 { + return Err(FERR); + } + let align = try_alignment.max(FIXED_PAGE); + let size = align_up(size, FIXED_PAGE); + + let _g = Guard::acquire(&LOCK); + if REGION_LEN.load(Ordering::Relaxed) == 0 { + return Err(FERR); + } + + // Page-aligned requests search from the HIGHEST extent down and settle at + // its top; coarsely-aligned ones search from the lowest up, as before. + let from_top = align == FIXED_PAGE; + let n = EXT_COUNT.load(Ordering::Relaxed); + for k in 0..n { + let i = if from_top { n - 1 - k } else { k }; + let base = EXT_BASE[i].load(Ordering::Relaxed); + let len = EXT_LEN[i].load(Ordering::Relaxed); + let Some(aligned) = place(base, len, size, align, from_top) else { + continue; + }; + let head = aligned - base; + let tail = (base + len) - (aligned + size); + + // Splitting an extent into head + tail costs one extra entry; growing + // the list by one must stay inside the bound, or nothing moves. + if head > 0 && tail > 0 && n + 1 > MAX_EXTENTS { + return Err(FERR); + } + + remove_at(i); + let mut at = i; + if head > 0 { + insert_at(at, base, head); + at += 1; + } + if tail > 0 { + insert_at(at, aligned + size, tail); + } + return Ok(Alloc { + ptr: core::ptr::with_exposed_provenance_mut(aligned), + is_large: false, + // Conservative: a recycled extent holds whatever its last tenant + // left. See the module doc. + is_zero: false, + }); + } + Err(FERR) +} + +/// Return an extent to the free list, coalescing with either neighbour. +/// +/// # Errors +/// [`FERR`] if the range is not inside the registered region, or if the list is +/// full and the range touches neither neighbour. The latter is the +/// [`MAX_EXTENTS`] bound biting; it refuses rather than dropping the range. +pub(super) unsafe fn free(ptr: *mut u8, size: usize) -> Result<(), PrimError> { + if size == 0 { + return Ok(()); + } + let base = ptr.expose_provenance(); + let size = align_up(size, FIXED_PAGE); + + let _g = Guard::acquire(&LOCK); + let rbase = REGION_BASE.load(Ordering::Relaxed); + let rlen = REGION_LEN.load(Ordering::Relaxed); + if rlen == 0 || base < rbase || base + size > rbase + rlen { + return Err(FERR); + } + + let n = EXT_COUNT.load(Ordering::Relaxed); + // Sorted insertion point: the first extent starting above `base`. + let idx = EXT_BASE[..n] + .iter() + .position(|e| e.load(Ordering::Relaxed) > base) + .unwrap_or(n); + + let prev_touches = idx > 0 && { + let pb = EXT_BASE[idx - 1].load(Ordering::Relaxed); + pb + EXT_LEN[idx - 1].load(Ordering::Relaxed) == base + }; + let next_touches = idx < n && EXT_BASE[idx].load(Ordering::Relaxed) == base + size; + + match (prev_touches, next_touches) { + // Bridges two extents: absorb both into the earlier one. + (true, true) => { + let grown = EXT_LEN[idx - 1].load(Ordering::Relaxed) + + size + + EXT_LEN[idx].load(Ordering::Relaxed); + EXT_LEN[idx - 1].store(grown, Ordering::Relaxed); + remove_at(idx); + } + (true, false) => { + let grown = EXT_LEN[idx - 1].load(Ordering::Relaxed) + size; + EXT_LEN[idx - 1].store(grown, Ordering::Relaxed); + } + (false, true) => { + EXT_BASE[idx].store(base, Ordering::Relaxed); + let grown = EXT_LEN[idx].load(Ordering::Relaxed) + size; + EXT_LEN[idx].store(grown, Ordering::Relaxed); + } + (false, false) => { + if n >= MAX_EXTENTS { + return Err(FERR); + } + insert_at(idx, base, size); + } + } + Ok(()) +} + +/// Always backed; reports NOT-known-zero, because [`decommit`] preserves +/// contents here. +#[allow( + clippy::unnecessary_wraps, + reason = "the prim backends share one signature; a no-op backend still returns the contract's Result" +)] +pub(super) unsafe fn commit(_ptr: *mut u8, _size: usize) -> Result { + Ok(false) +} + +/// No-op. `false` = no re-commit needed, contents preserved. +#[allow( + clippy::unnecessary_wraps, + reason = "the prim backends share one signature; a no-op backend still returns the contract's Result" +)] +pub(super) unsafe fn decommit(_ptr: *mut u8, _size: usize) -> Result { + Ok(false) +} + +#[allow( + clippy::unnecessary_wraps, + reason = "the prim backends share one signature; a no-op backend still returns the contract's Result" +)] +pub(super) unsafe fn reset(_ptr: *mut u8, _size: usize) -> Result<(), PrimError> { + Ok(()) +} + +/// No MMU. Fail loudly rather than pretend — same reasoning as the wasm arm. +pub(super) unsafe fn protect(_ptr: *mut u8, _size: usize, _on: bool) -> Result<(), PrimError> { + Err(FERR) +} + +pub(super) fn numa_node_count() -> usize { + 1 +} + +/// One thread, so one id. Must be non-zero: zero is the allocator's "segment is +/// abandoned" sentinel. +#[inline] +pub(super) fn thread_id() -> usize { + 1 +} + +/// No clock. A monotonic counter preserves purge ORDERING, which is all the +/// purge policy reads; duration does not survive. +/// +/// **Two 32-bit words, not one `AtomicU64`.** The seam's return type is `u64`, +/// but this backend's whole reason to exist is a target without 64-bit +/// atomics — an `AtomicU64` here would be the one §2.2 site the port itself +/// introduced. Widening two `AtomicU32`s under their own lock keeps the full +/// range without one, and a 32-bit counter alone would wrap and invert purge +/// ordering, which is exactly the property this function exists to provide. +/// +/// The lock is separate from [`LOCK`] on purpose: [`Guard`] is not reentrant, +/// and a shared lock would deadlock the moment an allocation path wanted a +/// timestamp. +static CLOCK_LOCK: AtomicBool = AtomicBool::new(false); +static TICK_LO: AtomicU32 = AtomicU32::new(0); +static TICK_HI: AtomicU32 = AtomicU32::new(0); + +pub(super) fn clock_now() -> u64 { + let _g = Guard::acquire(&CLOCK_LOCK); + let (lo, carry) = TICK_LO.load(Ordering::Relaxed).overflowing_add(1); + TICK_LO.store(lo, Ordering::Relaxed); + let hi = if carry { + let h = TICK_HI.load(Ordering::Relaxed).wrapping_add(1); + TICK_HI.store(h, Ordering::Relaxed); + h + } else { + TICK_HI.load(Ordering::Relaxed) + }; + (u64::from(hi) << 32) | u64::from(lo) +} + +/// TLS for a single-threaded world: a fixed static table. Destructors are +/// accepted and never run — there is no thread exit. +const MAX_TLS: usize = 8; +static TLS_VALUES: [AtomicUsize; MAX_TLS] = [const { AtomicUsize::new(0) }; MAX_TLS]; +static NEXT_SLOT: AtomicUsize = AtomicUsize::new(0); + +pub(super) struct TlsSlotImpl(usize); + +pub(super) fn tls_new(_dtor: Option) -> Option { + let idx = NEXT_SLOT.fetch_add(1, Ordering::Relaxed); + if idx < MAX_TLS { + Some(TlsSlotImpl(idx)) + } else { + None + } +} + +pub(super) fn tls_get(slot: &TlsSlotImpl) -> *mut c_void { + core::ptr::with_exposed_provenance_mut(TLS_VALUES[slot.0].load(Ordering::Relaxed)) +} + +pub(super) fn tls_set(slot: &TlsSlotImpl, value: *mut c_void) { + TLS_VALUES[slot.0].store(value.expose_provenance(), Ordering::Relaxed); +} + +pub(super) fn tls_raw(slot: &TlsSlotImpl) -> usize { + slot.0 +} + +pub(super) fn tls_from_raw(raw: usize) -> TlsSlotImpl { + TlsSlotImpl(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::SEGMENT_SIZE; + + /// Unlike the wasm backend, `free` here really frees — so the arena's + /// adopt-on-free path folds away, exactly as on every platform whose free + /// works. A `const` assertion because it is a compile-time fact. + const _: () = assert!(super::super::FREE_RETURNS_MEMORY); + + /// The whole free list, as `(base_offset, len)` against the region base. + fn extents() -> Vec<(usize, usize)> { + let _g = Guard::acquire(&LOCK); + let rbase = REGION_BASE.load(Ordering::Relaxed); + (0..EXT_COUNT.load(Ordering::Relaxed)) + .map(|i| { + ( + EXT_BASE[i].load(Ordering::Relaxed) - rbase, + EXT_LEN[i].load(Ordering::Relaxed), + ) + }) + .collect() + } + + fn free_total() -> usize { + extents().iter().map(|e| e.1).sum() + } + + /// P1's kill test: a 512 KiB static region, served and recycled. + /// + /// One `#[test]` rather than several, because the free list is process-wide + /// state that `init_region` deliberately refuses to re-initialise — so the + /// ordering has to be explicit rather than left to the harness. + /// P1's kill test asks for a 512 KiB region; this is it, plus ONE page. + /// `static mut` rather than a leak so the test needs no allocator of its + /// own — the one under test is the allocator. + /// + /// The odd page and the 64 KiB alignment are what make §2.9's half two + /// able to fail. A region that is an exact multiple of `SEGMENT_SIZE` + /// cannot tell the two placement policies apart — a page off either end + /// costs a segment — so at 512 KiB flat the assertion would pass under the + /// bug it exists to catch. `K * SEGMENT_SIZE + FIXED_PAGE` on an aligned + /// base is the shape the board actually has, and the shape that + /// discriminates. + /// + /// The alignment is taken at RUNTIME from an oversized backing array rather + /// than with `#[repr(align(65536))]`, because rustc 1.97.1 on MSVC crashes + /// (STATUS_ILLEGAL_INSTRUCTION) compiling a half-megabyte static at that + /// alignment. Carving the window also matches how a linker script hands a + /// chip its heap, so nothing is lost by it. + const REGION_ALIGN: usize = 64 * 1024; + const N: usize = 512 * 1024 + FIXED_PAGE; + static mut BACKING: [u8; N + REGION_ALIGN] = [0; N + REGION_ALIGN]; + static mut OTHER: [u8; FIXED_PAGE] = [0; FIXED_PAGE]; + + #[test] + fn serves_and_recycles_a_static_region() { + // The REGION_ALIGN-aligned window inside BACKING. `add` keeps the + // array's provenance, so the slice below is a real borrow of it. + let bp = (&raw mut BACKING).cast::(); + let skip = align_up(bp.expose_provenance(), REGION_ALIGN) - bp.expose_provenance(); + // SAFETY: `skip < REGION_ALIGN`, so `skip + N` is inside BACKING. + let rp = unsafe { bp.add(skip) }; + // SAFETY: the only reference ever taken to REGION, handed straight to + // init_region which requires (and consumes) exactly that exclusivity. + let region: &'static mut [u8] = unsafe { core::slice::from_raw_parts_mut(rp, N) }; + + init_region(region).expect("first registration succeeds"); + assert_eq!(free_total(), N, "the whole region starts free"); + assert_eq!(extents().len(), 1, "as one extent"); + + // A second registration is refused: the region is handed over once. + let op = &raw mut OTHER; + // SAFETY: as above; the call is expected to fail before it stores it. + let other: &'static mut [u8] = unsafe { &mut *op }; + assert!(init_region(other).is_err(), "no second region"); + + // Serve three page-aligned blocks. + // SAFETY: the prim contract — sizes are page multiples, alignment a + // power of two. + let (a, b, c) = unsafe { + ( + alloc(64 * 1024, FIXED_PAGE, true, false).expect("a"), + alloc(128 * 1024, FIXED_PAGE, true, false).expect("b"), + alloc(64 * 1024, FIXED_PAGE, true, false).expect("c"), + ) + }; + assert_eq!(free_total(), N - 256 * 1024, "three blocks are out"); + assert!(!a.is_zero, "recycled memory is never claimed zero"); + + // Blocks are distinct, in the region, and do not overlap. + let base = REGION_BASE.load(Ordering::Relaxed); + for (p, len) in [(a.ptr, 64 * 1024), (b.ptr, 128 * 1024), (c.ptr, 64 * 1024)] { + let off = p.expose_provenance() - base; + assert!(off + len <= N, "block lies inside the region"); + } + assert_ne!(a.ptr, b.ptr); + assert_ne!(b.ptr, c.ptr); + + // Write a pattern through each and read it back: the region is real + // memory, not just bookkeeping. + for (p, len, tag) in [(a.ptr, 64 * 1024, 0xA5u8), (b.ptr, 128 * 1024, 0x5Au8)] { + // SAFETY: `p` is a live block of `len` bytes from `alloc` above. + unsafe { + core::ptr::write_bytes(p, tag, len); + assert_eq!(*p, tag); + assert_eq!(*p.add(len - 1), tag); + } + } + + // Free the middle block: it becomes its own extent, no coalescing. + let holes = extents().len(); + // SAFETY: `b` came from `alloc` and is unfreed. + unsafe { free(b.ptr, 128 * 1024).expect("free b") }; + assert_eq!(free_total(), N - 128 * 1024); + assert_eq!(extents().len(), holes + 1, "an isolated hole"); + + // Free its neighbours: everything coalesces back to one extent. + // SAFETY: both came from `alloc` and are unfreed. + unsafe { + free(a.ptr, 64 * 1024).expect("free a"); + free(c.ptr, 64 * 1024).expect("free c"); + } + assert_eq!(free_total(), N, "the whole region is back"); + assert_eq!(extents().len(), 1, "coalesced into one extent"); + + // The region is reusable: the same 256 KiB can be served again. + // SAFETY: prim contract, as above. + let d = unsafe { alloc(256 * 1024, FIXED_PAGE, true, false).expect("d") }; + assert_eq!(free_total(), N - 256 * 1024); + // SAFETY: `d` is live and unfreed. + unsafe { free(d.ptr, 256 * 1024).expect("free d") }; + assert_eq!(free_total(), N); + + // ---- §2.1, both sides ---- + // + // This lives HERE, and not in a test of its own, because the free list + // is process-wide and the harness orders tests arbitrarily: standalone, + // it passed while NO region was registered, i.e. for the trivial reason + // rather than the interesting one. Measured, not assumed. + // + // P1 wrote this as a one-sided refusal, because at the shipped geometry + // a segment cannot come out of a chip-sized region. P2 made the + // geometry a parameter, so the property is now two-sided and says + // something either way — which is the point of having kept it. + assert_eq!(free_total(), N, "the whole region is free before this"); + // SAFETY: prim contract; SEGMENT_SIZE is a power of two. + let seg = unsafe { alloc(SEGMENT_SIZE, SEGMENT_SIZE, true, false) }; + if SEGMENT_SIZE > N { + // The shipped 32 MiB geometry: 512 KiB cannot hold a segment, and + // the refusal must cost nothing. + assert!( + seg.is_err(), + "a {SEGMENT_SIZE}-byte segment cannot come out of a {N}-byte region" + ); + assert_eq!( + free_total(), + N, + "a refused request leaves the list untouched" + ); + + // Nor can the ALIGNMENT be met — the half no larger region fixes. + // SAFETY: prim contract, as above. + let al = unsafe { alloc(FIXED_PAGE, SEGMENT_SIZE, true, false) }; + assert!( + al.is_err(), + "SEGMENT_SIZE alignment is unsatisfiable in a region smaller than it" + ); + } else { + // The small profile: this is what P2 bought. A whole segment, at + // segment alignment, served from a chip-sized region. + let a = seg.expect("a segment must fit once the geometry allows it"); + assert_eq!( + a.ptr.expose_provenance() % SEGMENT_SIZE, + 0, + "a segment must be SEGMENT_SIZE-aligned — `segment_of` masks on it" + ); + // `saturating_sub`: the compiler const-evaluates this arm even when + // the branch is dead, and at the shipped geometry SEGMENT_SIZE > N. + assert_eq!(free_total(), N.saturating_sub(SEGMENT_SIZE)); + // SAFETY: `a` is live and unfreed. + unsafe { free(a.ptr, SEGMENT_SIZE).expect("free the segment") }; + } + // Either way the region ends whole: a refusal consumed nothing, and a + // served segment was handed back. + assert_eq!(free_total(), N); + + // ---- §2.9, the two-ended placement, both halves ---- + // + // Also here rather than standalone, for the same process-wide-state + // reason as §2.1 above. + // + // HALF ONE, the mechanism: a merely page-aligned request goes to the + // TOP, leaving the low end of the region contiguous. Under the old + // bottom-only first-fit this offset was 0 and the surviving extent + // started at FIXED_PAGE — which is exactly how a 4 KiB heap block used + // to cost a whole segment of reach. + // SAFETY: prim contract — a page multiple at a power-of-two alignment. + let top = unsafe { alloc(FIXED_PAGE, FIXED_PAGE, true, false).expect("top") }; + assert_eq!( + top.ptr.expose_provenance() - REGION_BASE.load(Ordering::Relaxed), + N - FIXED_PAGE, + "a page-aligned request is placed at the top of the region" + ); + assert_eq!( + extents(), + vec![(0, N - FIXED_PAGE)], + "and leaves the low end as ONE contiguous extent" + ); + // SAFETY: `top` is live and unfreed. + unsafe { free(top.ptr, FIXED_PAGE).expect("free top") }; + assert_eq!(free_total(), N); + + // HALF TWO, the consequence that was actually measured: taking that + // page must not cost a single SEGMENT_SIZE-aligned segment. Counted + // both ways rather than asserted, so the test says what it means at + // whichever geometry it is compiled for (at the shipped 32 MiB one + // both counts are 0, and the equality still holds honestly). + let clean = greedy_segments(); + assert_eq!(free_total(), N, "counting segments leaves the region whole"); + // SAFETY: prim contract, as above. + let hdr = unsafe { alloc(FIXED_PAGE, FIXED_PAGE, true, false).expect("hdr") }; + let with_hdr = greedy_segments(); + assert_eq!( + with_hdr, clean, + "a page-sized block must not cost a whole segment of reach" + ); + // SAFETY: `hdr` is live and unfreed. + unsafe { free(hdr.ptr, FIXED_PAGE).expect("free hdr") }; + assert_eq!(free_total(), N, "and the region ends whole"); + } + + /// Serve `SEGMENT_SIZE`-aligned segments until the region refuses, then + /// hand them all back. Returns how many it managed — the region's segment + /// *reach*, which is the quantity §2.9's placement rule protects. + fn greedy_segments() -> usize { + let mut held = Vec::new(); + // SAFETY: prim contract — SEGMENT_SIZE is a power of two, and every + // pointer collected here is freed below before the function returns. + while let Ok(a) = unsafe { alloc(SEGMENT_SIZE, SEGMENT_SIZE, true, false) } { + held.push(a.ptr); + } + let n = held.len(); + for p in held { + // SAFETY: each `p` came from the `alloc` above and is unfreed. + unsafe { free(p, SEGMENT_SIZE).expect("free a counted segment") }; + } + n + } + + /// The no-MMU decisions, pinned so a future edit has to mean it. + #[test] + fn no_mmu_semantics_are_explicit() { + let cfg = mem_init(); + assert_eq!(cfg.page_size, FIXED_PAGE); + assert_eq!(cfg.large_page_size, 0, "no large pages without an MMU"); + assert!(!cfg.has_overcommit, "nothing to overcommit"); + assert!(cfg.has_partial_free, "any extent can be returned"); + assert_ne!(thread_id(), 0, "zero is the abandoned-segment sentinel"); + assert_eq!(numa_node_count(), 1); + // A monotonic counter, not a clock. + assert!(clock_now() < clock_now()); + // SAFETY: `protect` on this backend inspects nothing and always fails; + // it never dereferences the pointer, so a null one is in contract. + let p = unsafe { protect(core::ptr::null_mut(), FIXED_PAGE, true) }; + assert!( + p.is_err(), + "a guard page that cannot trap must not report success" + ); + } +} diff --git a/crates/rusty_alloc/src/prim/mod.rs b/crates/rusty_alloc/src/prim/mod.rs index b8a63ae..db364a9 100644 --- a/crates/rusty_alloc/src/prim/mod.rs +++ b/crates/rusty_alloc/src/prim/mod.rs @@ -28,6 +28,22 @@ pub mod mock; #[cfg(miri)] use mock as sys; +// A target with no OS at all — a microcontroller, where memory is a region the +// linker reserved (P1 of `docs/plans/small-metal.md`). This is the fifth arm +// P0 found missing: `riscv32imac-unknown-none-elf` is none of the four above, +// so before this existed no `sys` was named at all and every call through the +// seam failed together. +// +// ALWAYS COMPILED, so it is type-checked and unit-tested on the host; +// SELECTED only where no arm above matches, so no host build changes. +#[cfg_attr( + any(windows, unix, all(target_arch = "wasm32", not(miri)), miri), + allow(dead_code) +)] +pub mod fixed; +#[cfg(all(not(miri), not(windows), not(unix), not(target_arch = "wasm32")))] +use fixed as sys; + use core::ffi::c_void; /// OS error code (`GetLastError` on Windows, `errno` on unix, synthetic in mock). diff --git a/crates/rusty_alloc/src/random.rs b/crates/rusty_alloc/src/random.rs index e2fa6dd..3b26267 100644 --- a/crates/rusty_alloc/src/random.rs +++ b/crates/rusty_alloc/src/random.rs @@ -6,7 +6,7 @@ //! clock, addresses and thread id; the seed path is documented per platform //! so `secure` builds can state what they rest on. -use core::sync::atomic::{AtomicU64, Ordering}; +use core::sync::atomic::{AtomicUsize, Ordering}; /// A ChaCha8 stream. Not `Sync`: each heap owns one (no sharing, no locks). pub struct Random { @@ -58,12 +58,17 @@ impl Random { if !os_entropy(&mut key) { // Fallback mixing: clock, a stack address, a heap-ish address, // thread id, and a global counter (documented weaker path). - static COUNTER: AtomicU64 = AtomicU64::new(0x9E37_79B9_7F4A_7C15); - let stack = std::ptr::from_ref(&key) as usize as u64; + // `AtomicUsize`, not `AtomicU64`: this is a seed-mixing counter, + // not a value with a width contract, and 32-bit RISC-V / Xtensa + // have no 64-bit atomic (P3 of `docs/plans/small-metal.md`). The + // constant is the golden ratio truncated to the target's word. + const GOLDEN: usize = 0x9E37_79B9_usize; + static COUNTER: AtomicUsize = AtomicUsize::new(GOLDEN); + let stack = core::ptr::from_ref(&key) as usize as u64; let mut acc = crate::prim::clock_now() ^ stack.rotate_left(17) ^ (crate::prim::thread_id() as u64).rotate_left(33) - ^ COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed); + ^ COUNTER.fetch_add(GOLDEN, Ordering::Relaxed) as u64; for k in key.iter_mut() { // splitmix64 step acc = acc.wrapping_add(0x9E37_79B9_7F4A_7C15); @@ -181,7 +186,12 @@ fn os_entropy(key: &mut [u32; 8]) -> bool { status == 0 } -#[cfg(all(unix, not(miri)))] +// `feature = "std"`, not just `unix`: `/dev/urandom` is reached through +// `std::fs`, so a `no_std` build on a unix HOST — which is what CI's `no_std` +// clippy step is — matched this arm and failed to resolve `std`. The fifth case +// again (P0 in `prim/mod.rs`, P3 here, P5 in `stats.rs`): a platform selection +// written when `std` was unconditional. +#[cfg(all(unix, feature = "std", not(miri)))] fn os_entropy(key: &mut [u32; 8]) -> bool { // /dev/urandom: universally available and needs no libc feature probing. use std::io::Read; @@ -206,6 +216,25 @@ fn os_entropy(_key: &mut [u32; 8]) -> bool { /// counter as the only varying inputs. Free-list encoding under `secure` /// therefore has MUCH less entropy on wasm than on a native target — treat it /// as corruption detection, not as an exploit-mitigation claim. +/// No OS, so no OS entropy — the fifth arm this four-way selection was +/// missing, exactly as `prim/mod.rs` was missing one (P0 bucket A). The +/// caller's documented fallback mixing (clock, stack address, thread id, a +/// global counter) is what a bare-metal build gets, and the same caveat the +/// wasm arm carries applies with more force: free-list encoding under `secure` +/// is corruption DETECTION here, not an exploit-mitigation claim. A part with +/// a hardware RNG should wire it through `prim` rather than weaken this. +#[cfg(all( + not(miri), + not(windows), + not(target_arch = "wasm32"), + // Bare metal, AND unix-without-`std`: both reach here because neither can + // open `/dev/urandom`. + not(all(unix, feature = "std")) +))] +fn os_entropy(_key: &mut [u32; 8]) -> bool { + false +} + #[cfg(all(target_arch = "wasm32", not(miri)))] fn os_entropy(_key: &mut [u32; 8]) -> bool { false diff --git a/crates/rusty_alloc/src/segment.rs b/crates/rusty_alloc/src/segment.rs index a573908..0924ebc 100644 --- a/crates/rusty_alloc/src/segment.rs +++ b/crates/rusty_alloc/src/segment.rs @@ -776,6 +776,7 @@ pub fn huge_alloc( size: usize, align: usize, offset: usize, + arena_id: i32, ) -> Result<(*mut Segment, *mut u8), PrimError> { debug_assert!(align.is_power_of_two() && align <= SEGMENT_SIZE / 2); let header = SEGMENT_SLICE_SIZE; @@ -795,10 +796,21 @@ pub fn huge_alloc( // Huge blocks recycle through arenas too (contiguous chunks) — without // this, every huge alloc/free cycle is an OS round-trip (the Tier-A // malloc-large gate measured 3–4× slower before this path). + // + // `arena_id` is the OWNING HEAP's, exactly as `segment_alloc` above uses + // it. It used to be a hardcoded `-1`, which meant an exclusive-arena heap + // — the whole point of which is that its memory comes from ONE region — + // silently took its huge blocks from the default arena or straight from + // the OS. Upstream passes `heap->arena_id` here + // (`mi_segment_huge_page_alloc`, oracle segment.c:1671/1683); we did not. + // See `tests/heaps.rs::exclusive_arena_confines_huge_allocations`. let chunks = want.div_ceil(SEGMENT_SIZE); - let (bptr, total, mem_zero) = match crate::arena::chunk_alloc_n(-1, chunks) { + let (bptr, total, mem_zero) = match crate::arena::chunk_alloc_n(arena_id, chunks) { Some((p, zero)) => (p, chunks * SEGMENT_SIZE, zero), None => { + if arena_id >= 0 { + return Err(0); // exclusive-arena heap and its arena is full + } let (p, sz, zero) = reserve_backing(want)?; (p, sz, zero) } diff --git a/crates/rusty_alloc/src/segment_map.rs b/crates/rusty_alloc/src/segment_map.rs index 3a30081..24480db 100644 --- a/crates/rusty_alloc/src/segment_map.rs +++ b/crates/rusty_alloc/src/segment_map.rs @@ -7,27 +7,179 @@ //! as NOT ours — a false negative for `is_in_heap_region`, never a false //! positive. -// The window bitmap is the native representation; wasm replaces it wholesale -// with the slice-granular base table below, so the bitmap items are compiled -// out there rather than left as dead weight. -#[cfg(not(all(target_arch = "wasm32", not(miri))))] -use core::sync::atomic::{AtomicU64, Ordering}; +// Three representations, and each compiles the other two out rather than +// leaving them as dead weight: +// wasm -> the slice-granular `base_table` (segments are not +// SEGMENT_SIZE-aligned there, so `segment_of` cannot mask) +// small profile -> the exact `range_table` (the bitmap is sized by ADDRESS +// SPACE, which a chip cannot afford at any geometry) +// otherwise -> the window bitmap +#[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] +use core::sync::atomic::{AtomicU32, Ordering}; use crate::segment::Segment; use crate::types::SEGMENT_SIZE; -#[cfg(not(all(target_arch = "wasm32", not(miri))))] -const ADDR_BITS: usize = 48; -const WINDOW_SHIFT: usize = 25; // log2(SEGMENT_SIZE) -#[cfg(not(all(target_arch = "wasm32", not(miri))))] +/// Addressable bits the map has to span. +/// +/// 48 is the x86-64 / aarch64 user-VA limit. On a 32-bit target the whole +/// address space is 32 bits, and spanning 48 would size the table for memory +/// that cannot exist — 65,536x too large. Derived rather than written down +/// (P2 of `docs/plans/small-metal.md`). +#[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] +const ADDR_BITS: usize = if usize::BITS == 64 { + 48 +} else { + usize::BITS as usize +}; + +/// `log2(SEGMENT_SIZE)`. This was a hardcoded `25` with a const assert pinning +/// it; deriving it is what lets the geometry move at all — it and +/// `slice_pool::SLICE_SHIFT` were the only two lines in the crate that refused +/// a different `SEGMENT_SIZE`. +const WINDOW_SHIFT: usize = SEGMENT_SIZE.trailing_zeros() as usize; +#[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] const MAP_BITS: usize = 1 << (ADDR_BITS - WINDOW_SHIFT); -#[cfg(not(all(target_arch = "wasm32", not(miri))))] -const MAP_WORDS: usize = MAP_BITS / 64; +#[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] +const MAP_WORDS: usize = MAP_BITS / WORD_BITS; const _: () = assert!(1 << WINDOW_SHIFT == SEGMENT_SIZE); -#[cfg(not(all(target_arch = "wasm32", not(miri))))] -static MAP: [AtomicU64; MAP_WORDS] = [const { AtomicU64::new(0) }; MAP_WORDS]; +/// Bits per map word. `u32`, not `u64`: the width is a free choice for a +/// bitmap and 32-bit RISC-V / Xtensa have no 64-bit atomic (P3 of +/// `docs/plans/small-metal.md`). +#[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] +const WORD_BITS: usize = u32::BITS as usize; + +#[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] +static MAP: [AtomicU32; MAP_WORDS] = [const { AtomicU32::new(0) }; MAP_WORDS]; + +/// Small profile: an EXACT range table instead of a window bitmap. +/// +/// The bitmap's size is a function of the ADDRESS SPACE, not of the memory +/// actually owned — 1 MiB of BSS at 48 bits, and *worse* as `SEGMENT_SIZE` +/// shrinks (a 64 KiB segment would want 2^32 bits). On a part with 512 KiB of +/// SRAM that is not a tuning problem, it is a disqualifier, and it is +/// independent of §2.1's geometry: parameterising the segment size alone makes +/// this table bigger, not smaller. +/// +/// A chip owns a handful of segments carved from one region, so the exact set +/// fits in a fixed array and `contains` is a short scan. Exactness matters: +/// `contains` backs the `debug_checks` foreign-pointer guard, where a false +/// negative aborts a legitimate free — so a truncated bitmap (which would +/// merely under-report) is NOT an acceptable substitute here, even though the +/// module's own doc permits false negatives for `is_in_heap_region`. +#[cfg(all(ra_small_profile, not(all(target_arch = "wasm32", not(miri)))))] +mod range_table { + use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + /// Live ranges tracked at once — 1 KiB of BSS, against the 1 MiB the + /// bitmap costs. Each range is a whole segment or huge reservation, so at + /// a 64 KiB segment this spans 4 MiB of registered memory, comfortably + /// past any chip this profile targets (Janus firmwares declare 64–220 KiB + /// heaps). + /// + /// **A smaller segment multiplies every per-segment structure.** Going + /// 32 MiB → 64 KiB is 512x more live segments for the same bytes managed, + /// and this table was the first thing to notice: sized at 32 it overflowed + /// during the host battery and the allocator began aborting legitimate + /// frees. That is a property of the geometry, not of this table, and it is + /// the reason the overflow behaviour below matters more than the number. + const MAX_RANGES: usize = 64; + + static BASE: [AtomicUsize; MAX_RANGES] = [const { AtomicUsize::new(0) }; MAX_RANGES]; + static END: [AtomicUsize; MAX_RANGES] = [const { AtomicUsize::new(0) }; MAX_RANGES]; + static LOCK: AtomicBool = AtomicBool::new(false); + + /// Set once the table has ever been full, and never cleared. + /// + /// **Which way to fail is not symmetric here, and the module doc's + /// "false negative, never a false positive" is the wrong rule for this + /// consumer.** `contains` backs two callers: `is_in_heap_region`, a + /// diagnostic where a false positive merely misleads; and the + /// `debug_checks` foreign-pointer guard, where a false NEGATIVE aborts a + /// legitimate free. Dropping a range silently — the first version of this + /// table — produced exactly that: three integration suites aborting inside + /// `free` on pointers the allocator had certainly returned. + /// + /// So once membership can no longer be decided, `contains` degrades + /// PERMISSIVELY: the guard stops catching foreign pointers rather than + /// rejecting good ones, and the loss of the diagnostic is readable from + /// [`overflowed`] rather than inferred from a crash. + static OVERFLOWED: AtomicBool = AtomicBool::new(false); + + struct Guard; + impl Guard { + fn acquire() -> Self { + while LOCK + .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + core::hint::spin_loop(); + } + Self + } + } + impl Drop for Guard { + fn drop(&mut self) { + LOCK.store(false, Ordering::Release); + } + } + + /// Record `[base, base+size)`. On a full table, latch [`OVERFLOWED`] so + /// membership degrades permissively rather than silently wrong. + pub(super) fn set(base: usize, size: usize) { + let _g = Guard::acquire(); + for i in 0..MAX_RANGES { + if END[i].load(Ordering::Relaxed) == 0 { + BASE[i].store(base, Ordering::Relaxed); + END[i].store(base + size.max(1), Ordering::Release); + return; + } + } + // Deliberately NOT a `debug_assert`: this is reachable in a VALID + // configuration — the host battery under this profile manages far more + // segments than any chip does — and an assertion should mean + // "impossible", not "expected when you test it off-target". The latch + // is the signal; [`overflowed`] is how a test reads it. + OVERFLOWED.store(true, Ordering::Release); + } + + /// Whether the table has ever been full, i.e. whether `contains` has + /// stopped being exact. A chip-shaped workload must never set this; the + /// host battery does, which is what the two are for. + pub(super) fn overflowed() -> bool { + OVERFLOWED.load(Ordering::Acquire) + } + + /// Forget `[base, base+size)`. Matches on the base, so an unregister that + /// was never registered is a no-op. + pub(super) fn clear(base: usize, size: usize) { + let end = base + size.max(1); + let _g = Guard::acquire(); + for i in 0..MAX_RANGES { + if BASE[i].load(Ordering::Relaxed) == base && END[i].load(Ordering::Relaxed) == end { + END[i].store(0, Ordering::Release); + BASE[i].store(0, Ordering::Relaxed); + return; + } + } + } + + /// Exact membership while the table has held every range; permissive once + /// it has not (see [`OVERFLOWED`]). Lock-free: a racing + /// register/unregister can flip the answer, which is the same best-effort + /// the bitmap gives. + pub(super) fn contains(addr: usize) -> bool { + if OVERFLOWED.load(Ordering::Acquire) { + return true; + } + (0..MAX_RANGES).any(|i| { + let e = END[i].load(Ordering::Acquire); + e != 0 && addr >= BASE[i].load(Ordering::Relaxed) && addr < e + }) + } +} /// wasm: a slice-granular BASE table instead of the window bitmap. /// @@ -89,14 +241,14 @@ pub fn base_of(addr: usize) -> usize { base_table::get(addr) } -#[cfg(not(all(target_arch = "wasm32", not(miri))))] +#[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] #[inline] -fn locate(addr: usize) -> Option<(usize, u64)> { +fn locate(addr: usize) -> Option<(usize, u32)> { let idx = addr >> WINDOW_SHIFT; if idx >= MAP_BITS { return None; } - Some((idx / 64, 1u64 << (idx % 64))) + Some((idx / WORD_BITS, 1u32 << (idx % WORD_BITS))) } /// Register a segment's windows (Normal: one; Huge: every window the @@ -112,7 +264,11 @@ pub fn register_range(base: usize, size: usize) { { base_table::set(base, size); } - #[cfg(not(all(target_arch = "wasm32", not(miri))))] + #[cfg(all(ra_small_profile, not(all(target_arch = "wasm32", not(miri)))))] + { + range_table::set(base, size); + } + #[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] { let mut a = base; let end = base + size.max(1); @@ -136,7 +292,11 @@ pub fn unregister_range(base: usize, size: usize) { { base_table::clear(base, size); } - #[cfg(not(all(target_arch = "wasm32", not(miri))))] + #[cfg(all(ra_small_profile, not(all(target_arch = "wasm32", not(miri)))))] + { + range_table::clear(base, size); + } + #[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] { let mut a = base; let end = base + size.max(1); @@ -157,7 +317,11 @@ pub fn contains(p: *const u8) -> bool { { base_table::get(p.addr()) != 0 } - #[cfg(not(all(target_arch = "wasm32", not(miri))))] + #[cfg(all(ra_small_profile, not(all(target_arch = "wasm32", not(miri)))))] + { + range_table::contains(p.addr()) + } + #[cfg(all(not(ra_small_profile), not(all(target_arch = "wasm32", not(miri)))))] { match locate(p.addr()) { Some((w, bit)) => MAP[w].load(Ordering::Acquire) & bit != 0, @@ -165,3 +329,20 @@ pub fn contains(p: *const u8) -> bool { } } } + +/// Whether the small profile's range table has stopped being exact. +/// +/// Always `false` on the bitmap and base-table representations, which cannot +/// overflow. P2 of `docs/plans/small-metal.md` uses it to tell a chip-shaped +/// workload (must stay exact) from the host battery (legitimately does not). +#[must_use] +pub fn range_table_overflowed() -> bool { + #[cfg(all(ra_small_profile, not(all(target_arch = "wasm32", not(miri)))))] + { + range_table::overflowed() + } + #[cfg(not(all(ra_small_profile, not(all(target_arch = "wasm32", not(miri))))))] + { + false + } +} diff --git a/crates/rusty_alloc/src/slice_pool.rs b/crates/rusty_alloc/src/slice_pool.rs index 616e0d7..907d18a 100644 --- a/crates/rusty_alloc/src/slice_pool.rs +++ b/crates/rusty_alloc/src/slice_pool.rs @@ -25,21 +25,29 @@ //! of `prim/wasm.rs`); the atomics are for `static` soundness, not for //! concurrency, and are `Relaxed` throughout. -use core::sync::atomic::{AtomicU64, Ordering}; +use core::sync::atomic::{AtomicU32, Ordering}; use crate::types::SEGMENT_SLICE_SIZE; -const SLICE_SHIFT: usize = 16; +/// Derived, not written down: this was a hardcoded `16` with a const assert +/// pinning it to a 64 KiB slice, which made the slice size unchangeable +/// (P2 of `docs/plans/small-metal.md` — it was one of exactly two lines in the +/// crate that refused a different geometry). +const SLICE_SHIFT: usize = SEGMENT_SLICE_SIZE.trailing_zeros() as usize; const _: () = assert!(1 << SLICE_SHIFT == SEGMENT_SLICE_SIZE); -/// 4 GiB of address space in 64 KiB slices. +/// 4 GiB of address space in slice-sized steps. const SLOTS: usize = 1 << (32 - SLICE_SHIFT); -const WORDS: usize = SLOTS / 64; +/// Bits per pool word. `u32`, not `u64`: a bitmap's width is a free choice +/// and 32-bit RISC-V / Xtensa have no 64-bit atomic (P3 of +/// `docs/plans/small-metal.md`). +const WORD_BITS: usize = u32::BITS as usize; +const WORDS: usize = SLOTS / WORD_BITS; -static FREE: [AtomicU64; WORDS] = [const { AtomicU64::new(0) }; WORDS]; +static FREE: [AtomicU32; WORDS] = [const { AtomicU32::new(0) }; WORDS]; #[inline] -fn bit(idx: usize) -> (usize, u64) { - (idx / 64, 1u64 << (idx % 64)) +fn bit(idx: usize) -> (usize, u32) { + (idx / WORD_BITS, 1u32 << (idx % WORD_BITS)) } /// Return `[base, base + size)` to the pool. `false` (and no state change) @@ -84,14 +92,14 @@ pub fn alloc_run(slices: usize) -> Option { while idx < SLOTS { let (w, _) = bit(idx); let word = FREE[w].load(Ordering::Relaxed); - if word == 0 && idx.is_multiple_of(64) { + if word == 0 && idx.is_multiple_of(WORD_BITS) { // Whole word empty: skip it. Resetting the run is correct, not // merely convenient — a run cannot cross a zero word. run = 0; - idx += 64; + idx += WORD_BITS; continue; } - if word & (1 << (idx % 64)) != 0 { + if word & (1 << (idx % WORD_BITS)) != 0 { run += 1; if run == slices { let start = idx + 1 - slices; @@ -124,36 +132,45 @@ mod tests { LOCK.lock().unwrap_or_else(|e| e.into_inner()) } - const MIB: usize = 1024 * 1024; + /// Bytes for `n` slices. These tests are about the POOL's arithmetic — + /// runs, first fit, coalescing, word boundaries — all of which is counted + /// in slices, so they are written in slices. They used to be written in + /// MiB, which silently assumed a 64 KiB slice and inverted the moment + /// `SEGMENT_SLICE_SIZE` moved (P2, `docs/plans/small-metal.md`: at an + /// 8 KiB slice "1 MiB = 16 slices" became 128, and three tests failed on + /// arithmetic that had nothing to do with what they test). + const fn sl(n: usize) -> usize { + n * SEGMENT_SLICE_SIZE + } #[test] fn round_trips_and_coalesces() { let _g = lock(); - let base = 256 * MIB; - assert!(free_range(base, 2 * MIB)); - assert!(free_range(base + 2 * MIB, MIB)); // adjacent: coalesces by construction - // 3 MiB = 48 slices, spanning the two freed ranges as one run. + let base = sl(4096); + assert!(free_range(base, sl(32))); + assert!(free_range(base + sl(32), sl(16))); // adjacent: coalesces by construction + // 48 slices, spanning the two freed ranges as one run. assert_eq!(alloc_run(48), Some(base), "coalesced run"); // Pool drained: the same run is not served twice. assert!(alloc_run(1).is_none()); - assert!(free_range(base, 3 * MIB)); + assert!(free_range(base, sl(48))); assert_eq!(alloc_run(48), Some(base)); } #[test] fn first_fit_skips_too_small_holes() { let _g = lock(); - let base = 512 * MIB; - assert!(free_range(base, MIB)); // 16 slices - assert!(free_range(base + 8 * MIB, 4 * MIB)); // 64 slices, disjoint + let base = sl(8192); + assert!(free_range(base, sl(16))); + assert!(free_range(base + sl(128), sl(64))); // disjoint assert_eq!( alloc_run(32), - Some(base + 8 * MIB), + Some(base + sl(128)), "a 32-slice run must skip the 16-slice hole" ); // The small hole is intact; drain everything on the way out. assert_eq!(alloc_run(16), Some(base)); - assert_eq!(alloc_run(32), Some(base + 10 * MIB)); + assert_eq!(alloc_run(32), Some(base + sl(160))); assert!(alloc_run(1).is_none()); } @@ -161,21 +178,37 @@ mod tests { fn runs_cross_word_boundaries() { let _g = lock(); // Slice index 1000..1100 straddles the u64 word boundary at 1024. - let base = 1000 * 64 * 1024; - assert!(free_range(base, 100 * 64 * 1024)); + let base = sl(1000); + assert!(free_range(base, sl(100))); assert_eq!(alloc_run(100), Some(base)); assert!(alloc_run(1).is_none()); } + /// Every rejection, written in SLICES. + /// + /// This test was the last byte-denominated one in the module, and it failed + /// exactly the way P2's did: `MIB + 4096` was "misaligned" only while a + /// slice was 8 KiB, and became slice-ALIGNED the moment the small profile + /// went to 4 KiB. It then quietly *succeeded* in freeing two ranges it is + /// supposed to refuse, left their bits set in a pool the module doc calls + /// GLOBAL first-fit, and took down the other three tests instead of itself. + /// Offsets of `+1` and `SLOTS`-relative bases are misaligned and out of + /// range at every slice size there will ever be. #[test] fn rejects_what_it_cannot_track() { let _g = lock(); - assert!(!free_range(0, MIB), "slice 0 must be refused"); - assert!(!free_range(64 * 1024, 0), "empty range"); - assert!(!free_range(MIB + 4096, MIB), "misaligned base"); - assert!(!free_range(MIB, MIB + 4096), "ragged size"); - assert!(!free_range(usize::MAX - MIB, 2 * MIB), "unaddressable"); + assert!(!free_range(0, sl(16)), "slice 0 must be refused"); + assert!(!free_range(sl(16), 0), "empty range"); + assert!(!free_range(sl(256) + 1, sl(16)), "misaligned base"); + assert!(!free_range(sl(256), sl(16) + 1), "ragged size"); + assert!( + !free_range(sl(SLOTS - 1), sl(2)), + "a run ending past SLOTS is unaddressable" + ); assert!(alloc_run(0).is_none()); assert!(alloc_run(SLOTS + 1).is_none()); + // The pool must be untouched: every call above was a refusal, and a + // refusal that set a bit would strand it for whichever test runs next. + assert!(alloc_run(1).is_none(), "a refusal leaves the pool empty"); } } diff --git a/crates/rusty_alloc/src/stats.rs b/crates/rusty_alloc/src/stats.rs index 44c2c61..6ddb755 100644 --- a/crates/rusty_alloc/src/stats.rs +++ b/crates/rusty_alloc/src/stats.rs @@ -4,6 +4,7 @@ //! metrics (`mi_process_info`). use crate::heap::Stats; +#[cfg(feature = "std")] use crate::options::out_fmt; /// Sum the counters of every registered heap (`mi_stats_merge` semantics — @@ -30,8 +31,11 @@ pub fn merged() -> Stats { total } +/// std-only: the line is built as an owned string. See `options::out_fmt` for +/// the seam a `no_std` consumer uses instead. +#[cfg(feature = "std")] fn print_one(label: &str, s: &Stats) { - out_fmt(&format!( + out_fmt(&std::format!( "{label}: allocs {} frees {} (generic {}), pages fresh {} retired {}, \ segments {} freed {}, large {} huge {}, realloc {}/{} (in-place/moved), \ delayed {} reclaims {}\n", @@ -52,11 +56,12 @@ fn print_one(label: &str, s: &Stats) { } /// `mi_stats_print` / `mi_stats_print_out`: process-wide (merged) stats. +#[cfg(feature = "std")] pub fn print_process() { let m = merged(); print_one("heap stats (process)", &m); let (elapsed, user, sys, rss, peak_rss, commit, peak_commit, faults) = process_info(); - out_fmt(&format!( + out_fmt(&std::format!( "process: elapsed {elapsed} ms, user {user} ms, sys {sys} ms, rss {} KiB (peak {}), \ commit {} KiB (peak {}), faults {faults}\n", rss / 1024, @@ -67,6 +72,7 @@ pub fn print_process() { } /// `mi_thread_stats_print_out`: the calling thread's heap only. +#[cfg(feature = "std")] pub fn print_thread() { let s = crate::alloc::stats(); print_one("heap stats (thread)", &s); @@ -89,13 +95,31 @@ pub fn process_info() -> (usize, usize, usize, usize, usize, usize, usize, usize { win_process_info() } - #[cfg(all(unix, not(miri)))] + // `feature = "std"`: `unix_process_info` reads `/proc/self/statm` through + // `std::fs`, so a `no_std` build on a unix host cannot take this arm. + #[cfg(all(unix, feature = "std", not(miri)))] { unix_process_info() } - // Miri and wasm: no process accounting to report. Wasm has no RSS concept - // distinct from the size of linear memory, and no host time. - #[cfg(any(miri, all(target_arch = "wasm32", not(miri))))] + // Miri, wasm, and bare metal: no process accounting to report. Wasm has no + // RSS concept distinct from the size of linear memory, and no host time; a + // firmware has no process at all — there is no `/proc`, no `GetProcessMemoryInfo` + // and nothing the numbers would mean. The doc above already promises + // "unknown fields read 0", so this is the contract, not a stub. + // + // The bare-metal arm is the fifth this four-way selection was missing, the + // same shape P0 found in `prim/mod.rs` and P3 found in `random.rs`. + #[cfg(any( + miri, + all(target_arch = "wasm32", not(miri)), + // Bare metal AND unix-without-`std`: no `/proc` reachable either way. + all( + not(miri), + not(windows), + not(target_arch = "wasm32"), + not(all(unix, feature = "std")) + ) + ))] { (0, 0, 0, 0, 0, 0, 0, 0) } @@ -136,7 +160,7 @@ fn win_process_info() -> (usize, usize, usize, usize, usize, usize, usize, usize } } -#[cfg(all(unix, not(miri)))] +#[cfg(all(unix, feature = "std", not(miri)))] fn unix_process_info() -> (usize, usize, usize, usize, usize, usize, usize, usize) { // SAFETY: out-param is a valid local. unsafe { diff --git a/crates/rusty_alloc/src/types.rs b/crates/rusty_alloc/src/types.rs index f488a71..181d3b6 100644 --- a/crates/rusty_alloc/src/types.rs +++ b/crates/rusty_alloc/src/types.rs @@ -19,10 +19,44 @@ pub const SMALL_SIZE_MAX: usize = SMALL_WSIZE_MAX * INTPTR_SIZE; /// Segment slice size (`MI_SEGMENT_SLICE_SIZE` = 64 KiB on 64-bit): the /// granularity v2 segments are carved in. A small page is one slice. +#[cfg(not(ra_small_profile))] pub const SEGMENT_SLICE_SIZE: usize = 64 * 1024; +/// Segment slice size, small profile: 4 KiB — exactly one [`crate::prim`] page. +/// +/// This is the DOMINANT footprint lever, and §2.9 of docs/plans/small-metal.md +/// is why: a page serves exactly one size class and is at minimum one slice, so +/// the floor is `(distinct bins touched) x SEGMENT_SLICE_SIZE` and is +/// independent of how many bytes the workload actually wants. Measured on a +/// XIAO ESP32-S3, a 4,914-byte workload touched 10 bins; at the 8 KiB slice +/// this probe started with, that alone cost 106,496 bytes of pages — 4.6 % +/// occupancy. +/// +/// **4 KiB is the floor, and a 2 KiB probe is what proved it.** `bins::good_size` +/// answers the large range with `os::page_align_up`, but the large path +/// allocates EXACT SLICES — so `usable_size >= good_size` holds only while a +/// slice is at least an OS page. At every other geometry that is free (a 64 KiB +/// slice over a 4 KiB page), which is why the assumption was never written +/// down. At a 2 KiB slice it inverts: `good_size(49_153)` promised 53,248 while +/// the 25-slice span delivered 51,200, and `properties::usable_size_agrees_with_good_size` +/// caught it. `good_size` is ABI-visible and G2-pinned against the oracle, so +/// the slice is the side that moves. See the const assert in `prim/fixed.rs`. +#[cfg(ra_small_profile)] +pub const SEGMENT_SLICE_SIZE: usize = 4 * 1024; /// Slices per segment (`MI_SLICES_PER_SEGMENT` = 512). +#[cfg(not(ra_small_profile))] pub const SLICES_PER_SEGMENT: usize = 512; +/// Slices per segment, small profile: 16, so a segment is 64 KiB. +/// +/// Raised with the slice halving so `SEGMENT_SIZE` does NOT move. Segment size +/// is the wrong lever — it is the granule the region is carved in, and +/// shrinking it alone only trades one segment for two, with two header slices +/// instead of one. What matters is the number of PAGES a segment can hold: 16 +/// slices leaves 15 usable, and the measured workload needs 13. Holding the +/// slice COUNT while shrinking the slice is what collapsed this workload from +/// two 64 KiB segments to one 32 KiB one. +#[cfg(ra_small_profile)] +pub const SLICES_PER_SEGMENT: usize = 16; /// Segment size (`MI_SEGMENT_SIZE` = 32 MiB on 64-bit): the unit of OS/arena /// allocation, and the shift+mask that takes any block pointer to its segment @@ -45,7 +79,20 @@ pub const MAX_ALIGN_SIZE: usize = 16; pub const SMALL_PAGE_SIZE: usize = SEGMENT_SLICE_SIZE; /// A medium page spans 8 slices (`MI_MEDIUM_PAGE_SIZE` = 512 KiB). +#[cfg(not(ra_small_profile))] pub const MEDIUM_PAGE_SLICES: usize = 8; +/// A medium page, small profile: 4 slices = 16 KiB. +/// +/// Held at 4 rather than lowered with the slice, and `spans.rs` is why. This +/// constant sets `MEDIUM_OBJ_SIZE_MAX = MEDIUM_PAGE_SLICES * SEGMENT_SLICE_SIZE / 8`, +/// which is the TOP of the binned range — above it every allocation gets its +/// own single-block large span. At 2 slices that ceiling falls to 1,024 bytes, +/// so a burst of 2 KiB objects stops being packed into shared pages and takes +/// a whole slice each. `span_lifecycle_and_realloc` caught exactly that. The +/// binned range has to stay wide enough to be worth having; 2 KiB is the floor +/// that keeps it so. +#[cfg(ra_small_profile)] +pub const MEDIUM_PAGE_SLICES: usize = 4; /// Medium page size in bytes. pub const MEDIUM_PAGE_SIZE: usize = MEDIUM_PAGE_SLICES * SEGMENT_SLICE_SIZE; @@ -91,7 +138,15 @@ mod tests { // Pinned to mimalloc v2.4.5 on x86_64 / aarch64 (64-bit words). assert_eq!(INTPTR_SIZE, 8); assert_eq!(SMALL_SIZE_MAX, 1024); + #[cfg(not(ra_small_profile))] assert_eq!(SEGMENT_SIZE, 32 * 1024 * 1024); + // The small profile's geometry is a DECISION, pinned here so moving it + // has to be meant. 4 KiB slices x 16 = a 64 KiB segment + // (docs/plans/small-metal.md §2.10). + #[cfg(ra_small_profile)] + assert_eq!(SEGMENT_SIZE, 64 * 1024); + #[cfg(ra_small_profile)] + assert_eq!(SEGMENT_SLICE_SIZE, 4 * 1024); assert_eq!(BIN_FULL, 74); } } diff --git a/crates/rusty_alloc/tests/alloc_core.rs b/crates/rusty_alloc/tests/alloc_core.rs index 19c839f..13386dc 100644 --- a/crates/rusty_alloc/tests/alloc_core.rs +++ b/crates/rusty_alloc/tests/alloc_core.rs @@ -117,6 +117,15 @@ fn aligned_allocations() { (300_000, 4096), (1024, 1 << 20), ] { + // The allocator's alignment ceiling is SEGMENT_SIZE/2 (alloc.rs:451, + // heap.rs:986) — it cannot promise an alignment a segment cannot hold. + // At the default 32 MiB geometry that is 16 MiB and every case below + // is far under it; under the small profile (P2, small-metal.md) it is + // 32 KiB, so the larger cases are refused BY DESIGN and are skipped + // rather than deleted, because the ceiling is what they document. + if align > rusty_alloc::types::SEGMENT_SIZE / 2 { + continue; + } let p = malloc_aligned(size, align); assert!(!p.is_null(), "malloc_aligned({size}, {align})"); assert_eq!(p as usize % align, 0, "misaligned for ({size}, {align})"); @@ -224,6 +233,10 @@ fn aligned_at_offsets() { (100, 65536, 40), // big align, small size (20 * 1024 * 1024, 1 << 20, 64), // huge placement ] { + // Same ceiling as `aligned_allocations` above: SEGMENT_SIZE/2. + if align > rusty_alloc::types::SEGMENT_SIZE / 2 { + continue; + } let p = malloc_aligned_at(size, align, offset); assert!(!p.is_null(), "malloc_aligned_at({size},{align},{offset})"); assert_eq!( diff --git a/crates/rusty_alloc/tests/heaps.rs b/crates/rusty_alloc/tests/heaps.rs index 32a7998..cbcf2da 100644 --- a/crates/rusty_alloc/tests/heaps.rs +++ b/crates/rusty_alloc/tests/heaps.rs @@ -82,10 +82,23 @@ fn heaps_arenas_subprocs_options() { } // --- arenas: exclusive reserve + heap_new_in_arena --------------------- - let arena_id = arena::reserve_os_memory_ex(64 * 1024 * 1024, true, false, true) - .expect("arena reserve failed"); + // + // Sized in CHUNKS, because chunks are what the arena's fixed bitmap counts. + // As a flat `64 * 1024 * 1024` this read "two chunks" at the shipped 32 MiB + // geometry and "2048 chunks" at the small profile's 32 KiB one — past + // `arena::MAX_CHUNKS` (1024), so the reserve failed for a reason that had + // nothing to do with what the test checks. Half a megabyte covers this + // test's own largest allocation (200,000 bytes) with room over; the + // `.max(2)` keeps the shipped geometry at exactly the 64 MiB it always + // reserved, so nothing about the default-profile run changes. + let arena_bytes = (512 * 1024usize) + .div_ceil(rusty_alloc::types::SEGMENT_SIZE) + .max(2) + * rusty_alloc::types::SEGMENT_SIZE; + let arena_id = + arena::reserve_os_memory_ex(arena_bytes, true, false, true).expect("arena reserve failed"); let (abase, asize) = arena::arena_area(arena_id); - assert!(!abase.is_null() && asize == 64 * 1024 * 1024); + assert!(!abase.is_null() && asize == arena_bytes); let ha = init::create_heap(0, true, arena_id); // SAFETY: ha ours. unsafe { @@ -173,3 +186,305 @@ fn heaps_arenas_subprocs_options() { let (_, _, _, rss, ..) = rusty_alloc::stats::process_info(); assert!(rss > 0, "process_info rss"); } + +/// An exclusive-arena heap must take its HUGE blocks from that arena too. +/// +/// Found by P2 of `docs/plans/small-metal.md` (2026-09-07). `segment::huge_alloc` +/// asked `arena::chunk_alloc_n(-1, ..)` — a hardcoded "any non-exclusive +/// arena" — while `segment_alloc` next to it correctly passed the owning +/// heap's `arena_id`. So a heap created with `create_heap(_, _, arena_id)`, +/// whose entire purpose is that its memory comes from ONE region, silently +/// served every allocation above `LARGE_OBJ_SIZE_MAX` from the default arena +/// or straight from the OS. +/// +/// Upstream does not have this: `mi_segment_huge_page_alloc` takes a +/// `req_arena_id` and both call sites pass `heap->arena_id` +/// (`oracle/mimalloc/src/segment.c:1671,1683`). +/// +/// The small profile is what exposed it — at a 64 KiB segment the huge path +/// starts at 56 KiB, so an ordinary 100 KB allocation escaped — but the defect +/// is in the SHIPPED geometry too, and this test is written at that geometry +/// deliberately: it needs one allocation past `LARGE_OBJ_SIZE_MAX` (32 MiB − +/// 64 KiB), which any consumer of the exclusive-arena API can make. +#[test] +fn exclusive_arena_confines_huge_allocations() { + use rusty_alloc::types::LARGE_OBJ_SIZE_MAX; + + // Room for the huge block plus its header, rounded to whole chunks. + let arena_bytes = 256 * 1024 * 1024; + let Ok(arena_id) = arena::reserve_os_memory_ex(arena_bytes, true, false, true) else { + // A machine that cannot reserve the range has nothing to say about + // confinement; skipping is honest, silently passing would not be. + eprintln!("skipped: could not reserve a {arena_bytes}-byte exclusive arena"); + return; + }; + let (abase, asize) = arena::arena_area(arena_id); + assert!(!abase.is_null()); + + let ha = init::create_heap(0, true, arena_id); + // One byte past the in-segment span path is enough to reach `huge_alloc`. + let huge = LARGE_OBJ_SIZE_MAX + 1; + + // SAFETY: `ha` is ours on this thread; the block is freed below. + unsafe { + let s0 = (*(*ha).heap.get()).stats.segments; + let p = hmalloc(ha, huge); + assert!( + !p.is_null(), + "exclusive-arena heap could not serve {huge} bytes" + ); + assert!( + p.addr() >= abase.addr() && p.addr() < abase.addr() + asize, + "huge block at {:#x} escaped its exclusive arena [{:#x}, {:#x})", + p.addr(), + abase.addr(), + abase.addr() + asize + ); + // Writable through its whole extent — confinement must not have cost + // the block its backing. + core::ptr::write_bytes(p, 0xC3, huge); + assert_eq!(*p, 0xC3); + assert_eq!(*p.add(huge - 1), 0xC3); + + // A Huge segment is counted as a segment on the way IN as well as on + // the way out. The release path has always bumped `segments_freed` + // beside `huge_free`, so without the matching bump this pair could + // report more segments freed than were ever allocated. + let st = (*(*ha).heap.get()).stats; + assert!( + st.segments > s0, + "huge allocation did not count a segment ({} -> {})", + s0, + st.segments + ); + assert!( + st.segments >= st.segments_freed, + "more segments freed ({}) than allocated ({})", + st.segments_freed, + st.segments + ); + alloc::free(p); + } +} + +/// The arena chunk bitmap must reach chunks past its FIRST word. +/// +/// Written 2026-09-07 (P3 of `docs/plans/small-metal.md`) because the bitmap's +/// word type was narrowed from `u64` to `u32` — a 32-bit RISC-V / Xtensa target +/// has no 64-bit atomic, and a bitmap's word width is a free choice — and the +/// whole existing battery passed with the narrowing HALF applied: the element +/// type had moved but four loop bounds still said `div_ceil(64)` and +/// `(w + 1) * 64`. +/// +/// It passed because **every arena any test builds is 32 chunks or fewer**, +/// and at 32 chunks `div_ceil(64)` and `div_ceil(32)` are both 1. The +/// divergence starts at chunk 33, which nothing reached. A green suite over a +/// scenario that cannot express the defect. +/// +/// So: allocate past the first bitmap word. With the half-narrowed code the +/// scan stops at word 0 and the exclusive heap reports its arena full. +#[test] +fn arena_bitmap_reaches_past_its_first_word() { + use rusty_alloc::types::{LARGE_OBJ_SIZE_MAX, SEGMENT_SIZE}; + + // One chunk per huge block, and enough of them to cross a 32-bit word. + const CHUNKS: usize = 33; + let want = CHUNKS * SEGMENT_SIZE; + // At the shipped 32 MiB geometry that is ~1.06 GiB, eagerly committed + // (`reserve_os_memory_ex` ignores `commit=false` in v1, a recorded + // divergence). Skip rather than fail where the box will not give it — + // under the small profile the same test costs 2.1 MiB and always runs. + let Ok(arena_id) = arena::reserve_os_memory_ex(want, true, false, true) else { + eprintln!("skipped: could not reserve {want} bytes for a {CHUNKS}-chunk arena"); + return; + }; + let (abase, asize) = arena::arena_area(arena_id); + assert!(!abase.is_null() && asize >= want); + + let ha = init::create_heap(0, true, arena_id); + // Exactly one chunk each. `LARGE_OBJ_SIZE_MAX` is the largest in-segment + // span — it fills a segment's whole usable region, so the next allocation + // must take a fresh chunk. (`+1` would be a HUGE block, and header + size + // then spills into a SECOND chunk, which is what the first version of this + // test got wrong: it failed at 16 of 33 for arithmetic reasons rather than + // the defect it was written for.) + let one_chunk = LARGE_OBJ_SIZE_MAX; + let mut blocks = Vec::new(); + // SAFETY: `ha` is ours on this thread; every block is freed below. + unsafe { + for i in 0..CHUNKS { + let p = hmalloc(ha, one_chunk); + assert!( + !p.is_null(), + "chunk {i} of {CHUNKS} refused — the bitmap scan stopped at word \ + {} of {}", + i / 32, + CHUNKS.div_ceil(32) + ); + assert!( + p.addr() >= abase.addr() && p.addr() < abase.addr() + asize, + "chunk {i} escaped the arena" + ); + blocks.push(p); + } + for p in blocks { + alloc::free(p); + } + } +} + +/// A collect must reclaim a bin's LAST all-free page — at ANY level. +/// +/// Upstream's `mi_heap_page_collect` calls `_mi_page_free` whenever +/// `mi_page_all_free(page)`, with the comment "this will free retired pages as +/// well"; the keep-one-page-per-bin reuse cache is `mi_page_retire`'s policy on +/// the free path. Ours borrowed that exemption into `collect`, so no collect at +/// any level could hand a size class's slice to a different class. +/// +/// Invisible at the shipped 32 MiB geometry — 512 slices per segment absorb one +/// cached page per class. At `ra_small_profile`'s 16 it is fatal, and P4d +/// measured it on hardware: 512 B capacity decaying 168 -> 8 blocks and 45 % of +/// a churn workload returning null while 61,440 bytes of the region sat free. +/// +/// A private heap, so the bin under test holds exactly one page and nothing +/// else in the process can add a second. +#[test] +fn collect_reclaims_a_bins_last_page() { + for force in [false, true] { + let h = init::create_heap(0, true, -1); + // SAFETY: `h` is ours, freshly created, and destroyed below. + unsafe { + let p = hmalloc(h, 1536); + assert!(!p.is_null(), "fresh heap served a 1536-byte block"); + alloc::free(p); + + let before = (*(*h).heap.get()).stats.pages_retired; + alloc::heap_collect(h, force); + let after = (*(*h).heap.get()).stats.pages_retired; + assert!( + after > before, + "collect(force={force}) must reclaim an all-free page even when \ + it is the bin's only one (retired {before} -> {after})" + ); + init::heap_destroy(h); + } + } +} + +/// The automatic collect exists and fires. +/// +/// `generic_collect` was declared with a default of 10,000 and read by NOTHING, +/// so a heap never collected on its own no matter how long it ran. The option is +/// lowered here so the test does not have to make 10,000 slow-path trips. +#[test] +fn generic_collect_fires_on_its_own() { + let prev = options::get(options::GENERIC_COLLECT); + options::set(options::GENERIC_COLLECT, 8); + let h = init::create_heap(0, true, -1); + // SAFETY: `h` is ours, freshly created, and destroyed below. + unsafe { + // Touch a bin, empty it, then keep the generic path busy with OTHER + // sizes. Nothing here calls collect; only the periodic trigger can + // retire the emptied page. + let p = hmalloc(h, 1536); + assert!(!p.is_null()); + alloc::free(p); + let before = (*(*h).heap.get()).stats.pages_retired; + + // DISTINCT sizes: a repeated size is served from its bin's queue front + // and never reaches `malloc_generic`, so a loop over seven sizes makes + // seven generic trips, not sixty-four. The first version of this test + // did exactly that and failed for its premise rather than its property. + let g0 = (*(*h).heap.get()).stats.generic; + for i in 0..64usize { + let q = hmalloc(h, 24 + i * 8); + if !q.is_null() { + alloc::free(q); + } + } + let trips = (*(*h).heap.get()).stats.generic - g0; + assert!( + trips > 8, + "precondition: the loop must actually take the generic path more often than the threshold (took it {trips} times)" + ); + let after = (*(*h).heap.get()).stats.pages_retired; + assert!( + after > before, + "the periodic collect must retire pages with no explicit call \ + (retired {before} -> {after})" + ); + init::heap_destroy(h); + } + options::set(options::GENERIC_COLLECT, prev); +} + +/// A heap must reclaim its own idle pages before it reports OOM. +/// +/// A page allocator keeps a page per size class as a reuse cache, so it can be +/// "full" while holding empty pages for classes nobody is asking for. Before +/// P4d the generic path returned null in that state. On a XIAO ESP32-S3 that +/// meant 22,533 of 50,000 churn allocations failing from a heap that one +/// `collect` restored from 8 to 240 blocks of capacity. +/// +/// **`ra_small_profile` only, and that is the point.** The cache costs one +/// slice per class touched, so it only starves a heap when slices are scarce: +/// 16 per segment here against 512 at the shipped geometry. The first version +/// of this test used a 4-chunk arena without the gate and PASSED WITH THE FIX +/// REMOVED — at 32 MiB segments, 48 cached pages cannot exhaust anything, so it +/// asserted nothing. Two 64 KiB segments give 30 usable slices, and 24 cached +/// pages is most of them. +#[cfg(ra_small_profile)] +#[test] +fn generic_path_reclaims_before_returning_null() { + let bytes = 2 * rusty_alloc::types::SEGMENT_SIZE; + let Ok(arena) = arena::reserve_os_memory_ex(bytes, true, false, true) else { + return; // a host that cannot reserve it has nothing to say here + }; + let h = init::create_heap(0, true, arena); + // SAFETY: `h` is ours and is destroyed below. + unsafe { + // Touch many distinct small classes and free them all. Each leaves an + // idle cached page holding a slice that the class we ask for next + // cannot reach. + let mut held = Vec::new(); + for i in 0..24usize { + let p = hmalloc(h, 16 + i * 16); + if !p.is_null() { + held.push(p); + } + } + let touched = held.len(); + for p in held.drain(..) { + alloc::free(p); + } + assert!( + touched >= 16, + "precondition: the arena must actually hold the cached pages that \ + starve the retry (only {touched} classes were served)" + ); + + // Now demand ONE class hard. + let mut ptrs = Vec::new(); + for _ in 0..512 { + let p = hmalloc(h, 512); + if p.is_null() { + break; + } + ptrs.push(p); + } + let served = ptrs.len(); + for p in ptrs { + alloc::free(p); + } + // 192 is chosen against MEASURED arms, not guessed: with the + // reclaim-and-retry this serves 240 blocks (all 30 usable slices), and + // with it removed, 128. An earlier threshold of 64 sat below BOTH and + // so passed with the fix poisoned — the test asserted nothing. + assert!( + served > 192, + "a heap holding {touched} idle cached pages must reclaim them rather \ + than report OOM (served only {served} blocks of 512 B)" + ); + init::heap_destroy(h); + } +} diff --git a/crates/rusty_alloc/tests/secure.rs b/crates/rusty_alloc/tests/secure.rs index 3a95140..1d045aa 100644 --- a/crates/rusty_alloc/tests/secure.rs +++ b/crates/rusty_alloc/tests/secure.rs @@ -115,10 +115,17 @@ fn purge_returns_memory() { let before = rusty_alloc::alloc::stats().purges; let mut ps = Vec::new(); for _ in 0..24 { - let p = malloc(600 * 1024); // multi-slice spans + // A span big enough to purge: `span_free` only purges spans of + // >= MEDIUM_PAGE_SLICES. Derived, because "600 KiB" encodes the + // shipped geometry — under a different one it can land past + // LARGE_OBJ_SIZE_MAX and become a huge segment, which is released + // rather than purged. + let span = rusty_alloc::types::SEGMENT_SLICE_SIZE * rusty_alloc::types::MEDIUM_PAGE_SLICES + + rusty_alloc::types::SEGMENT_SLICE_SIZE; + let p = malloc(span); assert!(!p.is_null()); // SAFETY: live block; touch to commit. - unsafe { core::ptr::write_bytes(p, 1, 600 * 1024) }; + unsafe { core::ptr::write_bytes(p, 1, span) }; ps.push(p); } for p in ps { diff --git a/crates/rusty_alloc/tests/span_packing.rs b/crates/rusty_alloc/tests/span_packing.rs index 30c0b08..1e20bb0 100644 --- a/crates/rusty_alloc/tests/span_packing.rs +++ b/crates/rusty_alloc/tests/span_packing.rs @@ -12,8 +12,9 @@ //! memory because on wasm a reservation is real, permanent memory. use rusty_alloc::alloc::{free, malloc, usable_size}; -use rusty_alloc::types::SEGMENT_SIZE; +use rusty_alloc::types::{SEGMENT_SIZE, SEGMENT_SLICE_SIZE}; +#[cfg(not(ra_small_profile))] const MIB: usize = 1024 * 1024; fn segment_base(p: *mut u8) -> usize { @@ -21,6 +22,7 @@ fn segment_base(p: *mut u8) -> usize { } /// Allocate, verify usability, and prove co-tenancy in one segment. +#[cfg(not(ra_small_profile))] fn assert_share_one_segment(sizes: &[usize]) { let blocks: Vec<*mut u8> = sizes .iter() @@ -58,6 +60,15 @@ fn assert_share_one_segment(sizes: &[usize]) { /// The report's 60 % row: 20 MiB (320 slices) now shares its segment — /// 8 MiB (128 slices) and ~3.9 MiB (62 slices) fit in the tail (510 ≤ 511). +// Pinned to the SHIPPED 32 MiB geometry: these reproduce named rows of the +// segment-tax field report (its 60 % row, its 27 % row, its 16 MiB face), and +// a row is a size against a segment size. Under another geometry (P2, +// `docs/plans/small-metal.md`) the same byte counts are not the same rows, so +// re-expressing them in slices would keep them green while testing nothing +// the report said. The geometry-INDEPENDENT half of this file +// (`maximum_span_fills_one_segment_exactly`, +// `one_past_the_boundary_is_huge_and_correct`) runs at every geometry. +#[cfg(not(ra_small_profile))] #[test] fn twenty_mib_span_shares_its_segment() { assert_share_one_segment(&[20 * MIB, 8 * MIB, 62 * 64 * 1024]); @@ -65,6 +76,15 @@ fn twenty_mib_span_shares_its_segment() { /// The report's 27 % row: a 25.1 MiB detector tensor (402 slices) leaves a /// 109-slice tail that a 6 MiB block (96 slices) fits inside. +// Pinned to the SHIPPED 32 MiB geometry: these reproduce named rows of the +// segment-tax field report (its 60 % row, its 27 % row, its 16 MiB face), and +// a row is a size against a segment size. Under another geometry (P2, +// `docs/plans/small-metal.md`) the same byte counts are not the same rows, so +// re-expressing them in slices would keep them green while testing nothing +// the report said. The geometry-INDEPENDENT half of this file +// (`maximum_span_fills_one_segment_exactly`, +// `one_past_the_boundary_is_huge_and_correct`) runs at every geometry. +#[cfg(not(ra_small_profile))] #[test] fn detector_tensor_span_shares_its_segment() { assert_share_one_segment(&[402 * 64 * 1024, 6 * MIB]); @@ -74,6 +94,15 @@ fn detector_tensor_span_shares_its_segment() { /// pair with ITSELF (2 x 256 slices > 511 usable), but its 255-slice tail is /// live real estate — a 15 MiB block (240 slices) shares the segment. This /// held before the routing change too; the test pins it against regression. +// Pinned to the SHIPPED 32 MiB geometry: these reproduce named rows of the +// segment-tax field report (its 60 % row, its 27 % row, its 16 MiB face), and +// a row is a size against a segment size. Under another geometry (P2, +// `docs/plans/small-metal.md`) the same byte counts are not the same rows, so +// re-expressing them in slices would keep them green while testing nothing +// the report said. The geometry-INDEPENDENT half of this file +// (`maximum_span_fills_one_segment_exactly`, +// `one_past_the_boundary_is_huge_and_correct`) runs at every geometry. +#[cfg(not(ra_small_profile))] #[test] fn sixteen_mib_tail_is_usable() { assert_share_one_segment(&[16 * MIB, 15 * MIB]); @@ -94,7 +123,7 @@ fn maximum_span_fills_one_segment_exactly() { *p.add(n - 1) = 0xA5; assert_eq!( p as usize - segment_base(p), - 64 * 1024, + SEGMENT_SLICE_SIZE, "maximum span did not start at the first usable slice" ); assert_eq!(*p, 0x5A); diff --git a/crates/rusty_alloc/tests/spans.rs b/crates/rusty_alloc/tests/spans.rs index b834070..95d5a19 100644 --- a/crates/rusty_alloc/tests/spans.rs +++ b/crates/rusty_alloc/tests/spans.rs @@ -6,15 +6,33 @@ use rusty_alloc::alloc::{expand, free, malloc, realloc, stats, usable_size, zall #[test] fn span_lifecycle_and_realloc() { + // Sizes derived from the geometry, not written in MiB. "1 MiB" is a + // 16-slice large span at the shipped 32 MiB segment and a HUGE block at a + // 64 KiB one (P2, `docs/plans/small-metal.md`), so a literal silently + // stops testing the large path the moment the geometry moves. `L` is one + // slice past a medium page — large by definition — and `L2` is twice that, + // both comfortably inside `LARGE_OBJ_SIZE_MAX` at either geometry. + use rusty_alloc::segment::USABLE_SLICES; + use rusty_alloc::types::SEGMENT_SLICE_SIZE; + // Two slices is past MEDIUM_OBJ_SIZE_MAX at every geometry this crate + // builds, so `l` is a large SPAN; `l2` doubles it and both stay well + // inside `USABLE_SLICES` (511 at the shipped geometry, 7 at the small + // profile — which is what a fixed `2 MiB` overshot). + let l: usize = SEGMENT_SLICE_SIZE * 2; + let l2: usize = SEGMENT_SLICE_SIZE * 4; + // A span filling a large FRACTION of a segment, for the coalescing check + // below — the point is "most of one segment", not "12 MiB". + let big_span: usize = SEGMENT_SLICE_SIZE * (USABLE_SLICES * 3 / 8); + // --- Large path basics ------------------------------------------------- let s0 = stats(); - let p = malloc(1024 * 1024); // 1 MiB → 16-slice span + let p = malloc(l); // one slice past a medium page -> a large span assert!(!p.is_null()); - // SAFETY: live 1 MiB block. + // SAFETY: live `l`-byte block. unsafe { - assert!(usable_size(p) >= 1024 * 1024); + assert!(usable_size(p) >= l); p.write(7); - p.add(1024 * 1024 - 1).write(8); + p.add(l - 1).write(8); } let s1 = stats(); assert_eq!(s1.large_allocs - s0.large_allocs, 1, "large path not taken"); @@ -29,7 +47,9 @@ fn span_lifecycle_and_realloc() { 1, "large span not retired" ); - let q = malloc(900 * 1024); + // "Similar size": slightly smaller than `l`, so it must reuse the span + // just retired rather than take a fresh segment. + let q = malloc(l * 7 / 8); assert!(!q.is_null()); let s3 = stats(); assert_eq!( @@ -40,17 +60,17 @@ fn span_lifecycle_and_realloc() { unsafe { free(q) }; // --- zalloc over a RECYCLED span must be re-zeroed ---------------------- - let d = malloc(2 * 1024 * 1024); + let d = malloc(l2); // SAFETY: live block, dirtied then freed. unsafe { - core::ptr::write_bytes(d, 0xAB, 2 * 1024 * 1024); + core::ptr::write_bytes(d, 0xAB, l2); free(d); } - let z = zalloc(2 * 1024 * 1024); + let z = zalloc(l2); assert!(!z.is_null()); // SAFETY: live zeroed block. unsafe { - for i in (0..2 * 1024 * 1024).step_by(4096) { + for i in (0..l2).step_by(4096) { assert_eq!( z.add(i).read(), 0, @@ -82,12 +102,12 @@ fn span_lifecycle_and_realloc() { // --- Coalescing observable: after retiring everything, a full-segment // large alloc must fit in the SAME segment count ------------------------- - let big = malloc(12 * 1024 * 1024); + let big = malloc(big_span); assert!(!big.is_null()); let s7 = stats(); assert_eq!( s7.segments, s6.segments, - "coalescing failed — 12 MiB span needed a new segment" + "coalescing failed — the large span needed a new segment" ); // SAFETY: live block. unsafe { free(big) }; @@ -132,7 +152,23 @@ fn span_lifecycle_and_realloc() { let m = malloc(64); assert!(rusty_alloc::alloc::is_in_heap_region(m)); let stack_local = 0u8; - assert!(!rusty_alloc::alloc::is_in_heap_region(&stack_local)); + // The NEGATIVE direction needs an exact map. The small profile's range + // table (P2, `docs/plans/small-metal.md`) is chip-sized — 64 entries, 1 KiB + // of BSS against the bitmap's 1 MiB — and this battery manages orders of + // magnitude more segments than any chip, so it overflows and `contains` + // degrades PERMISSIVELY on purpose: over-reporting costs a diagnostic, + // under-reporting would abort legitimate frees through the `debug_checks` + // guard. Assert the property only while the map can still decide, and + // check the degradation is exactly what happened rather than skipping + // blind. + if rusty_alloc::segment_map::range_table_overflowed() { + assert!( + rusty_alloc::alloc::is_in_heap_region(&stack_local), + "an overflowed range table must answer permissively, not wrongly" + ); + } else { + assert!(!rusty_alloc::alloc::is_in_heap_region(&stack_local)); + } // SAFETY: live block. unsafe { free(m) }; diff --git a/crates/rusty_alloc_api/CHANGELOG.md b/crates/rusty_alloc_api/CHANGELOG.md index 8e09601..d39c288 100644 --- a/crates/rusty_alloc_api/CHANGELOG.md +++ b/crates/rusty_alloc_api/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-api-v1.1.6...rusty_alloc-api-v2.0.0) - 2026-09-07 + +### Breaking + +- **`default-features = false` now selects `no_std`.** This crate's own source + has been `no_std` since M2; only the core dependency's default features stood + in the way. `default = ["std"]` is new, and turning it off gives a firmware + the single-heap profile — which also requires `--cfg ra_single_threaded` on + the core. Consumers on default features are unaffected. + +### Added + +- `std` feature (default-on) forwarding to `rusty_alloc/std`, so + `RustyAlloc` can be a bare-metal `#[global_allocator]`. + ## [1.1.6](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-api-v1.1.5...rusty_alloc-api-v1.1.6) - 2026-08-28 ### Other diff --git a/crates/rusty_alloc_api/Cargo.toml b/crates/rusty_alloc_api/Cargo.toml index a8fd968..0d75c8a 100644 --- a/crates/rusty_alloc_api/Cargo.toml +++ b/crates/rusty_alloc_api/Cargo.toml @@ -14,9 +14,15 @@ readme = "README.md" [dependencies] # A published crate needs a VERSION alongside the path: the path is what the # workspace builds against, the version is what crates.io resolves. -rusty_alloc = { path = "../rusty_alloc", version = "1.1.6" } +rusty_alloc = { path = "../rusty_alloc", version = "2.0.0", default-features = false } [features] +# Mirrors the core's (P3, docs/plans/small-metal.md). Default-on, so every +# existing consumer is unchanged; a firmware takes `default-features = false` +# and gets the single-heap no_std profile. This crate's own source has been +# `no_std` since M2 — only the dependency's default features stood in the way. +default = ["std"] +std = ["rusty_alloc/std"] debug_checks = ["rusty_alloc/debug_checks"] profile = ["rusty_alloc/profile"] secure = ["rusty_alloc/secure"] diff --git a/crates/rusty_alloc_ffi/src/lib.rs b/crates/rusty_alloc_ffi/src/lib.rs index d85a97c..d1e57b5 100644 --- a/crates/rusty_alloc_ffi/src/lib.rs +++ b/crates/rusty_alloc_ffi/src/lib.rs @@ -6,9 +6,16 @@ //! M2 exports: the standard + extended allocation family (§5.1/§5.2 subset) //! and `mi_version`. Realloc/strdup land M3; posix/aligned family M5. //! -//! Note: the *core* crate is no_std; this FFI shell links std (a no_std cdylib -//! would need its own `#[panic_handler]`, which then collides with std in every -//! test build). Panic across the C boundary aborts via the release profile. +//! Note: the *core* crate can build `no_std` — `rusty_alloc`'s `std` feature is +//! default-on and turning it off selects the single-heap profile (P3 of +//! `docs/plans/small-metal.md`). **This shell always links std** and depends on +//! the core with its default features, because a `no_std` cdylib would need its +//! own `#[panic_handler]`, which then collides with std in every test build. +//! Panic across the C boundary aborts via the release profile. +//! +//! Until 2026-09-07 this said "the core crate is no_std" flatly, and it was +//! false: the core carried no `#![no_std]` at all. P0 of small-metal.md found +//! it by reading, after the plan had already believed it. #![deny(missing_docs)] diff --git a/docs/LEDGER.md b/docs/LEDGER.md index 88cc381..0ab6856 100644 --- a/docs/LEDGER.md +++ b/docs/LEDGER.md @@ -4,6 +4,1141 @@ One entry per milestone/brick: what landed, the numbers with their method lines, what was reverted and **which kind** of revert (measured-worse vs within-noise). Newest first. +## SMALL-METAL P5 — the six production blockers, closed (2026-09-07) + +"Is this commercial ready?" produced six blockers against the post-P4e state. +This is what closing them cost and what it changed. + +### 1. CI gated none of the embedded work + +`ci.yml` built wasm but never set `ra_small_profile`, never passed +`--no-default-features`, and never targeted a chip. Every defect P0-P4e found — +five in shipped code — would have passed it. New `embedded` job: the +small-profile test suite, clippy on the small profile AND `no_std`, and builds +of BOTH bare-metal RISC-V targets at BOTH geometries, plus `rusty_alloc-api`. +Xtensa stays out (not a stock rustup target); the board runs are evidence, not +a gate. Every command in the job was run locally first. + +### 2. The release state was incoherent + +`Cargo.toml` 1.1.5, README "1.1.4", tags stopping at v1.1.4, and a commit titled +`chore: release v2.0.0`. Cause: release-plz titled the PR after +`rusty_alloc_api`'s major while the workspace went to 1.1.5. README now says +what is released and that `main` is ahead; CHANGELOG `[Unreleased]` documents +every fix and addition from this campaign, so the next release notes are true +rather than generated from nothing. + +### 3. The published instruction counts were stale + +Measured at v1.1.5, predating every reclamation change. `bench/icount-arms.sh` +already produces every column and nothing re-ran it. Scheduled `icount` CI job +added (valgrind + oracle + override shim, uploads the table); README carries +provenance and says the ratios are a FLOOR until re-run. + +### 4. Vacuous tests + +Four tests this campaign passed under the exact bug they guarded. +`tools/gate-selftest.sh` reintroduces five real defects and requires the suite to +go red for each — placement, collect-reclaims-last-page, the periodic collect, +the reclaim-before-null, and split64's ordering normalisation. It refuses to +count a mutation that did not apply or did not compile, so a moved anchor is a +failure rather than a silent pass. Wired into CI beside `semgrep-selftest.sh`. +All 5 fire; the script leaves every file byte-identical (verified with `cmp`). + +### 5. `retire_expire` — replaced with a measured sweep period + +Upstream ages a retired page out after ~16 generic trips. Implementing it needs +a retired-bin range on the heap, and `alloc::retire_or_abort` is deliberately +written to decide keep-one-warm from the page's own links so it never resolves +the heap — a measured optimisation this machine cannot re-profile. Since +`collect` now reclaims a bin's last page, the sweep period buys the same ageing. +Swept on the board: + +| `generic_collect` | churn NULLs / 50,000 | ping | batch | churn | large | +|---:|---:|---:|---:|---:|---:| +| 10,000 (upstream) | 575 | 639 | 863 | 1,042 | 1,367 | +| **512 (shipped)** | **357** | 640 | 871 | 1,069 | 1,380 | +| 64 | 334 | 652 | 867 | **1,168** | 1,473 | + +64 costs 12 % of churn throughput for 23 fewer failures; 512 costs ~1 % for 218. +Default is now geometry-aware: 10,000 shipped, 512 small profile. Upstream's +per-page countdown stays unimplemented, recorded in §6. + +### 6. The `no_std` single-thread footgun + +`SingleThreadCell`'s `unsafe impl Sync`, `prim::fixed`'s constant thread id and +spin lock, and `options`' split 64-bit atomics are sound only with one thread, +and all three fail quietly. `no_std` now REFUSES to compile without +`--cfg ra_single_threaded`; CI asserts the negative case. + +### A defect this pass created, and the tell that caught it + +Python's text-mode write emits `\r\n` on Windows, so every file rewritten by a +helper script flipped LF -> CRLF. Real content diff: 85 lines in `heap.rs`. +What git showed: 4,043. **`git diff -w` disagreeing with `git diff` by two +orders of magnitude is the signature.** Normalised back to LF across 23 files; +two files left CRLF because they are CRLF at HEAD. + +### Final numbers + +| workload | esp-alloc | rusty_alloc | speedup | +|---|---:|---:|---:| +| 32 B alloc/free | 1,638 | 640 | **2.56x** | +| 64 mixed, batched | 1,792 | 871 | **2.06x** | +| churn 64 live | 3,987 | 1,069 | **3.73x** | +| 2048 B | 1,638 | 1,380 | 1.19x | + +Stress: capacity flat at 240 across the whole battery, 357 NULLs per 50,000 +churn allocations (from 22,533 before P4d). The two remaining refusals are the +documented structural floor. + +### Verification + +106 tests / 33 suites / 0 failed (default); 89 / 19 / 0 (`ra_small_profile`); +clippy `-D warnings` clean on default, small profile and `no_std`; fmt clean; +unsafe census RATCHET OK; gate selftest 5/5 fire; both RISC-V targets at both +geometries and wasm32 build; board kill test green at the 68 KiB floor. + +## SMALL-METAL P4e — reclamation fixed: churn NULLs 22,533 -> 575, capacity ratchet gone (2026-09-07) + +P4d found the capacity ratchet and fixed one third of it. P4e closes the rest and +re-runs the whole esp-alloc comparison, stress and speed, on the XIAO ESP32-S3. + +### Three changes + +1. **Keep-one exemption removed from `collect` entirely.** P4d gated it on + `force`; that was a partial port. Upstream's `mi_heap_page_collect` frees an + all-free page at EVERY collect level ("this will free retired pages as well") + — the keep-one cache is `mi_page_retire`'s, on the free path. +2. **`generic_collect` wired** — declared with a default of 10,000, read by + nothing. Now a per-heap countdown in the generic path, as upstream. +3. **`malloc_generic` reclaims once before returning null.** The one that + mattered: the battery makes ~649 generic trips total, so a 10,000 threshold + never fires. An allocator must not report OOM while holding empty pages for + classes nobody asked for. Free on the happy path — it runs only on failure. + +### Stress, head to head at 192 KiB + +| test | esp-alloc | rusty BEFORE | rusty AFTER | +|---|---|---|---| +| boundaries / realloc chain / zalloc-over-dirty | PASS | FAIL | **PASS** | +| fragmentation / exhaust-and-recover | PASS | PASS | PASS | +| distinct classes held at once | 24 | 9 | **21** | +| NULLs in 50,000 churn allocations | 0 | 22,533 | **575** | +| 512 B capacity across the battery | flat 383 | **168 -> 8** | **flat 240** | + +Capacity no longer decays at all. The two remaining refusals are the documented +structural floor: a `SEGMENT_SIZE`-aligned request needs a whole free 64 KiB +segment, and 21-of-24 classes is what 30 slices hold once classes above 512 B +cost four slices each. + +### Speed, and the price + +| workload | esp-alloc | rusty BEFORE | rusty AFTER | speedup | +|---|---:|---:|---:|---:| +| 32 B alloc/free | 1,638 | 625 | 639 | **2.56x** | +| 64 mixed, batched | 1,792 | 844 | 863 | **2.08x** | +| churn 64 live | 3,987 | 1,012 | 1,042 | **3.83x** | +| 2048 B | 1,638 | 1,267 | 1,367 | 1.20x | + +**2.2-7.9 % of throughput**, for an allocator that no longer fails while holding +reclaimable memory. esp-alloc reproduced to the nanosecond across sessions (same +162 ns floor, same checksums), so the deltas are ours and not drift. README and +crate README updated — the previously published 2.62x/2.12x/3.94x/1.29x are no +longer what the code does. + +### The host test that asserted nothing, twice + +The reclaim-and-retry regression test passed WITH THE FIX REMOVED, in two +versions running: (1) a 4-chunk arena is 128 MiB at the shipped geometry, where +48 cached pages starve nothing — regated to `ra_small_profile`; (2) still +passed, because the threshold was `served > 64` and the poisoned arm serves 128 +— both arms above it. Fixed by measuring both arms and putting the threshold +between them: 240 with, 128 without, assert `> 192`. A threshold chosen before +the arms are known is a guess. + +### Not implemented, and now on the list + +`mi_page_retire`'s `retire_expire` countdown does not exist here, which is why +cached pages accumulate instead of ageing out. Also: the README's callgrind +instruction counts predate these changes and need re-running under `LD_PRELOAD` +before the next release. + +### Verification + +106 tests / 33 suites / 0 failed (default); 88 / 19 / 0 (`ra_small_profile`); +three new tests, each poisoned to confirm it fires. + +## SMALL-METAL P4d — stress battery finds a collect defect: capacity decayed 168 -> 8 (2026-09-07) + +Every phase so far measured a workload that works. P4d ran eight adversarial +tests on the board — routing boundaries, alignment to a whole segment, a realloc +chain, zalloc over dirtied pages, a fragmentation adversary, exhaustion and +recovery, a size-class sweep, and 50,000 churn ops — with every allocation +null-checked so one failure does not end the run. + +### The defect + +`heap.rs::collect_inner` discarded `force` and applied the keep-one-page-per-bin +exemption on every path, so `mi_collect(true)` could never reclaim a bin's last +all-free page. Upstream's `mi_heap_page_collect` frees unconditionally at +`MI_FORCE`; the keep-one cache belongs to `mi_page_retire`. Invisible at 512 +slices per segment, fatal at the small profile's 16: + +``` +512 B capacity as the battery touched classes: 168 -> 104 -> 72 -> 56 -> 24 -> 8 +collect(true): 8 before, 8 after <- recovered nothing +churn: 22,533 of 50,000 allocations NULL, with 61,440 bytes free +``` + +Fix is `force || !only_page_in_bin`. Measured on hardware: + +| | before | after | +|---|---:|---:| +| `collect(true)` capacity recovery, 192 KiB | 8 -> 8 | **8 -> 240** | +| `collect(true)` capacity recovery, 68 KiB | — | **8 -> 120** | +| NULLs in 50,000 churn ops, 192 KiB | 22,533 | **2,925** | +| segments ever created (i.e. releasable) | 2 | **3** | + +Segments could not be released at all before: a single retained page pinned each +one. Regression test `heaps::forced_collect_reclaims_a_bins_last_page` asserts +both halves (unforced keeps the cache, forced reclaims it); poisoned it reports +`retired 0 -> 0`. + +### The gap it exposed, deliberately not closed + +**`generic_collect` is declared with a default of 10,000 and never read.** The +only collect callers are the two public entry points and teardown, so there is +no automatic collect and the capacity a forced collect now recovers is never +recovered on its own — which is why churn still returns NULL 2,925 times at +192 KiB and 24,853 at 68 KiB from a heap one `collect(true)` restores. Wiring it +moves behaviour on every platform including the published instruction counts, so +it belongs to the callgrind harness, not a board. Now item 1 of the plan's §6. + +### Three structural limits, documented + +- **A whole-segment request needs a whole free segment.** 61,439 / 61,440 / + 61,441 / 65,536 B and `align = 65,536` all return NULL at 192 KiB with 61,440 + free, because that free space is not a contiguous aligned 64 KiB. Size an + embedded region as `k * 64 KiB + 4 KiB`. +- **The `bins x page` floor, dynamically.** 21 of 24 classes in 2 segments — 30 + usable slices, small classes 1 slice, classes above 512 B cost 4. It stops + exactly where §2.9's arithmetic says. +- **68 KiB is workload-specific.** At that budget the battery holds 5 of 24 + classes. §2.10's floor is the least that runs the fs sketch and nothing more; + the README now says so. + +### What did not break, and the control + +With reclamation between tests, `realloc_chain`, `zalloc_dirty`, +`fragmentation` and `exhaust_recover` all PASS and capacity holds flat — prefix +preservation across every routing boundary, re-zeroing of recycled dirty pages, +2 KiB requests over a holed heap and full restoration after exhaustion are +sound. Every failure was capacity, never correctness. + +**esp-alloc control: every test PASS, capacity flat at 383, no decay, no churn +NULLs.** A linked-list heap has no per-class cache to starve on. + +### The battery's own double free + +The first run aborted in `page::double_free_abort` — a shared slot array the +harness never cleared, so a later test freed stale addresses. The allocator was +right and the harness was wrong, and the README's double-free claim is now +demonstrated on silicon. + +### Verification + +105 tests / 33 suites / 0 failed (default); 87 / 19 / 0 (`ra_small_profile`) — +both up one for the new regression test. + +## SMALL-METAL P4c — 2.1-3.9x faster than esp-alloc on silicon, at 8.5x the RAM (2026-09-07) + +Footprint was measured to death in P4b and esp-alloc wins it structurally. +Throughput is the half rusty_alloc is actually built for and it was +**unmeasured**, which made every claim about it an opinion. Measured now, on the +same XIAO ESP32-S3 Sense at 240 MHz, same one-source-two-arms harness, both arms +given the SAME 192 KiB. + +Nanoseconds per allocate/free pair, best of 5, NET of the measured harness floor: + +| workload | esp-alloc | rusty_alloc | speedup | +|---|---:|---:|---:| +| harness floor (no allocator call) | 162 | 162 | — | +| 32 B alloc/free | 1,638 | **625** | **2.62x** | +| 64 mixed blocks (8-512 B), batched | 1,792 | **844** | **2.12x** | +| **churn: 64 live, random 8-512 B** | 3,987 | **1,012** | **3.94x** | +| 2048 B alloc/free | 1,638 | **1,267** | 1.29x | + +Churn is the row that matters — the shape real code has, and the one that +fragments a first-fit list. 2048 B is narrowest because 2 KiB is exactly +`MEDIUM_OBJ_SIZE_MAX` here, so it takes a medium page rather than the small +fast path. + +### The guards + +- **The harness measures itself.** A baseline arm — identical loop, identical + non-inlined `touch`, identical four volatile accesses, no allocator call — + cost **162 ns/op in BOTH arms** and is subtracted from every row. It is not + cosmetic: unsubtracted, churn reads 3.53x instead of 3.94x, because a constant + added to both arms drags any ratio toward 1. +- **The optimiser cannot delete the work.** Volatile write/read per block folded + into a printed checksum. Without it an alloc/free pair is dead code and the + benchmark times an empty loop. +- **Work parity proven, not assumed.** Every checksum matches across arms + (25474400, 13944320, 9029440, 25474400); sizes come from a seeded xorshift32. +- **Null arm.** The same benchmark twice inside one arm reproduced to the + nanosecond in both arms (625/625, 1638/1638); spread <= 1 % on every row. + +### The first run was wrong and the number said so + +It reported 2,361 ns/op for a 32 B alloc/free pair — ~570 cycles for a path that +should be tens. Cause: `esp_hal::Config::default()` leaves the S3 at 80 MHz. +Pinning `CpuClock::max()` moved every row by almost exactly 3x, which is what +confirmed the clock rather than the allocator had been under measurement. + +### The cost, now measured instead of estimated + +§2.9 estimated esp-alloc's floor at ~5.4 KiB from arithmetic. Measured by +shrinking its heap until it fails: **esp-alloc runs the same fs workload in +8 KiB**, versus rusty_alloc's 68 KiB. The honest headline is therefore +**2.1-3.9x faster at 8.5x the RAM**, and both halves went into the README and +the crate README — a speed claim published without its cost is one nobody should +believe. + +### Validation re-run after the harness changed + +Footprint build re-flashed and green: 68 KiB region, `free 0` at peak, +`PEAK 4,914`, app 127,088 bytes, kill test seen. No crate source changed in this +phase — the benchmark lives entirely in the Janus harness. + +## SMALL-METAL P4b(iii) — 3.8 % page occupancy, and why 4 KiB is the floor (2026-09-07) + +Two questions left after 68 KiB: is the 4 KiB slice *right* or merely where we +stopped, and is anything else cheap. Both answered on the same XIAO ESP32-S3, +kill test green throughout, region still 68 KiB with `free 0` and `PEAK 4,914`. + +### The census that settles the geometry + +Request counts cannot say how full a page gets. A page serves one size class, so +what matters is blocks live AT ONCE — a live/peak pair per bin: + +``` +block 4 B: PEAK live 1 block 64 B: PEAK live 1 +block 8 B: PEAK live 2 block 96 B: PEAK live 1 +block 16 B: PEAK live 4 block 320 B: PEAK live 1 +block 24 B: PEAK live 2 block 384 B: PEAK live 2 +block 32 B: PEAK live 1 +``` + +**Nine pages, 36,864 bytes, holding 1,412 bytes at peak — 3.8 % occupancy.** No +class ever holds more than four blocks. (The other 3,502 bytes of the peak are +the 4 KiB allocations, which are large spans sized to the block and not part of +this.) + +**4 KiB is a wall on both sides, and both walls were found by probing past +them.** Below: `good_size` rounds to an OS page while the large path allocates +slices, so `usable >= good_size` needs `slice >= page` (P4b(ii)). Above— +strictly, below in slice size—the two largest small classes are 320 B and 384 B +and `SMALL_OBJ_SIZE_MAX = SLICE / 8`. At 4 KiB that ceiling is 512 B and both +sit in one-slice small pages, 8 KiB for the pair. At 2 KiB the ceiling is 256 B, +both become MEDIUM, and `MEDIUM_PAGE_SIZE` cannot follow the slice down because +`spans.rs` pins `MEDIUM_OBJ_SIZE_MAX` at 2 KiB — which pins a medium page at +16 KiB. The pair would cost **32 KiB instead of 8**. Arithmetic from the measured +profile, labelled as such. + +### The lever left on the table, with its number + +Coarsening the small bins to power-of-two classes would collapse nine pages to +three or four, fit a 32 KiB segment, and take the region to roughly **36 KiB**. +**Not taken**, deliberately: + +1. `bins.rs` says at the top of the file that the size -> `good_size` mapping IS + the ABI-visible contract, G2-pinned against the oracle. Every existing + small-profile divergence changes routing; none changes that mapping. This + would be the first — a decision, not an optimisation to slip in. +2. It trades a BOUNDED cost for an UNBOUNDED one: page cost is per class + touched, internal fragmentation is per live object. This workload holds 10 + tiny objects so it looks free — on a sample of one. Thousands of 24-byte + nodes would pay up to 2x each. + +### `portable-atomic` off the no_std path + +`options.rs` was the last 64-bit-atomic user on a 32-bit target (`VALUES`, an +`i64` API frozen at v2.0.0; `HEARTBEAT`, a C-ABI `u64`). Its lock-based fallback +provides atomicity nothing can observe — the crate already serves `no_std` only +on single-threaded targets, which `SingleThreadCell`, `prim::fixed`'s constant +thread id and its never-contended spin lock all rest on. Replaced by `split64`, +two `AtomicU32` halves, **adding no unsafe** (a struct of `AtomicU32` is `Sync`). +With `std` on a 32-bit target the shim stays: there, threads are real. + +| | .text | `.bss` symbols | `.bss` section | +|---|---:|---:|---:| +| `portable-atomic` | 84,907 | 136,423 | 201,996 | +| `split64` | 84,587 | 132,135 | 201,996 | +| | **−320** | **−4,288** | **0** | + +**The predicted 4,288-byte SRAM saving did not happen, and the section table is +what said so.** `LOCKS` is the only differing symbol and it does leave, but +`.bss` does not shrink — esp-hal's linker anchors its end, so the space becomes +slack the application cannot claim. Kept for the dependency removal and 320 +bytes of flash, not for RAM. Recorded because the first A/B I ran measured +*nothing*: a Python revert silently no-op'd on an MSYS `/f/...` path and both +arms built identically — the equal numbers were the tell. + +### Two defects in the shim, both caught before shipping + +- **Forwarding the caller's `Ordering` aborts the firmware.** `AtomicU32::load` + rejects `Release`/`AcqRel`, and `options::set_default` performs + `compare_exchange(.., AcqRel, ..)`. Orderings are normalised (loads `Acquire`, + stores `Release`); the test passes exactly the orderings `options.rs` uses and, + poisoned, panics in `core`'s `atomic.rs`. +- **The test could never run.** It sat inside a module `cfg`-gated to the target + that needs it, so it would not have executed anywhere anyone runs tests. The + module now also compiles under `test` — which is the only reason the ordering + bug was found. + +### Verification + +104 tests / 33 suites / 0 failed (default); 86 / 19 / 0 (`ra_small_profile`) — +both up one, the shim's test runs in each; clippy `-D warnings` clean on default, +small profile and `no_std`; fmt clean; census RATCHET OK (unchanged at 890 — the +shim adds none); riscv32imac + riscv32imafc `no_std` at both geometries, and +wasm32. Board: 68 KiB, `free 0`, `PEAK 4,914`, app 127,056 bytes, kill test green. + +## SMALL-METAL P4b(ii) — the levers, hammered: 192 KiB -> 68 KiB on the board (2026-09-07) + +§2.9 ranked the footprint levers. This entry is what taking them cost and +bought, each step measured on the same XIAO ESP32-S3 Sense with the same +`espino run --expect` kill test, still green at every step. + +| | region required | `PEAK` live | app image | +|---|---:|---:|---:| +| P4, as measured | 192 KiB | 4,914 | 127,328 | +| + lever 1 (two-ended placement) | **132 KiB** | 4,914 | — | +| + lever 2 (4 KiB slice) | **68 KiB** | 4,914 | 127,376 | + +**−64.6 % of the region. `PEAK` identical at every step**, which is the +work-parity check: the allocator stayed the only variable. +48 bytes of flash +across both, so this came out of geometry and not out of deleted code. Board +counters: `pages_fresh` 10 -> 9, `segments` **2 -> 1**, `used` 135,168 -> 69,632 +(= one 64 KiB segment + one 4 KiB heap block, `free 0` at peak — the exact +floor, confirmed the way P4 confirmed 192 KiB). + +### Lever 1 — a placement bug, not a leak + +The 61,440 stranded bytes were on the free list the whole time; nothing could +*use* them, because no `SEGMENT_SIZE`-aligned request can start mid-segment. +`prim::fixed` now places coarsely-aligned requests at the bottom of the lowest +extent that fits and merely page-aligned ones at the TOP of the highest — +requests that do not care about coarse alignment are the ones that can move. +Shipped cost: one pure arithmetic `fn place`. **Zero new unsafe in shipped +code**; the census went 883 -> 890 and all seven are `#[cfg(test)]`. + +**The test needed poisoning twice before it meant anything.** Written against +the existing 512 KiB region it passed under the bug — a region that is an exact +multiple of `SEGMENT_SIZE` cannot tell the two policies apart, since a page off +either end costs a segment either way. `K * SEGMENT_SIZE + FIXED_PAGE` on an +aligned base is what discriminates, and is what the board actually has. Poisoned +separately, the two halves report offset 0 instead of `N - FIXED_PAGE`, and 7 +segments of reach instead of 8. + +### Lever 2 — the slice, and the invariant a failed probe uncovered + +`SEGMENT_SLICE_SIZE` 8 KiB -> 4 KiB with `SLICES_PER_SEGMENT` 8 -> 16, so +`SEGMENT_SIZE` deliberately does not move: the quantity that mattered is +pages-per-segment, 7 -> 15. + +**A 2 KiB probe then failed, and failing is what it was for.** +`properties::usable_size_agrees_with_good_size`: `good_size(49_153)` promised +53,248 while the 25-slice span delivered 51,200. `bins::good_size` answers the +large range with `os::page_align_up` while the large path allocates EXACT +SLICES, so `usable_size >= good_size` — ABI-visible — holds only while **a slice +is at least an OS page**. Free at every other geometry (64 KiB slice, 4 KiB +page), which is exactly why it was never written down. `good_size` is G2-pinned +against the oracle, so the slice is the side that moves. Now a +`const _: () = assert!` in `prim/fixed.rs`, at the one backend where the two can +be tuned into conflict. + +### Three more findings, and one hypothesis killed + +- **`slice_pool::rejects_what_it_cannot_track`** — the module's last + byte-denominated test, and P2's defect shape verbatim. `MIB + 4096` was + "misaligned" only while a slice was 8 KiB; at 4 KiB it became slice-ALIGNED, + so the test *succeeded* at freeing two ranges it exists to refuse, and — the + pool being global first-fit — took down three other tests instead of itself. + Rewritten in slices, plus a drain assertion so a refusal that sets a bit fails + here rather than next door. +- **`heaps.rs` arena** — `reserve_os_memory_ex(64 * 1024 * 1024, ...)` reads + "two chunks" at 32 MiB segments and "2048 chunks" at 32 KiB ones, past + `arena::MAX_CHUNKS` (1024). Now sized in chunks; the `.max(2)` keeps the + default profile at exactly the 64 MiB it always reserved. +- **`MEDIUM_PAGE_SLICES` 4 -> 2 was a real regression, not a stale premise.** It + lowers `MEDIUM_OBJ_SIZE_MAX` to 1,024 B, collapsing the binned range so a + burst of 2 KiB objects takes a whole slice each instead of sharing a page. + `spans.rs` caught it. Held at 4. +- **Killed cheaply:** `slice_pool::FREE` is a bitmap over the entire 32-bit + address space (`1 << (32 - SLICE_SHIFT)` bits) — 64 KiB of BSS at the small + profile, larger than the region. It costs nothing: every call site is + `#[cfg(all(target_arch = "wasm32", not(miri)))]` and the linker drops the + static everywhere else. Checked against the shipped firmware's symbol table, + not argued from the source. + +### What this does NOT do + +esp-alloc's floor is bytes-live-plus-headers (~5.4 KiB here); rusty_alloc's is +`bins x slice`. These levers took 2.8x out of the gap and could not close it — +§2.9's conclusion is unchanged. 68 KiB is below the 96 KiB the esp-alloc arm is +*configured* with, but that is the example's number, not esp-alloc's floor, and +saying otherwise would be the dishonest version of this row. + +### Verification + +103 tests / 33 suites / 0 failed (default); 85 tests / 19 suites / 0 failed +(`ra_small_profile`); clippy `-D warnings` clean on default, small profile and +`no_std`; fmt clean; unsafe census RATCHET OK at 890 with `UNSAFE.md` updated; +builds for riscv32imac and riscv32imafc `no_std` at BOTH geometries, and wasm32. +Board kill test green. + +## SMALL-METAL P4b — WHY it costs 192 KiB: ten bins, ten pages, thirteen slices (2026-09-07) + +P4 measured the price. It did not measure the cause, and the difference decides +whether the gap to `esp-alloc` is a backlog or a floor. Asked on the board +rather than reasoned about: arm B now prints rusty_alloc's always-on counters +and a per-bin census of every `GlobalAlloc` request. + +**Method.** Same XIAO ESP32-S3 Sense, same one-source two-arm harness, same +`espino run --expect` kill test (still green, 62 lines). Two additions to arm B +only: `rusty_alloc::alloc::stats()` at each stage, and an `AtomicU32[74]` +incremented at `rusty_alloc::bins::bin(size)` on every acquiring path +(`alloc`/`alloc_zeroed`/`realloc`). The census is in the wrapper, outside the +allocator, so it counts requests the workload makes, not decisions the +allocator takes — the two can then be compared. + +``` +[pages] end: generic 25 pages_fresh 10 extends 11 segments 2 large 0 huge 0 +[bin] blocks touched: 4, 8, 16, 24, 32, 64, 96, 320, 384, 4096 B +[bin] distinct bins touched: 10 +``` + +### The number that answers the question + +**Distinct bins 10. Fresh pages 10.** An identity, not a correlation: a page +serves one size class and is at minimum one SLICE, so the floor is +`bins x slice`, independent of bytes demanded. + +| | slices | bytes | +|---|---:|---:| +| 9 small pages (all blocks ≤ `SMALL_OBJ_SIZE_MAX` = 1 KiB) | 9 | 73,728 | +| 1 medium page (block 4,096 = `MEDIUM_OBJ_SIZE_MAX` exactly) | 4 | 32,768 | +| **pages needed** | **13** | **106,496** | +| usable slices per segment (8 − 1 header) | 7 | | +| **segments** | | **2** — 14 usable, one spare | + +Region, to the byte: `4,096` (create_heap's one `os::alloc_aligned` page) +`+ 61,440` (first-fit alignment hole) `+ 2 x 65,536` = `196,608` = the 192 KiB +P4 found empirically by watching 128 KiB panic. The empirical number and the +arithmetic now agree, which is the check that the decomposition is real. + +**Occupancy: 4,914 live bytes in 106,496 bytes of pages — 4.6 %.** + +### Ranked levers, and the part that is not a lever + +1. **Alignment hole — 61,440 B, 31 % of the region.** The only line that is a + defect. Fix `prim::fixed` to return the skipped prefix; 192 KiB → 132 KiB. + Arithmetic, not projection. Not yet done. +2. **`SEGMENT_SLICE_SIZE`** multiplies all 106,496 B. `SEGMENT_SIZE` is not the + lever; the slice is. Coupled to `SMALL_WSIZE_MAX` via + `SMALL_OBJ_SIZE_MAX = SMALL_PAGE_SIZE/8`, which at 8 KiB lands exactly on + `SMALL_WSIZE_MAX * 8` = 1,024 — so the two must move together. Direction + certain, magnitude unmeasured, and it stays unmeasured until it is measured. +3. **Retention.** At `end`, live 0 and region used still 135,168 — freed, not + returned. mimalloc keeps retired pages as a reuse cache; on an MCU that turns + the peak into the floor. +4. Flash +11,296 B (+9.7 %) — real, not the scarce resource. + +**Not a lever:** esp-alloc's floor is `bytes live + headers` (~5.4 KiB here); +rusty_alloc's is `bins x slice` (106 KiB here) **regardless of byte demand**. +Different functions, not one function tuned differently. Levers 1 and 2 are +worth taking on their own merits; they do not close 20x, and saying they might +would be the dishonest version of this entry. + +**The corollary that is actually useful:** the page cost is roughly FIXED for a +bin profile — the same 13 slices serve 5 KB or 500 KB. The crossover is where +live bytes approach `bins x slice`. Below it esp-alloc wins by construction. + +**Kill test after instrumentation: still PASSED** (the counters are the +instrument's tax, and it is paid in the arm being measured, so the region +numbers are unchanged from P4: 135,168 used, 61,440 free, PEAK 4,914). + +## SMALL-METAL P4 — IT RUNS ON A CHIP, and the region costs 2x esp-alloc for a 4.9 KB workload (2026-09-07) + +P4 of `docs/plans/small-metal.md`, **on real hardware**: a Seeed XIAO +ESP32-S3 Sense (esp32s3 rev v0.2, 8 MB flash, MAC 68:ee:8f:51:74:64) on COM4, +Track B bare metal — `esp-hal` 1.2.0, `no_std`, `panic = "abort"`, the +`ra_small_profile` geometry, `rusty_alloc-api` as `#[global_allocator]`. + +### Kill test: PASSED + +The plan asks that the board print the filesystem geometry, the config file and +the page title, and blink at the file's rate, with this allocator underneath: + +``` +[heap] arm: rusty_alloc +littlefs 2.0: 1261 blocks of 4096 +config.json: { "blink_ms": 250, "greeting": "hello from data/config.json" } +index.html: 318 bytes, title "blink-fs" +blinking GPIO21 every 250 ms +``` + +250 ms is the value **read from `config.json`**, not the 500 ms fallback the +sketch uses when the filesystem is missing — which is what makes the line +evidence that the whole path worked. Reproduced identically across runs. + +**One thing this session cannot confirm:** the LED itself. The firmware reports +the interval it read; the espino ledger's own P1 row says "the LED confirmed by +eye", and nobody's eye is on this board from here. + +### Method — and the control came first + +Both arms are ONE binary source in one cargo project +(`espino/examples/blink-fs-p4`, copied from the green `blink-fs`), with the +allocator selected by `--cfg ra_arm_rusty`. One dependency graph, one +workload; the arms cannot drift. + +**A control arm ran before anything was changed**, and it earned its place: the +unmodified example flashed to this board reported *"no filesystem at the +record's partition"* and blinked at the 500 ms fallback — the board had no +LittleFS image. Every later reading would have been ambiguous. `espino run` +packs and flashes the filesystem alongside the app, and the control then passed +the full kill test under `esp-alloc`. + +### The instrument had to be built, because neither allocator's own stats answer the question + +The first attempt printed `esp_alloc::HEAP.used()` at three stages. It read +**`used 0` at every one** — the file buffers are dropped before each sample. A +perfectly clean number measuring nothing. And esp-alloc's `max_usage` is behind +a feature `rusty_alloc` has no counterpart for, so it would have compared +against nothing. + +So the peak is measured **outside both**, by the same code in both arms: a +`GlobalAlloc` wrapper tracking live bytes with `fetch_max`, forwarding +`alloc`/`alloc_zeroed`/`dealloc`/`realloc` so each allocator keeps its own +behaviour. esp-alloc's `global-allocator` feature is off so the wrapper can sit +in front of it. Its two atomics per call are the instrument's tax, paid +identically by both arms. + +### The numbers + +| | esp-alloc | rusty_alloc | +|---|---:|---:| +| **workload PEAK live bytes** (shared instrument) | **4,914** | **4,914** | +| region given | 96 KiB | 192 KiB | +| region consumed | 0 at every stage | **135,168** (132 KiB) | +| smallest region that runs | — | **192 KiB** (128 KiB panics) | +| app image | 116,032 B | **127,328 B** (+11,296, **+9.7 %**) | + +**PEAK is identical to the byte.** That is the work-parity check +(`codec-measurement` §4) passing exactly: both arms performed the same +allocation work, so the allocator is the only variable. It also discharges the +caution P3 left for this phase — the mid ledger's `alloc`-rung measurement that +first came back byte-identical because LTO dropped a rung nothing called. Here +both allocators are provably reached: the counter moved in both arms, and +rusty_alloc's region consumption moved from 0 to 132 KiB. + +### Where the 132 KiB goes, predicted before it was measured + +`135,168 = 4,096 + 2 x 65,536` — an arena descriptor plus two segments. And +`free 61,440` is **60 KiB stranded by alignment**: `prim::fixed` is first-fit, +so the 4 KiB page-aligned arena descriptor takes the bottom of the region, which +pushes the first `SEGMENT_SIZE`-aligned segment to offset 64 KiB and the second +to 128 KiB — so two segments need a 192 KiB region even though they occupy 132. + +That was written down as a prediction and then tested: **at a 128 KiB region the +board panics in `handle_alloc_error`**, exactly as predicted, because only one +segment can be placed and one segment's seven usable slices do not serve this +workload. + +**The actionable half:** placing sub-segment allocations at the TOP of the +region (or best-fit rather than first-fit) would make 132 KiB sufficient and +recover a third of the region. That is a `prim::fixed` change, not an +architecture change, and it is the single cheapest improvement P4 found. + +### The honest verdict on §5's "is the win real" + +The workload's true demand is **4.9 KB**. `esp-alloc` serves it from a 96 KiB +heap that is already 20x the demand; `rusty_alloc` needs **192 KiB — 2x the +region and 37 % of the S3's entire 512 KiB of SRAM** — plus **+9.7 % of app +flash**, to serve the same 4.9 KB. The reason is structural rather than +wasteful: a segment is the allocation unit, and 64 KiB is the smallest segment +this geometry offers. + +So the performance case is not merely absent, it is negative on the axis a chip +cares about, and §5's sentence stands as written: *"the safety argument has to +carry the whole weight on its own, and it may not."* P4's contribution is that +the sentence now has numbers under it instead of a suspicion — and that the +allocator demonstrably RUNS, which was never certain before today. + +## SMALL-METAL P3 — it builds for a chip: 39 errors to 0, and a half-finished narrowing the whole battery could not see (2026-09-07) + +P3 of `docs/plans/small-metal.md`: decide `portable-atomic` versus narrowing +**per site, with the reason recorded per site**, and add the single-heap +profile. **Kill test met on every target.** + +| target | geometry | result | +|---|---|---| +| `riscv32imac-unknown-none-elf` | default + small | **builds**, debug and release | +| `riscv32imafc-unknown-none-elf` | default + small | **builds** | +| `xtensa-esp32s3-none-elf` (esp toolchain, `-Z build-std=core`) | default + small | **checks clean** | +| x86-64 host, `--no-default-features` | — | builds | +| `wasm32-unknown-unknown` | — | builds | + +Host battery unchanged: **33 suites / 105 tests / 0 failed** (104 + P3's new +regression test); small profile 19 / 85 / 0; clippy `-D warnings` clean on the +default, small-profile AND `no_std` configurations; fmt clean; census +re-baselined with its entry. + +### The atomics decision, per site — narrow a CHOICE, shim a CONTRACT + +| site | what it is | decision | +|---|---|---| +| `arena.rs` `used` / `dirty` | the CAS'd chunk bitmaps — the **only correctness-path** 64-bit atomic in the crate | **narrow** `u64` → `u32` | +| `segment_map.rs` `MAP` | window bitmap | **narrow** | +| `slice_pool.rs` `FREE` | slice bitmap | **narrow** | +| `random.rs` `COUNTER` | seed-mixing counter | **narrow** to `usize` | +| `options.rs` `VALUES` | `options::{get,set}` are `i64` in an API **frozen at v2.0.0** | **`portable-atomic`** | +| `options.rs` `HEARTBEAT` | handed to a `DeferredFreeFun` whose **C ABI** declares it `u64` | **`portable-atomic`** | + +The rule that falls out, and it decided all six: **a bitmap's word width is a +free choice — same total bits either way — so narrowing costs nothing and keeps +the claim/verify loop lock-free. A width that appears in a frozen signature or +a C ABI is a contract, and hand-rolling a 64-bit atomic out of two 32-bit +halves inside an allocator is exactly how you get a subtle bug.** Four narrowed, +two shimmed. + +The dependency is `[target.'cfg(not(target_has_atomic = "64"))'.dependencies]`, +so **the crate stays dependency-free on every target it currently ships to** — +x86-64, aarch64, wasm32, Windows. It compiled on the Xtensa check and on +nothing else, which is the confirmation the gate works. + +### The near-miss: a half-narrowed bitmap that 105 tests could not see + +After the element type moved to `u32`, **four loop bounds still said +`div_ceil(64)` and `(w + 1) * 64`** — `arena.rs` lines 156, 157, 271, 275, 276, +281, 301 and 592, which the first regex pass had missed because it only matched +the `[idx / 64]` and `(idx % 64)` forms. + +**The entire battery passed.** Not because the bug is benign — a short scan +means chunks past the first word are unreachable, and the `dirty` init +under-marks — but because **every arena any test builds is 32 chunks or fewer, +and at ≤32 chunks `div_ceil(64)` and `div_ceil(32)` are both 1.** The +divergence starts at chunk 33. The largest arena in the suite was 2 chunks. + +That is `codec-measurement`'s "a green test can test the wrong scenario", +arrived at from the inside: the suite was not weak, it was *unable to express* +the defect. `tests/heaps.rs::arena_bitmap_reaches_past_its_first_word` now +allocates 33 chunks from an exclusive arena, and poisoning one bound back to +`64` reports precisely: **"chunk 32 of 33 refused — the bitmap scan stopped at +word 1 of 2"**, chunk 32 being the first in word 1. + +**The test's own first version was wrong, and measured rather than reasoned.** +It sized each allocation at `LARGE_OBJ_SIZE_MAX + 1` — "the smallest size that +takes a whole chunk" — and failed at chunk 16 of 33. That is not the defect: a +huge block's header pushes `header + size` into a **second** chunk, so 33 chunks +is genuinely 16 allocations. `LARGE_OBJ_SIZE_MAX` exactly (the largest +in-segment span, which fills a segment's usable region) is one chunk. The +premise was fixed, not the allocator. + +### The three things the crate used `std` for + +- **`abort()`** (4 sites). `core` has none, so without `std` it panics — and a + `no_std` consumer **must** build with `panic = "abort"`, which every Janus + firmware profile already does. Documented at the function, because an abort + that unwinds into a C caller is the guarantee gone. +- **`thread_local!` (4 sites) — this IS the single-heap profile.** With `std` + the macro expands to `std::thread_local!` verbatim, so the shipped build keeps + M10c's const-init initial-exec fast path untouched. Without it, a + thread-local becomes a plain `static`: one heap, **no TLS lookup at all**, a + *shorter* fast path than the threaded one. Sound because the crate serves + `no_std` only on single-threaded targets — the same standing assumption + `prim::fixed` already makes (constant thread id, TLS destructors that never + fire, a spin lock that never contends), and the one new `unsafe impl Sync` + says so and says what to revisit first if that changes. +- **The environment and the diagnostics** (§2.5). Deleted under `cfg`, not + ported — a firmware has no environment, so every option keeps its compiled-in + default and the pass does not exist rather than existing and returning + nothing. **But the seam a firmware would actually use survives:** + `options::out_fmt` takes `&str` and needs no allocation, so a firmware that + registers an output hook still gets the allocator's messages over its serial + log. Only the `eprint!` fallback and the `format!`-using CALLERS are std-only, + and the error path still delivers the error *code* to a registered hook + without one. + +`std` is a **feature** (default on) where the geometry is a `--cfg`, and the +contrast is the point: a feature is additive and unifies across a dependency +graph, which is exactly right for "does this build have std" and exactly wrong +for "how big is a segment". + +### A third instance of P0's bucket A + +`random::os_entropy` and `stats::process_info` both select on +`windows` / `unix` / `wasm32` / `miri` — and a bare-metal target matches +**none**, so neither had an arm at all. That is the same defect shape P0 found +in `prim/mod.rs` and P1 fixed: **three instances in one crate of a four-way +platform selection with no default.** Both have a fifth arm now (no OS entropy; +no process accounting — the latter is what `process_info`'s doc already +promised, "unknown fields read 0"). + +## SMALL-METAL P2 — the geometry was TWO LINES, and it uncovered a real defect in the shipped allocator (2026-09-07) + +P2 of `docs/plans/small-metal.md`: make the segment size a compile-time +parameter, add a small profile, and find out whether that ends in a port or in +a documented "no". **It is a port**, and on the way it found an escape from the +exclusive-arena API that is reachable in the SHIPPED configuration. + +**The ceiling probe first, and it is the headline.** Rather than refactor the +~250 uses of the geometry constants across 14 files, change the constants and +see what breaks. A small profile — `SEGMENT_SLICE_SIZE` 8 KiB, +`SLICES_PER_SEGMENT` 8, so a **64 KiB segment** — behind a `--cfg`, built for +the host: + +> **2 compile errors.** `segment_map.rs:27` and `slice_pool.rs:33` — both +> hardcoded shifts with a const assert pinning them to the shipped geometry. + +`segment.rs`, `heap.rs`, `page.rs`, `alloc.rs`, `arena.rs` and `bins.rs` +compiled **unchanged** at a segment 512x smaller. The geometry was already +symbolic everywhere it mattered; two literals were the whole wall. Both are now +derived (`SEGMENT_SIZE.trailing_zeros()`), and `ADDR_BITS` with them — it was a +flat `48`, which on any 32-bit target sizes the map for 65,536x the memory that +can exist. + +**Result: both profiles fully green.** + +| | suites | tests | failed | +|---|---:|---:|---:| +| default (32 MiB segments) | 33 | **104** | 0 | +| small profile (64 KiB segments) | 19 | **84** | 0 | + +clippy `-D warnings` clean on both. The shipped artifact is unchanged in +structure — dll 212,992 bytes and the same 316 exports as before P1 — though +that is a coarse instrument at 4 KiB PE alignment and is **not** a claim of +byte-identity on the fast path; the instruction counts need the Linux +callgrind harness, which did not run here. + +### §2.6 confirmed, and it needed a third representation + +The window bitmap is sized by the **address space**, not by the memory owned, +so shrinking the segment makes it *worse*: `WINDOW_SHIFT` 25 → 16 takes +`MAP_BITS` from 2²³ to 2³², i.e. 1 MiB of BSS to 512 MiB. Parameterising §2.1's +geometry alone would have made the crate LESS able to fit a chip. + +Replaced for the small profile by an exact 64-entry range table — **1 KiB of +BSS against the bitmap's 1 MiB** — joining the wasm base table as a third +representation of one question. No allocator code path forked; only the map. + +### Three defects, and only one of them was mine + +**1. `huge_alloc` ignored the owning heap's arena. This is in the shipped +build.** `segment.rs` asked `arena::chunk_alloc_n(-1, chunks)` — a hardcoded +"any non-exclusive arena" — while `segment_alloc` twenty lines away correctly +passed `arena_id`. So a heap created with `create_heap(_, _, arena_id)`, whose +entire purpose is that its memory comes from ONE region, served **every** +allocation above `LARGE_OBJ_SIZE_MAX` from the default arena or straight from +the OS. Upstream does not: `mi_segment_huge_page_alloc` takes a `req_arena_id` +and both call sites pass `heap->arena_id` +(`oracle/mimalloc/src/segment.c:1671,1683`). + +The small profile is what exposed it — at a 64 KiB segment the huge path starts +at 56 KiB, so an ordinary 100 KB allocation escaped — but **the defect is in +the 32 MiB geometry too**, reachable by any consumer of the exclusive-arena API +making one allocation past 32 MiB − 64 KiB. Fixed by threading the id and +refusing the OS fallback when `arena_id >= 0`, exactly as the normal path does. +The regression test is written at the **default** geometry so it guards the +shipped configuration, and it was poisoned back to the old behaviour to prove +it fires: `huge block at 0x1fdae010000 escaped its exclusive arena +[0x1fd9e000000, 0x1fdae000000)` — 64 KiB past the end. + +**2. `segments` and `segments_freed` could not both be right.** The huge path +bumped `huge_allocs` and not `segments`, while the release path bumps +`segments_freed` beside `huge_free` (heap.rs:1243). A workload cycling huge +blocks therefore reports **more segments freed than allocated** — an impossible +reading, from the counters this project uses as its work-parity instrument for +every A/B. One line, and the test now asserts the pair. + +**3. Mine, and the interesting half is the DIRECTION, not the size.** The range +table was sized at 32 entries; the host battery overflowed it and it dropped +ranges silently, so `contains` returned false for legitimate pointers and the +`debug_checks` guard began aborting good frees — three integration suites down. +Raising the number to 4096 made them pass, which **confirmed the cause and was +the wrong fix**: 64 KiB of BSS is not a chip-sized table. + +The real defect is that the module's own doc — *"a false negative for +`is_in_heap_region`, never a false positive"* — is the wrong rule for this +consumer. `contains` backs two callers, and they want opposite things: for the +public query a false positive merely misleads; for the guard a false NEGATIVE +aborts a legitimate program. So the table now degrades **permissively** once it +can no longer decide, with a latch (`range_table_overflowed()`) that a test +reads, and the size stays chip-sized at 64 entries / 1 KiB. The one test that +asserts the negative direction now checks the degradation happened rather than +skipping blind. + +Not a `debug_assert`: overflow is reachable in a VALID configuration (this +battery manages orders of magnitude more segments than any chip), and an +assertion should mean impossible, not "expected when you test off-target". + +### What the small profile actually costs + +- **Alignment ceiling is `SEGMENT_SIZE/2`** — 16 MiB shipped, **32 KiB** small. + Inherent: a segment cannot promise an alignment it cannot hold. `malloc_aligned` + returns null rather than aborting, which is the right failure. +- **`good_size` leaves the oracle.** Above `MEDIUM_OBJ_SIZE_MAX` it page-rounds, + and that constant moves with the geometry, so the mimalloc-pinned bin table is + a default-profile fixture. The rows the two geometries share still run. +- **Every per-segment structure scales as 1/`SEGMENT_SIZE`.** 512x smaller + segments is up to 512x more of them for the same bytes managed. The range + table noticed first; anything else sized by a guess will too. + +### The test suite was pinning the geometry in three different ways + +Nine tests failed at the small profile and **none of them was an allocator +defect** — a distinction worth making, because the raw count says otherwise: + +- **A literal where the unit is slices** — `slice_pool` written in MiB ("1 MiB + = 16 slices" became 128), `spans` allocating "1 MiB → 16-slice span" which at + a 64 KiB segment is a HUGE block and never touches the span path at all. + Rewritten in slices and in `SEGMENT_SLICE_SIZE` multiples. +- **A literal offset inside the segment** — the free-list link tests probed + "1 MiB into the segment", which a 64 KiB segment does not contain, inverting + three assertions about a predicate scoped to `SEGMENT_SIZE`. +- **Field-report fixtures** — `span_packing` reproduces named rows of the + segment-tax report (its 60 % row at 20 MiB, its 27 % row at 25.1 MiB). A row + is a size *against a segment size*; re-expressing them in slices would keep + them green while testing nothing the report said, so they are gated to the + shipped geometry and say why. + +P1's §2.1 assertion behaved exactly as the plan asked: it was written one-sided +("a segment cannot come out of a 512 KiB region"), P2 inverted it, and it is +now two-sided — at the small profile it asserts that a **whole segment, at +segment alignment, IS served from a 512 KiB region**, which is P2's deliverable +demonstrated rather than described. + +### The verdict on §5's first open question + +*"Whether the small profile is the same allocator or a different one wearing the +name. If P2 ends with two architectures in one crate, that is worse than saying +no."* + +**It is the same allocator.** No allocator code path is forked: the free path, +the page queues, the span carving, the cross-thread protocol, the arenas and +the bins are the shipped code running on different constants. The only +per-target divergence is the segment map's representation — which already had +two, for wasm — and one `--cfg` selecting three constants. A cargo *feature* +was deliberately not used: features are additive and unify across a dependency +graph, so two consumers wanting different geometries would silently get one of +them. The deliverable sets the cfg, the way a Janus firmware picks its chip. + +## SMALL-METAL P1 — the memory seam: 16 of 55 errors gone, the shipped artifact provably untouched, and a defect of my own caught by the instrument (2026-09-07) + +P1 of `docs/plans/small-metal.md`: introduce the primitive-memory seam and +implement it twice — the existing platform path, and a fixed-region path — +with nothing else changing. + +**What landed.** `crates/rusty_alloc/src/prim/fixed.rs`, plus a fifth arm in +`prim/mod.rs`'s backend selection. P0 found that a bare-metal RISC-V target +matches none of `windows` / `unix` / `wasm32` / `miri`, so no `sys` module was +named at all; the new arm is `all(not(miri), not(windows), not(unix), +not(target_arch = "wasm32"))`. The module is **always compiled** (so it is +type-checked and unit-tested on the host) and **selected** only where no arm +above it matches, which is what makes "nothing else changes" true rather than +hoped. + +The backend serves a `&'static mut [u8]` handed over once: a first-fit free +list of at most 32 extents in `AtomicUsize` arrays under a spin lock, with +splitting on alloc and coalescing on free. It cannot allocate its own +bookkeeping — it *is* the allocator's memory source — which is why the bound is +fixed and why exceeding it is reported rather than papered over. + +**It adds zero unsafe dereferences to the shipped crate.** The census grew +864 → 881, and every one of the 17 is either an `unsafe fn` signature the seam +requires (6, with bodies containing no unsafe operation at all — the free list +is atomics and the pointers come from the safe `with_exposed_provenance_mut`) +or a test (11). `UNSAFE.md` carries the entry; re-baselined in the same change, +as the ratchet demands. + +**The riscv debt, measured the way P0 established:** + +| bucket | P0 | P1 | | +|---|---:|---:|---| +| A. `prim` has no backend for this target | 16 | **0** | **P1's job** | +| B. explicit `std::` paths | 10 | 10 | P3 | +| C. 64-bit atomics | 5 | 5 | P3 | +| D. alloc-dependent text | 13 | 13 | with P1's follow-up | +| E. cascade from A and B | 11 | 11 | free once B lands | +| **total (no_std probe)** | **55** | **39** | | + +Every bucket P1 did not target moved by **+0**. The raw (non-probe) count went +224 → 237 — *up*, because with `sys` resolving, more code now reaches the +prelude cascade. That is the P0 artifact again, and it is why the probe number +is the one quoted. + +**Kill test, item by item.** + +1. **"The whole existing battery passes unchanged on the host."** 33 suites / + 103 tests / 0 failed; `clippy --workspace --all-targets --all-features + -D warnings` clean; `fmt --check` clean; unsafe ratchet OK; `wasm32` + still builds and still selects its own arm. + +2. **"The benches move by less than the harness's own null-arm floor."** Met + more strongly than asked, and the instrument had to be replaced to say so. + The first attempt compared `sha256` of the shipped cdylib: it **differed**. + A **null arm** — identical source, built twice — differed too + (`c157c8e…` → `97a0ba8…`), because a PE embeds a build timestamp. **The + hash cannot answer "unchanged" on this platform, and reading it would have + manufactured a regression out of a clock.** On quantities that are + deterministic, the artifact is identical: **dll size 212,992 both ways, + delta 0**, and the exported-name set **316 vs 316, identical, zero + differences**. There is no delta for a bench to resolve. + +3. **"A new test builds an arena over a static 512 KiB region and serves + allocations from it."** The **seam half is done and proven at 512 KiB**; + the **arena half is blocked by §2.1** and cannot be written yet. + `arena::arena_register` computes `chunks = size / SEGMENT_SIZE` and returns + `Err` when `chunks == 0`, so **every region below 32 MiB is refused by + arithmetic**. P1's kill test was written before that was known; the honest + report is two-thirds met, with the third part deferred to P2 rather than + redefined. + +**§2.1 is executable for the first time.** P0 recorded that the wall ranked +first has no compile-time signature. It now has a runtime one: on a registered, +**entirely free** 512 KiB region, a `SEGMENT_SIZE` request fails, and so does a +one-page request at `SEGMENT_SIZE` *alignment* — the half no larger region +fixes, and P2's actual subject. Both leave the free list untouched. + +**Two process findings, both from distrusting a green result.** + +*A test that passed for the wrong reason.* The §2.1 assertion first lived in a +`#[test]` of its own. Run alone it **passed with no region registered** — i.e. +because `alloc` refuses before it looks at size, not because 32 MiB is too big. +Measured, not assumed: the standalone test was run and observed passing in that +state. It now sits inside the main test after the region is registered and +verified fully free, where the only thing that can refuse a segment is its size. + +*The gate was poisoned to prove it fires.* Replacing +`try_alignment.max(FIXED_PAGE)` with `FIXED_PAGE` made the test fail at exactly +the alignment assertion; restoring it went green. A gate nobody has watched +fail is a claim, not a gate. + +**And a defect of my own, caught by the bucket table.** The first version of +this backend used an `AtomicU64` for its monotonic tick — a 64-bit atomic, in +the backend written *for* the target that does not have them. Bucket C read +**5 → 6** and named it. It is now two `AtomicU32` widened to `u64`, under a +**separate** lock: `Guard` is not reentrant, so sharing the free-list lock +would deadlock the first allocation path that wanted a timestamp. A 32-bit +counter alone was rejected because wrapping inverts the purge *ordering* that +is the only property this clock provides. + +Nothing else changed. `rust-toolchain.toml` also gained `wasm32-unknown-unknown`, +which the cross-check needed and which the repo already claims to support. + +## SMALL-METAL P0 — 224 errors are 55, and the wall ranked first is invisible to this probe (2026-09-07) + +P0 of `docs/plans/small-metal.md`: add `riscv32imac-unknown-none-elf` to the +toolchain file, build the core crate for it, **fix nothing**, and check the +error list against the plan's four walls. Nothing shipped but the target line. + +**Method:** `rustup target add riscv32imac-unknown-none-elf --toolchain +1.97.1`; `cargo build -p rusty_alloc --target riscv32imac-unknown-none-elf +--message-format=json`, errors counted from the JSON rather than the human +output. Deterministic — a compile, not a measurement, so no pinning, no ABBA +and no noise floor. Reproducible from the target line alone. + +**The raw number is not the finding.** The first build reports **224 errors** +(4 warnings). It is dominated by one root: two `E0463 can't find crate for +std`. Without `std` there is **no prelude**, so `Option`, `Some`, `None`, +`Result`, `Ok`, `Err`, `FnMut`, `Default`, `Sync`, `debug_assert`, `assert`, +`format`, `cfg` and `derive` all resolve to nothing. Classified: + +| | errors | +|---|---:| +| prelude items + prelude macros (cascade) | **183** | +| everything else | 41 | + +Acting on 224 would have meant "the plan is wrong, the list is materially +larger than the four walls" — the kill test's own failure condition, reached +entirely on an artifact. The rule this repository already writes down for +timings holds for compiler output: **a count dominated by one root is +measuring the root, not the program.** + +**The probe that makes the list legible.** One line, applied, measured, +reverted — `#![cfg_attr(ra_p0_probe, no_std)]` at the top of `lib.rs`, built +with `RUSTFLAGS="--cfg ra_p0_probe"`. Not a fix and not kept; it removes the +single masking cause so the real debt can be read. **224 → 55.** + +| # | bucket | errors | sites | plan | +|---|---|---:|---|---| +| A | `prim`: no backend selected for this target | 16 | `prim/mod.rs` (1 cause) | §2.4 | +| B | explicit `std::` paths — TLS, abort, io | 10 | `init.rs` 5, `options.rs` 3, `page.rs` 1, `random.rs` 1 | §2.3 | +| C | 64-bit atomics | 5 | `arena.rs`, `options.rs`, `random.rs`, `segment_map.rs`, `slice_pool.rs` — one each | §2.2 | +| D | alloc-dependent text | 13 | `options.rs` 7, `stats.rs` 3, `arena.rs` 2, `init.rs` 1 | **not in the plan** | +| E | cascade from A and B | 11 | `init.rs` 10, `random.rs` 1 | — | + +**A is one cause, not sixteen.** `prim/mod.rs` selects its backend with four +arms — `windows`, `unix`, `target_arch = "wasm32"`, `miri`. A bare-metal +RISC-V target matches **none**, so no `sys` module is named at all and all +sixteen call sites through it fail together. §2.4 confirmed, and cheaper than +it reads. + +**C is confirmed and its open question is answered.** The five sites split +one way on correctness: `arena.rs:51-52` `used`/`dirty` are the CAS'd chunk +bitmaps — the **only correctness site**, and a bitmap whose word width is a +free choice. `options.rs:195` `HEARTBEAT` and `random.rs:61` `COUNTER` are a +heartbeat and a seed counter. `segment_map.rs:30` and `slice_pool.rs:38` are +statics, discussed below. So `portable-atomic` may be needed nowhere; +narrowing covers every site. That is the same call the Janus programme made +for its own counters on 2026-09-06 (espino ledger: *"`AtomicU64` does not +exist on 32-bit RISC-V … Counters are `AtomicU32` now, `Stats` still reports +`u64`"*). + +**D is a fifth wall the plan does not have, and it is the cheapest one.** +All thirteen are environment parsing and human-readable diagnostics: +`options.rs::ensure_init` reads `RUSTY_ALLOC_*` / `MIMALLOC_*` through +`std::env::var` with `to_uppercase` / `to_ascii_lowercase` / `format!`; +`arena.rs:605` returns a `String` debug dump; `stats.rs::process_info` +reports RSS, commit and page faults. **A firmware has no environment, no +process and no stdout.** The fix is `cfg`-ing the layer out, not porting it — +so D costs less than its error count suggests, and it is deletion rather than +an `alloc` dependency. + +**§1 of the plan is wrong on its load-bearing claim.** +`crates/rusty_alloc/src/lib.rs` has **no `#![no_std]`** — the core is a std +crate, and its own module doc says so (*"A no_std profile returns post-v1"*). +The plan's *"The core is `no_std`"* cites +`crates/rusty_alloc_api/src/lib.rs:14`, which is the thin **API surface**, not +the core. `rusty_alloc_ffi/src/lib.rs:9` asserts *"the core crate is no_std"* +in a comment; that comment is false and should go with the P1 work. What IS +true, and is better news than the claim it replaces: **the core crate has +zero dependencies**, so nothing external can block the port. + +**§2.1 produced zero errors, and cannot produce any.** The wall the plan +ranks first and calls "the wall" — a 32 MiB segment against a chip's whole +address space — is a *space* property, not a *type* property. The compiler has +no opinion on it. **P0 is structurally blind to §2.1**, exactly as `opscan` +was blind to the park/unpark thrash, and for the same reason: the instrument +does not enter the regime. Sizing it needs P2, or a link, not a check. + +Two statics found while reading C, both space rather than type, and neither in +the plan: `segment_map::MAP` is `[AtomicU64; 131072]` — **1 MiB of BSS on +every non-wasm target**, against a Janus firmware heap of 64–220 KiB and an +ESP32-S3's 512 KiB of internal SRAM. `slice_pool::FREE` adds 8 KiB with no +`cfg` at all, on every target, though it is only used on wasm. wasm already +replaces the first wholesale with a 256 KiB base table, so the per-target +precedent §1 claims for the arena exists here too, and is stronger. + +**Kill test — the plan stands, amended.** The list is not materially larger +than the four walls: three are confirmed (A/§2.4, B/§2.3, C/§2.2), one is +unmeasurable by this phase (§2.1), one new wall is real but cheap (D), and +§1's premise is false. Rewriting is not needed; five corrections are. Nothing +was fixed and nothing was kept except the toolchain target line. + ## RE-BENCHMARK after the huge-path fix — zero cost, and the HARNESS was lying in the 4th digit (2026-08-19) Asked to confirm the `remove_huge_segment` fix cost nothing. It costs nothing, diff --git a/docs/plans/use-protection-please.md b/docs/plans/finished/use-protection-please.md similarity index 100% rename from docs/plans/use-protection-please.md rename to docs/plans/finished/use-protection-please.md diff --git a/docs/plans/wasm-recycling.md b/docs/plans/finished/wasm-recycling.md similarity index 100% rename from docs/plans/wasm-recycling.md rename to docs/plans/finished/wasm-recycling.md diff --git a/docs/plans/small-metal.md b/docs/plans/small-metal.md new file mode 100644 index 0000000..1921fb9 --- /dev/null +++ b/docs/plans/small-metal.md @@ -0,0 +1,1381 @@ +# small-metal — rusty_alloc on a microcontroller, and whether it should be + +**Source:** the Janus device programme (`coding/janus`), which ships firmware +for Espressif chips and today declares `esp-alloc` on bare metal and the +ESP-IDF heap on the ESP-IDF track. Janus's own plan carries this as an +unfiled decision item: *"`rusty_alloc` on Xtensa / bare-metal RISC-V — +firmware binaries switch allocators; no library changes."* Written +2026-09-06 against `main`. + +**Status: P0–P4 EXECUTED 2026-09-07 (+P4b/P4c/P4d, §2.9–§2.15: 192 KiB -> 68 KiB, 2.1-3.7x faster than esp-alloc, two reclamation defects fixed, and the six production blockers closed). It is a PORT, not a second allocator; it +BUILDS for a chip; and it RUNS on one** — the kill test passes on a Seeed XIAO +ESP32-S3 Sense with `rusty_alloc` as the global allocator. The cost is now +measured rather than suspected, and it is not flattering: **2x the region and ++9.7 % of app flash** for a workload whose true demand is 4.9 KB (§2.8). The plan stands, with the corrections folded in +below. The plan is arranged so the cheapest disqualifying answer comes first, +because this may end in a documented "no" and that is a fine outcome. + +| phase | state | riscv debt after | +|---|---|---:| +| **P0** — does it compile at all | ✅ done | 55 | +| **P1** — the memory seam | ✅ done (2 of 3 kill-test items; the third was P2's) | **39** | +| **P2** — the geometry | ✅ done — **2 compile errors**, both hardcoded shifts | — | +| **P3** — atomics and threads | ✅ done — 4 atomics narrowed, 2 shimmed | **0** | +| **P4** — on a chip | ✅ **PASSED on a XIAO ESP32-S3** — and priced (§2.8) | — | +| **P5** — the ESP-IDF track | ← next; needs the IDF track, not more allocator work | — | + +Numbers, methods and the bucket tables are in +[`docs/LEDGER.md`](../LEDGER.md). Two headlines worth carrying: the first +riscv build reports **224 errors** of which **183 are one root** (no +`#![no_std]` ⇒ no prelude), so the real debt was **55**; and P1 closed the +whole of bucket A (16 → 0) while every other bucket moved **+0** and the +shipped artifact stayed byte-for-byte identical on every deterministic +quantity. **P2's headline:** the geometry was already symbolic everywhere that +mattered — changing `SEGMENT_SLICE_SIZE` and `SLICES_PER_SEGMENT` to a 64 KiB +segment produced **two compile errors**, both hardcoded shifts, and +`segment.rs` / `heap.rs` / `page.rs` / `alloc.rs` / `arena.rs` / `bins.rs` +compiled unchanged. **P3's headline:** 39 riscv errors to **0** — the crate +builds for `riscv32imac-unknown-none-elf`, `riscv32imafc-unknown-none-elf` and +`xtensa-esp32s3-none-elf` in both geometries, with the host battery unchanged +(105 tests). Both P2 and P3 found real defects in the SHIPPED allocator along +the way — see §2.7. **P4's headline:** the sketch runs on silicon under this +allocator, and the price is §2.8. + +--- + +## 0. Why anyone wants this + +The house rule is that every deliverable declares `rusty_alloc` as its global +allocator, through a one-crate seam, never from a library. A firmware **is** +a deliverable. So the rule already points here, and the only reason the +portfolio's devices do not follow it is that nobody has tried. + +The reason to want it is the safety posture, not speed. On this allocator a +double free aborts instead of putting a block on a free list twice and +handing identical memory to two owners. A device that parses bytes off a +radio, a UART and a flash partition is exactly where that matters, and it is +the one class of bug the rest of the Janus doctrine cannot design away. + +The reason to be sceptical is that `esp-alloc` is small, simple, and +already correct for what it does, and this allocator's architecture was +built for machines with virtual memory. That tension is the whole plan. + +--- + +## 1. What is already true (verified against source) + +**~~The core is `no_std`.~~ CORRECTED BY P0 — it is not.** +`crates/rusty_alloc/src/lib.rs` carries **no `#![no_std]`**, and its own +module doc says so outright: *"A no_std profile returns post-v1 with the +nightly `#[thread_local]` or a platform TLS shim."* The +`#![cfg_attr(not(test), no_std)]` this plan cited is in +`crates/rusty_alloc_api/src/lib.rs:14` — the thin **API surface**, not the +core that holds the code. `rusty_alloc_ffi/src/lib.rs:9` asserted *"the core +crate is no_std"* in a comment, and that comment was false. + +So the starting position was a **std-first allocator**, and this section +claimed the opposite. What was true, and is worth more than the claim it +replaces: **the core crate has zero dependencies**, so nothing external could +block the port and every error was ours to fix. + +> **P3 (2026-09-07): the claim is TRUE NOW, and the comment has been +> corrected to say what is actually the case.** `rusty_alloc` has a default-on +> `std` feature; `--no-default-features` is `#![no_std]` and selects the +> single-heap profile. The zero-dependency property survives on every target +> the crate ships to today — the one dependency P3 added +> (`portable-atomic`) is gated to targets without a 64-bit atomic, which is +> none of them. + +**There is precedent for a target without an OS.** `rusty_alloc-wasm` and +the arena's wasm path already deal with a world where memory only grows and +there is no `munmap`. A chip is a harsher version of the same shape, and the +wasm work is the closest thing to a map. + +**The repository has the discipline this needs.** `docs/LEDGER.md`, a pinned +toolchain, `deny.toml`, a fuzz corpus and an oracle directory. Nothing below +proposes loosening any of it. + +--- + +## 2. The walls, with evidence + +Four when this was written; **six after P0**, and the two it added (§2.5, +§2.6) are the cheap ones. Each block quoted `> **P0:**` is what the compiler +said on 2026-09-07 — see [`docs/LEDGER.md`](../LEDGER.md) for the method. + +These are ordered by how likely they are to end the project. Each names the +file that establishes it. + +### 2.1 Segment geometry — the wall + +`crates/rusty_alloc/src/alloc.rs:115`: *"`free` masks the pointer to a 32 MiB +segment base and reads `slice_offset`"*. `crates/rusty_alloc/src/arena.rs:18`: +`MAX_CHUNKS = 1024 // 32 GiB per arena at 32 MiB chunks`. + +The free fast path is O(1) because it can find a block's metadata by masking +its address down to a 32 MiB-aligned segment header. That trick is the +architecture, and it requires a 32 MiB aligned reservation to exist. + +An ESP32-S3 has 512 KiB of internal SRAM. The XIAO board Janus runs adds +8 MiB of external PSRAM. An ESP32-C6 has no PSRAM at all. **A single segment +is larger than the entire address space we can allocate from**, by a factor +of 64 on internal memory. + +So this is not a port. Either `SEGMENT_SIZE` becomes a compile-time +parameter that the mask, the slice count and the size classes all follow, or +a small-memory profile replaces the segment scheme with something else. The +first is a large but bounded change; the second is a second allocator +wearing the same name, which is worse. + +> **P0: zero errors, and P0 cannot ever produce one.** This is a *space* +> property, not a *type* property, so the compiler has no opinion on it. The +> wall this plan ranks first is **structurally invisible to its own first +> phase** — the same shape as `opscan` being blind to the park/unpark thrash +> (`lets_win.md` §5.1.2): the instrument never enters the regime. Sizing it +> needs P2 or a link map, not a check. +> +> **The number is worse than the datasheet argument above.** Janus's +> firmwares declare their heaps explicitly, and the range is **64–220 KiB** +> (`xiao-s3-keys` 64, `c6-lora-p2p` 72, `blink-fs` and `c6-mesh-node` and +> `c6-ble-provision` 96, `xiao-s3-probe` 220). P4's own target, `blink-fs`, +> runs on **96 KiB** — a 32 MiB segment is **350×** that, not 64×. +> Corroborated from the chip rather than the source: `esp_alloc::HEAP` on +> the XIAO S3 reports 225,280 total / 172,800 used, unchanged across eight +> kernels (dsp ledger, 2026-09-06). +> +> **P2: RESOLVED, and it cost two lines.** The paragraph above says "this is +> not a port" and offers a large-but-bounded change or a second allocator. It +> was neither: the geometry was already symbolic everywhere that mattered. +> Changing `SEGMENT_SLICE_SIZE` to 8 KiB and `SLICES_PER_SEGMENT` to 8 — a +> **64 KiB segment**, 512x smaller — produced **two compile errors**, both +> hardcoded shifts (`segment_map`'s `WINDOW_SHIFT = 25`, `slice_pool`'s +> `SLICE_SHIFT = 16`), each with a const assert pinning it. `segment.rs`, +> `heap.rs`, `page.rs`, `alloc.rs`, `arena.rs` and `bins.rs` compiled +> unchanged. Both are derived now, and the small profile passes its full +> battery (19 suites, 84 tests). +> +> **What it costs, measured:** the alignment ceiling is `SEGMENT_SIZE/2` +> (alloc.rs:451, heap.rs:986) — 16 MiB shipped, **32 KiB** small — because a +> segment cannot promise an alignment it cannot hold; and `good_size` leaves +> the mimalloc oracle above `MEDIUM_OBJ_SIZE_MAX`, which moves with the +> geometry. Both are inherent, neither is a bug. + +### 2.2 Sixty-four-bit atomics — a hard, cheap blocker + +`crates/rusty_alloc/src/*.rs` constructs `AtomicU64` in four places and +imports it in five. Neither 32-bit RISC-V (ESP32-C3, C6) nor Xtensa +(ESP32-S3) has a 64-bit atomic. This is not theoretical: the Janus facade hit +exactly this on 2026-09-06 and the compiler's message was +`no AtomicU64 in sync::atomic`, on a C6 firmware. + +Two answers: `portable-atomic`, which supplies the type on targets that lack +it, or 32-bit counters where the width was never load-bearing. Janus took the +second for its own counters and said so in the code. Which is right here +depends on whether any of those five are on a correctness path rather than a +statistics path, and that is a reading job, not a design job. + +> **P0: confirmed — 5 errors, one per file, and the reading job is done.** +> +> | site | what it is | correctness? | +> |---|---|---| +> | `arena.rs:51-52` `used` / `dirty` | the CAS'd chunk bitmaps | **yes — the only one** | +> | `options.rs:195` `HEARTBEAT` | heartbeat counter | no | +> | `random.rs:61` `COUNTER` | seed counter | no | +> | `segment_map.rs:30` `MAP` | the window bitmap — see §2.5 | static, see below | +> | `slice_pool.rs:38` `FREE` | the wasm free-slice bitmap | static, wasm-only | +> +> The one correctness site is a **bitmap**, whose word width is a free +> choice. So `portable-atomic` may be needed **nowhere** — narrowing covers +> every site, which is the call Janus already made (espino ledger, +> 2026-09-06: *"Counters are `AtomicU32` now, `Stats` still reports `u64`"*). +> Note the cause recorded beside it there — *"The facade had never been +> compiled for the C6 or the C3"* — which is this crate's position exactly. +> +> **P3: RESOLVED, and "nowhere" was ALMOST right — four of six.** The rule +> that decided every site: **narrow a width that is a CHOICE, shim one that is +> a CONTRACT.** +> +> | site | decision | why | +> |---|---|---| +> | `arena.rs` `used`/`dirty` | narrow `u64`→`u32` | a bitmap's word width is free; stays lock-free | +> | `segment_map.rs` `MAP` | narrow | bitmap | +> | `slice_pool.rs` `FREE` | narrow | bitmap | +> | `random.rs` `COUNTER` | narrow to `usize` | a seed counter has no width contract | +> | `options.rs` `VALUES` | **`portable-atomic`** | `get`/`set` are `i64` in an API frozen at v2.0.0 | +> | `options.rs` `HEARTBEAT` | **`portable-atomic`** | `DeferredFreeFun`'s C ABI declares it `u64` | +> +> The shim is `[target.'cfg(not(target_has_atomic = "64"))'.dependencies]`, so +> **the crate stays dependency-free on every target it currently ships to.** +> +> **And narrowing a bitmap is not one edit.** After the element type moved, +> four loop bounds still said `div_ceil(64)`, and the whole 105-test battery +> passed — because every arena any test builds is ≤32 chunks, where +> `div_ceil(64)` and `div_ceil(32)` are both 1. See §2.7. + +### 2.3 Thread-local storage — needs a seam, not a fix + +`crates/rusty_alloc/src/init.rs` gives every thread a lazily-created heap +reached through a const-init `thread_local!` cell, and discusses choosing the +initial-exec TLS model over general-dynamic. A bare-metal firmware has no +`std::thread_local!` and, on the executors Janus runs, one thread that +matters. + +The honest shape is a profile with exactly one heap and no TLS lookup at all, +selected at compile time. That is a simplification rather than a port, and it +should make the fast path shorter, not longer. + +> **P0: confirmed — 10 errors**, and they are wider than TLS alone. Explicit +> `std::` paths in `init.rs` (5), `options.rs` (3), `page.rs` (1) and +> `random.rs` (1) — `thread_local!`, `process::abort`, and the io used by the +> option layer. A further **11 errors cascade from this and §2.4** (`init.rs` +> 10, `random.rs` 1: `HEAP_PTR`, `TID`, `SUBPROC`, `BACKING_PTR`, +> `os_entropy`), so they cost nothing extra to fix and should not be counted +> as separate work. +> +> **P3: RESOLVED — and the paragraph above was right that this is a +> SIMPLIFICATION.** `ra_thread_local!` is `std::thread_local!` verbatim with +> `std` (so the shipped build keeps M10c's initial-exec fast path untouched) +> and a plain `static` without it: one heap, **no TLS lookup at all**, a +> shorter fast path than the threaded one. Sound because the crate serves +> `no_std` only on single-threaded targets — the same assumption `prim::fixed` +> already makes — and the one new `unsafe impl Sync` names what to revisit +> first if that ever changes. `abort()` panics without `std`, so a `no_std` +> consumer **must** build with `panic = "abort"`; every Janus firmware profile +> already does. + +### 2.4 Where memory comes from — a seam that does not exist yet + +`crates/rusty_alloc/src/arena.rs` speaks in OS pages and reserves through the +platform. A chip has no OS: it has a region the linker gave it, and on the +S3 a second region behind a cache that must be initialised first. + +The arena needs a primitive-memory seam it can be handed a fixed +`&'static mut [u8]` through, with no growth and no unmapping. The wasm path +is the nearest existing case and should be read first. + +> **P0: confirmed, and it is ONE cause, not sixteen.** `prim/mod.rs` picks +> its backend with four arms — `windows`, `unix`, `target_arch = "wasm32"`, +> `miri`. A bare-metal RISC-V target matches **none**, so no `sys` module is +> named at all and all sixteen call sites through it fail together. Adding a +> fifth arm is the whole of P1's compile half. The seam size is set by §2.1's +> real number: a fixed region of **64–220 KiB**, not 512 KiB. + +### 2.5 The diagnostics layer — a fifth wall, and the cheapest one (added by P0) + +**13 errors**, in `options.rs` (7), `stats.rs` (3), `arena.rs` (2) and +`init.rs` (1), and none of them is an allocator problem: + +- `options.rs::ensure_init` reads `RUSTY_ALLOC_*` / `MIMALLOC_*` through + `std::env::var`, with `to_uppercase`, `to_ascii_lowercase` and `format!`; +- `arena.rs:605` returns a `String` debug dump of arena state; +- `stats.rs::process_info` reports RSS, commit and page faults. + +**A firmware has no environment, no process and no stdout.** So this layer is +not ported, it is `cfg`-ed out, and the option table falls back to its +compiled `DEFAULTS`. That makes D the cheapest bucket in P0 despite having +the second-largest error count — and it means the port needs **no `alloc` +dependency**, which a naive reading of "13 errors wanting `String` and +`format!`" would have concluded. + +> **P3: RESOLVED, and one seam was kept alive on purpose.** The environment +> pass does not exist without `std` rather than existing and returning nothing, +> and the `format!`-based printers are gated. **But `options::out_fmt` takes +> `&str` and needs no allocation, so it survives**: a firmware that registers +> an output hook still gets the allocator's messages over its serial log, and +> `options::error` still delivers the error CODE to a registered hook without a +> formatter. Deleting the layer wholesale would have thrown that away. A +> `no_std` consumer wanting the stats dump formats into a stack buffer and +> calls `out_fmt`. + +### 2.6 Two static arrays sized for a 48-bit machine (added by P0) + +Found while reading §2.2, and both are space rather than type, so like §2.1 +the compiler will not raise them: + +- `segment_map::MAP` is `[AtomicU64; 131072]` — **1 MiB of BSS on every + non-wasm target**. Against a 64–220 KiB heap and the S3's 512 KiB of + internal SRAM, this static alone is twice the entire address space the + allocator would serve from. +- `slice_pool::FREE` adds **8 KiB with no `cfg` at all**, on every target, + though it is only used on wasm. + +The good news is that the precedent §1 claims for the arena is stronger here: +**wasm already replaces `MAP` wholesale** with a 256 KiB slice-granular base +table (`segment_map::base_of`, F2 of `segment-tax.md`). A third +representation for a small-address-space target is a shape the file already +has, not a new idea. + +--- + +### 2.7 What the small profile FOUND in the shipped allocator (added by P2) + +Not a wall — the opposite. Shrinking the segment moves the huge-path boundary +from 32 MiB down to 56 KiB, which made an ordinary 100 KB allocation take a +path that is nearly unreachable at the shipped geometry. It escaped its arena. + +`segment::huge_alloc` asked `arena::chunk_alloc_n(-1, chunks)` — a hardcoded +"any non-exclusive arena" — while `segment_alloc` twenty lines away correctly +passed the owning heap's `arena_id`. So a heap created with +`create_heap(_, _, arena_id)`, whose entire purpose is that its memory comes +from ONE region, served **every** allocation above `LARGE_OBJ_SIZE_MAX` from +the default arena or straight from the OS. Upstream does not +(`mi_segment_huge_page_alloc` takes a `req_arena_id`; oracle +`segment.c:1671,1683`). + +**This is reachable in the 32 MiB build** by any consumer of the +exclusive-arena API making one allocation past 32 MiB − 64 KiB. Fixed, with the +regression test written at the DEFAULT geometry +(`tests/heaps.rs::exclusive_arena_confines_huge_allocations`) and poisoned back +to the old behaviour to prove it fires. + +A second, smaller one alongside it: the huge path bumped `huge_allocs` but not +`stats.segments`, while the release path bumps `segments_freed` beside +`huge_free`. The pair could report more segments freed than allocated — from +the counters this project uses as its work-parity instrument. + +**P3 added a second, of the same species.** Narrowing the arena bitmap's word +type from `u64` to `u32` left four loop bounds saying `div_ceil(64)` — a +half-narrowed bitmap, which cannot reach chunks past its first word. **The +whole 105-test battery passed**, because every arena any test builds is 32 +chunks or fewer and at ≤32 chunks `div_ceil(64)` and `div_ceil(32)` are both 1. +The suite was not weak; it was *unable to express* the defect. +`tests/heaps.rs::arena_bitmap_reaches_past_its_first_word` closes that, and +under the small profile it costs 2.1 MiB instead of the default geometry's +1.06 GiB — the reachability point again, from the other side. + +**The transferable point:** a geometry parameter is not only a portability +lever, it is a *reachability* lever. Shrinking the segment moved three code +paths from "needs a 32 MiB allocation to reach" to "reached by a 100 KB one", +and the first thing down there was a real defect. That is the same shape as +`lets_win.md` §5.1.2 — an instrument that never enters the regime cannot see +what lives in it. + +### 2.8 What it costs on silicon (added by P4, measured on a XIAO ESP32-S3) + +The kill test passes. This is the price, from the board — the same workload +under both allocators, one binary source, the peak measured by a `GlobalAlloc` +wrapper present in BOTH arms: + +| | esp-alloc | rusty_alloc | +|---|---:|---:| +| **workload PEAK live bytes** | **4,914** | **4,914** | +| region given | 96 KiB | 192 KiB | +| region consumed | 0 at every stage | 135,168 (132 KiB) | +| smallest region that runs | — | **192 KiB** (128 KiB panics) | +| app image | 116,032 B | 127,328 B (**+9.7 %**) | + +PEAK identical to the byte is the work-parity check: the allocator is the only +variable, and both are provably reached. + +**Where 132 KiB goes, and the cheapest fix P4 found.** It is `4,096 + 2 x +65,536` — an arena descriptor plus two segments — and the 60 KiB reported free +is **stranded by alignment**: `prim::fixed` is first-fit, so the 4 KiB +page-aligned descriptor takes the bottom of the region and pushes the first +`SEGMENT_SIZE`-aligned segment to 64 KiB and the second to 128 KiB. Predicted, +then confirmed by dropping the region to 128 KiB and watching the board panic in +`handle_alloc_error`. **Placing sub-segment allocations at the top of the region +(or best-fit) would make 132 KiB sufficient** — a `prim::fixed` change, not an +architectural one, and a third of the region back. + +**Read this against §0.** The reason to want this was never speed; §0 says so. +P4 establishes that it is not free either: 2x the region on a part with 512 KiB +of SRAM, for a workload needing 4.9 KB. See §5. + +### 2.9 WHY it costs that — the decomposition, measured (added by P4b) + +§2.8 said *what* the 192 KiB is. It did not say what drives it, and the +difference decides whether this is an optimisation backlog or an architectural +floor. So the region was decomposed on the board rather than argued about: arm B +now also prints rusty_alloc's own always-on counters and a per-bin census of +every request the workload makes. + +``` +[pages] end: generic 25 pages_fresh 10 extends 11 segments 2 large 0 huge 0 +[bin] 1: block 4 B, reqs 2 [bin] 14: block 96 B, reqs 2 +[bin] 2: block 8 B, reqs 6 [bin] 21: block 320 B, reqs 1 +[bin] 4: block 16 B, reqs 12 [bin] 22: block 384 B, reqs 12 +[bin] 6: block 24 B, reqs 6 [bin] 36: block 4096 B, reqs 7 +[bin] 8: block 32 B, reqs 2 +[bin] 12: block 64 B, reqs 1 [bin] distinct bins touched: 10 +``` + +**Ten distinct bins, ten fresh pages.** Not a correlation — an identity. A page +serves exactly one size class, and a page is at minimum a whole SLICE. So the +floor is *(distinct bins touched) x slice size*, and it is independent of how +many bytes the workload actually wants. + +The slice budget closes the segment count exactly: + +| | slices | bytes | +|---|---:|---:| +| 9 small pages (bins 1..22, all blocks ≤ `SMALL_OBJ_SIZE_MAX` = 1 KiB) | 9 x 1 | 73,728 | +| 1 medium page (bin 36, block 4,096 = `MEDIUM_OBJ_SIZE_MAX` exactly) | 1 x 4 | 32,768 | +| **pages needed** | **13** | **106,496** | +| usable per segment (`SLICES_PER_SEGMENT` 8 − `HEADER_SLICES` 1) | 7 | | +| **segments** | | **2** (14 usable slices — one to spare) | + +And the region closes to the byte: + +| | bytes | +|---|---:| +| heap+tld block (`create_heap`'s one `os::alloc_aligned` page) | 4,096 | +| **alignment hole** (first-fit skip to the next 64 KiB boundary) | **61,440** | +| segment 1 | 65,536 | +| segment 2 | 65,536 | +| **= region required** | **196,608 (192 KiB)** | + +**Occupancy: 4,914 live bytes in 106,496 bytes of pages — 4.6 %.** + +**The levers, ranked by measured contribution.** + +1. **The alignment hole — 61,440 B, 31 % of the region, no design change.** + Already named in §2.8; the decomposition confirms it is the single largest + line and the only one that is a defect rather than a consequence. Fix + `prim::fixed` to return the skipped prefix to the free list instead of + consuming it (or place aligned requests from the tail) and the region + requirement goes 192 KiB → 132 KiB. This is arithmetic, not a projection. + +2. **`SEGMENT_SLICE_SIZE` — the multiplier on all 106,496 bytes.** Every number + in the slice table is linear in it; `SEGMENT_SIZE` is not the lever, the + slice is. **Done — see §2.10.** + + > **Correction (§2.10).** This entry first said the slice was coupled to + > `SMALL_WSIZE_MAX` and that the two had to move together. They do not. + > `SMALL_SIZE_MAX` gates the `direct[]` table, which is only a cache of "the + > page currently serving this word size" and is indifferent to what KIND of + > page that is; `SMALL_OBJ_SIZE_MAX` separately decides page size at + > `heap.rs`'s `fresh_page`. The slice moved without `SMALL_WSIZE_MAX` + > moving at all. The real constraint was somewhere else entirely, and §2.10 + > is where it turned up. + +3. **Retention — the peak IS the floor.** At `end`, live is 0 and region used is + still 135,168: everything was freed and nothing was returned. That is + mimalloc's design (a retired page is a reuse cache), and on a part where + nothing else can claim the SRAM it converts a transient peak into a permanent + cost. esp-alloc's free list coalesces back to one block. + +4. **Code size — +11,296 B of flash (+9.7 %).** Real, but flash is not the + scarce resource here. + +**What is NOT on this list, and why that is the answer.** esp-alloc is a +linked-list heap: its floor is *bytes live + per-allocation headers*, about +5.4 KiB here. rusty_alloc is a size-class page allocator: its floor is *bins +touched x slice size*, 106 KiB here, **whatever the workload's byte demand**. +Those are different functions, not the same function tuned differently. Lever 1 +is a genuine 31 % win and lever 2 a real geometric one, but closing a 20x gap is +not what they do — after both, the residue is the architecture, and the +architecture is buying page-local O(1) free lists and lock-free cross-thread +frees that a 4.9 KB single-threaded workload never spends. + +The corollary is the useful one: **rusty_alloc's page cost is roughly FIXED for +a given bin profile.** The same 13 slices serve 5 KB or 500 KB. The crossover is +where live bytes approach `bins x slice`; below it esp-alloc wins by +construction, above it the page allocator starts earning its keep. §0's reason +for wanting this on metal has to survive that sentence, or it does not survive. + +### 2.10 Hammering the levers — 192 KiB to 68 KiB (added by P4b) + +§2.9 ranked the levers. This is what happened when they were taken, each one +measured on the same XIAO ESP32-S3 with the same kill test. + +| | region required | on the board | +|---|---:|---| +| P4, as measured | 192 KiB | 128 KiB panics in `handle_alloc_error` | +| + lever 1, two-ended placement | **132 KiB** | passes, `free 0` at peak | +| + lever 2, 4 KiB slice | **68 KiB** | passes, `free 0` at peak | + +**−64.6 %.** `PEAK live bytes` is 4,914 at every step — the work-parity check +holds, so the allocator is still the only variable. The app image moved +48 +bytes across both changes (127,328 -> 127,376), which is to say the footprint +came out of geometry, not out of code that was deleted. + +**Lever 1 — two-ended placement in `prim::fixed`.** The 61,440 bytes were never +leaked; they sat on the free list, unusable because no `SEGMENT_SIZE`-aligned +request could start there. That makes it a PLACEMENT bug, not a leak. The rule +is now: a request that needs coarse alignment takes the bottom of the lowest +extent that fits, and a merely page-aligned one takes the TOP of the highest — +because requests that do not care about coarse alignment are the ones that can +move, so they are the ones that move. Shipped-code cost: one pure arithmetic +`fn place`, zero new unsafe. + +The test for it is in `prim::fixed`, and it took two goes to make it mean +anything. Written against a 512 KiB region it passed under the bug, because a +region that is an exact multiple of `SEGMENT_SIZE` cannot tell the policies +apart — a page off either end costs a segment either way. `K * SEGMENT_SIZE + +FIXED_PAGE` on an aligned base is the shape that discriminates, and it is the +shape the board actually has. Both halves were then poisoned separately: the +placement half reports offset 0 instead of `N - FIXED_PAGE`, and the reach half +reports 7 segments instead of 8. + +**Lever 2 — `SEGMENT_SLICE_SIZE` 8 KiB -> 4 KiB, `SLICES_PER_SEGMENT` 8 -> 16.** +`SEGMENT_SIZE` deliberately does NOT move: what mattered was pages-per-segment, +which went 7 -> 15. The measured effect was exactly the §2.9 arithmetic — +`pages_fresh` 10 -> 9, `segments` 2 -> **1**, `used` 135,168 -> 69,632. The +ninth-to-tenth page disappeared because bin 36 (4,096 B blocks) crossed +`MEDIUM_OBJ_SIZE_MAX` and became a large span, which for a workload holding one +at a time is strictly cheaper than a dedicated page. + +**Why 4 KiB is the floor, and how that was established.** A 2 KiB probe built +and passed 35 of 36 unit tests. What it failed was +`properties::usable_size_agrees_with_good_size`: `good_size(49_153)` promised +53,248 bytes while the 25-slice span delivered 51,200. `bins::good_size` answers +the large range with `os::page_align_up`, but the large path allocates EXACT +SLICES — so `usable_size >= good_size`, an ABI-visible promise, holds only while +**a slice is at least an OS page**. Every other geometry gets that free (a +64 KiB slice over a 4 KiB page), which is precisely why nothing wrote it down. +`good_size` is G2-pinned against the oracle, so the slice is the side that +moves. The invariant is now a `const _: () = assert!` in `prim/fixed.rs`, at the +one backend where the two can be tuned into conflict. + +**Two more the probes turned up, both from the same defect shape as P2's.** +`slice_pool::rejects_what_it_cannot_track` was the module's last +byte-denominated test: `MIB + 4096` was "misaligned" only while a slice was +8 KiB, and at 4 KiB it became slice-ALIGNED, so the test quietly *succeeded* in +freeing two ranges it exists to refuse — and, the pool being global first-fit, +took down three other tests instead of itself. `heaps.rs` reserved an arena of +`64 * 1024 * 1024`, which reads "two chunks" at 32 MiB segments and "2048 +chunks" — past `arena::MAX_CHUNKS` — at 32 KiB ones. Both are now written in the +unit the code actually counts. + +And one that was a genuine regression rather than a stale premise: dropping +`MEDIUM_PAGE_SLICES` to 2 alongside the slice lowered `MEDIUM_OBJ_SIZE_MAX` to +1,024 B, collapsing the binned range so that a burst of 2 KiB objects took a +whole slice each instead of sharing a page. `spans.rs` caught it. It is held +at 4. + +**Where this leaves the comparison.** 68 KiB is now *below* the 96 KiB the +esp-alloc arm is configured with — but that is the example's chosen number, not +esp-alloc's floor, and the honest comparison in §2.9 is unchanged: esp-alloc's +floor is bytes-live-plus-headers (~5.4 KiB here) and rusty_alloc's is +bins x slice. Levers 1 and 2 took 2.8x out of the gap. They did not, and could +not, close it. + +**Not taken.** Lever 3 (retention) does not move the number that sizes the +region — `used` is still the peak, and the peak is what the region must hold — +so it is worth doing only if something else on the part wants the SRAM back. +Lever 4 (flash) stands at +9.8 % over esp-alloc. + +**A hypothesis that died cheaply, recorded so it is not re-run.** +`slice_pool::FREE` is a bitmap over the whole 32-bit address space — +`1 << (32 - SLICE_SHIFT)` bits — which at the small profile's slice is 64 KiB of +BSS, bigger than the region. It costs nothing: every call site is +`#[cfg(all(target_arch = "wasm32", not(miri)))]`, so the linker drops the static +on every other target. Confirmed against the symbol table of the shipped +firmware, not argued from the source. + +### 2.11 Where the floor actually is (added by P4b, per-bin peak-live census) + +§2.10 stopped at 68 KiB. This section establishes that 4 KiB is the right slice +for a measured reason rather than a lucky one, and names the only lever left. + +`BINS` counted REQUESTS, which cannot say how much of a page is ever in use. A +page is sized for a whole size class, so the question is how many blocks of that +class are live **at once**. Adding a live/peak pair per bin answers it: + +``` +[bin] 1: block 4 B, PEAK live 1 = 4 B in a 4096 B page +[bin] 2: block 8 B, PEAK live 2 = 16 B in a 4096 B page +[bin] 4: block 16 B, PEAK live 4 = 64 B in a 4096 B page +[bin] 6: block 24 B, PEAK live 2 = 48 B in a 4096 B page +[bin] 8: block 32 B, PEAK live 1 = 32 B in a 4096 B page +[bin] 12: block 64 B, PEAK live 1 = 64 B in a 4096 B page +[bin] 14: block 96 B, PEAK live 1 = 96 B in a 4096 B page +[bin] 21: block 320 B, PEAK live 1 = 320 B in a 4096 B page +[bin] 22: block 384 B, PEAK live 2 = 768 B in a 4096 B page +``` + +**Nine pages — 36,864 bytes — holding 1,412 bytes at peak. 3.8 % occupancy.** +Not one class ever holds more than four blocks. The remaining 3,502 bytes of the +4,914-byte peak are the 4 KiB allocations, which are large spans sized to the +block and therefore not part of this waste at all. + +**Why 4 KiB is the floor and not merely where §2.10 stopped.** The two largest +small classes are 320 B and 384 B, and `SMALL_OBJ_SIZE_MAX = SEGMENT_SLICE_SIZE / 8`. +At a 4 KiB slice that ceiling is 512 B, so both land in *small* pages of one +slice — 8 KiB for the pair. Halve the slice and the ceiling falls to 256 B, so +both become MEDIUM allocations; and `MEDIUM_PAGE_SIZE` cannot fall with them, +because §2.10 already established (via `spans.rs`) that `MEDIUM_OBJ_SIZE_MAX` +must stay at 2 KiB, which pins a medium page at 16 KiB. Two medium classes +touched would cost **32 KiB** where the pair currently costs 8. Arithmetic from +the measured profile, not a measurement — but the direction is not in doubt, and +it is the same trap in the opposite direction from the one `spans.rs` caught. + +So 4 KiB is the largest slice at which this workload's biggest small class still +fits a small page, and the smallest at which `good_size` stays honest (§2.10). +Both walls were found by probing past them. + +**The only lever left, and why it was not taken.** Nine classes hold 1,412 +bytes. Coarsening the small bins — power-of-two classes instead of mimalloc's +four-per-octave — would collapse those nine pages to three or four, fit the +workload in a 32 KiB segment, and take the region to roughly **36 KiB**. It is +not taken because: + +1. `bins.rs` states, at the top of the file, that the size -> `good_size` + mapping **is** the ABI-visible contract and is G2-pinned against the oracle + binary. Every existing small-profile divergence (`SEGMENT_SIZE`, + `LARGE_OBJ_SIZE_MAX`) changes *routing*; none changes that mapping. This + would be the first, and that is a decision to take deliberately, not a + footprint optimisation to slip in. +2. It trades a **bounded** cost for an **unbounded** one. Page cost is fixed per + class touched; internal fragmentation is paid per live object. This workload + holds 10 tiny objects, so coarsening looks free — on a sample of one. A + workload with thousands of 24-byte nodes would pay up to 2x on every one. + +Recorded here with its number so the choice can be made on evidence. + +### 2.12 One dependency removed: `portable-atomic` off the no_std path (P4b) + +`options.rs` was the crate's last 64-bit-atomic user on a 32-bit target — +`VALUES` (an `i64` API frozen at v2.0.0) and `HEARTBEAT` (a C-ABI `u64`). Both +pulled `portable_atomic`'s **lock-based** fallback, whose `LOCKS` table measured +4,288 bytes in the shipped firmware. That is atomicity nothing can observe: the +crate already serves `no_std` only on single-threaded targets, which is what +`lib.rs`'s `SingleThreadCell`, `prim::fixed`'s constant thread id, and its +never-contended spin lock all rest on. + +Replaced with `split64`, two `AtomicU32` halves. **It adds no unsafe** — a struct +of `AtomicU32` is already `Sync`. With `std` on a 32-bit target the shim stays, +because there threads are real. + +| | .text | `.bss` symbols | `.bss` section | +|---|---:|---:|---:| +| `portable-atomic` | 84,907 | 136,423 | 201,996 | +| `split64` | 84,587 | 132,135 | 201,996 | +| | **−320** | **−4,288** | **0** | + +**The BSS win is smaller than it looks, and the honest number is zero.** The +4,288 bytes leave the symbol table — `LOCKS` is the *only* differing symbol — +but the `.bss` SECTION does not shrink, because esp-hal's linker script anchors +its end. The space becomes slack the application cannot claim. This was +predicted as a 4,288-byte SRAM saving and the prediction was wrong; the section +table said so. Keep the change for the dependency and the 320 bytes of flash, +not for RAM. + +**Two defects in the shim, both caught before it shipped.** Forwarding the +caller's `Ordering` to the halves aborts the firmware: `AtomicU32::load` rejects +`Release`/`AcqRel`, and `options::set_default` performs +`compare_exchange(.., AcqRel, ..)`. Orderings are now normalised (loads +`Acquire`, stores `Release`) with a test that passes exactly the orderings +`options.rs` uses; poisoned, it panics in `core`'s `atomic.rs`. And the test +itself first sat inside a module `cfg`-gated to the target that needs it, so it +could never run anywhere it would be run — the module now also compiles under +`test`, which is the only reason the ordering bug was caught at all. + +--- + +### 2.13 The speed comparison — what rusty_alloc is actually FOR (added by P4c) + +§2.9 through §2.12 measured footprint, where esp-alloc wins structurally. +Throughput is the other half, it is what a size-class page allocator with +per-class free lists exists to buy, and until P4c it was **unmeasured** — which +means every claim about it, in either direction, was an opinion. + +Same board, same one-source-two-arms harness, both arms given the SAME 192 KiB +(footprint and speed are different experiments: one asks for the least an +allocator can live on, the other must not let a budget difference masquerade as +a speed difference). Nanoseconds per allocate/free pair, best of 5: + +| workload | esp-alloc | rusty_alloc | speedup | +|---|---:|---:|---:| +| harness floor, no allocator call | 162 | 162 | — | +| 32 B alloc/free, one size | 1,638 | 625 | 2.62x | +| 64 mixed blocks (8-512 B), batch out then back | 1,792 | **844** | **2.12x** | +| **churn: 64 live, random 8-512 B, random replace** | 3,987 | **1,012** | **3.94x** | +| 2048 B alloc/free | 1,638 | **1,267** | 1.29x | + +All rows are NET of the 162 ns harness floor. **Superseded by §2.15**, which +re-measured them after P4e's reclamation fixes: the fixes cost 2.2-7.9 %, so the +shipping numbers are 2.56x / 2.08x / 3.83x / 1.20x. The churn row is the one that +matters: it is the shape real code has and the shape that fragments a first-fit +list, and it is where the gap is widest. The 2048 B row is the narrowest because +2 KiB is exactly `MEDIUM_OBJ_SIZE_MAX` at this geometry, so it lands in a medium +page rather than the small fast path. + +**Four guards, because an allocator benchmark is unusually easy to fake.** + +1. **The harness measures itself.** A baseline arm runs the identical loop, the + identical non-inlined `touch`, the identical four volatile accesses, and + never calls the allocator. It reported **162 ns/op in BOTH arms** — equal, as + it must be, since it shares every line. Subtracting it matters: unsubtracted, + the churn ratio reads 3.53x instead of 3.94x, because a constant added to + both arms always drags a ratio toward 1. +2. **The optimiser cannot delete the work.** Each block is written and read back + with `write_volatile`/`read_volatile` and folded into a checksum. Without + this an alloc/free pair is dead code and the benchmark times an empty loop — + the classic way to measure an allocator as infinitely fast. +3. **Work parity is proven.** Every checksum is printed and **every one matches + across the two arms** (25474400, 13944320, 9029440, 25474400). Both + allocators provably serviced the identical size sequence — the sizes come + from a seeded xorshift32, never from a clock. +4. **A null arm.** The same benchmark twice inside one arm reproduced to the + nanosecond in both arms (625/625, 1638/1638). Spread over 5 runs was <= 1 % + on every row. So the instrument's resolution is far below any gap claimed. + +**The first run of this benchmark was wrong, and the number said so.** It +reported 2,361 ns/op for a 32-byte alloc/free pair — about 570 cycles for a fast +path that should be tens. The cause was `esp_hal::Config::default()` leaving the +CPU at 80 MHz rather than 240; pinning `CpuClock::max()` moved every row by +almost exactly 3x, which is the confirmation that the clock, and not the +allocator, was what had been measured. An impossible number is the instrument +asking for help. + +**And the footprint side, measured rather than estimated.** §2.9 asserted +esp-alloc's floor as "~5.4 KiB (bytes live + headers)" from arithmetic. It has +now been measured by shrinking its heap until it fails: **esp-alloc runs the +same fs workload in 8 KiB**, against rusty_alloc's 68 KiB. So the honest pair is +**2.1-3.9x faster, at 8.5x the RAM** — and both halves belong in the README, +because a speed claim published without its cost is the kind of claim nobody +should believe. + +--- + +### 2.14 The stress battery — what actually breaks (added by P4d) + +Everything up to here measured a workload that WORKS. P4d asks the opposite +question: given adversarial content types, where does this thing fail? Eight +tests, on the board, both arms, every allocation null-checked so one failure +does not end the run. + +**It found a real defect, a real gap, and three structural limits — and it also +found a bug in itself first.** + +#### 0. The battery's own double free, caught by the allocator + +The first run aborted in `page::double_free_abort`. The cause was the harness: +a shared `PTRS` slot array that `exhaust_recover` populated and never cleared, +which `class_sweep` then iterated further than it had written, freeing stale +addresses. **rusty_alloc was right and the harness was wrong** — and the +README's "a double free aborts instead of corrupting" is now demonstrated on +silicon rather than asserted. + +#### 1. DEFECT (fixed): a forced collect could not reclaim a bin's last page + +`heap.rs`'s `collect_inner` discarded `force` (`let _ = force;`) and applied the +keep-one-page-per-bin exemption on EVERY path: + +```rust +if page_all_free(p) && !((*q).first == p && (*q).last == p) { +``` + +Upstream's `mi_heap_page_collect` frees an all-free page **unconditionally** at +`MI_FORCE`; the keep-one cache is `mi_page_retire`'s policy, not collect's. So +`mi_collect(true)` could never return a size class's slice to a different class. + +Invisible at the shipped 32 MiB geometry — 512 slices per segment absorb one +cached page per class. **Fatal at the small profile's 16.** Measured, at +192 KiB, as the battery touched more classes: + +``` +512 B capacity: 168 -> 104 -> 72 -> 56 -> 24 -> 24 -> 24 -> 8 +collect(true): 8 before, 8 after <- recovered NOTHING +churn: 22,533 of 50,000 allocations returned NULL + ... with 61,440 bytes of the region still free +``` + +With the fix (`force || !only_page_in_bin`): + +| | before | after | +|---|---:|---:| +| capacity recovered by `collect(true)`, 192 KiB | 8 -> 8 | **8 -> 240** | +| capacity recovered by `collect(true)`, 68 KiB | — | **8 -> 120** | +| NULLs in 50,000 churn ops, 192 KiB | 22,533 | **2,925** | +| segments ever created (i.e. releasable) | 2 | **3** | + +That last row matters on its own: segments could not be RELEASED before, because +a retained page pinned every one of them. + +Regression test: `heaps::forced_collect_reclaims_a_bins_last_page` asserts both +halves — an unforced collect keeps the cache, a forced one reclaims it. Poisoned, +it reports `retired 0 -> 0`. + +#### 2. GAP (found, not fixed): there is no automatic collect at all + +`generic_collect` is declared in `OPTION_NAMES` with a default of 10,000 and is +**never read anywhere in the crate**. The only `collect` callers are the two +public entry points and teardown. Upstream runs a collect every +`generic_collect` trips of the generic path. + +So the capacity that `collect` can now recover is never recovered on its own, +which is exactly why churn still fails: **2,925 NULLs of 50,000 at 192 KiB, and +24,853 at 68 KiB**, from a heap that a single `collect(true)` restores. + +Wiring it is the obvious fix and it is deliberately NOT taken here: it changes +behaviour on every platform, including the instruction counts this crate +publishes in its README, so it needs the callgrind harness rather than a board. +It is the top item in §6. + +#### 3. Structural: a whole-segment request needs a whole free segment + +Sizes 61,439 / 61,440 / 61,441 / 65,536 and `align = 65,536` return NULL at a +192 KiB budget with 61,440 bytes free, because none of that free space is a +contiguous `SEGMENT_SIZE`-aligned 64 KiB. A region yields +`floor((N - FIXED_PAGE) / SEGMENT_SIZE)` segments and strands the remainder. +**Size an embedded region as `k * 64 KiB + 4 KiB`**, or the tail is dead to +large allocations. + +#### 4. Structural: the `bins x page` floor, confirmed dynamically + +`class_sweep` held 21 of 24 distinct classes in 2 segments — and that is +arithmetic, not a defect: 30 usable slices, small classes cost 1 slice, classes +above `SMALL_OBJ_SIZE_MAX` (512 B) cost `MEDIUM_PAGE_SLICES` = 4. Nineteen small +plus two medium is 27 slices; a third medium needs 4 more and there are 3. It +stops exactly where §2.9's formula says it must. + +#### 5. **68 KiB is a workload-specific floor, not a general budget** + +At 68 KiB the battery holds **5 of 24 classes** and starts refusing 1 KiB +allocations at any alignment. §2.10's 68 KiB is the least memory that runs *the +fs sketch*, and nothing more should be read into it. The README says "smallest +heap that runs the same workload", which is precise, but the caveat is worth +stating out loud. + +#### What did NOT break + +With reclamation between tests (`--cfg ra_isolate`), `realloc_chain`, +`zalloc_dirty`, `fragmentation` and `exhaust_recover` all **PASS**, and capacity +holds flat at 240. Prefix preservation across a realloc chain that crosses every +routing boundary, re-zeroing of recycled dirty pages, 2 KiB requests over a +holed heap, and full capacity restoration after exhaustion are all sound. Every +failure above is capacity, not correctness. + +#### The esp-alloc control + +Same battery, same budget, esp-alloc: **every test PASS, capacity flat at 383, +no decay, no NULLs in churn.** A linked-list heap has no per-class page cache to +starve on. This is the honest counterpart to §2.13's speed table, and the two +belong together. + +--- + +### 2.15 The retest — both reclamation gaps closed, head to head (added by P4e) + +§2.14 fixed one defect and named two more gaps. P4e closes them and re-runs the +whole comparison against esp-alloc, stress and speed, on the board. + +#### What changed + +1. **The keep-one exemption is gone from `collect` entirely.** §2.14 gated it on + `force`, which was a partial port. Upstream's `mi_heap_page_collect` calls + `_mi_page_free` whenever `mi_page_all_free(page)` at EVERY collect level — + the comment is "this will free retired pages as well" — and the + keep-one-page-per-bin cache is `mi_page_retire`'s policy on the free path. +2. **`generic_collect` is wired.** Declared with a default of 10,000 and read by + nothing; now a per-heap countdown in the generic path, exactly as upstream. +3. **`malloc_generic` reclaims once before returning null.** This is the one + that actually mattered here, and measuring said so: the battery makes only + ~649 generic trips in total, so a 10,000 threshold never fires. A page + allocator can be "full" while holding empty pages for classes nobody is + asking for, and reporting OOM in that state is wrong. Costs nothing on the + happy path — it runs only when the allocation was about to fail. + +#### Stress, head to head at 192 KiB + +| test | esp-alloc | rusty BEFORE | rusty AFTER | +|---|---|---|---| +| boundaries (14 sizes, every routing edge) | PASS | FAIL | **PASS** | +| alignments (8 B .. 64 KiB) | PASS | FAIL | FAIL (64 KiB only) | +| realloc chain 8 B -> 32 KiB, prefix held | PASS | FAIL | **PASS** | +| zalloc over dirtied pages | PASS | FAIL | **PASS** | +| fragmentation adversary | PASS | PASS | PASS | +| exhaust and recover | PASS | PASS | PASS | +| distinct classes held at once | 24 | 9 | **21** | +| NULLs in 50,000 churn allocations | 0 | 22,533 | **575** | +| 512 B capacity across the battery | flat 383 | **168 -> 8** | **flat 240** | + +**The capacity ratchet is gone.** It no longer decays at all. Churn failures are +down 97.5 %. + +The two remaining refusals are the documented structural floor, not defects: +a `SEGMENT_SIZE`-aligned request needs a whole free 64 KiB segment and only +61,440 bytes remain contiguous (§2.14.3); and 21-of-24 classes is exactly what +30 usable slices hold once classes above `SMALL_OBJ_SIZE_MAX` cost +`MEDIUM_PAGE_SLICES` = 4 each (§2.14.4). + +#### Speed, and what the fixes cost + +| workload | esp-alloc | rusty BEFORE | rusty AFTER | speedup now | +|---|---:|---:|---:|---:| +| harness floor | 162 | 162 | 162 | — | +| 32 B alloc/free | 1,638 | 625 | 639 | **2.56x** | +| 64 mixed, batched | 1,792 | 844 | 863 | **2.08x** | +| churn 64 live, 8-512 B | 3,987 | 1,012 | 1,042 | **3.83x** | +| 2048 B alloc/free | 1,638 | 1,267 | 1,367 | 1.20x | + +**The fixes cost 2.2-7.9 % of throughput.** Worth it: the alternative is an +allocator that reports OOM while hoarding reclaimable memory. The esp-alloc arm +reproduced to the nanosecond across sessions (same floor, same checksums), so +the deltas are the allocator and not drift. README and crate README are updated +to these numbers — the old ones are no longer what the code does. + +#### The host test that asserted nothing, twice + +The regression test for the reclaim-and-retry passed **with the fix removed**, +in two successive versions: + +1. A 4-chunk arena at the shipped geometry is 128 MiB; 48 cached pages cannot + starve it. Gated to `ra_small_profile`, where the cache is scarce. +2. Still passed: the threshold was `served > 64`, and the poisoned arm serves + **128**. Both arms sat above it. + +Fixed by measuring both arms first and putting the threshold between them — +240 with the fix, 128 without, assert `> 192`. A threshold picked before the +arms are known is a guess, and a guess that lands outside the interval asserts +nothing. `parameterizing-a-constant` §4 in one sitting, twice. + +#### One more upstream mechanism we do not have + +`mi_page_retire`'s `retire_expire` countdown — a retired page freed after N +further generic trips — is **not implemented**. It is why cached pages +accumulate rather than ageing out. Not needed now that collect and the +failure-path reclaim work, but it is the principled version and belongs on the +list. + +--- + +### 2.16 The production-readiness pass (added by P5) + +Six blockers were named when the question "is this commercial ready?" was asked +against the state after P4e. This is what closing them cost. + +**1. CI gated none of this work.** `ci.yml` built wasm but never set +`ra_small_profile`, never passed `--no-default-features`, and never targeted a +chip — so every defect P0-P4e found would have sailed through. A new `embedded` +job now runs the small-profile suite, clippy on both the small profile and +`no_std`, and builds BOTH bare-metal RISC-V targets at BOTH geometries, plus +`rusty_alloc-api`. Xtensa stays out because it is not a stock rustup target; the +board runs are evidence, not a gate. + +**2. The release state was incoherent.** `Cargo.toml` said `1.1.5`, the README +said `1.1.4`, tags stopped at `v1.1.4`, and a commit was titled +`chore: release v2.0.0`. The truth: release-plz titled the PR after +`rusty_alloc_api`'s major bump while the workspace went to 1.1.5. README now +states what is released and that `main` is ahead of it, and the CHANGELOG's +`[Unreleased]` section documents every fix and addition from this campaign so +the next release notes are true. + +**3. The published instruction counts were stale.** They were measured at +`v1.1.5` and predate every reclamation change. `bench/icount-arms.sh` already +regenerates every column; nothing ran it. A scheduled `icount` CI job now does, +and the README carries provenance plus the explicit warning that the ratios are +a floor rather than the number until it is re-run. + +**4. Vacuous tests.** Four tests in this campaign passed under the exact bug +they existed to catch. `tools/gate-selftest.sh` now reintroduces five defects +and requires the suite to go red for each; it runs in CI beside the semgrep +selftest and the unsafe census. A gate that stays green with its defect present +is reported as VACUOUS by name. + +**5. `retire_expire`.** Upstream gives each retired page a countdown so a sole +empty page ages out after ~16 generic trips. Implementing it needs a +retired-bin range on the heap, and `alloc::retire_or_abort` is deliberately +written to decide keep-one-warm from the page's own links so it never resolves +the heap — a measured optimisation this machine cannot re-profile. Since +`collect` now reclaims a bin's last page, a shorter sweep period buys the same +ageing without touching that path. Swept on the board: + +| `generic_collect` | churn NULLs / 50,000 | ping | batch | churn | large | +|---:|---:|---:|---:|---:|---:| +| 10,000 (upstream) | 575 | 639 | 863 | 1,042 | 1,367 | +| **512 (shipped)** | **357** | 640 | 871 | 1,069 | 1,380 | +| 64 | 334 | 652 | 867 | **1,168** | 1,473 | + +64 costs 12 % of churn throughput to buy 23 fewer failures; 512 costs ~1 % and +buys 218. The default is now geometry-aware — 10,000 at the shipped geometry, +512 at the small profile — and upstream's per-page countdown stays unimplemented +and recorded in §6. + +**6. The `no_std` single-thread footgun.** Three things were sound only because +there is one thread — `SingleThreadCell`'s `unsafe impl Sync`, `prim::fixed`'s +constant thread id and spin lock, and `options`' split 64-bit atomics — and all +three fail quietly rather than loudly. A doc comment is not a guard: `no_std` +now refuses to compile without `--cfg ra_single_threaded`, and CI asserts the +gate's negative case. + +**And one defect the pass created and caught.** Python's text-mode write +converts `\n` to `\r\n` on Windows, so every file rewritten by a helper script +this campaign flipped LF -> CRLF. The content diff was 85 lines; the diff git +showed was 4,043. Normalised back to LF across 23 files, and the tell was +`git diff -w` disagreeing with `git diff` by two orders of magnitude. + +--- + +## 3. The phases, cheapest disqualifier first + +Each phase ends in a kill test. A phase that fails its kill test ends the +plan with a ledger row, and that is a result. + +### P0 — does it compile at all? (host, hours) — ✅ **DONE 2026-09-07** + +Add `riscv32imac-unknown-none-elf` to the toolchain file and build the core +crate for it. Do not fix anything; collect the error list. + +**Kill test:** a complete, categorised list of what fails, checked against +§2. If the list is materially larger than the four walls above, this plan is +wrong and needs rewriting before any code moves. + +**Verdict: PASSED — the plan stands, amended.** Full numbers and method in +[`docs/LEDGER.md`](../LEDGER.md). 224 errors reported, **183 of them one +root** (no `#![no_std]` ⇒ no prelude); the real debt is **55 in four +buckets**: + +| bucket | errors | wall | +|---|---:|---| +| `prim` has no backend arm for this target | 16 (1 cause) | §2.4 ✅ | +| explicit `std::` paths | 10 | §2.3 ✅ | +| 64-bit atomics | 5 | §2.2 ✅ | +| alloc-dependent text | 13 | **§2.5, new** | +| cascade from the first two | 11 | — | + +Three walls confirmed, one (§2.1) shown to be **unmeasurable by this phase**, +one new wall found and it is cheap, and §1's central premise found false. +That is five corrections, not a rewrite. **Nothing was fixed**; the only +change kept is the target line in `rust-toolchain.toml`. + +Reproduce, including the one-line probe that separates the root from the +cascade (applied, measured, reverted): + +```sh +rustup target add riscv32imac-unknown-none-elf --toolchain 1.97.1 +cargo build -p rusty_alloc --target riscv32imac-unknown-none-elf # 224 +# then, temporarily, `#![cfg_attr(ra_p0_probe, no_std)]` atop lib.rs: +RUSTFLAGS="--cfg ra_p0_probe" \ + cargo build -p rusty_alloc --target riscv32imac-unknown-none-elf # 55 +``` + +**Read this before P1:** the 224 was within one step of tripping this +phase's own kill test ("materially larger than the four walls") on an +artifact. A count dominated by a single root measures the root, not the +program — the same rule this repository already applies to timings. + +### P1 — the memory seam (host) — ✅ **DONE 2026-09-07** (2 of 3 kill-test items; the third is blocked on P2) + +Introduce the primitive-memory seam and implement it twice: the existing +platform path, and a fixed-region path. Nothing else changes. + +**Kill test:** the whole existing battery passes unchanged on the host, the +benches move by less than the harness's own null-arm floor, and a new test +builds an arena over a static 512 KiB region and serves allocations from it. + +**What landed:** `crates/rusty_alloc/src/prim/fixed.rs` — a first-fit, +coalescing free list of at most 32 extents over a `&'static mut [u8]` handed +over once — and the fifth arm in `prim/mod.rs`. Always compiled, selected only +where no platform arm matches. **Zero unsafe dereferences added to the shipped +crate** (6 `unsafe fn` signatures with safe bodies, 11 in tests; `UNSAFE.md` +updated, ratchet re-baselined 864 → 881). Full numbers in +[`docs/LEDGER.md`](../LEDGER.md). + +| bucket | P0 | P1 | +|---|---:|---:| +| A. `prim` has no backend for this target | 16 | **0** | +| B / C / D / E | 39 | 39 (**+0** each) | +| **total** | **55** | **39** | + +**Kill test, item by item:** + +1. ✅ **Battery unchanged** — 33 suites / 103 tests / 0 failed, clippy + `-D warnings` clean, `fmt --check` clean, unsafe ratchet OK, `wasm32` still + builds and still picks its own arm. +2. ✅ **Benches** — met more strongly than asked. The shipped cdylib is + identical on every deterministic quantity: **size delta 0** (212,992 both + ways) and **export set 316/316 identical**. There is no delta for a bench to + resolve. Note for whoever tries this next: `sha256` of the artifact is + **inadmissible** on Windows — a null arm (same source, built twice) produced + different hashes, because a PE embeds a build timestamp. +3. ⚠️ **BLOCKED on P2, and the mechanism is exact.** + `arena::arena_register` computes `chunks = size / SEGMENT_SIZE` and returns + `Err` when `chunks == 0` — so **every region below 32 MiB is refused by + arithmetic**, and no arena can be built over 512 KiB until the geometry is a + parameter. The *seam* half is done and proven at 512 KiB; the *arena* half + moves to P2's kill test. This item was written before §2.1's shape was + known; it is deferred, not redefined. + +**§2.1 is executable now.** P0 recorded that the wall ranked first has no +compile-time signature. It has a runtime one: on a registered, entirely free +512 KiB region, both a `SEGMENT_SIZE`-sized request and a one-page request at +`SEGMENT_SIZE` *alignment* are refused, and both leave the free list untouched. +The alignment half is the one no larger region fixes — it is P2's real subject. + +**Carry into P2:** fold §2.5 in (it is deletion, not a port), and delete +`rusty_alloc_ffi/src/lib.rs:9`'s false "the core crate is no_std" comment. + +### P2 — the geometry (host) — ✅ **DONE 2026-09-07** + +Make the segment size a compile-time parameter and follow it everywhere the +mask, the slice count, `LARGE_OBJ_SIZE_MAX` and the size classes assume 32 +MiB. Add a small profile sized for a chip. + +**Kill test:** the default profile stays byte-identical where the doctrine +demands it, and the small profile passes the full battery, the fuzz corpus +and the oracle on the host at 512 KiB of arena. This is the phase most likely +to be abandoned, and abandoning it here costs nothing on a board. + +**Inherited from P1** — the item P1 could not reach: *an arena built over a +static 512 KiB region, serving allocations.* P1 proved the seam at that size; +`arena::arena_register`'s `chunks = size / SEGMENT_SIZE` is what refuses it, +and that line is this phase's subject. The two assertions at the end of +`prim::fixed::tests::serves_and_recycles_a_static_region` are the standing +before-picture: when P2 lands, the *alignment* one is what has to change +behaviour, and it should be re-read rather than deleted. + +**Sizing, from the chip rather than the datasheet** (§2.1): the small profile +is aimed at **64–220 KiB**, with 96 KiB the number to design against — that is +what `blink-fs`, P4's own target, declares. Note also §2.6: `segment_map::MAP` +is 1 MiB of BSS independent of `SEGMENT_SIZE`, so parameterising the geometry +alone does not make the crate fit. Both have to move in this phase. + +**Verdict: PASSED.** Full numbers in [`docs/LEDGER.md`](../LEDGER.md). + +- **The geometry is a `--cfg`.** `ra_small_profile` selects + `SEGMENT_SLICE_SIZE` 8 KiB / `SLICES_PER_SEGMENT` 8 / `MEDIUM_PAGE_SLICES` 4 + — a **64 KiB segment**. A `--cfg` and not a cargo feature on purpose: + features are additive and unify across a dependency graph, so two consumers + wanting different geometries would silently get one of them. The + DELIVERABLE sets it, the way a Janus firmware picks its chip. +- **Default profile:** 33 suites / **104 tests** / 0 failed; the shipped + artifact unchanged in structure (212,992 bytes, 316 exports). Not a + byte-identity claim — PE size at 4 KiB alignment is coarse, and the + instruction counts need the Linux callgrind harness. +- **Small profile:** 19 suites / **84 tests** / 0 failed. clippy + `-D warnings` clean on both. +- **§2.6 needed a third representation, not a smaller one.** The bitmap is + sized by ADDRESS SPACE, so a smaller segment makes it *worse* (2²³ → 2³² + bits). Replaced for this profile by an exact 64-entry range table — **1 KiB + of BSS against 1 MiB** — beside wasm's base table. `ADDR_BITS` is derived + now too, which shrinks the map on any 32-bit target. +- **P1's inherited item is DONE**: `prim::fixed`'s region test is two-sided, + and at the small profile it asserts a whole segment, at segment alignment, + served from a 512 KiB region. +- **Found on the way:** §2.7 — a real escape from the exclusive-arena API, + present in the shipped build. Fixed, with a default-geometry regression test. + +**What did NOT come free:** nine tests were pinning the geometry, in three +flavours — a literal where the unit is slices, a literal offset inside the +segment, and field-report fixtures that name specific MiB rows. The first two +were derived; the third is gated to the shipped geometry, because +re-expressing a report's rows in slices keeps them green while testing nothing +the report said. + +### P3 — atomics and threads (host) — ✅ **DONE 2026-09-07** + +Decide `portable-atomic` versus narrowing, per site, with the reason recorded +per site. Add the single-heap profile. + +**Kill test:** `cargo build` succeeds for `riscv32imac-unknown-none-elf` and +for the Xtensa target the Janus toolchain provides, and the host battery is +unchanged. + +**Verdict: PASSED, on every target and both geometries.** Full numbers in +[`docs/LEDGER.md`](../LEDGER.md). + +| target | geometry | result | +|---|---|---| +| `riscv32imac-unknown-none-elf` | default + small | **builds**, debug and release | +| `riscv32imafc-unknown-none-elf` | default + small | **builds** | +| `xtensa-esp32s3-none-elf` (esp toolchain, `-Z build-std=core`) | default + small | **checks clean** | +| x86-64 host `--no-default-features` | — | builds | +| `wasm32-unknown-unknown` | — | builds | + +Host battery **unchanged and larger**: 33 suites / **105 tests** / 0 failed; +small profile 19 / 85 / 0; clippy `-D warnings` clean on the default, +small-profile and `no_std` configurations. + +- **Atomics, per site with the reason** — §2.2's table. Four narrowed (the + bitmaps and the seed counter, whose widths are choices), two shimmed with + `portable-atomic` (the frozen `i64` option API and the `u64` C-ABI + heartbeat, whose widths are contracts). The shim is target-gated, so the + crate remains **dependency-free on every target it ships to today**. +- **The single-heap profile** — §2.3. `ra_thread_local!` is + `std::thread_local!` with `std` and a plain `static` without: one heap, no + TLS lookup, a *shorter* fast path. +- **§2.5 folded in**, as §6 asked, keeping the `out_fmt` hook seam alive. +- **A third instance of P0's bucket A**: `random::os_entropy` and + `stats::process_info` also selected four ways with no default arm. Both fixed. +- **`rusty_alloc_ffi/src/lib.rs:9`'s false comment is now true** — the core + crate really is `no_std` (behind the default `std` feature). + +### P4 — on a chip (bench) — ✅ **PASSED 2026-09-07, on a XIAO ESP32-S3 Sense** + +Declare it in the Janus `blink-fs` example, which is bare metal, has no +radio, and already passes a kill test on a board: it mounts a LittleFS +partition, reads a config file, and blinks at the rate that file sets. +Swap `esp-alloc` for this allocator and run that same test. + +**Kill test:** the board prints the filesystem geometry, the config file and +the page title, and blinks at the file's rate — with this allocator +underneath. Then a number: heap high-water for the same workload, against +`esp-alloc` as the baseline, by the measurement rules (pinned, interleaved, +counters before clocks). + +**Verdict: PASSED.** Board: esp32s3 rev v0.2, 8 MB flash, MAC +68:ee:8f:51:74:64, on COM4. Harness: `espino run --board xiao-esp32s3-sense +--expect`, which packs the filesystem, images, fits, flashes and monitors. + +``` +[heap] arm: rusty_alloc +littlefs 2.0: 1261 blocks of 4096 +config.json: { "blink_ms": 250, "greeting": "hello from data/config.json" } +index.html: 318 bytes, title "blink-fs" +blinking GPIO21 every 250 ms +``` + +250 ms is the value read FROM the file, not the 500 ms fallback — which is what +makes the line evidence rather than decoration. **Not confirmed here: the LED +itself.** The firmware reports the interval it read; nobody's eye is on the +board from this session, and the espino ledger's own P1 row says "confirmed by +eye" for a reason. + +The numbers are §2.8. Three things about the method are worth carrying: + +- **The control ran first, and caught a stale board.** The unmodified example + flashed to this XIAO reported "no filesystem at the record's partition" and + blinked at the fallback — no LittleFS image was on it. Every later reading + would have been ambiguous. +- **Neither allocator's own stats could answer the question.** + `esp_alloc::HEAP.used()` read **0 at every stage** because the file buffers + drop before each sample — a clean number measuring nothing — and its + `max_usage` is behind a feature `rusty_alloc` has no counterpart for. The + peak is therefore measured OUTSIDE both, by one wrapper present in both arms. +- **P3's LTO caution is discharged, not assumed.** Both arms report an + identical PEAK and rusty_alloc's region consumption moves 0 → 132 KiB, so + both allocators are provably reached. + +Artifacts: `janus/espino/examples/blink-fs-p4` — one source, both arms, the arm +chosen by `--cfg ra_arm_rusty`. It is a copy, so the pristine `blink-fs` +example is untouched. + +### P5 — the ESP-IDF track, honestly halved (bench) + +On the ESP-IDF track the framework's C calls its own capability-aware +allocator directly, because it must ask for memory that is internal, or in +external RAM, or reachable by DMA. A Rust global allocator governs Rust's +allocations and nothing else. + +So the claim here can only ever be "the Rust half is ours". Declare it in a +generated firmware beside the IDF heap and prove the device still boots, +joins a network and streams. + +**Kill test:** a generated camera firmware runs its existing cell kill test +with this allocator serving the Rust half, and the ledger row says plainly +which half that is. + +--- + +## 4. Non-goals + +Replacing ESP-IDF's C heap. It is capability-aware in ways a Rust global +allocator has no vocabulary for, and the framework calls it directly. + +DMA-capable or PSRAM-placement policy in v1. A chip allocator that must +answer "give me memory the DMA engine can reach" is a different contract, and +`esp-alloc` does not answer it either. + +Any claim that a device has no C in it. It does: on the ESP-IDF track a +firmware compiles over a thousand C translation units, and on both tracks the +second-stage bootloader is C and the radio is a closed binary. This plan +changes who hands out heap memory, nothing more. + +Interrupt-safety guarantees beyond what the current lock discipline gives. +If allocation from an interrupt handler is wanted, that is its own plan. + +--- + +## 5. Open questions + +~~Whether the small profile is the same allocator or a different one wearing +the name.~~ **ANSWERED by P2 — it is the SAME allocator.** No allocator code +path is forked: the free path, the page queues, span carving, the cross-thread +protocol, the arenas and the bins are the shipped code running on different +constants. The only per-target divergence is the segment map's representation, +which already had two (native bitmap, wasm base table) and now has three. One +`--cfg` selects three constants. There are not two architectures in this crate. + +Whether the win is real. `esp-alloc` is simple and correct; the case for +replacing it rests on the double-free abort and on one codebase across the +portfolio. If P4's numbers show a materially larger footprint on a part with +512 KiB, the safety argument has to carry the whole weight on its own, and it +may not. + +> **P4 (2026-09-07) SETTLES the measurement half, on silicon.** The workload's +> true demand is **4.9 KB**. esp-alloc serves it from 96 KiB; rusty_alloc needs +> **192 KiB — 2x the region, 37 % of the S3's entire SRAM — and +9.7 % of app +> flash** (§2.8). So the answer to "if P4's numbers show a materially larger +> footprint, the safety argument has to carry the whole weight" is: **they do, +> and it must.** What is now decidable is whether a double-free abort on a +> device that parses a radio, a UART and a flash partition is worth 96 KiB of +> SRAM and 11 KB of flash — a judgement, not an unknown. Two things move the +> price if it is close: the first-fit alignment fix in §2.8 (a third of the +> region, cheap) and a smaller segment than 64 KiB (P2's geometry is a +> parameter now, and 64 KiB was chosen to keep `SMALL_SIZE_MAX` consistent, +> not because it is a floor). + +**P2 sharpens this rather than settling it, and honestly in both directions.** +Against: the dsp ledger's four-stage table shows a firmware whose heap is fully +committed at buffer time and never moves across eight kernels — a workload with +no allocation churn, where nothing this allocator's architecture optimises +applies, so the performance case is *nil* and the footprint case is uphill. +For: P2 found a real arena escape in a shipped safety-relevant API (§2.7) that +had been invisible for want of a workload that reached it, which is exactly the +argument for one codebase across the portfolio rather than a second small +allocator nobody exercises. + +~~Which of the five `AtomicU64` sites are correctness and which are +statistics. Unknown until read.~~ **ANSWERED by P0 — one correctness site +(`arena.rs`'s `used`/`dirty` chunk bitmaps), and it is a bitmap whose word +width is a free choice. See §2.2.** + +Whether the Xtensa fork toolchain can build this crate at all. The pinned +toolchain here is 1.97.1 with two x86_64 targets; Xtensa needs Espressif's +fork, installed separately, and its LLVM has bitten the Janus programme +before. + +--- + +## 6. What to do first + +~~P0, and nothing else.~~ **P0 through P3 are done (2026-09-07). The question +this plan exists to answer is answered: it is a PORT, and the code half is +finished.** P0 confirmed three walls, found a fourth invisible to itself, added +a fifth and falsified §1; P1 built the seam; P2 made the geometry a parameter +for two lines; P3 took the remaining 39 errors to **zero** and the crate now +builds for `riscv32imac`, `riscv32imafc` and Xtensa `esp32s3-none-elf`. + +**P4 is done and passed on a XIAO ESP32-S3** (§2.8), and **P4b took the two +footprint levers** (§2.9, §2.10): the region went 192 KiB -> 132 KiB -> **68 KiB** +for the same 4,914-byte workload, with `PEAK` identical at every step and the +kill test green throughout. Flash is unchanged (+48 B), so it still costs ++9.8 % of app image over esp-alloc. The plan's own open question in §5 is a +judgement rather than an unknown. + +**What is worth doing next, in order:** + +1. **Re-measure the host instruction counts** (§2.15). P4e wired + `generic_collect`, removed collect's keep-one exemption and added a + reclaim-and-retry on the generic path. On the board that cost 2.2-7.9 % of + throughput; the README's callgrind figures were taken before it and need + re-running under `LD_PRELOAD` before the next release. +2. **`retire_expire`** (§2.15) — upstream ages a retired page out after N + further generic trips. Not implemented. It is the principled version of what + the failure-path reclaim now does reactively. +3. **P5**, the ESP-IDF track's honest half — and note it can only ever claim + "the Rust half is ours". It is now the largest untouched thing in the plan. +4. **Retention** (§2.9 lever 3). `used` never falls: freed, not returned, by + mimalloc's design. It does NOT shrink the region — the peak is what the + region must hold — so this is worth doing only if something else on the part + wants the SRAM back. Scope it against a workload that has a second consumer, + or it is measuring nothing. +5. **Flash** (§2.9 lever 4), the only line where the gap has not moved. + +**Closed, do not re-open:** the alignment fix (done, §2.10) and "a smaller +segment" (done differently, and the old framing here was wrong). Segment size +was never the lever; the SLICE was, and it moved without `SMALL_WSIZE_MAX` +moving at all — `direct[]` is a page-pointer cache indifferent to page kind. +The real constraint is `SEGMENT_SLICE_SIZE >= os::page_size()`, which +`bins::good_size` needs and nothing had written down; it is now a const assert +in `prim/fixed.rs` and it puts the floor at 4 KiB. + +**Do not skip when re-measuring:** P3's carried caution is discharged for P4 +(both arms reported an identical PEAK, so both allocators were provably +reached) but it applies again to every new arm. + +**Do not carry the 224 into any conversation about scope** — it was 55 after +the prelude cascade, 39 after P1, and it is **0** now. diff --git a/docs/plans/wasm-size.md b/docs/plans/wasm-size.md new file mode 100644 index 0000000..a097e42 --- /dev/null +++ b/docs/plans/wasm-size.md @@ -0,0 +1,347 @@ +# wasm-size — what rusty_alloc costs a browser, what it cost twice, and what it buys + +**Source:** a GitHub message from someone integrating the crate, reporting +**+12 % on their gzipped wasm bundle**. Written 2026-09-08 against `main` at +2.0.0. + +**Status: MEASURED, HALVED, and the other half of the question answered (§8).** The allocator's gzipped overhead on a minimal +consumer went **+7,760 → +3,829 bytes**. A CI ratchet (`tools/wasm-size.sh`) +now fails the build if it grows again. + +--- + +## 1. The instrument, before any conclusion + +Size claims are cheap and usually wrong, so the arms were built the way an +integrator actually ships — not the way this repo profiles: + +```toml +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true +``` + +A minimal consumer allocates, frees, resizes and returns a number. **Arm A** is +the Rust default for `wasm32-unknown-unknown` (dlmalloc). **Arm B** adds one +line — `#[global_allocator] static A: RustyAlloc = RustyAlloc;` — and changes +nothing else. + +**The null arm matters more than usual here.** The first attribution pass +bucketed `core::fmt` as the allocator's cost. Profiling the BASELINE showed +2.5 KiB of that was the consumer's own, and only 406 bytes were ours — so a +change made on the strength of the first reading (removing `format!` from +`options`) measured **exactly zero**. Attribution is therefore a **set +difference** between the two modules' function tables, never a per-module bucket: + +``` +rusty_alloc ADDS 14,659 B of functions +rusty_alloc REMOVES 8,012 B of dlmalloc +net +6,236 B of code +``` + +Tooling is three short Python files in the scratchpad, not a dependency: the +wasm binary format is sections of `(id, LEB size, payload)`, the Code section is +a vector of size-prefixed bodies, and the custom `name` section maps function +index to name. That is enough to attribute every byte. + +## 2. What it actually was + +**`options::get` was the largest function in the whole module at 3,708 bytes — +larger than anything in the allocator proper.** + +`ensure_init` runs an environment pass. On `wasm32-unknown-unknown`, +`std::env::var` is a stub that always fails. So on every startup it ran 38 +iterations, built 76 `format!` strings, allocated 76 `String`s and called +`to_uppercase` on 38 option names, in order to read an environment that target +does not have. The `OPTION_NAMES` table and the formatting machinery shipped +with it. + +The `no_std` arm had already deleted this pass in P3 of `small-metal.md`, for +exactly the same reason — *there is nothing to read*. wasm was simply never +added to the condition. + +| | raw | gzip | overhead | +|---|---:|---:|---:| +| dlmalloc (Rust default) | 15,536 | 6,706 | — | +| rusty_alloc, as reported | 34,285 | 14,466 | **+7,760 gz** | +| + env pass gated off wasm | 26,105 | 10,766 | +4,060 gz | +| + single-`static` TLS on wasm | **25,734** | **10,535** | **+3,829 gz** | + +`target_os = "unknown"` and not `target_arch` alone: **wasm32-wasip1 has a real +environment and keeps the pass.** + +## 3. The second cut: TLS that can never be used + +`wasm32-unknown-unknown` has one thread unless the atomics+threads proposal is +enabled — an assumption `prim/wasm.rs` has carried since it was written. But +`ra_thread_local!` still expanded to `std::thread_local!` there, linking lazy +initialisation, destructor registration and the *"cannot access a Thread Local +Storage value during or after destruction"* panic path. That string was visible +in the data section. + +The macro now takes the same single-`static` arm `no_std` uses, gated on +`not(target_feature = "atomics")` — the precise switch that +`-C target-feature=+atomics` sets, so a threaded wasm build keeps real TLS. +−371 raw, −231 gzipped, and the `bench/wasm-selftest.mjs` waste gate still +passes in a real VM. + +## 4. Three things that were NOT the answer + +Recorded because each looked promising and each cost a measurement. + +- **`core::fmt`.** ~2.5 KiB in the module, and mostly the consumer's. Removing + `format!`/`eprint!` from `options` saved **0 bytes**. Kept anyway: it removes + an allocation from an error path and gives `no_std` back a message it used to + lose — but it is not a size lever and is not claimed as one. +- **The data section.** +4,124 bytes raw looks like a third of the problem, and + is **535 bytes gzipped** — 13 %. Most of it is `init::EMPTY_HEAP_BOX`, a + statically-initialised sentinel `Heap` whose `direct` table points at + `EMPTY_PAGE` (129 pointers ≈ the 504 non-zero bytes in a 2,124-byte segment). + It is what makes `malloc`'s fast path branchless; zeros compress to nothing, + so shrinking it would trade a hot-path branch for no download. +- **`wasm-opt -Oz`.** Cuts raw hard (26,105 → 21,874) and gzip barely at all + (10,766 → 10,598), because gzip already captures most of what it does. Worth + recommending for parse time and memory; it is not a download lever, and it + does not change the overhead ratio because the baseline shrinks too. + +## 5. Also found: your build paths ship in the artifact + +The data section carried absolute paths from panic locations — +`F:\coding\rusty_alloc\crates\...` and `C:\Users\\.rustup\...` — in every +published module. `--remap-path-prefix` fixes it on stable (Cargo's `trim-paths` +is still nightly as of 1.98). Worth ~29 bytes gzipped; the point is not leaking +a developer's username and directory layout to everyone who downloads the page. + +## 6. What is left, and what it is worth + +- **`heap::huge_alloc`, 1,332 B.** The >32 MiB path. Cold, but reachable; cannot + be removed. +- **`init::init_thread_heap`, 1,211 B.** Real setup work — page queues, the + direct table, the RNG — not thread machinery. +- **`heap::adopt_segment`, 656 B.** Reclaims segments abandoned by *other* + threads, so it is dead on single-threaded wasm. **Not taken:** if anything + ever does abandon a segment, skipping the reclaim leaks it, and 656 raw + (~200 gzipped) is not worth a leak. +- **Recommend to integrators**, in the README: `wasm-opt -Oz`, + `--remap-path-prefix`, and `opt-level = "z"` with `lto = "fat"`. + + +## 8. And what the bytes buy — speed, in a real VM + +Size was measured for two rounds before anyone asked the other half of the +question. **Is rusty_alloc faster than the allocator it replaces on wasm?** If +not, +3,829 gzipped bytes is indefensible at any size. + +Same discipline as the ESP32 harness: one source, `--cfg` picks the allocator, +a FLOOR arm that allocates nothing, volatile touches folded into a checksum the +optimiser cannot elide past, seeded size sequences, best-of-7, run under node, and `bench/wasm-speed/` so it is re-derivable. +Nanoseconds per allocate/free pair, net of the floor: + +| workload | dlmalloc | rusty_alloc | | +|---|---:|---:|---| +| **churn: 64 live, random 8-512 B** | ~71-78 | ~10-15 | **4.9-7.3x faster** | +| 2048 B tight alloc/free | ~9-14 | ~16-22 | 0.55-0.83x | +| 32 B tight alloc/free | 5.3-9.8 | 5.1-8.5 | within noise | +| 64 mixed, batched | 7.5-12.4 | 7.3-11.7 | within noise | + +**Only two of those four rows are claims.** Five repeats put churn at 4.92, +5.26, 6.57, 5.22 and 7.30, and 2048 B at 0.70, 0.65, 0.65, 0.83 and 0.55 — wide, +but never near 1.0 from either side, which is what makes them claims at all. +"~5x" is the bottom of the churn range, not its middle. The other two straddle 1.0 run to run and are reported as ranges +rather than ratios, because a harness with +/-25 % between-process variance +cannot resolve a 10 % effect and should not pretend to. + +**The floor caught a broken first measurement.** It reported 5.2 ns/op for +dlmalloc and 0.6 ns/op for rusty_alloc — for *identical* code that allocates +nothing. V8 tiers wasm up per code path, and only one branch had been warmed. +The harness now warms every branch and prints a warning if the two floors differ +by more than 25 %, because a floor that differs is a harness measuring itself. + +### Why 2048 B loses, and why that is not a bug to fix + +Instrumented rather than guessed: exported `alloc::stats().generic` and counted +slow-path trips per operation. + +``` +32 B 0.008 per op +churn 0.060 per op +64 mixed 0.034 per op +2048 B 1.000 per op <- every single allocation +``` + +Every 2 KiB allocation takes the generic path. `Heap::malloc` has a medium fast +path, but the `GlobalAlloc` entry reaches `malloc_slow`, which goes straight to +`malloc_generic` — and `alloc.rs` says exactly why, dated and measured: + +> **REFUTED 2026-08-21** — peeking the MEDIUM bin's queue front here is a large +> regression: `big` and `large` +25.00 Ir/op each [...] A tight alloc/free loop +> frees into `local_free`, so the queue front's `free` list is ALWAYS dry when +> the next allocation arrives — the peek can never hit. + +The benchmark row is precisely that shape: one live block, freed into +`local_free`, so `free` is empty on every allocation. dlmalloc wins it because a +boundary-tag allocator's free-then-alloc of one size is a list push and pop. +**The repo had already tried the fix and measured it worse.** Recorded here so +the third person to notice the row does not try it again. + +### And a second attempt at it, also reverted (2026-09-08) + +The refuted experiment added a *peek*. A peek cannot hit, because `free` is dry. +So the obvious next idea is to add the **collect** — swap `local_free` into +`free` before popping, which is the same swap `malloc_generic_walk` performs a +few lines later, and is cheap in the common case (a local list swap plus one +acquire load; `page_collect` peeks before entering its exchange loop). Guarded +on `size <= MEDIUM_OBJ_SIZE_MAX`, so it cannot reproduce the refuted version's +`big`/`large` +25 Ir/op. + +It works, and it is still not worth it: + +| workload | effect, both orders agreeing | +|---|---| +| 2048 B tight loop | **~7 % faster** (1.08x and 1.07x) | +| **32 B tight loop** | **~3-4 % SLOWER** | +| churn, batched | orders disagree — noise | + +**Reverted in that form** -- 32 B is the commonest allocation there is, and +paying the hottest path to narrow a microbenchmark that stays lost is the wrong +trade. + +### Then GATED — and then reverted, on the contention it hid (2026-09-08) + +A change with a `+` and a `-` outcome is an invitation to find the predicate +that separates them. The predicate was in the counters: 2 KiB reaches +`malloc_generic` on **1.000** of its calls and 32 B on **0.008**. + +That ratio is ROUTING, not list state — which is what both earlier explanations +assumed, including the 2026-08-21 refutation's. `alloc::malloc` serves +`size <= SMALL_SIZE_MAX` from the direct table and tail-calls `malloc_slow`, +which goes straight to `malloc_generic`; `Heap::malloc`'s medium branch is on a +different entry point and is never reached through `GlobalAlloc`. Gating the +retry to `size > SMALL_SIZE_MAX && size <= MEDIUM_OBJ_SIZE_MAX` removed the +payer and grew the win: + +| workload | ungated | band-gated | +|---|---|---| +| 2048 B tight loop | +7 % | **+15 %** (wasm), **+15-19 %** (native) | +| 32 B tight loop | -3 to -4 % | no effect | +| churn, batched | noise | no effect | + +**And then it was reverted, because every one of those numbers is +single-threaded.** `page_collect` loads `xthread_free` — the line a remote +`free` pushes onto. wasm cannot show contention there (one thread by +construction) and neither could the native benchmark. So it was built: a +producer allocates a batch of 2 KiB blocks, a consumer thread frees THAT batch +while the producer is timed allocating another, so remote frees land on the +producer's own pages with no handshake anywhere in the timed loop. + +| workload | retry OFF | retry ON | | +|---|---:|---:|---| +| **producer alloc, consumer freeing its pages** | ~51-75 ns | ~71-77 ns | **0.67-1.00x** | +| same, single-threaded control | ~21-32 ns | ~24-32 ns | no effect | + +Never better, usually 15-33 % worse, across two harnesses and eight runs. That +killed the UNCONDITIONAL form: cross-thread frees are the producer/consumer +shape (thread pools, channels, async runtimes) and mimalloc exists to serve +them. A gain on a single-threaded tight loop does not buy a 20-30 % loss +there. What survived is the gated form below. + +**The obvious fix does not work, and that is the finding worth keeping.** The +first instinct was to do only the LOCAL half of the collect — swap `local_free` +into `free`, touch no atomic. It measured **worse** (0.67-0.77x): `free`, +`local_free` and `xthread_free` are adjacent fields of a `#[repr(C)]` `Page`, +so they share a cache line. Reading *any* of them pulls the line the remote +thread is invalidating. There is no version of "peek the queue front" that is +cheap while another core is freeing into that page — which is the mechanism the +2026-08-21 note observed the effect of without naming. + +### The predicate that works: ask the HEAP, not the thread count + +Built, because the contended harness was already in hand. `page_collect` now +reports whether it stole a cross-thread chain, and any steal this heap observes +latches a sticky `saw_remote_free`; the retry runs only while it is clear. + +**The predicate is not "is this program single-threaded".** It is "does THIS +heap receive remote frees", which is finer and more useful: a worker that owns +its allocations keeps the win however many threads the process has. + +| workload | before the latch | with the latch | +|---|---|---| +| tight 2 KiB loop, 1 thread | +15-19 % | **+14-16 %** (1.08-1.23, seven runs) | +| tight 2 KiB loop, 2 threads, each freeing its own | not measured | **+12-26 %** | +| producer alloc / consumer freeing its pages | **-20 to -33 %** | **neutral** (0.84-1.15) | + +The contended row straddles 1.0 with wide variance, so the claim is that the +regression is gone, not that contention got faster. + +**Latching on the retry's own steal was not enough**, and a second measurement +is what found it: the frees that hurt land on OTHER pages of the same heap, so +the page being retried never sees them and the retry never switched itself off. +The latch is set from `malloc_generic_walk`'s collects as well, which is where +those pages are actually reached. + +**And the harness had to be told about the latch.** Sticky is correct in +production and useless in an A/B: the contended case ran first in warmup and +left the retry off for every case after it, so the win read as 1.00x until the +latch was reset between timed runs. An instrument sometimes has to know about +the mechanism it is measuring. + +### Validated on every platform that can run it (2026-09-08) + +The retry was measured on native Windows and reasoned about everywhere else, so +it was run everywhere else. **The same one workload shape moves on all three +targets, and nothing else moves anywhere.** + +| target | 2 KiB tight alloc/free | every other row | +|---|---|---| +| native x86-64 (Windows) | **+14-16 %** (1.08-1.23, 7 runs) | no effect | +| wasm32 in V8 (node) | **+1-16 %** (1.01-1.16, 4 runs, both orders) | no effect | +| Xtensa ESP32-S3, `no_std`, small profile | **+15 %** (1,380 -> 1,200 ns/op) | within 1-2 % | + +The board is the cleanest of the three: its harness reports 0-1 % spread, and on +the small profile `SMALL_SIZE_MAX` is 512 B, so the other three rows (32 B, a +mixed batch of 8-512 B, and churn over 8-511 B) are all BELOW the band and the +retry provably cannot touch them. Their 1-2 % movement is layout, not effect. + +**"A win across the board" is the wrong phrase for it.** It is a win on one +workload shape -- medium sizes cycled through a tight alloc/free loop -- that +happens to hold on every target. Everything else is unchanged, and the contended +case is neutral rather than better. + +**Correctness, all green on the same build:** 109 tests / 33 suites default, +90 / 19 at the small profile, clippy `-D warnings` on default / small profile / +`no_std`, `riscv32imac` and `riscv32imafc` at both geometries, `rusty_alloc-api` +`no_std`, the wasm self-test's waste gate inside a real VM, and on hardware both +the 68 KiB footprint kill test (`used` 69,632, `PEAK` 4,914 -- unchanged) and +the eight-test stress battery (capacity flat at 240, same passes, churn nulls +458 against a 334-575 range already seen). Plus the three ratchets: unsafe +census, gate selftest 5/5, wasm size. + +**Still not covered, and worth saying plainly:** host INSTRUCTION counts under +callgrind, which is the instrument the README's figures use and which no +Windows box can run -- the scheduled `icount` job is the gate for that -- and +contention with more than one consumer thread. + + + +**Two harnesses, one lesson.** The first version of the contention benchmark +handed blocks over an SPSC ring and timed the producer loop; at ~113 ns/op it +was timing the ring's spin, not the allocator, and its numbers wandered (a +1.37x outlier beside two 0.95x). Removing the handshake from the timed path is +what made the effect readable. + +## 7. The gate + +`tools/wasm-size.sh` builds the fixture under `bench-dist` (the repo's `release` +keeps debug symbols, and a 2 MB artifact hides a 4 KB regression), gzips it, and +fails past a 3 % tolerance. Baseline in `tools/wasm-size-baseline.txt`, updated +with `--update` in the same commit as the change that moves it — the same +discipline as the unsafe census. + +Poisoned by restoring the env pass on wasm, it fires. + +**The lesson is the gate, not the bug.** Nothing measured wasm size, so a +3,700-byte-gzipped regression sat in the crate for its entire life and reached a +user before it reached us. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a68ca4c..104d6f0 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -3,4 +3,20 @@ # apart — build different compilers. Bump deliberately, with the gate battery. channel = "1.97.1" components = ["rustfmt", "clippy"] -targets = ["x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"] +targets = [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + # P0 of docs/plans/small-metal.md: the bare-metal RISC-V target the Janus + # C6/C61/H2 firmwares use. Added to collect the error list, not because the + # crate builds for it. + "riscv32imac-unknown-none-elf", + # The `imafc` variant the ESP32-P4 and the C6's HP core use. CI's `embedded` + # job builds both, and a target listed for the action but not here is one + # `rustup` will not have on a fresh machine. + "riscv32imafc-unknown-none-elf", + # The crate has shipped a wasm backend and a wasm gate since 2026-08-06, but + # the target was never listed here — so `wasm-gate` needed a manual + # `rustup target add` on every fresh machine. P1 needed it to prove its new + # backend arm had not captured wasm; listing it makes that check repeatable. + "wasm32-unknown-unknown", +] diff --git a/tools/corpus/README.md b/tools/corpus/README.md new file mode 100644 index 0000000..ea8cd57 --- /dev/null +++ b/tools/corpus/README.md @@ -0,0 +1,85 @@ +# The downstream corpus + +A major version is a promise that consumers can keep compiling, and **nothing +inside this repo can check it.** 2.0.0 makes two breaking changes that are +invisible from here and land on other people: + +- `default-features = false` now selects `no_std` (which additionally refuses to + build without `--cfg ra_single_threaded`). It used to be identical to the + default, because there were no default features. +- `heap::Heap` gained a field, and every field on it is `pub`. + +```sh +bash tools/corpus/run.sh # compile gate +bash tools/corpus/run.sh --test # + each consumer's test suite +``` + +Consumers are registered in `corpus.toml`. Add one the moment it takes a +dependency on this crate. + +## What it does + +Two arms per consumer. **BASELINE** is the consumer as it sits on disk, with its +pinned `rusty_alloc` from crates.io — it exists to prove the consumer was green +to begin with, so a red candidate can be blamed on us rather than on it. +**CANDIDATE** is a copy with every `rusty_alloc*` requirement rewritten to this +working tree. + +`[patch.crates-io]` cannot do this. A patch has to satisfy the original +requirement, and `=1.1.6` is not satisfied by `2.0.0`. Simulating an upgrade +therefore means rewriting the requirement, which is done in a **copy** — this +script never writes inside a consumer's checkout. + +## First run, 2026-09-08 + +| consumer | result | +|---|---| +| `spacedb-sdk` | PASS | +| `spacedb-sdk` (`secure`) | PASS | +| `rusty_alloc_default` | PASS | +| `rusty_zstd` | PASS | +| **`rusty_maplibre`** | **FAIL — broken by 2.0.0** | + +`rusty_maplibre` takes both `rusty_alloc` and `rusty_alloc-api` with +`default-features = false`, which is exactly the redefinition. It stops at the +`compile_error!` that 2.0.0 added. **The migration is one line per dependency +and it was verified here, not guessed:** + +```toml +rusty_alloc = { version = "2", default-features = false, features = ["std"] } +rusty_alloc-api = { version = "2", default-features = false, features = ["std"] } +``` + +With that applied to the copy, the `rusty_alloc` error is gone. + +## Four things this harness got wrong before it got anything right + +Recorded because each one made it lie, and a corpus that lies is worse than none +— it gets muted. + +1. **Tab is IFS whitespace.** The registry was tab-separated, bash collapses runs + of whitespace IFS, and an empty `features` field shifted every column after + it — so each consumer's *note* was passed to `--features`. All five baselines + went red and the report blamed the world. The delimiter is `|` now. +2. **A missing workspace root is not a red build.** `packages/spacedb/*` inherit + their sibling deps with `workspace = true` and mata-master carries no + manifest above them, so cargo could not parse the manifest at all. Reported + as SKIP with the reason, and then fixed properly: the crates inherit only + *dependencies*, never package fields, so a root is reconstructable. The + harness synthesises one **in the copy** and SpaceDB became testable. +3. **A red candidate is not automatically our fault.** Once `rusty_alloc` + compiled, `rusty_maplibre` failed on an unrelated `bytes` import. A candidate + whose error never mentions `rusty_alloc` is now reported UNRELATED, not FAIL. +4. **A resource-starved run misclassifies.** One full run hit + `fork: retry: Resource temporarily unavailable` and maplibre's real error was + replaced by `only metadata stub found for rlib dependency core` — which the + UNRELATED rule then swallowed. Re-running that consumer alone restored the + true FAIL. **If a run shows fork or cygheap errors, re-run the affected + consumer in isolation before believing its row.** + +## What it still does not do + +Compile gates only, unless `--test` is passed. It does not measure downstream +*performance*, so an allocator change that compiles everywhere and slows a +consumer down would pass this and want `bench/wasm-speed`-style A/B in the +consumer's own repo. diff --git a/tools/corpus/corpus.toml b/tools/corpus/corpus.toml new file mode 100644 index 0000000..599ac4e --- /dev/null +++ b/tools/corpus/corpus.toml @@ -0,0 +1,51 @@ +# The downstream corpus: crates that ship rusty_alloc to production. +# +# A major version is a promise that consumers can keep compiling. 2.0.0 makes +# two breaking changes -- `default-features = false` now selects `no_std`, and +# `heap::Heap` gained a field -- and neither is visible from inside this repo. +# The only way to know what they cost is to build the things that depend on us. +# +# `path` is absolute because these are sibling checkouts, not submodules; a +# consumer whose path is missing is SKIPPED and reported as such, never silently +# passed. Add a consumer here the moment it takes a dependency on this crate. +# +# `features`/`no_default` describe the configuration that actually SHIPS. There +# is no value in testing a shape nobody deploys. + +[[consumer]] +name = "spacedb-sdk" +path = "F:/coding/mata-master/packages/spacedb" +pkg = "spacedb-sdk" +synth = "yes" +# `rusty-alloc` is default-ON here on purpose: a SpaceDB replica often runs on a +# machine its operator does not control, so the unconfigured node is the +# hardened one. That makes this the shape in production. +features = "" +note = "rusty_alloc-api pinned =1.1.6, optional, default-on" + +[[consumer]] +name = "spacedb-sdk (secure)" +path = "F:/coding/mata-master/packages/spacedb" +pkg = "spacedb-sdk" +synth = "yes" +features = "secure" +note = "forwards rusty_alloc-api/secure -- guard pages + encrypted free lists" + +[[consumer]] +name = "rusty_alloc_default" +path = "F:/coding/rusty_alloc_default" +features = "" +note = "the #[global_allocator] shim every Remade UI crate installs; rusty_zstd reaches us THROUGH it" + +[[consumer]] +name = "rusty_zstd" +path = "F:/coding/rusty_zstd" +features = "rusty-alloc" +pkg = "rusty_zstd" +note = "depends via rusty_alloc_default, not directly" + +[[consumer]] +name = "rusty_maplibre" +path = "F:/coding/rusty_maplibre" +features = "" +note = "KNOWN RISK: takes rusty_alloc AND rusty_alloc-api with default-features = false, which is exactly what 2.0.0 redefines" diff --git a/tools/corpus/run.sh b/tools/corpus/run.sh new file mode 100644 index 0000000..9a5a4c0 --- /dev/null +++ b/tools/corpus/run.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# Build the crates that ship this allocator, BEFORE and AFTER the upgrade. +# +# A major version is a promise that downstreams can keep compiling, and nothing +# inside this repo can check it. 2.0.0 redefines `default-features = false` to +# mean `no_std` (which additionally refuses to build without +# `--cfg ra_single_threaded`) and adds a field to `heap::Heap`. Both are +# invisible here and land on consumers. +# +# BASELINE the consumer exactly as it is on disk -- its pinned rusty_alloc +# from crates.io. Establishes it was green to begin with, so a red +# CANDIDATE can be blamed on us rather than on it. +# CANDIDATE the same consumer with every rusty_alloc* requirement rewritten +# to this working tree. +# +# `[patch.crates-io]` CANNOT do this: a patch has to satisfy the original +# requirement, and `=1.1.6` is not satisfied by 2.0.0. Simulating the upgrade +# therefore means rewriting the requirement, which is done in a COPY -- this +# script never writes inside a consumer's checkout. +# +# bash tools/corpus/run.sh # compile gate (fast) +# bash tools/corpus/run.sh --test # compile + that consumer's test suite +set -uo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +work="${TMPDIR:-/tmp}/ra-corpus" +mode="check" +[ "${1:-}" = "--test" ] && mode="test" + +ALLOC="$root/crates/rusty_alloc" +API="$root/crates/rusty_alloc_api" + +pass=0; fail=0; skip=0 +declare -a ROWS + +# Rewrite every rusty_alloc* requirement in a tree to point at this checkout. +# Handles the three shapes seen in the corpus: a bare version, a version with +# `default-features`, and a workspace-level entry. +repoint() { + local tree="$1" + find "$tree" -name Cargo.toml -not -path "*/target/*" -print0 | + while IFS= read -r -d '' f; do + python3 - "$f" "$ALLOC" "$API" <<'PY' +import re, sys +path, alloc, api = sys.argv[1], sys.argv[2], sys.argv[3] +s = open(path, encoding='utf-8').read() +orig = s +# `name = { version = "...", ... }` -> keep the rest, swap in a path +def patch(m): + name, body = m.group(1), m.group(2) + tgt = api if 'api' in name else alloc + body = re.sub(r'version\s*=\s*"[^"]*"\s*,?\s*', '', body) + body = body.strip().strip(',').strip() + inner = f'path = "{tgt}"' + (f', {body}' if body else '') + return f'{name} = {{ {inner} }}' +s = re.sub(r'^(rusty[_-]alloc(?:[_-]api)?)\s*=\s*\{([^}]*)\}', patch, s, flags=re.M) +# `name = "1.1.6"` -> path form +s = re.sub(r'^(rusty[_-]alloc(?:[_-]api)?)\s*=\s*"[^"]*"', + lambda m: f'{m.group(1)} = {{ path = "{api if "api" in m.group(1) else alloc}" }}', s, flags=re.M) +if s != orig: + open(path, 'w', encoding='utf-8').write(s) +PY + done +} + +# Some consumers live in a workspace whose ROOT is not in this checkout -- +# `packages/spacedb/*` inherit their sibling dependencies with +# `workspace = true`, and mata-master carries no manifest above them. Without a +# root, cargo cannot parse the manifest at all, so the corpus could not answer +# for SpaceDB either way. +# +# They inherit only DEPENDENCIES (every package field is literal), so a root is +# reconstructable: list the sibling crate directories as members and declare +# each as a path dependency. Written into the COPY, never the checkout. +synth_workspace() { + local tree="$1" + python3 - "$tree" <<'PY' +import pathlib, sys, re +root = pathlib.Path(sys.argv[1]) +if (root / 'Cargo.toml').exists(): + sys.exit(0) +members = sorted(d.name for d in root.iterdir() if (d / 'Cargo.toml').exists()) +if not members: + sys.exit(0) +names = {} +for m in members: + t = (root / m / 'Cargo.toml').read_text(encoding='utf-8') + n = re.search(r'^name\s*=\s*"([^"]+)"', t, re.M) + if n: + names[n.group(1)] = m +lines = ['# SYNTHESISED by tools/corpus/run.sh -- this checkout has no workspace', + '# root above these crates, and they inherit their sibling deps from one.', + '[workspace]', 'resolver = "2"', + 'members = [' + ', '.join(f'"{m}"' for m in members) + ']', + '', '[workspace.dependencies]'] +for n, d in sorted(names.items()): + lines.append(f'{n} = {{ path = "{d}" }}') +(root / 'Cargo.toml').write_text('\n'.join(lines) + '\n', encoding='utf-8') +print(f' synthesised a workspace root over {len(members)} crates', file=sys.stderr) +PY +} + +run_one() { + local name="$1" path="$2" feats="$3" pkg="$4" synth="$5" note="$6" + if [ ! -d "$path" ]; then + echo " SKIP $name -- $path not on this machine" + ROWS+=("SKIP|$name|not on this machine") + skip=$((skip + 1)) + return + fi + local fargs=() + [ -n "$feats" ] && fargs=(--features "$feats") + # `-p` where the consumer names one: applying a `rusty-alloc` feature across a + # whole workspace can install the global allocator twice (rusty_zstd's CLI + # bins and `rusty_alloc_default` both claim it), which is this harness picking + # the wrong target rather than anything being wrong downstream. + [ -n "${pkg:-}" ] && fargs+=(-p "$pkg") + + echo "== $name" + echo " $note" + + local tag; tag="$(echo "$name" | tr -c 'A-Za-z0-9_.-' '_' | sed 's/_*$//')" + local dst="$work/$tag" base_dir="$path" + copy_tree() { + rm -rf "$1"; mkdir -p "$1" + # `target/` and `.git/` are the whole cost of the copy; exclude both. + (cd "$path" && tar -cf - --exclude=./target --exclude=./.git .) | (cd "$1" && tar -xf -) 2>/dev/null + } + + # BASELINE. Normally the checkout itself; when a root has to be synthesised + # the baseline needs a copy too, or there is nothing to compare against. + if [ "${synth:-}" = "yes" ]; then + base_dir="$work/${tag}__base" + copy_tree "$base_dir" + synth_workspace "$base_dir" + fi + local base_out base_rc + base_out="$(cd "$base_dir" && cargo "$mode" --quiet "${fargs[@]}" 2>&1)" + base_rc=$? + + # CANDIDATE: a copy, repointed at this tree. + copy_tree "$dst" + [ "${synth:-}" = "yes" ] && synth_workspace "$dst" + repoint "$dst" + local cand_out cand_rc + cand_out="$(cd "$dst" && cargo "$mode" --quiet "${fargs[@]}" 2>&1)" + cand_rc=$? + + if grep -q "failed to find a workspace root" <<<"$base_out"; then + echo " SKIP workspace root absent from this checkout -- cannot answer here" + ROWS+=("SKIP|$name|workspace root not in this checkout") + skip=$((skip + 1)) + return + fi + if [ $base_rc -ne 0 ] && [ $cand_rc -ne 0 ]; then + echo " BASELINE ALREADY RED -- not ours" + ROWS+=("BASELINE-RED|$name|red before the upgrade too") + skip=$((skip + 1)) + elif [ $base_rc -ne 0 ]; then + echo " baseline red, candidate GREEN (consumer is behind, not broken by us)" + ROWS+=("OK|$name|baseline red, candidate green") + pass=$((pass + 1)) + elif [ $cand_rc -eq 0 ]; then + echo " PASS both arms green" + ROWS+=("PASS|$name|both arms green") + pass=$((pass + 1)) + elif ! grep -qiE "rusty[_-]alloc" <<<"$cand_out"; then + # The candidate is red but nothing in the error mentions us. A consumer can + # be broken for its own reasons -- a missing dependency, a resolver shift + # from the rewritten manifest -- and blaming the upgrade for it would make + # this harness worse than useless: it would cry wolf until someone muted it. + echo " UNRELATED candidate red, but no rusty_alloc symbol in the error" + echo "$cand_out" | grep -E "^error" | head -2 | sed 's/^/ /' + ROWS+=("UNRELATED|$name|$(echo "$cand_out" | grep -E '^error' | head -1 | cut -c1-70)") + skip=$((skip + 1)) + else + echo " FAIL 2.0.0 BREAKS THIS CONSUMER" + echo "$cand_out" | grep -E "^error" | head -3 | sed 's/^/ /' + local first + first="$(echo "$cand_out" | grep -E "^error" | head -1 | cut -c1-110)" + ROWS+=("FAIL|$name|$first") + fail=$((fail + 1)) + fi +} + +echo "downstream corpus ($mode) against $root" +echo +python3 - "$root/tools/corpus/corpus.toml" <<'PY' > "$work.list" 2>/dev/null || mkdir -p "$(dirname "$work.list")" +import re, sys +s = open(sys.argv[1], encoding='utf-8').read() +for blk in s.split('[[consumer]]')[1:]: + g = lambda k: (re.search(rf'^{k}\s*=\s*"(.*)"', blk, re.M) or [None, ''])[1] + print('|'.join([g('name'), g('path'), g('features'), g('pkg'), g('synth'), g('note')])) +PY +mkdir -p "$work" +# `|`, not tab: tab is IFS WHITESPACE, so bash collapses runs of it and an empty +# `features` field silently shifts every column after it. That fed each +# consumer's NOTE to `--features` and turned all five baselines red -- the +# harness reporting on itself, again. +while IFS='|' read -r n p f pk sy note; do + [ -n "$n" ] && run_one "$n" "$p" "$f" "$pk" "$sy" "$note" +done < "$work.list" + +echo +printf '%-14s %-26s %s\n' RESULT CONSUMER DETAIL +for r in "${ROWS[@]}"; do + IFS='|' read -r a b c <<<"$r" + printf '%-14s %-26s %s\n' "$a" "$b" "$c" +done +echo +echo "$pass passed, $fail broken by the upgrade, $skip skipped" +[ $fail -gt 0 ] && exit 1 +exit 0 diff --git a/tools/gate-selftest.sh b/tools/gate-selftest.sh new file mode 100644 index 0000000..905de8c --- /dev/null +++ b/tools/gate-selftest.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Prove the load-bearing tests are not vacuous. +# +# A green test is not evidence until you have seen it go red for the right +# reason. This campaign found FOUR tests that passed under the exact bug they +# existed to catch — a region-shape that could not tell two placement policies +# apart, an arena too large to starve, a threshold below both arms, and an +# assertion that held with no region registered at all. Every one of them looked +# like coverage and was worth nothing. +# +# So: reintroduce each defect, run the test that owns it, and require it to +# FAIL. A mutation that leaves the suite green is a gate that is gone, and this +# script exits non-zero saying so. +# +# Sibling of `tools/semgrep-selftest.sh` (same idea, applied to lint rules) and +# `tools/unsafe-census.sh`. Add an entry here whenever you fix a defect that a +# test now guards; that is the cheapest moment, because the mutation is the diff +# you just reversed. +set -uo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +pass=0 +fail=0 +declare -a FAILED + +# name | file | perl -0pe substitution | cargo test filter | extra RUSTFLAGS +run_case() { + local name="$1" file="$2" subst="$3" filter="$4" flags="${5:-}" + local backup + backup="$(mktemp)" + cp "$file" "$backup" + # shellcheck disable=SC2064 + trap "cp '$backup' '$file'; rm -f '$backup'" RETURN + + if ! perl -0pi -e "$subst" "$file"; then + echo " !! $name: mutation could not be applied (source moved?)" + FAILED+=("$name (mutation did not apply)") + fail=$((fail + 1)) + return + fi + if cmp -s "$backup" "$file"; then + echo " !! $name: mutation matched NOTHING — the anchor has moved" + FAILED+=("$name (anchor moved)") + fail=$((fail + 1)) + return + fi + + local out + if out="$(RUSTFLAGS="$flags" cargo test -p rusty_alloc $filter 2>&1)"; then + echo " !! $name: VACUOUS — the suite is GREEN with the defect present" + FAILED+=("$name") + fail=$((fail + 1)) + else + # A compile error is not the gate firing; it means the mutation was invalid. + if grep -qE '^error(\[E[0-9]+\])?: ' <<<"$out" && ! grep -q 'test result: FAILED' <<<"$out"; then + echo " !! $name: mutation did not COMPILE — rewrite it so it is a real defect" + FAILED+=("$name (did not compile)") + fail=$((fail + 1)) + else + echo " ok $name: gate fires" + pass=$((pass + 1)) + fi + fi +} + +echo "poisoning each load-bearing gate; every one must go red" +echo + +# --- P4b: two-ended placement in the fixed-region backend ------------------- +# Bottom-up first-fit puts a page-sized block below the first segment boundary +# and costs a whole segment of reach (small-metal §2.10). +run_case "prim::fixed two-ended placement" \ + "crates/rusty_alloc/src/prim/fixed.rs" \ + 's/let from_top = align == FIXED_PAGE;/let from_top = false;/' \ + "--lib prim::fixed" + +# --- P4d/P4e: collect must reclaim a bin's last all-free page --------------- +run_case "collect reclaims a bin's last page" \ + "crates/rusty_alloc/src/heap.rs" \ + 's/if page_all_free\(p\) \{/if page_all_free(p) \&\& !((*q).first == p \&\& (*q).last == p) {/' \ + "--test heaps collect_reclaims" + +# --- P4e: the periodic collect must actually be wired ---------------------- +run_case "generic_collect fires on its own" \ + "crates/rusty_alloc/src/heap.rs" \ + 's/unsafe \{ self\.collect_inner\(false, false\) \};/{}/' \ + "--test heaps generic_collect_fires" + +# --- P4e: the generic path must reclaim before reporting OOM --------------- +# Small profile only: the cache starves a heap only where slices are scarce. +run_case "generic path reclaims before null" \ + "crates/rusty_alloc/src/heap.rs" \ + 's/unsafe \{ self\.collect_inner\(true, true\) \};/{}/' \ + "--test heaps generic_path_reclaims" \ + "--cfg ra_small_profile" + +# --- P3: options 64-bit atomics on a target without them ------------------- +# Forwarding the caller's Ordering to 32-bit halves aborts on AcqRel. +run_case "split64 normalises orderings" \ + "crates/rusty_alloc/src/options.rs" \ + 's/self\.lo\.load\(Ordering::Acquire\)/self.lo.load(_ord)/' \ + "--lib options::split64" + +echo +if ((fail > 0)); then + echo "GATE SELFTEST FAILED: $fail of $((pass + fail)) gates did not fire." + for f in "${FAILED[@]}"; do echo " - $f"; done + echo + echo "A gate that stays green with its defect reintroduced is not protecting" + echo "anything. Either the test needs to be able to fail, or the mutation no" + echo "longer describes the defect and this script needs updating." + exit 1 +fi +echo "GATE SELFTEST OK: all $pass gates fire." diff --git a/tools/unsafe-baseline.txt b/tools/unsafe-baseline.txt index 1cafd3c..0adca93 100644 --- a/tools/unsafe-baseline.txt +++ b/tools/unsafe-baseline.txt @@ -1,10 +1,12 @@ 94 crates/rusty_alloc/src/alloc.rs 13 crates/rusty_alloc/src/arena.rs - 67 crates/rusty_alloc/src/heap.rs + 71 crates/rusty_alloc/src/heap.rs 36 crates/rusty_alloc/src/init.rs + 2 crates/rusty_alloc/src/lib.rs 6 crates/rusty_alloc/src/options.rs 12 crates/rusty_alloc/src/os.rs 37 crates/rusty_alloc/src/page.rs + 25 crates/rusty_alloc/src/prim/fixed.rs 8 crates/rusty_alloc/src/prim/mock.rs 17 crates/rusty_alloc/src/prim/mod.rs 27 crates/rusty_alloc/src/prim/unix.rs diff --git a/tools/wasm-size-baseline.txt b/tools/wasm-size-baseline.txt new file mode 100644 index 0000000..67bc757 --- /dev/null +++ b/tools/wasm-size-baseline.txt @@ -0,0 +1 @@ +22574 diff --git a/tools/wasm-size.sh b/tools/wasm-size.sh new file mode 100644 index 0000000..db6d544 --- /dev/null +++ b/tools/wasm-size.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# The wasm size ratchet. +# +# An integrator reported rusty_alloc adding ~12% to their gzipped bundle, and +# the cause had been sitting in the crate for its whole life: the option +# environment pass ran on `wasm32-unknown-unknown`, where `std::env::var` is a +# stub that always fails. `options::get` was the LARGEST function in a wasm +# module at 3,708 bytes -- ahead of anything in the allocator proper -- and it +# dragged `to_uppercase`, `alloc::fmt::format` and the whole `OPTION_NAMES` +# table along with it. +# +# Nothing measured wasm size, so nothing could notice. This is what notices. +# +# GZIPPED, because that is what a browser downloads and what the report was in. +# The distribution profile (`bench-dist`), because the repo's `release` keeps +# debug symbols on purpose and a 2 MB artifact hides a 4 KB regression. +# +# Update the baseline deliberately, in the same commit as the change that moves +# it, the way `tools/unsafe-census.sh` is updated -- a size increase is allowed, +# it just has to be meant. +set -uo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +BASELINE_FILE="tools/wasm-size-baseline.txt" +ART="target/wasm32-unknown-unknown/bench-dist/rusty_alloc_wasm.wasm" +# Headroom for toolchain drift: rustc's own codegen moves a little between +# patch releases, and a gate that cries wolf gets disabled. +TOLERANCE_PCT=3 + +update=0 +[ "${1:-}" = "--update" ] && update=1 + +echo "building the wasm fixture (bench-dist: no debug info)" +cargo build -p rusty_alloc-wasm --target wasm32-unknown-unknown --profile bench-dist \ + >/dev/null 2>&1 || { echo "build FAILED" >&2; exit 1; } +[ -f "$ART" ] || { echo "missing $ART" >&2; exit 1; } + +gz=$(python3 -c "import gzip,sys;print(len(gzip.compress(open(sys.argv[1],'rb').read(),9)))" "$ART") +raw=$(python3 -c "import os,sys;print(os.path.getsize(sys.argv[1]))" "$ART") + +if [ "$update" = "1" ]; then + printf '%s\n' "$gz" > "$BASELINE_FILE" + echo "baseline updated: $gz bytes gzipped (raw $raw)" + exit 0 +fi + +if [ ! -f "$BASELINE_FILE" ]; then + echo "no baseline; run: bash tools/wasm-size.sh --update" >&2 + exit 1 +fi +base=$(tr -d '[:space:]' < "$BASELINE_FILE") +limit=$(( base + base * TOLERANCE_PCT / 100 )) + +echo "wasm size: $gz bytes gzipped (raw $raw), baseline $base, limit $limit (+${TOLERANCE_PCT}%)" +if [ "$gz" -gt "$limit" ]; then + echo + echo "WASM SIZE RATCHET FAILED: $((gz - base)) bytes gzipped over the baseline." + echo + echo "That is not automatically wrong -- an allocator legitimately grows -- but" + echo "it must be DELIBERATE. Every byte here is downloaded by every visitor to" + echo "every page that ships this crate. Find it with a set difference against a" + echo "baseline module rather than guessing (docs/plans/wasm-size.md), then" + echo "re-run with --update in the same commit." + exit 1 +fi +if [ "$gz" -lt "$((base - base * TOLERANCE_PCT / 100))" ]; then + echo "wasm got $((base - gz)) bytes SMALLER; re-run with --update to bank it." +fi +echo "WASM SIZE OK"