Skip to content

perf(eryx): pool instances and pre-instantiate stores for stateless execution - #411

Draft
sd2k wants to merge 3 commits into
mainfrom
perf/instantiation-overhead
Draft

sd2k wants to merge 3 commits into
mainfrom
perf/instantiation-overhead

Conversation

@sd2k

@sd2k sd2k commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Takes WebAssembly instance creation and teardown off the request path of stateless Sandbox::execute(), the path every SandboxFactory.create_sandbox().execute() render takes.

Profiling pass on a fresh instance (~1.3 ms locally) showed only ~15% of the time running Python:

  • ~30% is wasmtime eagerly building a VMFuncRef for each of the runtime's ~6k function-table entries at instantiation. The dynamically linked modules' elem segments are offset by an imported __table_base into an imported table, and wasmtime's lazy table init (try_func_table_init) bails on both conditions, so it synthesizes a module-start function that calls ref.func ~6k times.
  • ~45% is mapping the 16 MB pre-initialized heap image copy-on-write and tearing it down again: ~430 page faults per execution, with sys time exceeding user time.

Changes

Pooling allocator (AllocatorSettings in wasm.rs). The shared engine now uses wasmtime's pooling allocator, so a released memory slot is reused by the next instance with its pages still mapped, and on Linux only dirtied pages are reset. Configured by ERYX_ALLOCATOR, ERYX_POOL_INSTANCES (default 1000), ERYX_POOL_KEEP_RESIDENT_MB (64), ERYX_POOL_PAGEMAP_SCAN (1). If the pool's address-space reservation is refused, engine creation logs a warning and falls back to on-demand. Both engine constructors now share one base_engine_config(). This does not affect .cwasm compatibility.

Warm-instance pool (crates/eryx/src/warm.rs). After each stateless execution, a background task drops the used store and instantiates a replacement; the next execute() takes it and swaps in the per-execution ExecutorState (WASI ctx, VFS, channels, memory tracker) before any export is called. Keyed by component so short-lived sandboxes share it; only active on a multi-threaded Tokio runtime (on current-thread it would just add to the next request's latency); executions whose memory limit is below the snapshot baseline take the cold path so they fail exactly as before. ERYX_WARM_INSTANCES (default 1, 0 disables) sizes it; PythonExecutor::warm_instances_ready() reports it. Per-execution channel senders are detached synchronously before the hand-off, otherwise run_inner's handler-task joins would wait on the background drop.

Also: examples/profile_stateless.rs (the harness used for these measurements — supports a gap between executions so replenishment gets the same chance it has between real requests), a Performance Tuning book page, README/guide cross-links, and a stale bench comment fixed.

Measurements (7950X, release, pass, no callbacks)

Configuration Tight loop 2 ms gap between executions
main (on-demand, no pool) 1.28 ms 1.44 ms
pooling allocator only 1.26 ms
pooling + warm pool (ERYX_WARM_INSTANCES=1) 0.94 ms 0.81 ms
pooling + ERYX_WARM_INSTANCES=2 0.82 ms 0.81 ms

Inside execute_internal, cold is acquire 0.62 ms + run 0.66 ms + drop 0.16 ms; warm is acquire 0.001 ms + run 0.76 ms. Page faults per execution: ~430 (on-demand) → ~100 (pooling; the remainder is glibc growing/trimming the brk heap for wasmtime's ~450 KB per-instance VMContext, which wasmtime 48 heap-allocates).

Page faults in cloud VMs cost several times what they do here, so the production win should be larger than the local one.

Not in this PR (follow-ups)

  • setup_callbacks runs on every execute when callbacks are registered (and always for sessions): it recompiles ~200 lines of Python each time. That is ~3.2 ms of the criterion bench's 4.7 ms stateless_execution/pass. Guest-side change (python.rs), needs a runtime rebuild — separate PR.
  • Guest compiles the user code twice (_eryx_exec("""…""") wrapper + compile()) plus two more PyRun_SimpleString per execute; also guest-side.
  • wasmtime's PAGEMAP_SCAN reset only handles 32 dirty regions (MAX_REGIONS in sys/unix/pagemap.rs), which is why linear_memory_keep_resident barely helps CPython's scattered refcount writes here — worth an upstream issue.

Testing

  • cargo nextest run --workspace --features embedded: 637 passed (new: tests/warm_instances.rs — pool fills after an execution, is shared across sandboxes, isolation between executions, callbacks/result variable/VFS/fuel limit on a warm instance, below-baseline memory limit still rejected without consuming a warm instance, inactive on a current-thread runtime; unit tests for AllocatorSettings parsing and that the pooling config is accepted by wasmtime).
  • cargo clippy --workspace --all-targets --all-features -- -D warnings clean.

Checklist

  • Tests added/updated
  • Documentation updated
  • Linting passes

🤖 Generated with Claude Code

sd2k and others added 2 commits September 10, 2026 20:19
Prior to this commit, mise.toml asked for rust "1.98", which rustup had
installed as 1.98.0, while rust-toolchain.toml and the workspace
rust-version both require 1.98.1. Every cargo invocation through the mise
shim failed with "rustc 1.98.0 is not supported" until the toolchain was
overridden by hand.

This commit pins mise to the same 1.98.1 so all three agree.

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

Prior to this commit, every stateless `Sandbox::execute()` paid for creating
and destroying its WebAssembly instance on the request path. Profiling
`pass` on a fresh instance (~1.3 ms) showed only ~15% running Python: ~30%
was wasmtime eagerly building a VMFuncRef for each of the runtime's ~6k
table entries (the dynamically linked modules' element segments are offset
by an imported `__table_base` into an imported table, so lazy table init
does not apply), and ~45% was mapping and then tearing down the 16 MB
pre-initialized heap image, taking ~430 page faults per execution.

This commit moves that work off the request path in two layers:

- The wasmtime engine now uses the pooling allocator. A released memory
  slot is reused by the next instance with its pages still mapped, and on
  Linux only the dirtied pages are reset. `ERYX_ALLOCATOR`,
  `ERYX_POOL_INSTANCES`, `ERYX_POOL_KEEP_RESIDENT_MB` and
  `ERYX_POOL_PAGEMAP_SCAN` tune it; if the pool's address-space
  reservation is refused, engine creation falls back to on-demand.
- A process-wide warm-instance pool (`crates/eryx/src/warm.rs`) keeps an
  instantiated store ready per runtime component. `execute()` takes it,
  swaps in the per-execution state, and hands the used store to a
  background task that drops it and instantiates the replacement. The pool
  is keyed by component so short-lived sandboxes (the `SandboxFactory`
  pattern) share it, only runs on a multi-threaded Tokio runtime, and
  leaves executions whose memory limit is below the snapshot baseline to
  the cold path so they fail as before. `ERYX_WARM_INSTANCES` sizes it.

With a 2 ms gap between executions (a request-like pattern), `pass` on a
fresh sandbox drops from 1.44 ms to 0.81 ms on a 7950X; in a tight loop
from 1.28 ms to 0.94 ms. Isolation is unchanged: every execution still
runs on an instance that has never run user code.

Also adds `examples/profile_stateless.rs` for profiling this path and a
Performance Tuning page to the book.

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

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Prior to this commit, a fresh instantiation set the execution's fuel before
instantiating, so `fuel_consumed` included the instructions of the modules'
table-initialising start functions, while an execution on a pre-instantiated
store did not. The same code therefore reported different fuel depending on
whether it happened to get a warm instance, which the book's determinism
doctest caught.

This commit instantiates on an unlimited tank and sets the requested fuel
afterwards, so fuel measures only the user's code on both paths. Fuel limits
now bound user code rather than user code plus instantiation, which is what
the limit was meant to express.

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

This branch has not been deployed

No deployments
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