Skip to content

chore: release v2.2.1 - #36

Merged
Ttimmahlax merged 2 commits into
mainfrom
release-plz-2026-09-10T23-05-31Z
Sep 25, 2026
Merged

Ttimmahlax merged 2 commits into
mainfrom
release-plz-2026-09-10T23-05-31Z

Conversation

@github-actions

@github-actions github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

🤖 New release

  • rusty_alloc: 2.2.0 -> 2.3.0 (✓ API compatible changes)
  • rusty_alloc-api: 2.2.0 -> 2.3.0 (✓ API compatible changes)
Changelog

rusty_alloc

2.3.0 - 2026-09-25

Added

  • four rounds of deterministic instruction wins, a Rust GlobalAlloc fast path, and 16-byte natural alignment

Other

  • the three Ubuntu-only compile errors a Windows box cannot see
  • Openheimer, split: land the boundary fixes, drop the hot-path guards, write OH-11

Fixed

  • Growing a huge block with realloc copied its whole reservation. A
    huge segment's page reported its usable size as the chunk-rounded
    reservation minus the header — 64 MiB for a 33 MB request, 96 MiB for a
    64 MB one — and realloc copies usable_size bytes when it moves a block,
    so growing a 33 MB block copied, and first-touched on both sides, twice what
    the caller had written; zalloc on a recycled chunk zeroed the same extent.
    A consumer measured the two realloc steps that cross the segment size at
    1.41x and 1.23x mimalloc, 0/6 pairs, with every step below it faster
    than mimalloc, and a Vec pushed to 64 MB at 1.23x
    (docs/plans/youslowbro.md §3). The page now reports the request rounded
    up to a slice — upstream's psize, and what mi_usable_size returns there
    — while the reservation itself is unchanged. On the consumer's own
    harness, pinned and ABBA-paired against the tree before the fix: see the
    LEDGER entry for the numbers. tests/alloc_core.rs::huge_usable_size_is_the_request_not_the_reservation
    pins the reported size and the bytes a move preserves.
  • The options pass made 251 allocations through the global allocator on
    the first allocation of every process.
    options::ensure_init built its
    38 x 2 environment keys with to_uppercase and format! and read them with
    std::env::var, every one an owned String — inside the heap that was
    still being set up, and on every short CLI run. mimalloc makes none. The
    keys are now built in a stack buffer sized by the table and read through
    prim::getenv (a raw getenv / GetEnvironmentVariableA, the calls
    upstream's prim makes) into a second stack buffer, and the value grammar is
    parsed in place. On the consumer's counting probe the process's start-up
    went from 263 allocations to 12, which is exactly what mimalloc reads
    there
    . tests/options_env.rs proves the variables still arrive (both
    prefixes, precedence, the boolean grammar, KiB scaling) from a child
    process, and rusty_alloc_api/tests/reentrancy.rs fails the build if the
    allocator ever allocates through the global allocator again while serving
    a request (docs/plans/youslowbro.md §4).
  • A cross-thread double free could hang the next collect instead of
    aborting.
    The README says a double free aborts "on both the local and the
    cross-thread path". The cross-thread arm of that check lived in
    page_collect and compared the drained chain's length against used —
    AFTER walking the chain. A block freed twice from another thread is linked
    onto xthread_free twice, which makes the chain CYCLIC, so the walk never
    reached the compare: the owner's next collect, or on an abandoned page the
    next reclaim, spun forever, and nothing aborted. Now remote_free refuses a
    block that is already the chain head — one compare on the cross-thread arm,
    and the local path is byte-identical, checked on the x86-64 release
    assembly for Windows and Linux — and the collect walk is bounded by used,
    so the interleaved case (A, B, A) aborts on the first collect instead of
    hanging. Four child-process regressions in tests/double_free.rs (a block
    abandoned by a dying thread; set_default_heap then exit; a first-class
    heap then exit; interleaved A-B-A), green under secure, blockmap and
    both. Found by the Openheimer campaign as OH-rusty_alloc-11, whose log
    claimed a fix that had never been written.
  • Thread exit abandoned only the heap in done_slot. A first-class heap
    never heap_deleted before its thread exited, or one installed with
    set_default_heap, stayed DELAYED under a dead owner: its blocks could be
    freed any number of times onto a delayed list no thread would ever drain,
    and a thread that only ever called create_heap had no exit hook at all.
    create_heap now bootstraps the thread first and exit abandons every heap
    the thread still owns; upstream deletes non-backing heaps at thread exit
    for the same reason (OH-rusty_alloc-13).
  • Integer wraps a caller could reach with a size, alignment, offset or
    option value, all on cold paths:
    malloc(usize::MAX) overflowed
    header + size in huge_alloc; a huge offset to malloc_aligned_at or
    realloc_aligned_at wrapped p + offset (debug panic, release wild
    pointer); guarded malloc(MAX) wrapped payload + page; os::page_align_up
    and os::alloc_aligned on an unrepresentable size or a garbage alignment;
    bin_size of a garbage index; page_under_utilized with a huge percentage;
    options::get_size KiB scaling; get_clamp with inverted bounds;
    reserve_huge_os_pages pages * 1 GiB; prim::fixed::dedicated_segments
    and region_for at usize::MAX; segment_map::register_range within one
    window of the top of the address space (the window walk is bounded);
    subproc_new exhaustion and subproc_add_current_thread out of range,
    which were assert!s on public entries; and is_aligned_to(x, 0), which
    was true for every x. Each returns null, Err, false or a refused id
    instead (OH-rusty_alloc-5, 6, 8, 9, 10, 14, 18–21, 28–34, 42–45).
  • manage_os_memory adopted anything it was handed. Null, a range that
    wrapped, an unmapped address, or a window this allocator already owns all
    became an arena, and the next default malloc wrote a Segment header
    there. The range must now be non-null, non-wrapping, mapped (mincore /
    VirtualQuery behind prim::range_is_reserved) and not already a
    registered window; a real OS reservation still adopts. arena_register
    claims its slot with a CAS and frees the descriptor and the mapping when
    refused; chunk_alloc_n(0) is None, and chunk_free_n / chunk_free
    refuse a count past the bitmap or an interior pointer instead of releasing
    a live chunk (OH-rusty_alloc-12, 15, 22, 25, 27, 201, 202).
  • Hook re-entry. A deferred-free, error or output hook that allocated,
    printed or errored re-entered itself until the stack was gone; each hook
    now runs under a thread-local in-hook flag (OH-rusty_alloc-17, 23, 24).
  • --release --features debug_checks did not check. The foreign-pointer
    guard was a debug_assert! inside the debug_checks cfg, compiled out of
    the one build a consumer enables the feature for. It is an assert!
    (OH-rusty_alloc-7). malloc_small / zalloc_small forward an oversize
    request instead of debug_assert!ing on it (OH-62).
  • OS wrappers on a lie. prim::alloc with a garbage alignment and
    prim::commit of null are Err; the latter used to hand Windows an
    untracked mapping (OH-rusty_alloc-38, 60).
  • C ABI. mi_dupenv_s / mi_wdupenv_s clear the caller's out-pointers
    on EINVAL as the CRT contract says; mi_realpath never writes past
    PATH_MAX; every out-parameter write refuses a null or misaligned pointer;
    a null, misaligned or immortal-empty heap handle is "no heap" for every
    mi_heap_* entry rather than a dereference, and mi_heap_set_default
    refuses such a handle instead of installing it (OH-rusty_alloc-16, 26, 54,
    58, 65, 66, 97–102).

Performance

  • Process start-up: the options pass walks the environment once (Linux)
    instead of calling getenv 76 times, each of which walked all of it —
    about 25,000 fewer instructions on the first allocation of every process
    (a one-line sort −1.6 % whole-process), more with a larger environment.
    Same semantics, pinned by tests/options_env.rs.
  • Four more instruction-count reductions, CURIOSITY ROUND FOUR in
    docs/LEDGER.md: posix_memalign and Rust's over-aligned alloc carry the
    aligned fast path inline (opscan aligned −9.2 %); a medium allocation pops
    its page before collecting it (big/large −3.7 %, mixed −3.0 %); C++17
    aligned new takes the power-of-two fast path; and Rust realloc above
    two words of alignment keeps a block in place when it fits instead of
    always moving it (−27.3 % against 2.2.0 on an over-aligned buffer workload).
  • Rust programs: the GlobalAlloc entry points were paying for a shim
    and a detour on every call
    (rusty_alloc-api). The trait methods are now
    #[inline], as the mimalloc crate's are, so __rust_alloc and friends
    carry the fast paths instead of jumping through the GOT to them; dealloc
    carries the free body with its null test folded away; and layouts aligned
    up to two words (16 bytes on x86-64, what every hashbrown table asks for)
    are served from the ordinary size classes, which are already aligned that
    far, instead of the aligned path, and can now realloc in place instead of
    always allocating, copying and freeing. Two deterministic Rust workloads
    (bench/rust-globalalloc.sh), measured against 2.2.0: −21.1 % and −5.8 % whole-program
    instructions, for about 2–3 KB of text. tests/natural_align.rs pins the
    alignment for every size class through alloc, alloc_zeroed and realloc.
  • Two more on the allocator core, with the three above making up
    CURIOSITY ROUND THREE in docs/LEDGER.md: the page-extend floor now matches upstream's
    MI_MIN_EXTEND of four blocks (perl −454,165 whole-program, opscan
    big/large −4.5 %), and span marking no longer rewrites interior slices
    that already point to their span (opscan huge −53 %).
  • Ten deterministic instruction-count reductions on the allocation and
    free paths, each measured alone under callgrind on opscan and on real
    programs; docs/LEDGER.md (CURIOSITY) has every number and the seven
    refutations. The largest: realloc(NULL, n) no longer pays the moving
    path's five-register frame — Lua routes every allocation through
    realloc, and its allocator instruction count fell 39.9 % — and
    malloc_slow is a tail call again, which with a leaf calloc fast path, a
    single-branch keep-one-warm test, a load-first options::get and a
    thread-local that LLVM had hoisted above its guard takes opscan
    big/large −16.5 %, mixed −12.9 %, calloc −11.9 % and huge −7.4 %.
    No op regressed. stats.delayed_frees is now counted in debug builds
    only, like allocs and frees.
  • Ten more, measured the same way plus Python, jq, gawk and sort
    (docs/LEDGER.md, CURIOSITY, ROUND TWO). Cross-thread frees are 31 %
    cheaper
    on opscan xthread: a remote free to a parked page now releases
    the page to NORMAL after its one delayed-list push, as upstream mimalloc
    does, so later remote frees are a single CAS onto the page's own list
    instead of three CASes and a per-block drain by the owner (single-block
    pages keep the delayed route; the loom model gained a case for it).
    Also: realloc decides a move in one compare, a medium allocation whose
    front page is dry grows it without re-running the heartbeat, the generic
    path tail-calls page growth again, free_general needs no stack frame,
    and posix_memalign / GlobalAlloc skip a power-of-two re-test through
    the new alloc::malloc_aligned_pow2. perl −456,939 and Python −389,886
    whole-program instructions; opscan big/large/mixed read +0.7…+1.0
    on the medium-grow change against round one's intermediate state and are
    still −1 % over the round.

Changed

  • usable_size (mi_usable_size) of a huge block is the request rounded
    up to a slice, no longer the reservation.
    Two behaviours follow from that
    and both match upstream: a realloc that grows a huge block into what used
    to be reported as slack now moves it instead of returning it in place, and
    expand refuses such a growth. A shrink to at least half the request stays
    in place, where before it could MOVE (a 33 MB block shrunk to 20 MB was
    below half of the 64 MiB it reported). The address space reserved for a
    huge block is exactly what it was.
  • What was NOT taken from the Openheimer campaign, and why. The
    campaign's log carries 202 findings; 139 of them are one probe — an
    internal unsafe fn handed null, 0x1, or an address just past a
    constant "floor" — chased through 25 helpers and up a ladder of floors
    (0x1000, 0x10000, SEGMENT_SIZE, then 2×, 3×, 4× that) and finally
    answered with a segment-map membership lookup on every internal handle:
    page_of, page_index, page_area, free_local, retire_emptied,
    box_of_xheap (a registry walk under a global lock, on the free path) and
    twenty more. Those guards validated contracts the caller inside this crate
    already upholds, duplicated the residual the threat model accepts for free
    (R-001: release free trusts its pointer's window), and taxed the hot path
    — free +27, realloc +164, page_extend +68 instructions when the
    campaign stopped, which it never measured. None of it landed. What did:
    every check on a value a caller can actually choose — sizes, counts,
    alignments, offsets, option values, out-pointers, C strings, heap handles at
    the C ABI, manage_os_memory ranges — on paths that are cold or, for
    free, cost the local path nothing. x86-64 release assembly against 2.2.0,
    Windows / Linux: free +4 / +4 (both on the cross-thread arm; no frame),
    malloc 0 / 0, malloc_aligned_at 0 / 0, realloc +7 / +6,
    realloc_aligned_at +1 / +1, page_extend 0 / 0, usable_size 0 / 0.
    The disposition, finding by finding, is docs/plans/openheimer-run.md.
  • bins::is_aligned_to spells its power-of-two test as
    align & (align - 1) == 0 rather than is_power_of_two(): without popcnt
    in the target features the latter was emitted as a 20-instruction SWAR bit
    count inside realloc_aligned_at (+43 on that function, now +1).

rusty_alloc-api

2.3.0 - 2026-09-25

Added

  • four rounds of deterministic instruction wins, a Rust GlobalAlloc fast path, and 16-byte natural alignment

Other

  • release v2.2.0


This PR was generated with release-plz.

@github-actions
github-actions Bot force-pushed the release-plz-2026-09-10T23-05-31Z branch 4 times, most recently from 71be889 to 91567cf Compare September 16, 2026 12:23
@github-actions
github-actions Bot force-pushed the release-plz-2026-09-10T23-05-31Z branch 2 times, most recently from 98e5764 to 4d13beb Compare September 20, 2026 00:39
@github-actions github-actions Bot changed the title chore: release v2.2.1 chore: release v2.3.0 Sep 25, 2026
@github-actions
github-actions Bot force-pushed the release-plz-2026-09-10T23-05-31Z branch from 4d13beb to f9b434f Compare September 25, 2026 16:33
… the README status

Maintainer's call: ship as a patch (2.2.1) rather than release-plz's 2.3.0.
The release adds public items and moves none (cargo-semver-checks: API
compatible). The generated changelog bullets duplicated the hand-written
sections and are removed; rusty_alloc-api gets its own Performance notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ttimmahlax Ttimmahlax changed the title chore: release v2.3.0 chore: release v2.2.1 Sep 25, 2026
@Ttimmahlax
Ttimmahlax merged commit 79e29e5 into main Sep 25, 2026
8 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants