Sponsored Content
Skip to content

append's growth path is the entire sieve benchmark gap (not slice indexing) β€” and the obvious realloc fix is unsoundΒ #578

Description

@javimosch

bench/native-speed has always shown machin trailing Rust and Zig on the sieve
kernel by ~1.4x, and the benchmark README explained it as:

On the array-heavy sieve machin trails by ~1.4Γ— β€” its slice indexing/layout
is less optimal than a Rust Vec or a Zig slice.

That diagnosis is wrong. Phase-timing the kernel shows slice indexing is not
the problem at all β€” the sieve loop ties Rust exactly:

phase machin Rust
build the 10M array by append 70–83 ms 27–29 ms
the sieve loop itself 110 ms 111 ms
the count loop 5–7 ms 3 ms

The generated C for the hot loop is already what you would write by hand β€” direct
pointer indexing, no bounds check, no indirection:

while ((v_m <= v_n)) { ((int64_t*)(v_sieve).data)[v_m] = 0LL; v_m = (v_m + v_p); }

The entire gap is append's growth path.

Mechanism

mfl_append doubles capacity through mfl_realloc, and mfl_realloc can only
ever copy:

static void* mfl_realloc(void* old, size_t sz) {
    void* p = mfl_alloc(sz);                 /* a brand-new malloc'd arena block */
    if (old) { size_t o = ((mfl_blk*)old - 1)->size; memcpy(p, old, o < sz ? o : sz); }
    return p; /* old reclaimed with its arena β€” never freed individually */
}

So growing to 10M elements walks ~21 doublings, each one allocating a fresh block
and memcpy'ing, and never releasing the previous buffer (an arena frees nothing
mid-life). The process therefore touches roughly 2x the final array in freshly
faulted pages. Vec::push hands the block to realloc, and glibc extends a large
block in place via mremap β€” no copy, no new pages.

The obvious fix is unsound β€” do not just do it

When the block being grown is the arena's most recent allocation, nothing is
layered on top of it in the block list, so it looks safe to hand it straight to
realloc() and let glibc mremap it. I implemented and then rejected this,
because MFL slices share backing storage:

func mutate(s) { s[0] = 77 }

a := []int{1,2,3}
b := a            // b.data == a.data  (verified: mutating a changes b)
mutate(c)         // params share too  (verified)

Today, a = append(a, x) allocating a fresh block leaves every existing alias
pointing at the old block, which is still live because the arena frees nothing.
That is Go-like "append may stop sharing" β€” surprising, but memory-safe:

a := []int{1,2,3}
b := a
i := 0
while i < 1000 { a = append(a, i)  i = i+1 }   // ~8 reallocations
println(b[0])                                   // prints 1 β€” still valid today

With an in-place realloc, that b[0] becomes a use-after-free whenever the
block moves. Trading a 45 ms benchmark win for a silent dangling read is exactly
the bug class machin exists to eliminate, so the naive version must not ship.

Directions that could be sound

  1. Escape/alias analysis on the slice. machin already computes provenance
    interprocedurally for ARENA001. If we can prove a slice has no live alias at
    the append site, in-place growth on the arena-head block is safe. This reuses
    machinery that exists rather than adding a new concept.
  2. Free the abandoned block when provably unaliased, keeping the copy but
    letting the pages be reused β€” cheaper than in-place growth but far simpler to
    justify.
  3. A capacity hint (make([]int, 0, n) or similar) so the common
    "I know the final size" case does one allocation. Sidesteps the analysis
    entirely for the case the benchmark actually hits, though it does not help
    code that genuinely grows unboundedly.

(1) is the one that matches machin's existing direction β€” inferred, no
annotations.

Why this is worth fixing beyond the benchmark

append in a loop is the single most common way to build a collection in MFL, so
this is not a synthetic-benchmark artifact β€” it is on the hot path of ordinary
code. grange's index builds and any parser accumulating tokens pay it.

Reproduce: bench/native-speed/./run.sh, then the phase breakdown in
bench/native-speed/README.md.

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