Skip to content

perf(ai): build request bodies from the previous request's bytes - #76

Merged
Kushalkhemka merged 3 commits into
pi-boltfrom
perf/network-096
Oct 20, 2026
Merged

Kushalkhemka merged 3 commits into
pi-boltfrom
perf/network-096

Conversation

@Kushalkhemka

Copy link
Copy Markdown
Member

Problem

Every model request resends the whole conversation. In 0.9.5 each long string's JSON is reused (json-body.ts), but every request still walks the whole conversation, copies it into a new 9 MB string (at 40 prompts of bench/long_session.py), and fetch copies that string again to encode it as UTF-8.

Microbenchmark, 400 messages / 8.85 MB, POST to a local server, user CPU per request including fetch: plain JSON.stringify 5.6 ms, 0.9.5's cache 2.7-3.3 ms.

Fix

Conversations grow at the end. packages/ai/src/utils/request-body.ts:

  • Per conversation (options.sessionId; the last 4 kept, dropped after 32 requests unused), each message's UTF-8 bytes and a snapshot of its structure (plain objects with keys in order, arrays, primitives; strings shared, not copied).
  • For request N+1, message i is compared with snapshot i of request N (=== on strings, Object.is on numbers, for-in key order, prototype and toJSON checks). Equal: reuse the bytes. Otherwise: TextEncoder.encode(JSON.stringify(message)). Snapshots, never the previous objects, so an in-place change (Anthropic's cache_control moving to the latest block, compaction, an extension's onPayload) is seen.
  • The rest of the body is JSON.stringify({ ...body, [key]: PLACEHOLDER }), split at the placeholder. JSON.stringify of an array is [ + the items' JSON joined by , + ], so the parts concatenate to exactly the SDK's text.
  • The body goes to fetch as a Blob of the parts: one copy when the Blob joins them, and Bun's fetch sends the Blob as is. (Uint8Array: fetch copies it again, 1.55 ms; a string rope: fetch flattens and encodes it, 1.7 ms; Blob 0.95 ms, same microbenchmark.)

Seam: the SDK client's buildBody (openai and @anthropic-ai/sdk), wrapped per client. It receives the exact object the SDK would serialize (after Anthropic drops betas etc.), so no per-SDK body shapes; the JSON headers come from the SDK itself (a probe with an empty body). Used in openai-completions, openai-responses, azure-openai-responses, anthropic-messages.

Falls back to the SDK's serialization (0.9.5 behaviour) when:

  • the client's fetch is not the runtime's own: a custom options.fetch or a wrapping global fetch may read init.body as a string (Pi's own tests do). On Node the global fetch is JavaScript, so Node is unchanged;
  • a message has its own toJSON (it would get its index as key), the body or array is not plain, or anything throws.
    Values JSON.stringify treats specially inside a message (Date, class instances, raw JSON, functions) only make that message re-serialize every time.

Verification

  • Byte identity in real sessions (body-dumping fake model for completions, anthropic and responses; base 16807c4 vs this branch, both built with the 0.9.5 runtime; compared after normalising temp and build directory names; headers identical, content-length equal to the body size):
session requests bytes differ
completions, 40 prompts (includes Pi's auto-compaction requests, which rewrite earlier messages) 419 959 MB 0
anthropic, 15 prompts (cache_control moves every request) 75 127 MB 0
responses, 15 prompts 75 127 MB 0
completions with cacheControlFormat: "anthropic", 15 prompts 144 135 MB 0
completions, file reads that differ every prompt, 25 prompts 254 372 MB 0
anthropic, file reads that differ every prompt, 10 prompts 50 56 MB 0
  • packages/ai/test/request-body.test.ts: awkward values (escapes, lone surrogates, undefined, functions, NaN, -0, Date, class instances, boxed strings, raw JSON, __proto__ keys, null prototypes, holes, integer keys); in-place edits, moved markers, key order, compaction; a 300-step random-edit fuzz; interleaved conversations; the fallbacks; json-body's toJSON; and through the real SDKs (openai-completions, openai-responses, anthropic) over 8 growing requests with compaction: the Blob's text equals JSON.stringify(payload) and the content type is application/json; a custom fetch still gets the string. A mutation that makes every object compare equal fails 6 of them.
  • npm run check clean. ./test.sh: packages/ai all pass; the only failures are packages/env (needs the Rust daemon), and process-spawning tests that time out under the machine's load and pass when run alone (acp-mode, mcp-mode, codemode worker-lifetime).

Numbers (this Mac, load average 40-120: instructions are the steady figure)

bench/long_session.py --prompts 40 --every 20, 10 interleaved sessions per build, medians per prompt:

base @20 this @20 base @40 this @40
instructions 152.9M 124.0M -18.9% 299.0M 204.3M -31.7%
cycles 83.0M 74.2M -10.5% 154.0M 126.2M -18.1%
CPU 39.2 ms 34.1 ms -13.0% 73.0 ms 57.7 ms -21.0%
own memory 49.6 MB 49.5 MB 63.2 MB 61.7 MB

bench/benchmark.py --runs 15: headless 312M -> 306M instructions (-2%), interactive (5 prompts) 790M -> 767M (-3%); wall and CPU within noise.

Memory: per kept conversation, the last request's bytes as UTF-8 plus small snapshot objects.

Every request resends the whole conversation; the SDK serializes all of it
again and fetch copies the string again to encode it. Conversations grow at
the end, so each message is now compared with a snapshot of the message at the
same index in the conversation's previous request (structure only, strings
shared) and reuses that request's UTF-8 bytes when equal; changed or new
messages are serialized again. The body goes to fetch as a Blob of the parts.

The seam is the SDK client's buildBody (openai, @anthropic-ai/sdk), so the
body object is the one the SDK would serialize. Applied in openai-completions,
openai-responses, azure-openai-responses and anthropic-messages, only when the
client sends with the runtime's own fetch (a custom or wrapping fetch still
gets the SDK's string). Anything JSON.stringify treats specially falls back to
re-serializing or to the SDK.

Bodies byte-identical to 0.9.5 over 1,017 requests (1.8 GB) of completions,
anthropic and responses sessions, with cache markers moving, compaction and
unique file reads. long_session.py, 10 interleaved sessions, per prompt at
20 / 40 prompts: instructions -18.9% / -31.7%, cycles -10.5% / -18.1%,
CPU -13.0% / -21.0%.
Copilot AI balanced review requested due to automatic review settings October 20, 2026 01:09

@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:14:30.372469Z 75c3920 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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75c3920c47

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/ai/src/utils/request-body.ts Outdated
JSON.stringify reads an array by index and ignores an overridden
Symbol.iterator. The check for items with their own toJSON used for..of, so an
onPayload array whose iterator skips such an item bypassed the fallback, and a
side-effecting iterator ran during serialization. It now reads by index like
the rest of the path.
@Kushalkhemka
Kushalkhemka merged commit 861e8b4 into pi-bolt Oct 20, 2026
12 checks passed
@Kushalkhemka
Kushalkhemka deleted the perf/network-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