diff --git a/crates/bashkit-cpython-wasm/artifacts/MANIFEST b/crates/bashkit-cpython-wasm/artifacts/MANIFEST index 37673ca25..50b15c499 100644 --- a/crates/bashkit-cpython-wasm/artifacts/MANIFEST +++ b/crates/bashkit-cpython-wasm/artifacts/MANIFEST @@ -3,5 +3,5 @@ wasi_sdk=24 wasmtime_wizer=49.0.1 zlib=1.3.1 sqlite=sqlite-amalgamation-3500400 -9049f2fa9d9bfda5fd44803c0aada4f6c8175fae4db190c9db6735093fb7943f python.wasm.gz -1f6ab556e47987bb7d3b9779e5c9e94022dae90ba1882455123933fa995fd830 python314.zip +05c3768259ce082ff943818127331aa2581c853f137f5e17a16ef584b2f9c359 python.wasm.gz +d9f3b24ca8e6c5c6a4ec91f599741fcb37538ff94fe93afcc965972944e26a66 python314.zip diff --git a/crates/bashkit-cpython-wasm/artifacts/python.wasm.gz b/crates/bashkit-cpython-wasm/artifacts/python.wasm.gz index 33b0d7be6..cd5e973df 100644 Binary files a/crates/bashkit-cpython-wasm/artifacts/python.wasm.gz and b/crates/bashkit-cpython-wasm/artifacts/python.wasm.gz differ diff --git a/crates/bashkit-cpython-wasm/artifacts/python314.zip b/crates/bashkit-cpython-wasm/artifacts/python314.zip index fde6c4223..0b7080963 100644 Binary files a/crates/bashkit-cpython-wasm/artifacts/python314.zip and b/crates/bashkit-cpython-wasm/artifacts/python314.zip differ diff --git a/crates/bashkit-cpython-wasm/guest/_bashkit_boot.py b/crates/bashkit-cpython-wasm/guest/_bashkit_boot.py index b4f8a62f7..ff251e8a8 100644 --- a/crates/bashkit-cpython-wasm/guest/_bashkit_boot.py +++ b/crates/bashkit-cpython-wasm/guest/_bashkit_boot.py @@ -11,6 +11,13 @@ tenant the same sequence. - The snapshot heap is frozen (`gc.freeze`) so garbage collection only scans objects created by the current call. +- Snapshot objects are made immortal before freezing: reference counting + then never writes to them, so a call that only uses preloaded modules + leaves their pages shared instead of copy-on-write faulting them in + (~20% fewer faults per call). They are never freed, which is fine: the + snapshot outlives every call. +- The per-call environment is installed from C (`_bashkit.load_environ`); + per-key `os.environ` updates were measurable driver overhead on Pulley. - asyncio's selector loop normally wakes itself through a socketpair. WASI has no sockets and the guest has no threads or signals that could need a wakeup, so the self-pipe is disabled (patched once, in the snapshot). @@ -73,9 +80,24 @@ def preload(): import gc gc.collect() + _immortalize_snapshot() gc.freeze() +def _immortalize_snapshot(): + # Immortal objects skip reference count writes, so a call that only uses + # preloaded modules leaves their pages shared instead of copy-on-write + # faulting them in. Covers every GC-tracked object plus what they refer + # to directly (strings, ints, bytes are not tracked themselves). + import gc + + import _bashkit + + objects = gc.get_objects(generation=None) + _bashkit.immortalize(objects) + _bashkit.immortalize(gc.get_referents(*objects)) + + def _patch_asyncio(): try: from asyncio import selector_events @@ -160,11 +182,9 @@ def _refresh_process_state(): import _bashkit - os.environ.clear() - for item in _bashkit.environ(): - key, sep, value = item.partition("=") - if sep and key: - os.environ[key] = value + # The C side replaces the process environment and hands back the + # encoded mapping; per-key os.environ updates cost ~1 ms on Pulley. + os.environ._data = _bashkit.load_environ() limit = os.environ.pop("__BASHKIT_RECURSION_LIMIT", None) if limit: try: diff --git a/crates/bashkit-cpython-wasm/guest/bashkit_main.c b/crates/bashkit-cpython-wasm/guest/bashkit_main.c index b8f9aef61..f58e4a7d6 100644 --- a/crates/bashkit-cpython-wasm/guest/bashkit_main.c +++ b/crates/bashkit-cpython-wasm/guest/bashkit_main.c @@ -13,6 +13,9 @@ // leak into the next call because the host discards the instance. #include +#define Py_BUILD_CORE 1 +#include "internal/pycore_object.h" +#undef Py_BUILD_CORE #include #include #include @@ -99,9 +102,87 @@ static PyObject *bk_environ(PyObject *self, PyObject *unused) { return list; } +// _bashkit.load_environ() -> dict[bytes, bytes]: replace the C environment +// with this call's WASI environment and return it in `os.environ._data` form. +// Doing this in C instead of `os.environ.clear()` + per-key `__setitem__` +// (each one a putenv) cuts most of the driver's per-call Python work. +static PyObject *bk_load_environ(PyObject *self, PyObject *unused) { + (void)self; + (void)unused; + __wasi_size_t count = 0, buf_size = 0; + if (__wasi_environ_sizes_get(&count, &buf_size) != 0) { + return PyErr_Format(PyExc_OSError, "environ_sizes_get failed"); + } + char **env = calloc(count + 1, sizeof(char *)); + char *buf = malloc(buf_size + 1); + if (env == NULL || buf == NULL) { + free(env); + free(buf); + return PyErr_NoMemory(); + } + PyObject *dict = NULL; + if (__wasi_environ_get((uint8_t **)env, (uint8_t *)buf) != 0) { + PyErr_Format(PyExc_OSError, "environ_get failed"); + goto done; + } + clearenv(); + dict = PyDict_New(); + if (dict == NULL) { + goto done; + } + for (__wasi_size_t i = 0; i < count; i++) { + char *eq = strchr(env[i], '='); + if (eq == NULL || eq == env[i]) { + continue; + } + *eq = '\0'; + if (setenv(env[i], eq + 1, 1) != 0) { + PyErr_NoMemory(); + Py_CLEAR(dict); + goto done; + } + PyObject *k = PyBytes_FromString(env[i]); + PyObject *v = PyBytes_FromString(eq + 1); + if (k == NULL || v == NULL || PyDict_SetItem(dict, k, v) < 0) { + Py_XDECREF(k); + Py_XDECREF(v); + Py_CLEAR(dict); + goto done; + } + Py_DECREF(k); + Py_DECREF(v); + } +done: + free(env); + free(buf); + return dict; +} + +// _bashkit.immortalize(objects) -> None: snapshot-time only. Immortal +// objects skip reference count writes, so a call that merely uses a +// preloaded module no longer dirties (and copy-on-write faults) the pages +// holding it. Immortal objects are never freed, which is the point: the +// snapshot heap outlives every call. +static PyObject *bk_immortalize(PyObject *self, PyObject *objects) { + (void)self; + PyObject *seq = PySequence_Fast(objects, "immortalize() expects a sequence"); + if (seq == NULL) { + return NULL; + } + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + PyObject **items = PySequence_Fast_ITEMS(seq); + for (Py_ssize_t i = 0; i < n; i++) { + _Py_SetImmortal(items[i]); + } + Py_DECREF(seq); + Py_RETURN_NONE; +} + static PyMethodDef bk_methods[] = { {"argv", bk_argv, METH_NOARGS, "WASI argv for this call."}, {"environ", bk_environ, METH_NOARGS, "WASI environment for this call."}, + {"load_environ", bk_load_environ, METH_NOARGS, "Install this call's environment."}, + {"immortalize", bk_immortalize, METH_O, "Make snapshot objects immortal."}, {NULL, NULL, 0, NULL}, }; diff --git a/crates/bashkit/docs/cpython.md b/crates/bashkit/docs/cpython.md index 0763c4944..47977e610 100644 --- a/crates/bashkit/docs/cpython.md +++ b/crates/bashkit/docs/cpython.md @@ -42,7 +42,7 @@ No runtime opt-in variable is needed (unlike Monty's the guest never runs native code in your process. The interpreter loads on the first call of a process: the first `python3` -takes about 22 ms on the reference machine, later calls about 6 ms. To move +takes about 20 ms on the reference machine, later calls about 5 ms. To move that one-time cost out of the first request, call `bashkit::CPython::warm_up()` at startup. @@ -60,7 +60,7 @@ return a value. Scripts written by people and agents expect a Python | CLI | `-c`, file, `-` | `-c`, `-m`, file, directory with `__main__.py`, `-`, stdin, `-x`, `-W`, `-V`, `-h` | | Errors | Monty-specific text | CPython tracebacks, exit codes, `sys.exit` semantics | | Isolation | In-process Rust interpreter | WebAssembly sandbox (memory-safe boundary), fresh instance per call | -| Start per call | ~15 µs | ~5-6 ms (first call in a process ~22 ms) | +| Start per call | ~15 µs | ~5 ms (first call in a process ~20 ms) | | CPU-bound speed | Native | ~4-30x slower than Monty (interpreted wasm) | | Host callbacks | Yes (external functions) | Not yet | diff --git a/crates/bashkit/tests/integration/cpython_integration_tests.rs b/crates/bashkit/tests/integration/cpython_integration_tests.rs index ba5cbe92d..386cfca94 100644 --- a/crates/bashkit/tests/integration/cpython_integration_tests.rs +++ b/crates/bashkit/tests/integration/cpython_integration_tests.rs @@ -507,6 +507,26 @@ async fn interpreter_state_does_not_persist_between_calls() { assert_eq!(out, "False False False\n"); } +#[tokio::test] +async fn preloaded_modules_stay_mutable_and_reimportable() { + // Snapshot objects are immortal (no refcount writes); mutating, deleting + // and re-importing them must still behave like regular CPython. + let out = ok( + "python3 -c 'import json, sys; json.dumps = None; del sys.modules[\"json\"]; \ + import json as j2; print(j2.dumps([1]), j2 is not json)'", + ) + .await; + assert_eq!(out, "[1] True\n"); +} + +#[tokio::test] +async fn environ_is_replaced_each_call() { + let out = ok("export A=1; python3 -c 'import os; os.environ[\"B\"] = \"x\"; print(os.environ[\"A\"])'; \ + unset A; export C=3; python3 -c 'import os; print(\"A\" in os.environ, \"B\" in os.environ, os.environ[\"C\"], os.getenv(\"C\"))'") + .await; + assert_eq!(out, "1\nFalse False 3 3\n"); +} + #[tokio::test] async fn local_modules_are_reimported_each_call() { let out = ok( diff --git a/knowledge/log.md b/knowledge/log.md index 9dd01f465..6c962896e 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Bashkit Knowledge Update Log +## 2026-10-07 + +* **Performance**: Profiled CPython start. A warm `python3 -c 'print(1)'` was ~4.5 ms of Pulley interpreting ~500K guest instructions plus ~1.5 ms in ~340 copy-on-write faults; the first call adds ~13 ms of one-time memory-image build (the 40 MB snapshot span copied into a memfd). Snapshot objects are now immortal (no refcount writes, faults -20%) and the per-call environment is installed from C; warm calls went from ~6.2 to ~5.1 ms. Native AOT instead of Pulley measured 2.3 ms per call but needs executable memory and a 41 MB copy on first load; not taken yet. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md). + ## 2026-10-06 * **Decision**: Step 3 of the extraction. Python `ScriptedTool` moves to `bashkit.scripted` (old `bashkit.ScriptedTool` warns `FutureWarning` via module `__getattr__`), JS to the `@everruns/bashkit/scripted` entry (root keeps `@deprecated` aliases of the same class; shared helpers in non-exported `internal.ts` avoid an ESM cycle). Found while packing: `publish-js.yml`'s `js-stubs` artifact carried only `wrapper.*`, so npm 0.18.2 shipped without the `langchain`/`ai`/`anthropic`/`openai` entry files it exports; the artifact now lists every `tsc` output, guarded by `scripts/tests/test_js_publish_files.py`. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md) and [Release Process](operations/release-process.md). diff --git a/knowledge/runtimes/cpython-wasm.md b/knowledge/runtimes/cpython-wasm.md index 1db599d9c..3b3c0a6dc 100644 --- a/knowledge/runtimes/cpython-wasm.md +++ b/knowledge/runtimes/cpython-wasm.md @@ -59,11 +59,17 @@ call: 5. **`gc.freeze()` in the snapshot**: without it every call's exit-time `gc.collect()` walked the whole snapshot heap, dirtying its pages; calls cost ~100 ms instead of ~5 ms. +6. **Immortal snapshot objects**: every GC-tracked snapshot object and its + direct referents are made immortal (`_Py_SetImmortal`) before freezing, + so reference counting stops writing to snapshot pages. Copy-on-write + faults per call dropped ~20% (341 to 273 for `print(1)`). +7. **Lean driver**: the per-call environment is installed from C + (`_bashkit.load_environ`) instead of per-key `os.environ` updates. Measured on a 4-vCPU x86-64 VM (see `criterion-python-*` results under `crates/bashkit/benches/results/`): first `python3` in a fresh process -~22 ms (one-time engine/module load and first-touch page faults), then -`python3 -c pass` ~5.5 ms per call warm; Monty ~15 µs (first call ~0.4 ms). +~20 ms (one-time engine/module load and first-touch page faults), then +`python3 -c 'print(1)'` ~5 ms per call warm; Monty ~15 µs (first call ~0.4 ms). CPU-bound Python is ~4-30x slower than Monty. 1024 concurrent tenants × 4 calls: 0 failures, ~90 calls/s on 4 vCPUs, 4.7 GB peak RSS. @@ -155,6 +161,14 @@ compile-affecting configuration that produced it. 49.x needs rustc 1.96. - Python cannot call back into the shell (subprocess bridge) or host functions (`ToolDef`). TODO: design a `bashkit` module over a host import. +- Where a warm call goes (`print(1)`, 2026-10-07 profile): ~4 ms Pulley + interpreting ~500K guest instructions (two thirds driver: compile, env, + `random` reseed, exit `gc.collect`), ~1 ms in ~270 page faults. The + one-time first-call cost is ~13 ms building the copy-on-write memory image + (wasmtime copies the 40 MB snapshot span, 14 MB of real data, into a + memfd). Native AOT compiled in `build.rs` (no run-time compile) measured + 2.3 ms per call, but native code cannot run from the binary's read-only + static, so the first load copies the 41 MB module (+35 ms) unless warmed. - ~650 page faults per call (host Pulley stack + guest CoW writes); kernel fault cost grows under concurrency, so 4 vCPUs reach ~2x, not 4x, single thread throughput. TODO: profile which pages fault; consider pooling the