Skip to content

perf(runtime): only reinstall callbacks when their declarations change - #416

Merged
sd2k merged 2 commits into
mainfrom
perf/guest-callback-setup-cache
Sep 11, 2026
Merged

sd2k merged 2 commits into
mainfrom
perf/guest-callback-setup-cache

Conversation

@sd2k

@sd2k sd2k commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

The guest ran its callback setup script on every execute unless the host flagged a fresh instance with no callbacks. That script compiles a few hundred lines of Python (invoke, list_callbacks, the _EryxNamespace/_EryxCallbackLeaf classes, one wrapper per callback) and costs ~2.5 ms per execution — session_execution/pass measured 2.7 ms while running pass itself is well under 0.2 ms. Every persistent session paid it on each call (sessions pin reuse_empty_callbacks: false), as did every sandbox with callbacks, and the JavaScript host never sets the flag so it paid on every call too.

This makes setup_callbacks idempotent: the guest keeps the declarations it installed (the JSON handed to the script, in a static that is part of the pre-init snapshot) and only runs the script when the host's current declarations differ. clear_state / restore_state invalidate the record so the next execution reinstalls.

Companion to #411 (host side); independent of it.

Changes

  • crates/eryx-wasm-runtime/src/python.rs: INSTALLED_CALLBACKS fingerprint; setup_callbacks early-returns on a match; invalidation in clear_state and restore_state; the CALLBACKS_PRE_INITIALIZED flag is gone (pre-init's setup_callbacks(&[]) now records the empty set instead).
  • crates/eryx-wasm-runtime/src/lib.rs: initialize_callbacks no longer consults reuse-empty-callbacks; the WIT field stays for host compatibility and is documented as advisory.
  • crates/eryx-runtime/prebuilt/liberyx_runtime.so.zst rebuilt from the new guest (mise run build-eryx-runtime).
  • New crates/eryx/tests/callback_setup_cache.rs.

Behaviour note

A session that redefines or deletes a callback wrapper in user code now keeps that change until the callback set changes (previously the next execute silently reinstalled the wrappers over it). clear_state still restores everything.

Measurements (7950X, release, cargo bench --package eryx --features embedded)

Benchmark before after
session_execution/pass 2.71 ms 0.18 ms
stateless_execution/pass (3 callbacks) 4.72 ms 4.80 ms (unchanged: each fresh instance starts from the snapshot's empty set)

Follow-up for stateless-with-callbacks: either key #411's warm pool by callback fingerprint and pre-run pass on the warm instance, or let SandboxFactory/preinit bake the callback declarations into the snapshot so fresh instances match immediately.

Testing

  • cargo nextest run --workspace --features embedded: 629 passed (new tests: unchanged set across executions, changed set picked up in both directions, callbacks survive clear_state and snapshot/restore, stateless sandbox with callbacks repeatedly). Existing test_session_refreshes_empty_callbacks_after_callback_execution still passes.
  • cargo clippy --workspace --all-targets --all-features -- -D warnings clean.

Checklist

  • Tests added/updated
  • Documentation updated (WIT/host doc comments)
  • Linting passes

🤖 Generated with Claude Code

sd2k and others added 2 commits September 10, 2026 20:24
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>
Prior to this commit, the guest ran its callback setup script on every
`execute` unless the host flagged a fresh instance with no callbacks. The
script compiles a few hundred lines of Python (the `invoke` coroutine, the
namespace classes, a wrapper per callback) and cost about 2.5 ms per
execution: `session_execution/pass` measured 2.7 ms, of which running `pass`
was well under 0.2 ms. Persistent sessions and every sandbox with callbacks
paid it on each call; the JavaScript host never set the flag, so it paid on
every call too.

This commit makes `setup_callbacks` idempotent. The guest remembers the
declarations it installed (as the JSON handed to the script) and only runs
the script when the host's current declarations differ. Pre-initialization
installs the empty set, and that record is part of the snapshot, so fresh
instances without callbacks skip the script as before without needing the
host's hint. `clear_state` and `restore_state` forget the record so the next
execution reinstalls, since restored globals can shadow the wrappers.

The `reuse-empty-callbacks` execution option stays in the WIT for
compatibility with existing hosts; the guest ignores it.

`session_execution/pass`: 2.71 ms -> 0.18 ms. Stateless executions with
callbacks are unchanged: each fresh instance starts from the snapshot's
empty set, so they still install once per instance.

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

Copy link
Copy Markdown
Contributor

@sd2k
sd2k marked this pull request as ready for review September 11, 2026 08:28
@sd2k
sd2k merged commit 0fb4474 into main Sep 11, 2026
31 checks passed
@sd2k
sd2k deleted the perf/guest-callback-setup-cache branch September 11, 2026 08:28
@eryx-release-plz eryx-release-plz Bot mentioned this pull request Sep 11, 2026
sd2k added a commit that referenced this pull request Sep 11, 2026
A fresh sandbox starts from the pre-init snapshot, which was taken with
the empty callback set, so its first execute() always reinstalled the
callback wrappers (~3.4 ms of Python for three callbacks) even after #416
taught the guest to skip unchanged sets.

Let the declarations be supplied at pre-init time: PreInitOptions /
pre_initialize_with_options() in eryx-runtime (the list-callbacks stub
answers with them and a no-op execute installs them into the snapshot),
eryx::preinit::callback_declaration(), `eryx-precompile --callbacks
<json>`, and SandboxFactory(callbacks=...) in pyeryx, whose sandboxes and
sessions register those callbacks by default. The host now presents its
callbacks to the guest sorted by name so registration order cannot cause
a mismatch with the baked set.

stateless_execution/pass with three callbacks: 4.70 ms -> 1.30 ms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@eryx-release-plz eryx-release-plz Bot mentioned this pull request Sep 11, 2026
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