feat(embedded)!: no_std on bare metal, a small-metal geometry, and three reclamation fixes (v2.0.0) - #17
Merged
Merged
Conversation
…ree reclamation fixes
Puts rusty_alloc on a microcontroller and fixes what running it there exposed.
Measured on a Seeed XIAO ESP32-S3 Sense at 240 MHz against esp-alloc 0.11, one
firmware source with --cfg selecting the allocator, equal budgets, a subtracted
harness floor and checksums proving work parity: 2.06-3.73x faster across four
allocate/free workloads.
Three reclamation defects, all found by an adversarial stress battery:
- `collect` borrowed `mi_page_retire`'s keep-one-page-per-bin rule, so no
collect at any level could return a size class's page to a different class.
Upstream frees an all-free page unconditionally ("this will free retired pages
as well"). Harmless at 512 slices per segment; at 16 it decayed 512 B capacity
from 168 to 8 blocks while 61,440 bytes sat free.
- `generic_collect` was declared with a default and read by nothing, so nothing
ever collected automatically.
- The generic path reported OOM while still holding empty pages for classes
nobody had asked for. It now reclaims once and retries before returning null,
which costs nothing on the happy path. Churn failures fell 22,533 -> 357 per
50,000 allocations.
Also fixed, from the same campaign: an exclusive arena's huge path could escape
to the OS instead of failing; the arena chunk bitmap scan stopped at word 0, so
an arena over 32 chunks could not allocate past chunk 31; `stats.segments` did
not balance `segments_freed`; and the fixed-region backend placed everything
bottom-up, so one page below a segment boundary cost a whole segment of reach.
Production gates, because none of the above was covered before:
- CI now runs the small-profile suite, clippy on the small profile and no_std,
and both bare-metal RISC-V targets at both geometries.
- `tools/gate-selftest.sh` reintroduces five real defects and requires the suite
to go red for each. Four tests in this campaign passed under the exact bug
they existed to catch; this is what stops the fifth.
- no_std refuses to compile without `--cfg ra_single_threaded`, because three
things in it are sound only with one thread and all three fail quietly.
- A scheduled `icount` job regenerates the published instruction counts, which
had aged silently.
The host callgrind figures in the README predate the reclamation fixes and are
marked as a floor pending re-measurement.
BREAKING CHANGE: `default-features = false` now selects the no_std profile. The
crate previously had `default = []`, so `default-features = false` was identical
to the default; it now additionally requires `--cfg ra_single_threaded`. Set
`features = ["std"]` to keep the previous behaviour. Separately, `heap::Heap`
gained a field, and since all its fields are `pub` and it is not
`#[non_exhaustive]`, a struct literal naming every field no longer compiles.
Consumers on default features are unaffected by both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ttimmahlax
force-pushed
the
feat/no-std-small-metal
branch
from
September 8, 2026 05:27
b156a55 to
20723d0
Compare
CI's `no_std` clippy step runs on a unix HOST, and `random::os_entropy` and `stats::process_info` both selected their unix arm on `cfg(unix)` alone — arms that reach `/dev/urandom` and `/proc/self/statm` through `std::fs`. Three E0433s, and only on unix: the local checks used the Windows target, whose arm goes through `windows_sys` and needs no `std`. This is the fifth-case defect shape a third time — P0 found it in `prim/mod.rs`, P3 in `random.rs`, and here it is in both files again, because a platform selection written when `std` was unconditional gains a new case the moment `std` becomes a feature. Both unix arms now require `feature = "std"`, and both fallbacks widen to cover unix-without-`std` alongside bare metal. Also lists `riscv32imafc-unknown-none-elf` in `rust-toolchain.toml`: the `embedded` job builds it, and a target the workflow names but the toolchain file does not is one `rustup` will not have on a fresh machine. Reproduced locally before the fix with `cargo check -p rusty_alloc --no-default-features --target x86_64-unknown-linux-gnu` (3 errors), which is the check that was missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…k on wasm
An integrator reported rusty_alloc adding ~12% to their gzipped wasm bundle.
Measured against the Rust default allocator (dlmalloc) on a minimal consumer
built distribution-shaped (opt-level="z", lto=fat, panic=abort, strip):
raw gzip
dlmalloc 15,536 6,706
rusty_alloc 34,285 14,466 +7,760 gz of overhead
after this 26,105 10,766 +4,060 gz
`options::get` was the LARGEST function in the module at 3,708 bytes — ahead of
anything in the allocator proper. `ensure_init` runs an environment pass that,
on `wasm32-unknown-unknown`, calls a `std::env::var` stub that always fails: 38
iterations, 76 `format!`s, 76 `String` allocations, `to_uppercase` on each of 38
option names, every startup, to read an environment the target does not have.
The `OPTION_NAMES` table and the formatting machinery came along with it.
Gated on `not(all(target_arch = "wasm32", target_os = "unknown"))` — the same
deletion the `no_std` arm already had, for the same reason. `wasm32-wasip1` has
a real environment and keeps the pass.
Also removes the two remaining formatters from `options`: `error` renders its
code into a stack buffer, and `out_fmt` writes bytes rather than `eprint!`-ing
an argument that is already a `&str`. Both measured ~0 on wasm size — the
`core::fmt` in that module turned out to be the consumer's, not ours, which only
the baseline profile could show — but they remove an allocation from an error
path and give `no_std` back the message P3 had to delete. No new `unsafe`:
`from_utf8` validates 30 ASCII bytes on a path that only runs after something
has already gone wrong.
Attribution came from a set difference against a baseline module, not a guess:
rusty_alloc adds 14,659 bytes of functions and removes 8,012 of dlmalloc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An integrator reported ~12% added to their gzipped bundle. Measured against the
Rust default allocator on a minimal consumer, built the way one ships:
raw gzip overhead
dlmalloc 15,536 6,706 -
before 34,285 14,466 +7,760
after 25,734 10,535 +3,829
Second cause, after the option environment pass: `ra_thread_local!` expanded to
`std::thread_local!` on `wasm32-unknown-unknown`, linking lazy initialisation,
destructor registration and an "accessed during or after destruction" panic path
that can never run on a target `prim/wasm.rs` has assumed single-threaded since
it was written. It now takes the same single-`static` arm `no_std` uses, gated on
`not(target_feature = "atomics")` so a threaded wasm build keeps real TLS.
-371 raw, -231 gzipped, and the self-test's waste gate still passes in a VM.
`tools/wasm-size.sh` is the point of the commit. Nothing measured wasm size, so
a 3,700-byte-gzipped regression sat in the crate for its whole life and reached
a user before it reached us. The gate builds the fixture under `bench-dist`
(`release` keeps debug symbols; a 2 MB artifact hides a 4 KB regression), gzips
it, and fails past 3%. Poisoned by restoring the env pass, it fires.
Three things that were NOT the answer are recorded in docs/plans/wasm-size.md,
because each looked promising and each cost a measurement: `core::fmt` was
mostly the consumer's (the change made on the first reading saved exactly zero);
the data section is +4,124 raw but +535 gzipped and is the sentinel heap that
makes malloc's fast path branchless; and `wasm-opt -Oz` moves raw hard but gzip
barely, because gzip already does most of that work.
Also documents the integrator-facing recipe in the README, including that Rust
embeds absolute panic-location paths -- a published .wasm can carry the
developer's home directory and username -- and that `--remap-path-prefix` is the
stable fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… KiB loses Two rounds of size work went by before anyone asked the other half of the question: is rusty_alloc actually faster than the allocator it replaces on wasm? If not, +3,829 gzipped bytes is indefensible at any size. Measured in node, one source with `--features ra` swapping the allocator, a FLOOR arm that allocates nothing, volatile touches folded into a checksum, and seeded size sequences so both arms provably do identical work: churn, 64 live, 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, and 64 mixed batched straddle 1.0 across repeats -- no claim Only two of four rows are claims. Between-process variance is +/-25%, which cannot resolve a 10% effect, so the middle rows are reported as ranges rather than dressed up as ratios. The floor caught a broken first measurement: 5.2 ns/op for dlmalloc against 0.6 for rusty_alloc, on 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 warns when the two floors disagree by more than 25%, because a floor that differs is a harness measuring itself. Why 2 KiB loses is instrumented, not guessed: exported `stats().generic` and counted slow-path trips per op -- 0.008 for 32 B, 0.060 for churn, and 1.000 for 2048 B, every single allocation. `alloc.rs` already carries a dated REFUTED 2026-08-21 note for exactly this: a tight alloc/free loop frees into `local_free`, so the medium bin's `free` list is always dry and the peek can never hit. The repo had tried the fix and measured it worse. Recorded in docs/plans/wasm-size.md so the third person to notice the row does not try it a third time. `bench/wasm-speed/` vendors the harness so the numbers are re-derivable rather than asserted, with the floor/checksum/repeat discipline in its README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n on its evidence Chasing the one row where rusty_alloc loses to dlmalloc on wasm (a 2 KiB tight alloc/free loop, 0.55-0.83x). `alloc.rs` already documents a REFUTED attempt: a peek at the medium bin's queue front, which cannot hit because a tight loop frees onto `local_free` and leaves `free` permanently dry. Tried the version that CAN hit -- add the collect, not just the peek. It is the same `local_free` -> `free` swap `malloc_generic_walk` does a few lines later, cheap in the common case, and guarded on `size <= MEDIUM_OBJ_SIZE_MAX` so it cannot reproduce the refuted version's `big`/`large` +25 Ir/op. Measured, both orders agreeing: 2048 B tight loop ~7% FASTER 32 B tight loop ~3-4% SLOWER churn, batched orders disagree -- noise REVERTED. 32 B is the commonest allocation there is, 7% does not flip the row it helps (2 KiB still loses), and the native instruction-count cost is not measurable from this machine. Recorded in docs/plans/wasm-size.md so the next person sees the number rather than the idea. The durable part is the harness. The first two A/B runs produced orderings that disagreed in SIGN, because the runner measured one module to completion and then the other, so drift -- another process waking, a thermal step, the scheduler -- landed entirely on one arm. `run.mjs` now interleaves the arms within each repeat and takes the per-arm minimum, cancelling anything slower than one repeat. That is what made a 7% effect resolvable, and what turned "the orders disagree" into a decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o its band
A change with a + and a - outcome is an invitation to find the predicate that
separates them. The previous commit measured a collect-and-retry ahead of the
slow-path heartbeat at +7% on a 2 KiB tight loop and -3 to -4% on 32 B, and
reverted it as a bad trade. The predicate was already in the counters.
2 KiB reaches `malloc_generic` on 1.000 of its calls; 32 B on 0.008. That is not
list state -- which is what the two previous explanations assumed, including the
2026-08-21 refutation's -- it is ROUTING. `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`. Every medium
allocation arrives at the slow path by construction, and small ones almost never
do, which is exactly why the ungated version helped one and taxed the other.
Gated to `size > SMALL_SIZE_MAX && size <= MEDIUM_OBJ_SIZE_MAX`:
ungated band-gated
2048 B tight loop +7% +15%
32 B tight loop -3 to -4% no effect
churn, batched noise no effect
Three passes in each order through the interleaved harness. The win is LARGER
gated than ungated, because the check no longer runs on calls that cannot use
it. The band also excludes the `big`/`large` sizes the 2026-08-21 experiment
regressed by +25 Ir/op, and the small sizes that have their own fast path.
The row still loses: 2 KiB against dlmalloc goes ~0.65x -> ~0.72x. Kept on the
strength of costing nothing measurable elsewhere, not on winning that row.
UNVERIFIED ON NATIVE. The routing is the same on every target so the win should
carry, but instruction counts cannot be taken from this machine -- the scheduled
`icount` job confirms or refutes before a release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit shipped the collect-and-retry with "unverified on native; the routing is the same so it should carry". That is a prediction, and this repo does not ship predictions as results. Measured with a temporary runtime toggle in `malloc_generic_once`, so one process can time the retry on and off INTERLEAVED -- the same fix that made the wasm A/B stop disagreeing in sign. Windows x86-64, four runs, best-of-15: 2048 B tight loop 11.4-12.9 -> 9.6-11.2 ns 1.15-1.19x 4096 B tight loop 10.4-13.4 -> 8.9-11.5 ns 1.14-1.19x 32 B tight loop no effect (0.97-1.05x) churn 8-512 B no effect (1.00-1.04x) Same shape as wasm and slightly larger, which is what the routing argument predicted -- but it is worth more now that it is a number. The toggle and its harness are removed again: a runtime branch in `malloc_generic` to support an A/B is not something to ship. The method is recorded instead. Two things this still does not establish, both written down rather than glossed: the repo's published figures are INSTRUCTION counts under callgrind and this is wall-clock on one box; and it is single-threaded, so the `Acquire` load `page_collect` performs on `xthread_free` -- a line other threads push to -- has had no contention measured on it by either wasm or this benchmark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ad frees The last two commits shipped a band-gated collect-and-retry ahead of the slow-path heartbeat, measured at +15% on wasm and +15-19% on native for a 2 KiB tight alloc/free loop, and closed with an explicit caveat: every one of those numbers was single-threaded, and `page_collect` loads `xthread_free` -- the line a remote `free` pushes onto. So that was measured. A producer allocates a batch of 2 KiB blocks; a consumer thread frees THAT batch while the producer is timed allocating another. Remote frees land on the producer's own pages, and nothing in the timed loop synchronises with anything. producer alloc, consumer freeing its pages ~51-75 -> ~71-77 ns 0.67-1.00x same, single-threaded control no effect Never better, usually 15-33% worse, across two harnesses and eight runs. Reverted. Cross-thread frees are the producer/consumer shape -- thread pools, channels, async runtimes -- and mimalloc's architecture exists to serve them. A single-threaded tight-loop gain does not buy that. THE OBVIOUS FIX DOES NOT WORK, which is the part worth keeping. Doing only the LOCAL half of the collect -- swap `local_free` into `free`, touch no atomic -- measured WORSE (0.67-0.77x). `free`, `local_free` and `xthread_free` are adjacent fields of a `#[repr(C)]` Page and share a cache line, so reading any of them pulls the line the remote thread is invalidating. There is no cheap peek at a page another core is freeing into. That is the mechanism the 2026-08-21 note observed without naming, now named. Recorded in docs/plans/wasm-size.md with the numbers, the cache-line mechanism, the sticky per-heap predicate a next attempt should use instead, and why the first contention harness (an SPSC ring, ~113 ns/op) was timing itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The revert two commits ago was right about the evidence and wrong about the conclusion. A + and a - outcome is an invitation to find the predicate, and the predicate here is not "is this program single-threaded" -- it is "does THIS heap receive cross-thread frees", which is finer and more useful: a worker that owns its allocations keeps the win however many threads the process has. `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 that is clear. tight 2 KiB loop, 1 thread +14-16% (1.08-1.23, 7 runs) tight 2 KiB loop, 2 threads, own frees +12-26% producer alloc / consumer freeing its pages neutral (0.84-1.15, was 0.67-0.96) The contended row straddles 1.0 with wide variance, so the claim is that the regression is gone, not that contention got faster. Two things this needed that the first attempt did not have. Latching on the RETRY's own steal was not enough: the frees that hurt land on other pages of the same heap, so the retried page never sees them and it never switched off -- the latch is set from `malloc_generic_walk`'s collects too. And the harness had to be told about the latch, because sticky is correct in production and useless in an A/B: the contended case ran first in warmup and left the retry off for everything after it, so the win read as 1.00x until the latch was reset between timed runs. Still unmeasured: host instruction counts under callgrind, and more than one consumer thread. Recorded in docs/plans/wasm-size.md with the numbers, the cache-line mechanism (`free`, `local_free` and `xthread_free` are adjacent in a `#[repr(C)]` Page, so no variant of the peek is cheap under contention), and why the local-only collect measured worse rather than better. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two anchors in the doc script used `--` where the file has an em-dash, so both `assert`s failed, nothing was written, and the commit landed the self-disabling retry while `docs/plans/wasm-size.md` still said the change was reverted and the per-heap predicate was "not tried". The CHANGELOG had no entry for it at all. Fixed line-based rather than by exact string match, which is what the dash mismatch argued for. The doc now carries the predicate section, the seven-run numbers, why latching on the retry's own steal was not enough, and why the harness had to reset the latch between arms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It had been measured on native Windows and reasoned about everywhere else. So it was run everywhere else, and the same one workload shape moves on all three targets while nothing else moves anywhere. native x86-64 (Windows) 2 KiB tight loop +14-16% wasm32 in V8 (node) 2 KiB tight loop +1-16% Xtensa ESP32-S3, no_std, small profile 2 KiB tight loop +15% (1380 -> 1200 ns) The board is the cleanest of the three. Its harness reports 0-1% spread, and at the small profile `SMALL_SIZE_MAX` is 512 B, so its other three rows (32 B, a mixed 8-512 B batch, churn over 8-511 B) sit 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 and the doc now says so: it is a win on ONE workload shape that happens to hold on every target. Everything else is unchanged and the contended case is neutral, not better. Correctness on the same build: 109/33 default, 90/19 small profile, clippy on three configs, riscv32imac and imafc at both geometries, rusty_alloc-api no_std, the wasm 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, churn nulls 458 against a 334-575 range already seen). Three ratchets green: unsafe census, gate selftest 5/5, wasm size. Still not covered: host instruction counts under callgrind -- the instrument the README's figures use, which no Windows box can run -- and contention with more than one consumer thread. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fter 2.0.0 A major version is a promise consumers can keep compiling, and nothing inside this repo can check it. 2.0.0 redefines `default-features = false` to mean `no_std` and adds a field to `heap::Heap`; both are invisible here and land on other people. `tools/corpus/` builds each registered consumer twice: BASELINE as it sits on disk with its pinned crates.io version, and CANDIDATE as a copy with every `rusty_alloc*` requirement rewritten to this tree. `[patch.crates-io]` cannot do this -- a patch must satisfy the original requirement and `=1.1.6` is not satisfied by 2.0.0 -- so the upgrade is simulated by rewriting, in a COPY. The script never writes inside a consumer's checkout. First run: 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`, stops at the `compile_error!` 2.0.0 added, and is fixed by one line per dependency -- verified by applying it to the copy and watching the error go, not assumed. The CHANGELOG now carries that migration with the corpus result behind it. Four things the harness got wrong before it got anything right, all in its README because a corpus that lies gets muted: tab is IFS whitespace, so an empty `features` column shifted every field after it and fed each consumer's NOTE to `--features` (all five baselines went red and it blamed the world); a missing workspace root is a SKIP, not a red build, and for SpaceDB it is reconstructable because those crates inherit only dependencies -- the harness synthesises one in the copy, which is what made SpaceDB testable at all; a candidate whose error never mentions rusty_alloc is UNRELATED, not FAIL; and a run that hits `fork: retry: Resource temporarily unavailable` misclassifies, so an affected consumer must be re-run alone before its row is believed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The corpus commit's own doc edit missed its anchor (the line wraps differently than the script assumed) and the CHANGELOG shipped without the result. The breaking-change entry now says which consumers were actually built against 2.0.0, which one it breaks, and that the one-line migration was tested by applying it rather than reasoned about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The published table had a fresh rusty_alloc arm against an esp-alloc arm from
an earlier session, and the medium-band collect-and-retry had since moved the
2 KiB row. A table with one stale arm is not a measurement.
Both re-run in one session, same 162 ns/op floor in each, checksums matching:
esp-alloc rusty_alloc speedup
32 B alloc/free 1638 647 2.53x
64 mixed, batched 1792 881 2.03x
churn 64 live 3987 1087 3.67x
2048 B alloc/free 1638 1200 1.37x (was 1.19x)
Headline moves 2.1-3.7x -> 2.0-3.7x, and the 2 KiB row improves because the
retry landed. Both READMEs updated.
Also demotes a `///` on the `no_std` `compile_error!` to a plain comment.
A doc comment on a macro INVOCATION documents nothing and warns
(`unused_doc_comments`); it only fires in the failing no_std build, which is why
every green gate walked past it. Found by building the board's esp-alloc arm
without `--cfg ra_single_threaded`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Puts
rusty_allocon a microcontroller, and fixes what running it there exposed.Measured on hardware
Seeed XIAO ESP32-S3 Sense at 240 MHz, against
esp-alloc0.11. One firmware source with--cfgselecting the allocator, equal budgets, a subtracted harness floor (162 ns/op, identical in both arms) and checksums proving work parity.It costs RAM: 68 KiB against esp-alloc's 8 KiB for the same workload. That gap is structural (
bins x page sizevsbytes + header), it is in both READMEs, and it is not sold as anything else.Three reclamation defects, found by an adversarial stress battery
collectcould not reclaim a bin's last empty page. It had borrowedmi_page_retire's keep-one-page rule; upstream frees an all-free page unconditionally. Harmless at 512 slices per segment, fatal at 16: 512 B capacity decayed 168 -> 8 blocks while 61,440 bytes sat free.generic_collectwas declared and read by nothing.Churn failures: 22,533 -> 357 per 50,000 allocations. Capacity no longer decays at all.
Plus, from the same campaign: an exclusive arena's huge path could escape to the OS; the arena bitmap scan stopped at word 0;
stats.segmentsdid not balancesegments_freed; and the fixed-region backend's bottom-up placement cost a whole segment of reach per page-sized block.Production gates, because none of this was covered
no_std, and both bare-metal RISC-V targets at both geometries.tools/gate-selftest.shreintroduces five real defects and requires the suite to go red for each. Four tests in this campaign passed under the exact bug they existed to catch.no_stdrefuses to compile without--cfg ra_single_threaded— three things in it are sound only with one thread, and all three fail quietly.icountjob regenerates the published instruction counts, which had aged silently.Breaking
Neither affects a consumer on default features.
default-features = falsenow selectsno_std(it used to be identical to the default). Addfeatures = ["std"]to keep the old behaviour.heap::Heapgained a field; all its fields arepuband it is not#[non_exhaustive], so a struct literal naming every field no longer compiles.Known, and stated in the README
The host callgrind figures predate the reclamation fixes and are marked as a floor pending re-measurement — that needs Linux + valgrind, which the
icountjob now provides. Theretire_expireper-page countdown remains unimplemented; a measured sweep-period default replaces it.Verification: 106 tests / 33 suites default, 89 / 19 small profile, 0 failed; clippy clean on default / small profile /
no_std; fmt clean; unsafe census RATCHET OK; gate selftest 5/5; both RISC-V targets at both geometries and wasm32 build; board kill test green at the 68 KiB floor.Detail in
docs/plans/small-metal.mdanddocs/LEDGER.md.🤖 Generated with Claude Code