Skip to content

Optimizations and bugfixes throughout the codebase - #40

Merged
tschneidereit merged 7 commits into
mainfrom
opts
Sep 28, 2026
Merged

tschneidereit merged 7 commits into
mainfrom
opts

Conversation

@tschneidereit

Copy link
Copy Markdown
Member

In the final (he says) run-up to bringing in support for custom WIT interfaces, and driven by the results of a fairly comprehensive benchmark suite, I went through the codebase with a fine comb to address performance concerns.

In doing so, I also found and addressed a number of spec adherence issues and other bugs. The result is a set of 7 commits touching most parts of the codebase. Each of them follows a reasonably coherent theme and compiles independently. I didn't run the entire test suite on each of them, so I can't say with 100% certainty that they pass individually. If the full CI run shows anything, I'll fold the fixes into the commit(s) introducing the issue(s), though.

With these applied, we're almost universally at parity or faster than the old StarlingMonkey. Here's an overview of the performance on a number of different benchmark cases, focused on the performance of runtime builtins:

ng-wasm-p3 vs cpp-wasm

All tests run in the following environment:

  • host: wasmtime 49.0.0
  • host machine: Apple M5 (Darwin 27.0.0), 10 cpus

First, an apples-to-apples comparison, with all requests running in a fresh instance:

benchmark ng-wasm-p3 cpp-wasm ng-wasm-p3/cpp-wasm
compute 1816 req/s 1792 req/s 1.01× ≈
echo 4899 req/s 4157 req/s 1.18×
headers 3625 req/s 3618 req/s 1.00× ≈
hello 5247 req/s 5414 req/s 0.97×
json 4797 req/s 3532 req/s 1.36×
proxy 1991 req/s 2166 req/s 0.92×
baseline/object-literal 3908 req/s 3492 req/s 1.12×
alloc/response-64k-x100 849 req/s 228 req/s 3.73×
alloc/headers-x1000 1037 req/s 1288 req/s 0.81×
fetch/response-text-64k 3220 req/s 2751 req/s 1.17×
fetch/response-json 3548 req/s 3228 req/s 1.10×
fetch/request-clone-with-body 3375 req/s 3055 req/s 1.10×
fetch/headers-construct-from-object-32 3499 req/s 3125 req/s 1.12×
incoming/request-headers-iterate 3432 req/s 3218 req/s 1.07×
streams/pipeThrough-transform-64 1474 req/s 1190 req/s 1.24×
streams/reader-read-64 2625 req/s 2249 req/s 1.17×
url/parse-complex 3583 req/s 3377 req/s 1.06×
codec/decode-utf8-64k 2661 req/s 2635 req/s 1.01× ≈
events/dispatch-8-listeners 3353 req/s 3207 req/s 1.05×
clone/structuredClone-nested 3335 req/s 3280 req/s 1.02× ≈
loop/promise-all-100 2931 req/s 2524 req/s 1.16×

Mostly wins, but some losses, and some ties. The best results is probably alloc/response-64k-x100, which applies an optimization the C++ version could also gain: it batches up small body chunks instead of sending them to the host one by one.

Next up, the same C++ numbers, but this time in comparison to the WASIp3 version running with instance reuse enabled, while still only using a single instance at a time:

benchmark ng-wasm-p3 cpp-wasm cpp-wasm/ng-wasm-p3
compute 2355 req/s 1792 req/s 1.31×
echo 13136 req/s 4157 req/s 3.16×
headers 10841 req/s 3618 req/s 3.00×
hello 15638 req/s 5414 req/s 2.89×
json 13977 req/s 3532 req/s 3.96×
proxy 2624 req/s 2166 req/s 1.21×
baseline/object-literal 13931 req/s 3492 req/s 3.99×
alloc/response-64k-x100 1623 req/s 228 req/s 7.12×
alloc/headers-x1000 1327 req/s 1288 req/s 1.03×
fetch/response-text-64k 10578 req/s 2751 req/s 3.84×
fetch/response-json 12863 req/s 3228 req/s 3.98×
fetch/request-clone-with-body 12089 req/s 3055 req/s 3.96×
fetch/headers-construct-from-object-32 11664 req/s 3125 req/s 3.73×
incoming/request-headers-iterate 11575 req/s 3218 req/s 3.60×
streams/pipeThrough-transform-64 2251 req/s 1190 req/s 1.89×
streams/reader-read-64 6020 req/s 2249 req/s 2.68×
url/parse-complex 12139 req/s 3377 req/s 3.60×
codec/decode-utf8-64k 6847 req/s 2635 req/s 2.60×
events/dispatch-8-listeners 10924 req/s 3207 req/s 3.41×
clone/structuredClone-nested 11193 req/s 3280 req/s 3.41×
loop/promise-all-100 7340 req/s 2524 req/s 2.91×

As expected, compute-heavy workloads don't benefit too much, but everything else is between 2.6x and 7x as fast.

Anyway, all this seems pretty nice!

(The benchmark suite itself will also land eventually. I want to whittle it down to something with a more reasonable runtime first, though.)

`Runtime::init` returns initializer-script failures as `Err` instead of exiting the process, and `starling` exits with status 1 on any error and with clap's statuses on argument errors. An imported module's compile error now propagates with its own message and location. `--async-stacks` records async parent frames for `Error.stack` in every realm, not only under a debugger.

A failed `wasi:http` startup is final, so later requests fail with the same error instead of repeating the bootstrap. Cancelling one event loop's async-promise futures keeps the unowned ones any loop may wait on, and `Runtime`'s `Drop` drops the rest while the context is alive. Invocations drive their event loops through shared references, and the wasm drivers sleep with `platform::clock::sleep`.

Tests share one "evaluate and read `__out`" helper, the deadline-order test registers its longest timer last, and a new end-to-end test covers a fetch with a request header the host refuses.
The mozjs update builds the WASI archives at `-O3` with SIMD128 and brings the ICU4X data provider's locale fallback. The Rust code for wasm32-wasip2 and wasm32-wasip3 is compiled with the `simd128` target feature too, and the C++ builtins with `-msimd128` whenever the Rust code has it. The wasm constructor wrapper's run-once guard is an `AtomicBool` instead of a `static mut`.
Scopes root values on one stack shared by all scopes instead of each allocating its own pages, and `js::gc::add_root_tracer` registers tracers that run along with it. Call arguments are rooted before they are converted, so a conversion that allocates can no longer invalidate the values converted before it.

A class instance's private data is read from its fixed slot inline, `Stack<T>::data()` returns its guard directly, `[LegacyUnforgeable]` accessors are copied from a per-global template, and hot property keys are interned once per global. Strings convert between Rust and JS with `encoding_rs`, and `TextDecoder` and `TextEncoder` work on JS strings without a Rust `String` in between. `ArrayBuffer::transfer` moves the contents instead of copying them.

The event loop indexes its tasks by readiness and deadline, events are timestamped with a coarse monotonic clock, header lookups lowercase the name once, and streamed bodies are written in batches of up to 64 KiB.
Builtin handles such as `Array`, `Map` and `Promise` convert from JS values with a class check, and the raw-pointer `ToJSVal` and `FromJSVal` implementations are removed. `EnforceRange<T>` converts `[EnforceRange]` integers, and `js::Callable` holds WebIDL callback functions, which the streams' callback dictionary members and queuing strategy `size` now use. `cast_payload` recovers a native callback's payload, `CallbackArgs::from_values` builds the arguments for calling one directly, `exception::get_pending` and `take_pending` return `Option`, and `AssociatedMemory::set` takes a rooted object.

`Headers` and `URLSearchParams` share the WebIDL pair iterator steps, and `Result<Promise, E>` operations throw their error before rejecting. The class macros no longer support `#[post_init]`, `#[jsglobals]` rejects options, and macro-generated modules no longer allow unused imports. `Array::define_element`, `Object::set_immutable_prototype` and `RuntimeConfig::content_script` are removed.
Stream algorithms are passed as `AlgorithmArg`s, and native ones are called directly instead of through a JS function created per algorithm. A readable stream's controller and reader are held as WebIDL unions, controllers get their stream at construction, tee states keep their branches' controllers, byte-stream buffers and views sit in typed slots, a pipe's shutdown state is one `PipeShutdown`, and the close sentinel is a `ValueWithSize` variant. An absent size algorithm is stored as `undefined`, and internal promises and reactions are created once per global.

Failures with no caller to propagate to are reported and cleared instead of being left pending or unwrapped, promise-returning operations reject by returning an error, and the async iterator clears its ongoing promise once it settles, following the annotated WebIDL steps. The rest of the code uses existing helpers instead of hand-written equivalents.
`Request` and `Response` keep their body state in one `BodyStorage`, with `HostBackedBodyOwner` and `BodyMixin` providing the body methods over it, and hold their headers and signal in bare heaps. Getters of `URL`, `Request`, `Response`, `DOMException` and `Event` return a `Ref<'_, str>` into the instance's data, and methods that never used their `scope` or never failed drop both. web-url shares its URL-encoded parsing and serialization.

Across the runtime, `unsafe` blocks get SAFETY comments, stale docs are corrected, hand-written checks use existing helpers, and values are passed to `impl ToJSVal` parameters without being rooted first.
The timer globals are `#[jsglobals]` functions with a `TimerHandler` union. Event listener arguments convert as `EventListener?` and `(EventListenerOptions or boolean)`, `AbortSignal.any` takes a WebIDL sequence, and `structuredClone`'s options and `RequestInit`'s `referrerPolicy` and `signal` convert as their WebIDL types. `DOMException` gets a Rust constructor with required arguments. Error-like classes such as `DOMException` capture their stack as a `SavedFrame`, which a `stack` accessor like `Error.prototype.stack` formats on first read. `btoa` and `atob` are annotated with their spec steps.

A `once` listener is removed from its target's listener list when it runs, where it used to stay in the list for good. `URLSearchParams Iterator` is no longer installed as a global, and `respondWith` locks a materialized body stream when it sends it. web-fetch and web-url no longer hold class data borrowed while running code that can reenter. The CORS-safelisted header value limits count bytes instead of UTF-8 code units, the promise from cancelling an aborted request's body is marked handled, and `new Request` checks the input request's current body for unusability. `navigator.userAgent` loses a stray parenthesis.
@tschneidereit
tschneidereit merged commit 9ffbe02 into main Sep 28, 2026
19 of 20 checks passed
@tschneidereit
tschneidereit deleted the opts branch September 28, 2026 22:07
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