Skip to content

perf(build): revisit the mimalloc v2 pin when libmimalloc-sys vendors >=3.4.4 — upstream fixed the co-residency mechanism #448

Closed

Description

@niko86

A watch item with a checkable trigger, not a task to start now. The v2 pin (#301) is still correct today; what changed is that upstream has fixed the underlying mechanism, so the pin now has an expiry condition instead of being permanent.

The trigger

libmimalloc-sys vendors mimalloc >= 3.4.4 on its v3 line. Check it at the source rather than by crate version, which does not name the C version:

# after `cargo update -p libmimalloc-sys --dry-run` tells you a newer release exists
grep "define MI_MALLOC_VERSION" \
  ~/.cargo/registry/src/*/libmimalloc-sys-*/c_src/mimalloc/v3/include/mimalloc.h
# 30302 = v3.3.2 (what 0.1.49 ships — the faulting build)
# >= 30404 = the fixed build

At the time of filing: libmimalloc-sys 0.1.49 (published 2026-05-22) is the newest release and vendors v3 = 3.3.2, with the Apple fixed-slot path still in c_src/mimalloc/v3/include/mimalloc/prim.h. mimalloc 3.4.4 was released 2026-08-01. So the fix exists and has not reached Rust.

Why the pin can eventually go

microsoft/mimalloc#1327 is closed as completed. The mechanism is macOS-specific and narrower than our own notes suggest: v3 defaulted to fixed TLS slots 108/109, which Apple's libpthread reserves for the Swift runtime, so every co-resident v3 instance shared those two cells regardless of symbol visibility. The maintainer's closing note is that v3.4.4 uses pthreads TLS by default on macOS, which removes the collision.

That also explains the shape of the evidence gathered in #447: either side moving off the fixed slot clears the fault, which is why pyarrow 25.0.1 alone was enough with an unfixed v3 on our side.

Why it cannot go yet

  • We do not choose the user's pyarrow, and 24.0.0-25.0.0 remain installable. Arrow bundles v3 across the board — 23.0.0 -> 3.1.5, 24.0.0 -> 3.2.7, main -> 3.3.1 (from the upstream thread) — so a fixed-slot v3 partner is the general case, not an exotic one.
  • Our exposure note says "pyarrow 24.0.0-25.0.0", which is narrower than the mechanism. Any fixed-slot v3 co-resident qualifies.
  • Shipping v3 from 0.1.49 today is shipping the faulting build and relying on everyone else in the process having been fixed.

What to re-run when the trigger fires

The harness from #447, with its controls validated (the positive control must reproduce the documented UnicodeDecodeError, or the run proves nothing):

  1. 2x2 corruption matrix — our pin (v2 / v3) x pyarrow (25.0.0 / 25.0.1), the test(python): intermittent heap corruption in an in-process native call fails the coverage gate ~1 run in 4 #294 minimal repro as probe, checking exit codes as well as output: the upstream report's crash is at teardown, which a probe reading stdout alone will miss.
  2. The arm that is still missing — a free-threaded CPython 3.14 (3.14t) build. The 3.14 arm run in test: gate the mimalloc v2 pin off the manifests — and close out #297, which was #294 all along #447 was GIL-enabled, where CPython's vendored mimalloc is not the active allocator, so the CPython partner our Cargo.toml comment leans on has never actually been measured. That gap is worth closing whether or not the pin moves.
  3. Perf — re-measure rather than quoting the figure recorded when the pin landed; take it from the benchmark suite, and remember the criterion drift on the dev machine (A/B/A, or say the instrument cannot resolve it).

Note for whoever picks this up

tests/test_allocator_pin_faithful.py (#447) asserts the feature on every crate that sets a #[global_allocator]. That is deliberate: unpinning has to be an edit to a test that explains why not, rather than a quiet dependency change. Update the gate as part of the decision, not around it.

Related: #294, #297, #301, #447, ags-wiki/concepts/arrow-c-ffi-allocator-ownership.md.

Activity

  1. niko86 commented on Aug 20, 2026

    @niko86
    OwnerAuthor

    Measured: the blanket pin costs Linux and Windows nothing

    This issue notes that the pin is not platform-gated while the fault it guards is
    macOS-specific — v3 defaulted to Apple's fixed TLS slots 108/109, which
    libpthread reserves for the Swift runtime. So Linux and Windows carry a macOS
    fix, and what that cost them had never been measured. It has now, on the
    self-hosted runners, at the 25 MB forge scale rung, 15 reps per arm.

    Two independent runs, both platforms

    Each run builds lat three ways — v2, v3, and no #[global_allocator] at
    all
    — and times them A/B/A. The system arm is a positive control: the perf
    ledger records mimalloc-vs-system as a real win, so if it fails to separate, the
    harness is not resolving allocators and nothing else in the run means anything.

    Linux (laterite-linux, glibc 2.31, rustc 1.98.0)

    run v2 v3 system (control) noise floor verdict
    32418268428 300.1 ms 310.1 ms (+3.3%) 408.7 ms (+36.2%) 3.8% indistinguishable
    32421189772 300.6 ms 305.9 ms (+1.8%) 407.2 ms (+35.5%) 3.0% indistinguishable

    Windows (laterite-windows, Win11 VM, rustc 1.98.0)

    run v2 v3 system (control) noise floor verdict
    32418268428 413.9 ms 406.8 ms (−1.7%) 672.9 ms (+62.6%) 8.2% indistinguishable
    32421189772 393.6 ms 395.3 ms (+0.4%) 603.9 ms (+53.4%) 13.1% indistinguishable

    What it says

    The control separated hard everywhere — mimalloc beats the system allocator
    by ~36% on Linux and 53–63% on Windows. The instrument demonstrably resolves
    allocator effects at this scale, which is what makes the null result below worth
    anything.

    Against that, v3 does not beat v2 on either platform. Every v2/v3 gap sits
    inside its own run's noise floor, and the Windows deltas change sign between
    runs
    (−1.7%, then +0.4%) — which is what a null result looks like from the
    inside.

    So narrowing the pin to cfg(target_os = "macos") would buy Linux and Windows
    nothing measurable. That inverts the case for doing it: the change would have
    zero measured upside against three real costs — platform-divergent allocator
    behaviour in a fault class that has already bitten this project (#294, #297,
    #301), a cfg in three manifests that today carry one flat line, and a more
    complicated tests/test_allocator_pin_faithful.py.

    One honest caveat on the premise

    "The fault isn't on Linux or Windows" is an inference from mechanism, not a
    measurement — the fixed TLS slots are an Apple libpthread fact, and no Linux arm
    was ever run (arrow-c-ffi-allocator-ownership.md has no linux/glibc
    mention). It is a strong inference, and it does not need testing: since
    unpinning buys nothing, the safe choice and the fast choice are the same one.

    What this does and does not close

    It closes the cost half — that is no longer an open premise.

    It does not close the issue. The trigger is unchanged and has not fired:
    libmimalloc-sys 0.1.49 still vendors v3 = 30302 (3.3.2), below the 30404
    threshold, and cargo update -p libmimalloc-sys --dry-run locks 0 packages, so
    no newer release exists. And mac is unaffected by any of the above — pyarrow
    25.0.1 being fixed does not release it, for the reasons laterite-py/Cargo.toml
    already gives: we do not choose the user's pyarrow, and CPython 3.14 vendors its
    own v3 regardless of pyarrow.

    The re-run list's 3.14t arm also stands, and stays where it is — it only
    becomes decision-relevant at the moment someone is actually deciding to unpin.

    The harness is retired in the same PR that lands this, per its reliquary row.

  2. niko86 commented on Aug 20, 2026

    @niko86
    OwnerAuthor

    Closing: the pin is a decision now, not a watch item

    Two things were open here. Both are answered, and neither leaves anything to
    wait for.

    1. What the blanket pin costs Linux and Windows — measured, and it is nothing

    Recorded in the comment above: two runs on each self-hosted runner, 25 MB rung.
    The system-allocator control separated by 35–36% (Linux) and 53–63% (Windows),
    so the harness demonstrably resolved allocator effects — and against that, every
    v2/v3 gap sat inside its own run's noise floor, with the Windows delta changing
    sign between runs.

    2. The CPython partner — answered by source, not by a corruption run

    The re-run list asked for a free-threaded 3.14t arm, because "the CPython
    partner our Cargo.toml comment leans on has never actually been measured."

    It has now been settled more strongly than that arm could have:

    CPython vendors mimalloc v2.1.2, not v3. Include/internal/mimalloc/mimalloc.h
    carries MI_MALLOC_VERSION 212 on both the 3.14 branch and main, and the
    shipped free-threaded build agrees. And v2 takes the contested macOS TLS slot
    only under MI_MALLOC_OVERRIDE, which CPython never defines — v3 made fixed
    slots 108/109 the macOS default for everyone, which is the whole mechanism.

    So CPython is not on a different version of the same hazard; it is structurally
    incapable of being in the collision. Three mimallocs share the process — ours,
    pyarrow's (default_memory_pool().backend_name → mimalloc), CPython's — and
    only two were ever in the fault. Corrected in #480; the harness retires in #479.

    Why this closes rather than staying open on the 3.4.4 trigger

    The trigger was only ever a gate on a decision: upstream fixes v3 → we could
    unpin → is unpinning worth it?
    That last step now has an answer, so the trigger
    firing would not change what we do. Waiting on it keeps an issue open forever for
    a question already settled.

    And the one thing that would genuinely force a change is already automated.
    If libmimalloc-sys ever drops the v2 line, the pin stops being a choice — and
    cargo catches that at resolution, before any build:

    $ cargo metadata     # with a feature that no longer exists
    error: failed to select a version for `mimalloc`
    exit=101
    

    A hard build failure is a better watch than an issue whose manual check is "run
    cargo update --dry-run, then grep MI_MALLOC_VERSION out of a vendored C
    header" — which is exactly the check that never gets run.

    The decision, recorded

    mimalloc v2 is pinned on all three native artifacts, permanently, unless
    libmimalloc-sys drops the v2 feature.
    Its job is to keep us off the fixed
    slot so we cannot collide with pyarrow's copy, which is the only partner that was
    ever in the fault. It is enforced by tests/test_allocator_pin_faithful.py,
    which DISCOVERS the crates that set a #[global_allocator] rather than listing
    them, so a new surface cannot ship unpinned by omission.

    Dropping our mimalloc entirely would sidestep co-residency altogether, and is
    firmly the wrong trade: the control arm prices it at +36% (Linux) and +53–63%
    (Windows).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions