Skip to content

perf(coding-agent): images within the limits skip photon; resizes run in Bun.Image - #67

Merged
Kushalkhemka merged 2 commits into
pi-boltfrom
perf/images-096
Oct 20, 2026
Merged

Kushalkhemka merged 2 commits into
pi-boltfrom
perf/images-096

Conversation

@Kushalkhemka

@Kushalkhemka Kushalkhemka commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Reading an image with the read tool, or attaching one, costs seconds to over a minute of CPU on Pi-Bolt. After this change it costs about 0 ms for an image that needs no resize, and 0.5-1.1 s for one that does.

Problem

Pi-Bolt runs with the JIT off, so JSC interprets photon's WebAssembly. Pi sends every image through photon:

  • An image that needs no change is decoded in full only to learn its width and height, then sent as it is. A 1800x1100 PNG: 1.16 s of CPU.
  • An image over the limits is decoded, resized with Lanczos3, then encoded as a PNG and as a JPEG at all five qualities before the first one that fits is picked.
  • A read result then goes through normalizeToolResultImages, which decodes the result again.

A 2442x1438 screenshot: 36.7 s of CPU. By operation, interpreted (from the review): decode 2.0 s, resize 11.4 s, PNG 3.7 s, five JPEGs about 4 s each, second decode 1.7 s.

Fix

  1. Size from the header. image-header.ts reads width and height from the PNG, JPEG, GIF or WebP structure. With EXIF orientations 5-8 the two are swapped, as photon's rotation does. An image within the limits is returned as it is: no photon, no worker. The bytes are the same as before. The parsers accept only intact files. Anything else takes the decode path as before. They check:
    • PNG: every chunk CRC; IHDR first; IEND at the end of the file.
    • JPEG: SOF0-2 only, 8-bit, 1 or 3 components; the EOI marker at the end.
    • GIF: every block walked; frames inside the screen; the trailer at the end.
    • WebP: the RIFF size; one VP8/VP8L bitstream, or VP8X with a bitstream of the canvas size; no animation.
  2. Encode on demand. The JPEG qualities are encoded only when the PNG does not fit, and the loop stops at the first candidate that fits. The order is the same, so the result is the same.
  3. No second pass. The read tool marks the image blocks it builds (a WeakMap keyed by the block, holding the data string and the resolved options). normalizeToolResultImages skips a marked block when its data and options are unchanged. Processing the block again would return it unchanged anyway.
  4. Native resize when the runtime has it. The pinned Bun (1.4.3) has Bun.Image: libspng, libjpeg-turbo and libwebp codecs, and a SIMD Lanczos3 resize, on Bun's own worker threads. When Bun.Image exists, an image over the limits is resized there. It keeps the same target sizes, candidate order (PNG, then JPEG 80, 85, 70, 55, 40, then 75% steps) and size limit. The EXIF orientation is applied with rotate/flip/flop, which matches photon's rotation pixel for pixel for orientations 2-8. If Bun.Image throws, the photon path runs as before. Under Node, photon runs as before.

Before / after (CPU per image, Pi-Bolt build, 0.9.5 runtime)

pi -p reads one file against a local fake model. base = 0.9.5 (origin/pi-bolt). Each figure is the median of 3 interleaved runs, user+sys CPU. The Mac was shared, with a load average of 30-140.

file base this PR
text file (no image: start-up and one turn) 60 ms 57 ms
1800x1100 PNG, no resize 1,162 ms 56 ms (second commit: 996 to 47 ms)
1800x1100 WebP, no resize (second commit) 6,328 ms 68 ms
1600x1200 JPEG, no resize 3,651 ms 68 ms; second commit, photon decodes it again: 2,094 to 1,052 ms (one decode instead of two)
2442x1438 PNG screenshot, resized 36,676 ms 521 ms
3024x1964 PNG, resized 39,946 ms 702 ms
4032x3024 JPEG photo, resized (PNG too large, JPEG) 75,501 ms 1,093 ms

Wall time for the screenshot: 45.6 s to 1.06 s. Peak RSS: 98 to 66 MB for the 1800x1100 PNG, because photon is never loaded; 113 to 108 MB for the screenshot.

Without Bun.Image (Node, or a native failure), points 1-3 still apply. This is photon in process with the JIT off, one call, the same output bytes as before:

file before after
2442x1438 PNG 36.0 s 15.1 s
4032x3024 JPEG 65.0 s 52.5 s

What the model receives

  • Images that need no resize: byte for byte the same. For the text, PNG, WebP and JPEG cases, and for a damaged JPEG and a damaged WebP, the captured provider requests were identical between the two builds, apart from the install path in the system prompt.
  • Resized images through photon (Node or fallback): byte for byte the same. The old and new resizeImageInProcess gave the same output on 7 cases, including the JPEG fallback and the 75% shrink.
  • Resized images through Bun.Image: the same dimensions, format choice and dimension note, but different bytes. The text block is identical. Pixel differences against photon's output:
case photon (base) Bun.Image difference
2442x1438 PNG PNG 2000x1178, 1.73 MB b64 PNG 2000x1178, 1.61 MB mean 0.16/255, 91% of channels equal
3024x1964 PNG PNG 2000x1299, 3.85 MB PNG 2000x1299, 3.96 MB mean 0.05, max 3
4032x3024 JPEG JPEG q80 2000x1500, 507 KB JPEG q80 2000x1500, 335 KB mean 2.75 (two JPEG encoders)
  • libjpeg-turbo's JPEGs are smaller. Near the size limit, Bun.Image can fit at a larger size than photon could. Example: with maxBytes 200 KB, the 4032x3024 photo is now sent at 2000x1500; photon had to go down to 1500x1125.
  • Bun.Image keeps an ICC profile; photon dropped it.
  • Damaged image data (second commit). This means files whose structure is intact but whose data is damaged, for example a JPEG with corrupt scan data ending in FF D9. They get the 0.9.5 outcome: photon decodes them, and read reports "Image omitted" when the decode fails. An in-limit image skips photon only in these cases:
    • PNG or WebP, and Bun.Image decodes it in full. This takes about 5 ms for an 1800x1100 PNG and about 25 ms for an 1800x1100 WebP.
    • JPEG and GIF always go through photon, because libjpeg-turbo and Bun's GIF decoder accept damaged data that photon rejects.
    • Without Bun.Image (Node), every format goes through photon, as before.

Tests

  • image-header.test.ts:
    • parsers on 8 valid files: PNG, baseline and progressive JPEG, GIF, animated GIF, and lossy, lossless and extended WebP. Photon decodes each file to the same size.
    • 40 malformed or uncommon files that must return undefined: bad CRC, truncation, trailing bytes, SOF3/SOF9, 12-bit, CMYK, frames outside the screen, a RIFF size mismatch, animation, and others.
    • resizeImageInProcess: returns in-limit images without loading photon; uses the displayed size for EXIF orientation 6; falls back to photon when the header cannot be read.
  • image-resize-encode.test.ts: no JPEG encode when the PNG fits; qualities in order until one fits; the shrink loop; the native pipeline with a stand-in for Bun.Image (orientation, Lanczos3/fill, stops at the first fit, errors reach the caller).
  • suite/agent-session-read-images.test.ts: a session read of an in-limit PNG is byte-identical and processed once; an over-limit PNG is resized once, with the dimension note.
  • npm run check is clean. These pass: the existing image, read, tool-result and clipboard tests (17 files, 295 tests).

Not in this PR

  • The Kitty transcode (pi-tui components/image.ts) still converts non-PNG images to PNG with photon, synchronously inside render().
  • BMP to PNG conversion (image-convert.ts) still uses photon.
  • The macOS clipboard conversion still uses photon.
  • The worker is still created per image on the photon path.

Each of these could move to Bun.Image.

… in Bun.Image

Pi-Bolt runs photon's WebAssembly in the interpreter. Reading a 1800x1100 PNG
that needs no resize cost 1.16 s of CPU (a full decode to learn its size), and a
2442x1438 screenshot 36.7 s (decode, Lanczos3 resize, a PNG and five JPEG
encodes, then a second decode when the tool result was normalized).

- Read width and height from the PNG, JPEG, GIF or WebP structure. An image
  within the limits is returned as it is, without photon or a worker. The
  parsers accept only intact files (every PNG chunk CRC, end markers, frame
  bounds); anything else takes the decode path as before.
- Encode candidates one at a time and stop at the first that fits: the JPEG
  qualities are encoded only when the PNG is too large. Same output.
- The read tool marks the image blocks it produced; tool-result normalization
  skips them when the options match instead of processing them again.
- When the runtime has Bun.Image (Pi-Bolt's runtime, Bun 1.4), resize there:
  native decoders, SIMD Lanczos3, libspng/libjpeg-turbo encoders, off the main
  thread. Same target sizes, candidate order and size limit; different bytes.
  Photon remains the path under Node and the fallback when it fails.

CPU per `pi -p` read (0.9.5 runtime, medians of 3): 1800x1100 PNG 1162 -> 56 ms
(a text read is 57 ms), 1600x1200 JPEG 3651 -> 68 ms, 2442x1438 PNG 36.7 -> 0.52 s,
3024x1964 PNG 39.9 -> 0.70 s, 4032x3024 JPEG 75.5 -> 1.09 s.
Copilot AI balanced review requested due to automatic review settings October 19, 2026 21:18

@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 19, 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-19T21:21:14.046077Z b660514 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: b660514a8a

ℹ️ 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/coding-agent/src/utils/image-header.ts
…re sent as they are

The header check alone let through files whose structure is intact but whose
image data is damaged, for example a JPEG with corrupt scan data ending in
FF D9. 0.9.5 decoded every image with photon and omitted those; the previous
commit sent them as they were, which a provider can reject for the whole
request.

- PNG and WebP within the limits skip photon only when Bun.Image decodes them
  (a full native decode to 1x1: about 5 ms for an 1800x1100 PNG, 25 ms for an
  1800x1100 WebP). When it fails, photon decodes them as in 0.9.5.
- JPEG and GIF within the limits always go through photon: libjpeg-turbo and
  Bun's GIF decoder accept damaged data that photon rejects.
- Without Bun.Image (Node, photon with a JIT), every format goes through
  photon, as in 0.9.5.
- resizeImageInProcess (photon path) no longer skips the decode.

CPU per `pi -p` read against 0.9.5 (medians of 3): 1800x1100 PNG 996 -> 47 ms,
1800x1100 WebP 6328 -> 68 ms, 1600x1200 JPEG 2094 -> 1052 ms (one photon
decode instead of two). Requests are identical to 0.9.5 for these files and for
a damaged JPEG ("Image omitted") and a damaged WebP.
@Kushalkhemka
Kushalkhemka merged commit 625c13e into pi-bolt Oct 20, 2026
12 checks passed
@Kushalkhemka
Kushalkhemka deleted the perf/images-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