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
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)", "cfg(ra_segment_size, values(\"256k\"))"] }
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\"))", "cfg(ra_generic_collect, values(\"64\", \"4096\", \"65536\"))"] }

# 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
19 changes: 19 additions & 0 deletions crates/rusty_alloc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`--cfg ra_generic_collect="64" | "4096" | "65536"`**, so a bare-metal
firmware can move the periodic-collect heartbeat. It is the only lever over a
real trade — a sweep returns an empty page, and the next allocation of that
class carves and extends a fresh one, so a short period costs page churn
while a long one costs capacity — and it was unreachable: `options::set` is a
no-op under `ONE_REGION`, and the option's own doc told firmwares to "change
the default it is built with" when no cfg existed. The default does not move.
- **`prim::fixed::shape_of(size) -> Shape`** — page bytes, dedicated segments
and `direct_route`, `const` and derived from the active geometry. Answers
"which page kind, and how many region bytes, does this size cost", which
`region_stats()` cannot because it reports over region extents.
**`Shape::direct_route` names a boundary that moves with POINTER WIDTH**:
`SMALL_SIZE_MAX` is `128 * size_of::<usize>()`, so it is 1,024 on a 64-bit
host and 512 on a 32-bit chip. The Kairos RTOS measured a 16 % step there on
a device and could not reproduce it on a workstation for exactly that reason
(`docs/plans/finished/fixed-prim-small-step.md`).

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

### Added
Expand Down
35 changes: 33 additions & 2 deletions crates/rusty_alloc/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,43 @@ const _: () = assert!(GENERIC_COLLECT < OPTION_COUNT);
/// 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.
/// **The period is a TRADE, and on bare metal it is the only lever over it.**
/// A sweep returns an empty page to its segment; the next allocation of that
/// class then carves and extends a fresh one. In a tight alloc/free loop that
/// keeps one block live, the page IS empty at every sweep, so a short period
/// turns into steady page churn — measured on a host at exactly
/// `100_000 / 512 = 195` carve-and-retire cycles per 100,000 allocations on
/// the `direct[]` route (`size <= SMALL_SIZE_MAX`), against **zero** on the
/// bin route just above it. That is the visible half of the 512-byte step the
/// Kairos RTOS reported (`docs/plans/finished/fixed-prim-small-step.md`);
/// raising this option takes the churn to zero.
///
/// Which way to err is workload-dependent and neither answer is free. Short
/// sweeps cost page churn; long ones cost capacity, which is the defect the
/// 512 exists to prevent. **Do not raise it because a benchmark that keeps one
/// block live got faster** — that workload cannot decay.
///
/// 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.
/// Small profile: 512 by default, `--cfg ra_generic_collect="N"` to move it.
///
/// **The cfg exists because this doc used to tell a firmware to "change the
/// default it is built with" and there was no way to.** `options::set` is a
/// no-op under `crate::ONE_REGION` — options are compile-time constants there,
/// which is what lets the whole option table leave a firmware image — so a
/// bare-metal consumer could neither set it at run time nor override the
/// built-in. Now it can, and the value is still a constant the linker folds.
#[cfg(ra_small_profile)]
pub const GENERIC_COLLECT_DEFAULT: i64 = 512;
pub const GENERIC_COLLECT_DEFAULT: i64 = if cfg!(ra_generic_collect = "64") {
64
} else if cfg!(ra_generic_collect = "4096") {
4096
} else if cfg!(ra_generic_collect = "65536") {
65_536
} else {
512
};

/// Option names in ABI index order (also the env-var suffixes, uppercased).
pub const OPTION_NAMES: [&str; OPTION_COUNT] = [
Expand Down
105 changes: 105 additions & 0 deletions crates/rusty_alloc/src/prim/fixed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,64 @@ pub const fn region_for(usable: usize) -> usize {
segments * seg
}

/// What one allocation of a given size costs, and which path serves it —
/// the compile-time answer to "why did that size behave differently?".
///
/// Every field is derived from the active geometry, so it moves with
/// `--cfg ra_small_profile` and `--cfg ra_segment_size` instead of being a
/// second copy of the routing rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Shape {
/// Bytes of page the request is served from: a small page, a medium page,
/// its own span, or its own segments.
pub page_bytes: usize,
/// Whole segments this allocation reserves for ITSELF; `0` when it shares
/// (see [`dedicated_segments`]).
pub dedicated_segments: usize,
/// Whether the request takes the `direct[]` fast-path route, i.e.
/// `size <= SMALL_SIZE_MAX`.
///
/// **This is the boundary that moves with POINTER WIDTH**, not with the
/// profile: `SMALL_SIZE_MAX` is `128 * size_of::<usize>()`, so it is 1,024
/// on a 64-bit host and **512 on a 32-bit chip**. A sweep that looks flat
/// on a workstation can step on the device for that reason alone, which is
/// exactly what the Kairos RTOS measured
/// (`docs/plans/finished/fixed-prim-small-step.md`).
pub direct_route: bool,
}

/// The [`Shape`] of an allocation of `size` bytes.
///
/// ```ignore
/// use rusty_alloc::prim::fixed::shape_of;
/// // On a 32-bit target this steps at 512; on a 64-bit one, at 1024.
/// const _: () = assert!(shape_of(512).direct_route);
/// ```
#[must_use]
pub const fn shape_of(size: usize) -> Shape {
use crate::types::{
LARGE_OBJ_SIZE_MAX, MEDIUM_OBJ_SIZE_MAX, MEDIUM_PAGE_SIZE, SEGMENT_SLICE_SIZE,
SMALL_OBJ_SIZE_MAX, SMALL_PAGE_SIZE, SMALL_SIZE_MAX,
};
let dedicated = dedicated_segments(size);
let page_bytes = if size <= SMALL_OBJ_SIZE_MAX {
SMALL_PAGE_SIZE
} else if size <= MEDIUM_OBJ_SIZE_MAX {
MEDIUM_PAGE_SIZE
} else if size <= LARGE_OBJ_SIZE_MAX {
// Its own span of whole slices inside a shared segment.
size.div_ceil(SEGMENT_SLICE_SIZE) * SEGMENT_SLICE_SIZE
} else {
dedicated * crate::types::SEGMENT_SIZE
};
Shape {
page_bytes,
dedicated_segments: dedicated,
direct_route: size <= SMALL_SIZE_MAX,
}
}

/// The largest allocation that can SHARE a segment with other allocations.
///
/// At or below this, a request is carved as a span of slices inside a segment
Expand Down Expand Up @@ -1979,6 +2037,53 @@ mod tests {
}
}

/// The Kairos RTOS measured a step at 512 on a 32-bit device and could not
/// reproduce it on a 64-bit host. The reason is here, as an assertion
/// rather than as prose: the `direct[]` route's top is a function of
/// POINTER WIDTH, so it lands on a different size on the two machines and
/// a host sweep cannot see the device's boundary
/// (`docs/plans/finished/fixed-prim-small-step.md`).
#[test]
fn the_direct_route_boundary_moves_with_pointer_width() {
use crate::types::{SMALL_OBJ_SIZE_MAX, SMALL_SIZE_MAX, SMALL_WSIZE_MAX};
assert_eq!(
SMALL_SIZE_MAX,
SMALL_WSIZE_MAX * core::mem::size_of::<usize>()
);
assert!(shape_of(SMALL_SIZE_MAX).direct_route);
assert!(!shape_of(SMALL_SIZE_MAX + 1).direct_route);
// The route top is 1024 on 64-bit and 512 on 32-bit, whatever the
// geometry -- it is a pointer-width fact, not a profile one.
if core::mem::size_of::<usize>() == 8 {
assert_eq!(SMALL_SIZE_MAX, 1024);
} else if core::mem::size_of::<usize>() == 4 {
assert_eq!(SMALL_SIZE_MAX, 512);
}
// WHETHER it coincides with the small-page top is a fact about the
// GEOMETRY, so it is derived rather than asserted -- an unconditional
// `assert_ne!` here passed at the default slice and failed under
// `ra_segment_size="256k"`, where an 8 KiB slice puts
// SMALL_OBJ_SIZE_MAX at 1024 and the two meet on 64-bit too.
//
// The coincidence is the interesting part and is what made the device
// confusing: where the two constants land on the same byte, one step
// hides two boundaries and a sweep cannot tell them apart.
assert_eq!(SMALL_OBJ_SIZE_MAX, crate::types::SEGMENT_SLICE_SIZE / 8);
let coincide = SMALL_SIZE_MAX == SMALL_OBJ_SIZE_MAX;
assert_eq!(
coincide,
SMALL_WSIZE_MAX * core::mem::size_of::<usize>() == crate::types::SEGMENT_SLICE_SIZE / 8,
"the two boundaries coincide exactly when the arithmetic says so"
);
// The page kinds a firmware could not observe before.
assert!(shape_of(16).page_bytes <= shape_of(SMALL_OBJ_SIZE_MAX).page_bytes);
assert!(
shape_of(SMALL_OBJ_SIZE_MAX + 1).page_bytes > shape_of(SMALL_OBJ_SIZE_MAX).page_bytes
);
assert_eq!(shape_of(16).dedicated_segments, 0);
assert!(shape_of(LARGEST_SHARED_ALLOC + 1).dedicated_segments >= 1);
}

#[test]
fn usable_bytes_answers_the_question_a_firmware_asks() {
let seg = SEGMENT_SIZE;
Expand Down
37 changes: 37 additions & 0 deletions docs/LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,43 @@ slice from `dedicated_segments` and the sizing test goes red); the reproduction
is a permanent property-based test against the real extent allocator. Unsafe
+4, all `#[cfg(test)]` — the fix is arithmetic and adds none to shipped code.

## SMALL-PATH STEP — not the prim, the POINTER WIDTH; the heartbeat knob bare metal could not reach (2026-09-10)

`docs/plans/finished/fixed-prim-small-step.md`: the Kairos RTOS measured one
alloc+free stepping 314 -> 271 cycles/op across 512 bytes on a 32-bit ESP32-S3,
could not reproduce it on a 64-bit host, and concluded it "points at
`prim::fixed`".

**Re-attributed.** `SMALL_SIZE_MAX` is `128 * size_of::<usize>()` — 1,024 on
their host, **512 on the device**, where it coincides with
`SMALL_OBJ_SIZE_MAX`. Their refutation ("the 512 step is not `SMALL_SIZE_MAX`")
was run on a machine where that constant sits at 1,024. Moving ONE variable,
pointer width, with `prim::windows` in both arms (ABBA, 50k ops/arm, min of 40
blocks): x86-64 **+0.1 %** at 512 vs 513, i686 **+8.6 %**, control (256 vs 264)
under 1 % on both. The prim is exonerated; the run that settled it was
`--target i686-pc-windows-msvc`, outside the crate, not the fixed-prim-on-host
build the report asked us for.

**Mechanism, by counter not clock.** Per 100,000 pairs with one block live, the
`direct[]` route carves/extends/retires **195** pages and the bin route just
above it **0**, while `generic`/op is 1.0000 on both — so the slow path is not
the difference, page churn is. `100_000 / 512 = 195` is
`GENERIC_COLLECT_DEFAULT` at the small profile. The boundary is the ROUTE, not
the page kind: on 64-bit, 513–1024 are medium pages that still churn.

**Shipped:** `--cfg ra_generic_collect` (the trade was real and the knob was
unreachable — `options::set` is a no-op under `ONE_REGION`); and
`prim::fixed::shape_of` so the route and page kind are a `const` answer rather
than a silicon discovery. Default unchanged: the 512 exists because 10,000
starved this profile (168 -> 8 blocks, 22,533/50,000 nulls).

**Withdrawn rather than quoted:** how much of the 16 % the churn accounts for.
Raising the heartbeat took churn to a measured zero, but that experiment's
timing control flipped +8.0 % -> +0.6 % on re-run, so the instrument was
deciding it. The device is the right box; the counter half needs no quiet one.

**Gates:** 20 suites at both profiles, gate-selftest 11/11, wasm 20,168.

## REGION ALIGNMENT DISSOLVED — segments stride from the base; 24,144 B of stack back, +3 instructions per free, one knob (2026-09-09)

`docs/plans/finished/region-alignment-dissolve.md`: the Janus firmware's
Expand Down
Loading
Loading