Skip to content

v0.9.0 — Real GlobalAlloc Implementation

Pre-release
Pre-release

Choose a tag to compare

@jamesgober jamesgober released this 14 May 03:17
· 14 commits to main since this release

mod-alloc v0.9.0 — Release Notes

Date: 2026-05-13
Compare: v0.1.0...v0.9.0

Headline

Transforms mod-alloc from a name-claim placeholder into a working
global-allocator wrapper. Installing ModAlloc as
#[global_allocator] now tracks every allocation, deallocation,
reallocation, and zero-init allocation against four lock-free
AtomicU64 counters. Measured per-allocation overhead is well
under the 50 ns target set by REPS section 6.

What's new

Public API: real implementations behind the existing surface

  • unsafe impl GlobalAlloc for ModAlloc — the four hot-path
    methods (alloc, alloc_zeroed, dealloc, realloc) now
    delegate to std::alloc::System for the underlying allocation
    and record the event in atomic counters. Every unsafe block
    carries a SAFETY comment per .dev/DIRECTIVES.md section 3.
  • ModAlloc::snapshot — now returns the running counter values
    from the live GlobalAlloc path. In v0.1.0 it always returned
    zeros.
  • ModAlloc::reset — zeroes the counters atomically. Documented
    caveat: resetting while live allocations exist can cause
    current_bytes to wrap on subsequent deallocations; reset before
    the workload begins for clean accounting.
  • Profiler::start / Profiler::stop — wired against a
    process-wide AtomicPtr<ModAlloc> handle that the GlobalAlloc
    impl populates lazily on first call. No explicit registration
    step is required. The signature of Profiler::start() -> Self is
    preserved exactly as the REPS API surface specifies.

Profiler::stop returns deltas for alloc_count, total_bytes,
and current_bytes. peak_bytes is the absolute high-water mark
observed during the profiling window (peak-delta has no meaningful
semantic). The rustdoc on Profiler::stop calls this out
explicitly so callers do not misread peak_bytes.

Counter strategy

Counter Updated on alloc Updated on dealloc
alloc_count += 1 (unchanged)
total_bytes += size (unchanged)
current_bytes += size -= size
peak_bytes fetch_max(current_after_add) (unchanged)

All updates use Ordering::Relaxed. peak_bytes uses
AtomicU64::fetch_max (stable since Rust 1.45). realloc is
counted as one allocation event; total_bytes increases by the
growth delta on a growing realloc and is unchanged on a shrinking
realloc, which preserves the invariant peak_bytes ≤ total_bytes.

Reentrancy guard

The allocator hook is recursion-safe. A thread-local
Cell<bool> flag (initialised via const { Cell::new(false) } so
the TLS access path itself does not allocate) is set on entry to
the tracking path. Any allocation transitively triggered from
inside the hook sees the flag and bypasses tracking, forwarding
straight to System. A RAII guard struct clears the flag on drop
(including on panic).

Test additions

tests/ gains four new integration tests, each compiled as its
own binary so each can own its #[global_allocator] without
conflicting with the others:

  1. counters_accuracy.rs — single-thread counter correctness.
    Asserts that alloc_count and total_bytes move by at least
    the workload's expected amount and that peak_bytes never
    decreases after a drop.
  2. concurrent_alloc.rs — multi-thread atomic-contention
    stress test. 64 worker threads × 5,000 allocations each (320,000
    total). Asserts the aggregate counter movement matches the
    workload within sanity bounds and that the run terminates within
    a generous timeout (no deadlock).
  3. profiler_delta.rs — Profiler delta math. Asserts the
    returned delta covers the in-window workload and that
    peak_bytes (absolute) is at least current_bytes (delta).
  4. reentrancy.rs — reentrancy-guard smoke test. Allocation-
    heavy workload with formatting paths and Drop impls that
    allocate. A broken guard would stack-overflow; reaching the end
    proves the invariant holds.

The existing tests/smoke.rs is preserved unchanged.

src/lib.rs #[cfg(test)] mod tests adds 7 new unit tests
covering the private record_alloc / record_dealloc /
record_realloc helpers and the ReentryGuard::enter nesting
semantics, alongside the existing 3 placeholder smoke checks.

Example additions

  • examples/basic.rs — rewritten to install ModAlloc as
    #[global_allocator] and print real counter values for both the
    profiler delta and the process-wide snapshot.
  • examples/bench_overhead.rs — per-allocation overhead
    micro-benchmark. Runs 1,000,000 fixed-size allocate/deallocate
    cycles and reports average nanoseconds per cycle. Sanity-check
    only; criterion is banned by the zero-dep policy and
    #[bench] is unstable.

Measured on the development workstation: 34.9 ns per
alloc + dealloc cycle end-to-end
(which includes the cost of
System itself). The crate's overhead on top of System is a
small fraction of that figure, well under the 50 ns target from
REPS section 6.

Design notes

Per-thread arena deferred to v0.9.1

The original ROADMAP entry for v0.9.0 envisaged a 64 KB
per-thread arena with periodic global aggregation. This release
ships with direct atomic increments on four shared counters
instead. Rationale:

  • For four u64 counters, the indirection of per-thread buffering
    does not pay off. The bench above shows the <50 ns target is
    achievable with direct atomics.
  • Per-thread buffering becomes load-bearing in v0.9.1 when
    backtrace state (32-64 bytes per allocation) would otherwise
    serialise on the global path. Introducing it then is the right
    point in the line.

Full rationale: .dev/DESIGN.md section 2.

Profiler lookup via lazy AtomicPtr

Profiler::start() cannot take a &'static ModAlloc argument
without changing the REPS-frozen signature. Rather than require
an explicit registration call, the GlobalAlloc::alloc impl
records self into a process-wide AtomicPtr<ModAlloc> on its
first non-reentrant call. Profiler reads from that handle. Cost
on the hot path: one Relaxed load per alloc call (essentially
free) plus a one-time compare_exchange on the very first call.

Feature flags

backtraces and dhat-compat remain defined in Cargo.toml so
build matrices stay green and downstream callers can opt in once
the features ship. In v0.9.0 both compile as no-ops. Real
implementations land in v0.9.1 (Tier 2: inline backtrace
capture) and v0.9.3 (Tier 3: DHAT-compatible JSON output).

Migration

The public API surface is unchanged. Callers using the v0.1.0
placeholder API (ModAlloc::new, snapshot, reset,
Profiler::start, Profiler::stop, AllocStats) continue to
compile and run. Behavioural changes:

  • Callers that did not install ModAlloc as
    #[global_allocator] see no change. snapshot still returns
    zeros from a fresh ModAlloc::new() instance because no
    allocations are recorded against it.
  • Callers that did install it now see real counter values
    where v0.1.0 returned zeros.

No code edits required.

Verification

Full matrix run on Windows host:

  • cargo build (default features)
  • cargo build --no-default-features
  • cargo build --features counters
  • cargo build --features dhat-compat (compiles; feature is a
    no-op in v0.9.0 as documented)
  • cargo build --all-features
  • cargo +1.75 build --all-features (MSRV)
  • cargo fmt --all -- --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --all-features: 28/28 pass (10 unit + 4 new
    integration + 5 smoke + 9 doctests)
  • Banned-word scan (comprehensive, robust, seamless,
    leverage): 0 hits across shipping files
  • Em-dash scan: 0 hits across shipping files

Downstream impact

  • dev-bench currently uses dhat 0.3 for alloc-tracking and is
    pinned at MSRV 1.85 by that dependency chain
    (backtrace 0.3.76 → addr2line 0.25.1). With v0.9.0 shipping
    Tier 1 counters, dev-bench can begin its swap and the MSRV
    unblock for the wider dev-* family becomes possible. Full
    drop-in replacement of dhat lands once Tier 2 (backtraces) and
    Tier 3 (DHAT-compat output) ship in v0.9.1 / v0.9.3.
  • Strategy context: C:\Dev\libraries\Rust\_strategy\MSRV_AUDIT_DEV_LIBS.md.

Limitations

  • reset() underflow. Calling reset() while allocations are
    outstanding zeroes current_bytes; subsequent deallocations
    then fetch_sub from zero and wrap. Documented in the rustdoc.
    Reset before the workload begins.
  • backtraces and dhat-compat are no-ops. Both features
    compile but produce no functional change. Tier 2 lands in
    v0.9.1; Tier 3 in v0.9.3.
  • total_bytes semantics for realloc. This release uses
    growth-delta accounting (a growing realloc adds the delta, a
    shrinking realloc adds zero). DHAT counts realloc differently
    (the full new size each time). v0.9.3 will revisit this when
    dhat-compat JSON output ships; the in-memory counter semantics
    may stay as they are with a separate translation layer at
    serialisation time.
  • AtomicPtr registration is first-call-wins. If two
    ModAlloc instances ever existed (one as #[global_allocator],
    another constructed in user code), Profiler would lock onto
    whichever called alloc() first. In practice this is always
    the #[global_allocator] instance, so the failure mode is
    theoretical.

Release ceremony

Standard pattern:

  1. git tag -a v0.9.0 -m "Release v0.9.0 - real GlobalAlloc implementation"
  2. git push origin main
  3. git push origin v0.9.0
  4. cargo publish --dry-run --all-features
  5. cargo publish
  6. Confirm https://crates.io/crates/mod-alloc/0.9.0 is live.

GitHub release title: v0.9.0 - real GlobalAlloc implementation.
Tag as pre-release; 1.0.0 is the API-stability handshake and
comes only after v0.9.4 (dev-bench integration) ships and bakes.


Full Changelog: v0.1.0...v0.9.0