Skip to content

perf(eryx): keep the instance heap mapped with glibc malloc tunables - #419

Closed
sd2k wants to merge 1 commit into
perf/instantiation-overheadfrom
perf/malloc-tunables
Closed

sd2k wants to merge 1 commit into
perf/instantiation-overheadfrom
perf/malloc-tunables

Conversation

@sd2k

@sd2k sd2k commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Wasmtime heap-allocates each instance's VMContext (~450 KB for this runtime's ~10k function references) and frees it on teardown. With glibc's default malloc settings that block is the top of the heap, so every Sandbox::execute() trims it back to the kernel on free and grows it again on the next instantiation: three brk calls and roughly 100 page faults per execution, all on the request path.

This sets two glibc tunables once, when the process-wide engine is created (AllocatorSettings::apply_malloc_tuning, called from build_engine):

  • M_TRIM_THRESHOLD = -1 — never return freed heap to the kernel;
  • M_TOP_PAD = 64 MiB — grow the heap in large steps so the growth happens once.

Both are process-wide, which is why it is documented in guide/performance.md next to the other engine-level knobs and has an opt-out, ERYX_GLIBC_MALLOC_TUNING=0. It only compiles in on Linux + glibc with the embedded/preinit features (the mallopt call is the crate's usual per-item #[allow(unsafe_code)] under deny(unsafe_code), like from_precompiled); elsewhere it is a no-op. A process using jemalloc/mimalloc/musl is unaffected.

The cold-start spike bounded this first with MALLOC_TRIM_THRESHOLD_/MALLOC_TOP_PAD_ in the environment and got identical numbers, so a wasmtime-side instance pool (the original idea for this cost) is not needed.

Also adds an ERYX_PROFILE_TRACE=0 knob to examples/profile_stateless.rs so the harness can measure without sys.settrace overhead (trace collection is on by default in SandboxBuilder and dominates anything that is not pass).

Stacked on #411 (perf/instantiation-overhead) because that is where the harness and the allocator settings live.

Benchmarks

profile_stateless, stock wasmtime 48.0.1, Ryzen 9 7950X, 2000 executions, median of 3, pooling allocator (default). "off" = ERYX_GLIBC_MALLOC_TUNING=0, i.e. current behaviour.

Path tuning off tuning on Δ faults/exec off → on
cold pass, trace collection on 1279 µs 1140 µs −10.9% 344 → 248
cold pass, trace collection off 1078 µs 955 µs −11.4% 283 → 183
cold render (json + string.Template), trace on 5177 µs 4982 µs −3.8% 725 → 628
cold render, trace off 2247 µs 2117 µs −5.8% 671 → 573
warm instance (ERYX_WARM_INSTANCES=1, 2 ms gap) 833 µs 841 µs ≈0 343 → 306

The ~100 faults removed are exactly the heap ones; the remainder are linear-memory pages that wasmtime 48.0.1's PAGEMAP_SCAN reset decommits (its 32-region cap — separate wasmtime fix from the same spike). With that fix in place the same change measured 502 → 378 µs (−25%) on cold pass and left zero page faults per execution.

Testing

  • cargo nextest run --workspace --features embedded --cargo-profile release: 639 passed, 0 failed (includes new malloc_tuning_is_on_by_default_and_can_be_disabled; invalid values are rejected like the other ERYX_POOL_* knobs).
  • cargo clippy --workspace --all-targets --all-features -- -D warnings and cargo clippy -p eryx (default features, forbid(unsafe_code) path): clean.
  • cargo fmt --all --check: clean.

🤖 Generated with Claude Code

Wasmtime heap-allocates each instance's VMContext (~450 KB for this
runtime's ~10k function references) and frees it on teardown. With glibc's
defaults that block is the top of the heap, so every execution trims it
back to the kernel and grows it again: three brk calls and ~100 page
faults on the request path.

Set M_TRIM_THRESHOLD=-1 and M_TOP_PAD=64MiB once when the engine is
created (Linux/glibc only, ERYX_GLIBC_MALLOC_TUNING=0 opts out) and
document it in the performance guide. Cold `pass` on the pooling
allocator: 1279 µs -> 1140 µs, 344 -> 248 faults per execution.

Also add ERYX_PROFILE_TRACE=0 to profile_stateless so the harness can
measure without sys.settrace overhead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sd2k

sd2k commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Closing for now. The effect is real but small on the paths that matter, and the shape is wrong: the allocator is the embedding binary's decision, not something the library should set process-wide with mallopt.

What the follow-up measurements showed (stock wasmtime 48.0.1, perf/instantiation-overhead, Ryzen 9 7950X):

Harness (profile_stateless, no callbacks, 2000 executions, median of 3) — mimalloc as #[global_allocator] in the binary is indistinguishable from the glibc tunables in this PR:

cold pass, ERYX_WARM_INSTANCES=0 glibc default glibc tuned (this PR) mimalloc
gap 0, trace collection on 1283 µs / 347 faults 1149 µs / 248 1144 µs / 248
gap 0, trace collection off 1078 µs / 283 955 µs / 183 957 µs / 183
20 ms gap (mimalloc purge delay exposure) 1786 µs / 341 1625 µs / 245 1649 µs / 244
warm instance, 2 ms gap 833 µs 841 µs 842 µs
cold render, trace off 2247 µs 2117 µs 2160 µs

Criterion stateless_execution (3 callbacks registered), ERYX_WARM_INSTANCES=0:

glibc default glibc tuned mimalloc
pass 4.520 ms 4.484 ms 4.517 ms
print('hello') 4.557 ms 4.507 ms 4.600 ms

All within noise: ~4 ms of each of those iterations is the guest re-running setup_callbacks on the fresh instance (the follow-up named in #416), so a ~130 µs saving is invisible there. That callback cost is the thing to remove first — baking the declarations into the preinit snapshot — and it is a much larger win than this one.

If we still want the ~100 heap faults per execution gone afterwards, the way to do it is #[global_allocator] mimalloc in eryx-server / pyeryx and the benches, not library code; it becomes worth revisiting once the wasmtime PAGEMAP_SCAN fix lands, at which point these are the only page faults left per execution (502 → 378 µs there).

The ERYX_PROFILE_TRACE=0 harness knob from this branch is worth keeping independently — without it nothing but pass can be measured sensibly, since trace collection is on by default.

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.

1 participant