Skip to content

perf(render): memoize rendered pages, filter PNGs, split request timing - #158

Open
akumrazor wants to merge 2 commits into
teamchong:mainfrom
akumrazor:pr/render-perf
Open

perf(render): memoize rendered pages, filter PNGs, split request timing#158
akumrazor wants to merge 2 commits into
teamchong:mainfrom
akumrazor:pr/render-perf

Conversation

@akumrazor

Copy link
Copy Markdown

The proxy re-renders identical PNGs on every turn. history.ts freezes old chunks so they stay byte-identical for the upstream prompt cache, which means a long session pays full render cost for pages that provably did not change. Measured on a real session: 134 images cost ~10s of single-threaded CPU per request while the payload hash held constant across consecutive turns.

Three changes, each measured.

1. Rendered-page cache (render.ts)

Rendering is a pure function of its inputs — atlases decode once at module init and never mutate — so identical inputs can return identical pages. Bounded LRU over retained bytes (PNG payloads plus the key strings, which embed the source text and are the larger half). 64 MiB default, PXPIPE_RENDER_CACHE_BYTES to tune, 0 to disable.

The key embeds the source text verbatim rather than a digest: a digest collision would serve the wrong image, a silent correctness bug far worse than a miss. slotText is length-prefixed so ("a b", "c") and ("a", "b c") cannot collide, and undefined stays distinct from '' because the renderer branches on it.

134 images: 14.4s → 1.0s.

2. PNG Average filter (png.ts)

Residuals of 5×8 bitmap glyphs on a flat background collapse to mostly zeros, which deflate encodes in both less space and less time.

filter level size encode
None (before) 6 49.9 KB 30 ms
Average 6 32.7 KB 13 ms

34% smaller IDAT at roughly half the compression cost. Lossless — verified against skia (@napi-rs/canvas), an independent decoder, rather than a hand-rolled one that could share a bug with the encoder. The images the model sees are bit-identical; only the bytes on the wire shrink. Filtering is pure JS ahead of the compressor, so it stays portable to Workers unlike a deflate-level change.

3. Request timing split (proxy.ts, node.ts, tracker.ts)

durationMs alone could not distinguish our own CPU from a slow provider. tx is the local transform, fb is time to upstream response headers, so fb - tx isolates upload plus provider processing and duration - fb is generation:

→ 200 (23045ms tx=11200ms up=11845ms fb=9800ms) compressed ...

Persisted as transform_ms in the JSONL so it is analyzable across many requests, not just readable line by line. The timer deliberately closes before the Google count_tokens probe, so network latency is never charged to local CPU.

vitest.config.ts: testTimeout 30s → 60s

The append-only cache-stability case alone runs ~30s against a 30s limit, so it timed out purely from CPU contention whenever another file joined the parallel pool — a failure that says nothing about the code under test. The existing comment already documented this class of problem when it went 5s → 30s.

Verification

tsc --noEmit clean. 933 tests pass, including 11 new cache tests (byte identity, every input that must miss, the slot/text ambiguity, style key ordering, droppedCodepoints isolation, LRU eviction, exact byte accounting, kill switch) and 3 lossless-PNG tests.

The 3 failures in tests/node-security.test.ts are pre-existing and Windows-only — POSIX permission assertions (0o700) that Windows does not apply. Confirmed against a clean checkout of this base; they should pass on Linux CI.

🤖 Generated with Claude Code

akumrazor and others added 2 commits July 28, 2026 15:29
The proxy re-rendered identical PNGs on every turn. history.ts freezes old
chunks so they stay byte-identical for the upstream prompt cache, which means
a long session pays full render cost for pages that provably did not change:
measured on a real session, 134 images cost ~10s of single-threaded CPU per
request while the payload hash held constant across consecutive turns.

Three changes, each measured:

* Rendered-page cache (render.ts). Rendering is a pure function of its inputs
  -- atlases decode once at module init and never mutate -- so identical inputs
  return identical pages. Bounded LRU over retained bytes (PNG payloads plus
  the key strings, which embed the source text and are the larger half),
  64 MiB default, PXPIPE_RENDER_CACHE_BYTES to tune, 0 to disable.
  The key embeds the source text verbatim rather than a digest: a digest
  collision would serve the WRONG image, a silent correctness bug far worse
  than a miss. 134 images: 14.4s -> 1.0s.

* PNG Average filter (png.ts). Residuals of 5x8 bitmap glyphs on a flat
  background collapse to mostly zeros, which deflate encodes in both less
  space and less time. Lossless -- verified against skia, an independent
  decoder, rather than a hand-rolled one that could share a bug with the
  encoder. 34% smaller IDAT at roughly half the compression cost; the images
  the model sees are bit-identical.

* Request timing split (proxy.ts, node.ts, tracker.ts). durationMs alone could
  not distinguish our own CPU from a slow provider. tx is the local transform,
  fb is time to upstream response headers, so fb - tx isolates upload plus
  provider processing and duration - fb is generation. Persisted as
  transform_ms in the JSONL so it is analyzable across requests.

vitest.config.ts: testTimeout 30s -> 60s. The append-only cache-stability case
alone runs ~30s, so it timed out purely from CPU contention whenever another
file joined the parallel pool -- a failure that says nothing about the code
under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render cache added a public env var without listing it alongside the
others, so the only way to discover it was reading render.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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