Skip to content

perf(engine): 16-bit JSON escaping on ARM64, microtask call cache per VM - #74

Merged
Kushalkhemka merged 6 commits into
pi-boltfrom
perf/engine-json-await-096
Oct 20, 2026
Merged

Kushalkhemka merged 6 commits into
pi-boltfrom
perf/engine-json-await-096

Conversation

@Kushalkhemka

@Kushalkhemka Kushalkhemka commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Two engine commits appended to patches/webkit.patch (it applies to the pin with git apply --cached; the tree matches my WebKit checkout), plus tests/aot cases. Runtime run with exactly this patch: 39351585272 (all four platforms). Pi-level numbers follow when it finishes; the micro numbers below come from run 39326293812, which also contained a third change that has since been dropped (see "Dropped").

1. JSON escaping of 16-bit strings on ARM64 (WTF)

Problem. One character outside Latin-1 (an arrow, an em dash, a curly quote) makes the whole string 16-bit. Answers and source files contain such characters all the time. JSON escaping of a 16-bit string used a generic loop that stopped at every character needing an escape, which in source code is every few dozen characters. 8-bit strings use a NEON shuffle path.

Example. 163K characters of agent-session.ts plus one →:

8-bit 16-bit
escape speed ~4 GB/s ~0.5 GB/s
escape time 342 µs

Fix. The 16-bit input is taken 16 characters at a time:

  • A block whose characters are all Latin-1 is narrowed and escaped with the existing NEON shuffle; for 16-bit output it is widened again on store.
  • A block with a wider character is copied as is when nothing in it needs escaping. Otherwise it goes one character at a time, including a surrogate pair that crosses the block's end.
  • For Latin-1 output, a wider character still returns false, as before.

The same string now escapes in 57 µs. Over 200K random strings, with both output widths, the result is identical to the scalar escaper (checked with a harness built with LLVM 23 against the WTF header). tests/aot/json-stringify.mjs covers this too.

2. One microtask call cache per VM (JSC)

Problem. Each microtask drain built an 8-entry cache of async function entry points on the stack. Bun drains after every callback, so the first resume of each async function in each drain missed the cache, and so did every resume once more than 8 functions were taking turns. A miss costs about 190 instructions (MicrotaskCall::initialize, prepareForMicrotaskCall, getCallDataInline). In a chain of an async generator and three nested async calls, 6 of the 10 resumes per event missed.

Fix.

  • The VM owns a drain cache of 32 entries in 8 sets of 4, keyed by the callee's executable, next to its existing synchronous-resume cache.
  • It is cleared and reconciled the same way: when code is detached, and at the end of each GC.
  • An entry is not used after the call it makes, so nested drains can share the cache.
  • Awaiting a non-object queues its fulfilment directly, skipping the promise checks. This is the same job, queued at the same moment.

No job is added, removed or reordered.

Numbers (macOS arm64; instructions, because the Mac is shared and wall time is noise)

0.9.5 this change
JSON.stringify, one 163K-character 16-bit string 1.74M 1.15M -34%
JSON.stringify, array of four of them 6.36M 4.04M -36%
JSON.stringify, 9 MB request-shaped body, no toJSON, 16-bit ASCII strings 42.2M 30.5M -28%
the same with real non-Latin-1 characters 49.6M 41.8M -16%
await undefined (AOT) 558 527 -6%
16 async functions taking turns, per await 702 518 -26%
one streamed event through generator + 3 nested async calls 15,360 14,065 -8%
await of a settled promise / async call + await / async generator yield 586 / 1,956 / 1,348 603 / 1,940 / 1,326 noise

Pi-level, same Pi built with the 0.9.5 runtime ("base") and with run 39351585272's darwin-arm64 runtime ("final"):

base final
long_session.py, M instructions per prompt at 20 prompts (median of 3 interleaved) 140.1 140.5
long_session.py, at 40 prompts 254.2 254.7
benchmark.py headless instructions (7 runs) 300M 300M
benchmark.py interactive, 5 prompts 770M 768M
benchmark.py startup 85M 85M

At the Pi level the effect is within noise on these benchmarks. Their content is almost all ASCII: three of the four fixtures have no non-ASCII character, and Pi's cache already turns long strings into raw JSON. The escaping gain applies when a conversation has non-Latin-1 text. The microtask cache gain applies when many async functions take turns. Neither changes behaviour.

Run 39351585272 so far: darwin-arm64 and linux-arm64 succeeded, tests/aot and tests/runtime included. linux-x64 and win32-x64 were still running at 01:58 UTC.

Tests

  • tests/aot/json-stringify.mjs covers:
    • toJSON: own, hidden, inherited, on arrays, and one returning an object that has its own toJSON.
    • What a toJSON can change while it runs: later keys deleted or added, values changed, an array pushed or shortened, a toJSON added to a prototype.
    • Getters, and errors from toJSON and from getters.
    • Cycles through a toJSON, BigInt, Proxy, boxed primitives.
    • Raw JSON: narrow, wide, at the top level, inside toJSON results.
    • 16-bit strings with every kind of escape at every position in a block, and surrogates at block ends.
    • Indentation at every depth, chains 3000 deep, and replacer functions and lists.
  • tests/aot/await-order.mjs covers the order in which awaits resume:
    • plain values, settled and pending promises;
    • thenables: then called, a then getter, a then that throws;
    • subclassed promises, and promises with an own then or another constructor;
    • all of these interleaved with queueMicrotask, then() and process.nextTick;
    • async generators (for await, next/return), nested async calls, and 48 async functions taking turns (more than the cache holds).
  • Both tests must print exactly what stock Bun prints. They pass in all three AOT modes:
    • on the 0.9.5 runtime locally;
    • on run 39326293812, all four platforms. Its tests/aot and tests/runtime pass.

Dropped

Hand-over from the general JSON serializer to the fast one (run 39326293812):

  • The change. Stringifier gave each object it reached, after its toJSON, to FastStringifier. FastStringifier also handled raw JSON and found a hidden own toJSON first.
  • Micro-benchmarks gained:
before after
9 MB body with Pi's cached toJSON 30.0M 24.3M
toJSON on the body object 108M 30.5M
  • Inside Pi it lost. Measured as per-thread instructions of each request body's JSON.stringify, toJSON included, over a 40-prompt session in the same Pi with both runtimes:
request size 0.9.5 with the change
4.7 MB 7.12M 7.87M
6.5 MB 9.72M 11.04M
9 MB 15.42M 16.32M

That is 6-13% more, and long_session.py instructions per prompt were 1-3% higher. The likely cause is an extra copy and allocation of the whole messages JSON, plus the garbage that goes with it, against Pi's small per-request cost. Not proven.

  • It was correct (the tests above passed), but it did not pay off where it matters.

Not changed (findings)

  • A body with any non-Latin-1 character is UTF-16 as a whole. Bun's fetch then costs 5.8-7.4 ms CPU instead of 3.1 ms for a 12.5 MB body: a simdutf length pass, a second length pass for Content-Length, and a conversion into a fresh buffer.
    • An em dash anywhere in a conversation makes every following request body wide.
    • Fixing it needs Pi to send bytes instead of a string body, or Bun to encode rope pieces separately.
  • Strings from JSON.parse are not slower to escape. The slow ones are 16-bit strings, which are fixed above.
  • Inline allocation of promises and async-function generators in AOT code. This would save about 170-200 instructions per async call. It changes the AOT lowering, which was too much risk for tonight.

…a toJSON or raw JSON

The general serializer hands each object or array it reaches to the fast one, which now also copies JSON.rawJSON()
values and finds a hidden own toJSON before it serializes an object. tests/aot/json-stringify.mjs checks toJSON order,
what a toJSON changes while it runs, getters, errors, cycles, raw JSON, wide strings and indentation against stock Bun.
… time on ARM64

A string with one character beyond Latin-1 is 16-bit, and its escaping stopped at every character that wants it:
163K characters of source code with arrows in it took 342 us, now 57 us. tests/aot/json-stringify.mjs covers every kind
of character at every place in a block, and surrogates at block ends.
Bun drains the microtask queue after every callback, and each drain made a new 8-entry cache of async function entry
points: the first resume of each function in each drain missed it (about 190 instructions). The VM now owns a 32-entry,
set-associative cache that it clears and reconciles like its existing one. An await of a non-object queues its
continuation without the promise checks. tests/aot/await-order.mjs checks await order of settled and pending promises,
plain values, thenables, subclassed promises, generators and 48 async functions taking turns against stock Bun.
…to Object.prototype

The AOT build makes the built-in prototypes immutable.
Measured inside Pi (per-thread instructions of each request body's JSON.stringify over a 40-prompt session), it made
requests 6-13% more expensive, although micro-benchmarks gained: kept are the 16-bit JSON escaping and the microtask
call cache. tests/aot/json-stringify.mjs stays: it covers the escaping and toJSON semantics.
Copilot AI balanced review requested due to automatic review settings October 20, 2026 01:07

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T01:10:11.347929Z c6b423c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Kushalkhemka
Kushalkhemka merged commit b312452 into pi-bolt Oct 20, 2026
16 checks passed
@Kushalkhemka
Kushalkhemka deleted the perf/engine-json-await-096 branch October 20, 2026 07:37
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.

3 participants