Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,16 @@ jobs:
cargo clippy -p rusty_alloc --all-targets -- -D warnings
env:
RUSTFLAGS: --cfg ra_small_profile --cfg ra_single_threaded --cfg ra_aligned_region
# `ra_segment_size="256k"` is the geometry a large-allocation consumer
# runs (docs/plans/finished/esp32-large-alloc-ceiling.md). It moves
# SEGMENT_SIZE, LARGE_OBJ_SIZE_MAX and every sizing rule, so the WHOLE
# suite runs there -- a geometry that only builds is a geometry that rots.
- name: test + clippy (256k segment geometry)
run: |
cargo test -p rusty_alloc
cargo clippy -p rusty_alloc --all-targets -- -D warnings
env:
RUSTFLAGS: --cfg ra_small_profile --cfg ra_segment_size="256k"
# 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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ unsafe_op_in_unsafe_fn = "deny"
# 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)", "cfg(ra_max_extents, values(\"8\", \"16\", \"64\"))", "cfg(ra_aligned_region)"] }
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)", "cfg(kani)", "cfg(ra_small_profile)", "cfg(ra_single_threaded)", "cfg(ra_max_extents, values(\"8\", \"16\", \"64\"))", "cfg(ra_aligned_region)", "cfg(ra_segment_size, values(\"256k\"))"] }

# Lint policy (hardening gate H-15). `pedantic` and `nursery` are ENABLED at
# workspace level and the build is clean under them, because every group
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ region change by `.stack` or the section sum, never by `.bss` alone.
| `Region::give` (or `init_region`) | every allocation fails; the backend has no memory |
| *(optional)* `--cfg ra_max_extents="8"` | the free-extent table keeps its default 32 slots (256 B of `.bss`, i.e. stack); 8 is plenty for a region of a few segments and returns 192 B — the doc on `MAX_EXTENTS` states the bound |
| *(optional)* `--cfg ra_aligned_region` | segments stride from the region's base: `Region` is 16-byte aligned, the linker leaves no gap before it, and `free` pays three instructions. With the flag, the 2.0.4 layout: the address mask on `free`, `Region` segment-aligned, up to 64 KiB of gap in front of it |
| *(optional)* `--cfg ra_segment_size="256k"` | a 64 KiB segment, so `LARGEST_SHARED_ALLOC` is 61,440 and any bigger request takes a dedicated **two** segments. With the flag, an 8 KiB slice x 32: a 64 KiB block is a span and three pack into one segment. Set it when your allocation unit is tens of KB; leave it unset for small objects, since it doubles the page floor |

Size the region with the two `const fn`s in `prim::fixed`, not a round
number: `good_region_size(220 * 1024)` is 196,608 — three whole segments,
Expand Down Expand Up @@ -400,6 +401,49 @@ That floor is roughly **fixed** for a given mix of sizes: the same pages serve a
page allocator starts earning what it charges, and the throughput above is what
it buys.

#### The floor model does NOT cover large allocations — read this if your unit is tens of KB

**Everything above is about a workload of small objects, and it inverts for one
whose unit approaches the segment.** A `rusty_zstd` firmware allocating 64 KiB
match tables measured the inversion on an ESP32-S3: in a 256 KiB region it got
**one** 64 KiB block, and the second failed with 192 KiB unused
([`docs/plans/finished/esp32-large-alloc-ceiling.md`](docs/plans/finished/esp32-large-alloc-ceiling.md)).

The reason is structural and worth stating plainly. A segment's slice 0 holds
its header, so the largest object that can live in a segment is
`SEGMENT_SIZE - SEGMENT_SLICE_SIZE` — **61,440 bytes** at the default small
profile, exposed as `prim::fixed::LARGEST_SHARED_ALLOC`. One byte over that and
the request gets a dedicated run of segments, and since **no allocation of
`SEGMENT_SIZE` can share a segment with the metadata describing it**, a 64 KiB
request takes two segments and a 128 KiB request takes three. So the cost is
not a fixed floor that amortises; for segment-sized blocks it is a
**granularity tax that scales with how many are live**.

Two things follow, and the crate now answers both:

- **Size the region with `region_for_allocs(size, count)`**, not by adding up
payloads. It knows the tax, and it adds the segment the first small
allocation claims — which is what took the reporting firmware from two blocks
to one.
- **If your unit is at or above `LARGEST_SHARED_ALLOC`, set
`--cfg ra_segment_size="256k"`.** It moves the small profile to an 8 KiB
slice x 32, so `LARGEST_SHARED_ALLOC` becomes 253,952 and a 64 KiB request is
a span the allocator packs three-to-a-segment instead of a dedicated
two-segment run. Measured on the same board, same 256 KiB region:

| geometry | 64 KiB blocks served | payload live | region used for it |
|---|---:|---:|---:|
| default | 1 | 64 KiB | 25 % |
| `--cfg ra_segment_size="256k"` | **3** | **192 KiB** | **75 %** |

It is opt-in because it doubles the page floor every small workload pays
(`(classes touched) x 8 KiB`), which is the wrong trade for the sketch above
and the right one for a codec.

And when an allocation does fail, `prim::fixed::region_capacity()` says why:
free BYTES hide this, free SEGMENTS do not. On the failing run it read
`free_segments=1, largest_servable=61440` with 126,976 bytes free.

We got from 192 KiB to 68 KiB by fixing a placement bug in the fixed-region
backend and halving the slice, and from 68 KiB to 64 KiB by moving the first
heap's descriptor out of the region into a static, so the region is whole
Expand Down
41 changes: 41 additions & 0 deletions crates/rusty_alloc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`--cfg ra_segment_size="256k"`, for a firmware whose allocation unit is
tens of kilobytes.** A segment's slice 0 is its header, so the largest object
that can share a segment is `SEGMENT_SIZE - SEGMENT_SLICE_SIZE` — 61,440
bytes at the default small profile — and one byte over that takes a dedicated
run of segments. Since no allocation of `SEGMENT_SIZE` can share a segment
with its own metadata, a 64 KiB request costs **two** segments and a 128 KiB
request three. A `rusty_zstd` firmware measured the consequence on an
ESP32-S3: one 64 KiB block served from a 256 KiB region, the second refused
with 192 KiB unused. The flag moves the small profile to an 8 KiB slice x 32,
raising `LARGEST_SHARED_ALLOC` to 253,952 so a 64 KiB request becomes a span
three of which pack into one segment — **3 blocks instead of 1 in the same
256 KiB region, measured on the board**. Opt-in, because it doubles the page
floor `(classes touched) x slice` that a small-object workload pays.
- **`prim::fixed::LARGEST_SHARED_ALLOC`, `dedicated_segments(size)` and
`region_for_allocs(size, count)`** — the sizing API that predicts the above at
compile time, instead of leaving a firmware to discover it on silicon.
`region_for_allocs` also counts the segment the first small allocation claims,
which is what took the reporting firmware from two blocks to one.
- **`prim::fixed::PrimError`**, re-exported so the whole fixed-region recipe is
reachable from one path. The type has always been public as
`prim::PrimError`, but only from the parent module, so a seam re-exporting
this API in a single `pub use` could name `Region`, `good_region_size`,
`init_region` and the `FERR_*` values but not the type they fail with. The
Kairos RTOS allocator seam hit exactly that and carried an "arrives with the
next release" comment for it. Same type, one more path.
- **`prim::fixed::region_capacity() -> (free_segments, largest_servable)`.**
`region_stats` reports free BYTES, and free bytes hide this failure: the
refused allocation above had 126,976 bytes free and read
`free_segments=1, largest_servable=61440`.

### Fixed

- **The README's footprint model did not cover large allocations and implied
the opposite of the truth for them.** It described the floor as
`(classes touched) x (page size)`, "independent of bytes requested" and
amortising as the working set grows. That holds for small objects; for
segment-sized ones the cost is a granularity tax that scales with how many
are live. The section now says so, with the measured numbers and the flag.

## [2.0.5](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.0.4...rusty_alloc-v2.0.5) - 2026-09-09

### Semver note, read this one
Expand Down
15 changes: 15 additions & 0 deletions crates/rusty_alloc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ 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.

**That model covers small objects only, and inverts once your unit approaches
the segment.** A segment's slice 0 is its header, so the largest object that
can share one is `SEGMENT_SIZE - SEGMENT_SLICE_SIZE` — 61,440 bytes at the
default small profile (`prim::fixed::LARGEST_SHARED_ALLOC`). One byte over and
the request takes a dedicated run of segments, and because no allocation of
`SEGMENT_SIZE` can share a segment with its own metadata, a 64 KiB block costs
**two** segments: a firmware allocating 64 KiB match tables got one of them out
of a 256 KiB region with 192 KiB unused. For segment-sized blocks the cost is a
granularity tax that scales with how many are live, not a floor that amortises.
Size such a region with `prim::fixed::region_for_allocs(size, count)`, ask
`region_capacity()` when an allocation fails with bytes to spare, and set
**`--cfg ra_segment_size="256k"`** if your unit is tens of KB — it packs three
64 KiB blocks into one segment instead of one, measured on the board. The full
account is in `docs/plans/finished/esp32-large-alloc-ceiling.md`.

**It also costs flash and static RAM, measured on the linked ELF of one
firmware built both ways:** about **+3.2 KB of flash** and **+0.3 KB of
static RAM**, down from +16.6 KB and +3.1 KB two releases earlier —
Expand Down
2 changes: 1 addition & 1 deletion crates/rusty_alloc/UNSAFE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ 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` | 33 | **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 **+1 on 2026-09-09 (morning, `#21`):** the two-sided alignment test frees the SEGMENT_SIZE-aligned page it is served when the region straddles a boundary — `#[cfg(test)]`, banked without a row here at the time; recorded now. **+5 on 2026-09-09 (`docs/plans/finished/region-alignment-bug.md` §7): the module ships its first unsafe OPERATIONS.** (1) `unsafe impl Sync for Region<N>` and (2) the `&mut *self.bytes.get()` in `Region::give` — the once-only handoff the Janus seam has carried since 2.0.0, moved here so the alignment travels with it; a module-wide `REGION_GIVEN` swap precedes the `&mut`, so a second `give` on any instance is refused before it could alias. (3) `unsafe impl Sync for FirstHeapBox`, the bare-metal static holding the first heap's descriptor (handed out once by `take_first_heap_box`, on the one thread such a build has). The other two are `#[cfg(test)]`: a `from_raw_parts_mut` carving the report's misaligned base out of a static for the refusal probe, and a `Box::new_zeroed().assume_init()` allocating a `Region` in place, because materialising a 64 KiB-aligned value on the stack first faults on Windows. Each carries its SAFETY line. **+2 on 2026-09-09 (`docs/plans/finished/region-alignment-dissolve.md`): zero new unsafe in shipped code** — segments now stride from the region's base, and every piece of that is safe: `stride_base` is a relaxed load, `place` takes an `origin` and does arithmetic, `install_region` aligns the base up. The two are `#[cfg(test)]`, the `--cfg ra_aligned_region` arm of the two-sided alignment test (an `alloc` and a `free` through the `unsafe fn` seam: the 2.0.4 straddle case, kept under the knob that restores the mask), and the `Box::new_zeroed().assume_init()` moved under that same cfg — the default arm is a plain `static Region<N>` now, which a 16-byte-aligned type can be on every host toolchain. Each carries its SAFETY line | 2026-09-09 |
| `prim/fixed.rs` | 37 | **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 **+1 on 2026-09-09 (morning, `#21`):** the two-sided alignment test frees the SEGMENT_SIZE-aligned page it is served when the region straddles a boundary — `#[cfg(test)]`, banked without a row here at the time; recorded now. **+5 on 2026-09-09 (`docs/plans/finished/region-alignment-bug.md` §7): the module ships its first unsafe OPERATIONS.** (1) `unsafe impl Sync for Region<N>` and (2) the `&mut *self.bytes.get()` in `Region::give` — the once-only handoff the Janus seam has carried since 2.0.0, moved here so the alignment travels with it; a module-wide `REGION_GIVEN` swap precedes the `&mut`, so a second `give` on any instance is refused before it could alias. (3) `unsafe impl Sync for FirstHeapBox`, the bare-metal static holding the first heap's descriptor (handed out once by `take_first_heap_box`, on the one thread such a build has). The other two are `#[cfg(test)]`: a `from_raw_parts_mut` carving the report's misaligned base out of a static for the refusal probe, and a `Box::new_zeroed().assume_init()` allocating a `Region` in place, because materialising a 64 KiB-aligned value on the stack first faults on Windows. Each carries its SAFETY line. **+2 on 2026-09-09 (`docs/plans/finished/region-alignment-dissolve.md`): zero new unsafe in shipped code** — segments now stride from the region's base, and every piece of that is safe: `stride_base` is a relaxed load, `place` takes an `origin` and does arithmetic, `install_region` aligns the base up. The two are `#[cfg(test)]`, the `--cfg ra_aligned_region` arm of the two-sided alignment test (an `alloc` and a `free` through the `unsafe fn` seam: the 2.0.4 straddle case, kept under the knob that restores the mask), and the `Box::new_zeroed().assume_init()` moved under that same cfg — the default arm is a plain `static Region<N>` now, which a 16-byte-aligned type can be on every host toolchain. Each carries its SAFETY line. **+4 on 2026-09-09 (`docs/plans/finished/esp32-large-alloc-ceiling.md`): all four are `#[cfg(test)]`, and shipped code gained none.** The large-allocation ceiling reported from `rusty_zstd` is reproduced against the real extent allocator by `greedy_dedicated`, which serves `huge_alloc`-shaped reservations until the region refuses and frees every one before returning (two calls through the `unsafe fn` seam), plus one `alloc`/`free` pair modelling the whole segment a first small allocation claims. The fix itself -- the `ra_segment_size` geometry knob, `dedicated_segments`, `region_for_allocs` and `region_capacity` -- is arithmetic over the existing atomics and adds no unsafe operation at all. Each carries its SAFETY line | 2026-09-09 |
| `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 |
Expand Down
Loading
Loading