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
- 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.
- 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.
- 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.
bench/native-speedhas always shown machin trailing Rust and Zig on the sievekernel by ~1.4x, and the benchmark README explained it as:
That diagnosis is wrong. Phase-timing the kernel shows slice indexing is not
the problem at all β the sieve loop ties Rust exactly:
appendThe generated C for the hot loop is already what you would write by hand β direct
pointer indexing, no bounds check, no indirection:
The entire gap is
append's growth path.Mechanism
mfl_appenddoubles capacity throughmfl_realloc, andmfl_realloccan onlyever copy:
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::pushhands the block torealloc, and glibc extends a largeblock 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 glibcmremapit. I implemented and then rejected this,because MFL slices share backing storage:
Today,
a = append(a, x)allocating a fresh block leaves every existing aliaspointing 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:
With an in-place
realloc, thatb[0]becomes a use-after-free whenever theblock 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
interprocedurally for ARENA001. If we can prove a slice has no live alias at
the
appendsite, in-place growth on the arena-head block is safe. This reusesmachinery that exists rather than adding a new concept.
letting the pages be reused β cheaper than in-place growth but far simpler to
justify.
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
appendin a loop is the single most common way to build a collection in MFL, sothis 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 inbench/native-speed/README.md.