Skip to content

Carry JPEG tokens and WebP bits at their natural width - #320

Merged
Alek99 merged 2 commits into
mainfrom
alek/mem-audit-2
Jul 26, 2026
Merged

Carry JPEG tokens and WebP bits at their natural width#320
Alek99 merged 2 commits into
mainfrom
alek/mem-audit-2

Conversation

@Alek99

@Alek99 Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member

Second pass over engine memory, following #309. Same method: peak RSS and
tracemalloc.reset_peak() probes per workload, each in a fresh subprocess, with
byte-identical output as the gate. This PR is the encode paths; four sibling PRs
carry the rest.

Stage attribution found the whole cost in two functions rather than spread
around, so the fixes are narrow.

JPEG: the token pipeline was int64 everywhere

_component_tokens was the single largest allocation in the engine. Replaying
encode with reset_peak() between stages, a 3200x2400 photographic image:

stage stage peak live after
rgb→ycbcr planes 234.4 MB 87.9 MB
pad+blockify / dct / quantize (luma) 174.9 MB 117.2 MB
tokens (luma, 3.09M) 730.7 MB 176.3 MB
argsort MCU order 363.3 MB 242.4 MB
pack entropy 208.6 MB 52.5 MB

The token stage held ~20 parallel int64 arrays over the nonzero coefficients.
None of those values need eight bytes: AC positions top out at 62, run lengths
at 62, magnitude categories at 12, and symbols are a byte by definition. The
coefficients themselves fit int16 — _DCT is orthonormal, so a level-shifted
8-bit block transforms to at most sqrt(64) * 128 = 1024 in magnitude, and
quantizers are always >= 1.

Carrying block indices as int32, amplitudes as int16 and everything else as
uint8 — plus int32/uint8 Huffman tables, since both are gathered once per
token — takes that encode from 730.7 MB to 199.7 MB and makes it 2.6%
faster
. The MCU sort key is explicitly widened to int64 on the way in: a
maximal 65535x65535 frame has 67M blocks and block * 3 << 8 leaves int32 well
before that.

The RGB→YCbCr transform also promoted the whole frame to interleaved float
before splitting it into planes — three floats per pixel on top of the three
planes, 92 MB of pure transient at that size. It now runs in row bands, which is
elementwise-identical.

WebP: _pack_lsb was the entire peak

216.2 MB of a 216.2 MB export. It ran one masked pass per bit position over the
whole token stream, and each pass built a boolean mask plus three compacted
gathers — about five machine words per entry, forty times over. The passes now
run over bounded entry blocks, each iterating only as far as its own widest
entry. Every entry still scatters to the same absolute bit position, so the
output cannot move. The header also rides in the token buffer instead of being
concatenated onto the front of it, which was a second full copy of the stream.
216 MB -> 135 MB at unchanged speed.

_flatten_alpha returned a plane JPEG never reads

The JPEG determinism backstop composited leftover alpha over white for the whole
frame at once, in uint16 — six full-frame temporaries — and returned a
four-channel image whose alpha the encoder immediately discards (_jpeg.encode
reads rgba[..., :3], and both call sites feed it directly). Returning
(h, w, 3) and compositing in row bands: 117.2 MB -> 27.4 MB, 30.8% faster.
rgb * a + 255 * (255 - a) + 127 peaks at 65152 for any 8-bit input, so the
uint16 intermediates stay exact and the band size cannot move a byte.

Measured

20-workload memory suite, warm __pycache__, native core held constant:

workload before after
jpeg_export_photo traced peak 417.7 MB 205.1 MB
jpeg_export_photo peak RSS 845.6 MB 431.4 MB
webp_export traced peak 245.5 MB 164.1 MB
webp_export peak RSS 397.8 MB 289.8 MB

Every other workload is identical to the hundredth of a megabyte.

Equivalence

  • 117 JPEG encodes (13 images x 9 qualities: flat, 1x1, single row/column,
    RGB-vs-RGBA, pixel and 8x8 checkerboards, ZRL-heavy sparse runs, smooth
    gradients, an unaligned photographic case) byte-identical to the pristine
    encoder, plus all 8 error paths.
  • 10 WebP encodes byte-identical, including a run past the 4096-pixel length cap
    and one crossing the new entry-block boundary.
  • A 97-artifact fingerprint sweep (payloads, SVG/PNG/JPEG/WebP/HTML exports,
    selections through both zone-pruning branches, density views, append,
    transition keys, pyplot) identical to main.
  • New tests pin the coefficient bound, the token field widths, and the
    transparency of both new chunk sizes against one-shot runs.

Full suite: 2428 passed, 4 skipped. Ruff clean.

Perf

Full 103-region CodSpeed suite in walltime mode, interleaved A/B against main
with the native core held constant: median -0.96%, mean -0.91%, 5 regions more
than 5% better.
One region read +20% (factorize_fixed_categorical) — it has
52% intra-arm spread in the reference arm (0.90–1.37 ms) and shares the same
dylib in both arms, so it cannot be caused by this change.

Alek99 added 2 commits July 26, 2026 01:03
Second-pass memory audit of the encode paths, measured with per-workload peak
RSS and tracemalloc probes in fresh subprocesses. Both changes are
byte-identical by construction and validated as such.

JPEG. `_component_tokens` was the single largest allocation in the engine: a
3200x2400 photographic encode peaked at 731 MB, of which the token stage alone
was 742 MB of the traced high-water. It held roughly twenty parallel int64
arrays over the nonzero coefficients — but AC positions top out at 62, run
lengths at 62, magnitude categories at 12, and symbols are a byte by
definition. The coefficients themselves fit int16: `_DCT` is orthonormal, so a
level-shifted 8-bit block transforms to at most sqrt(64) * 128 = 1024 in
magnitude, and quantizers are >= 1. Carrying block indices as int32,
amplitudes as int16 and everything else as uint8 — plus int32/uint8 Huffman
tables, since both are gathered once per token — cuts that encode to 200 MB
and makes it ~3% faster. The MCU sort key is explicitly widened to int64 on
the way in: a maximal 65535x65535 frame has 67M blocks, and `block * 3 << 8`
leaves int32 well before that.

The RGB->YCbCr transform also promoted the whole frame to interleaved float
before splitting it into planes — three floats per pixel on top of the three
planes, 92 MB of pure transient at that size. It now runs in row bands, which
is elementwise-identical.

WebP. `_pack_lsb` was the entire peak of a WebP export (216.2 MB of 216.2 MB).
It ran one masked pass per bit position over the whole token stream, and each
pass built a mask plus three compacted gathers — about five machine words per
entry, forty times over. The passes now run over bounded entry blocks, each
iterating only as far as its own widest entry; every entry still scatters to
the same absolute bit position, so the output cannot move. The header also
rides in the token buffer instead of being concatenated onto the front of it,
which was a second full copy of the stream. 216 MB -> 135 MB at unchanged
speed.

Measured on the 20-workload memory suite, warm __pycache__, native core held
constant: jpeg_export_photo 417.7 -> 205.1 MB traced peak (845.6 -> 431.4 MB
RSS), webp_export 245.5 -> 164.1 MB (397.8 -> 289.8 MB RSS). Every other
workload is identical to the hundredth of a megabyte.

Equivalence: 117 JPEG encodes (13 images x 9 qualities, including flat, 1x1,
single row/column, RGB-vs-RGBA, pixel checkerboards, ZRL-heavy sparse runs and
an unaligned photographic case) and 10 WebP encodes are byte-identical to the
pristine encoders, as are all eight JPEG error paths. New tests pin the
coefficient bound, the token field widths, and the transparency of both new
chunk sizes against one-shot runs.
`_flatten_alpha` is the JPEG determinism backstop: it composites leftover
alpha over white before encoding. It did so for the whole frame at once, in
uint16 — six full-frame temporaries — and returned a four-channel image whose
alpha plane the encoder immediately discards (`_jpeg.encode` reads
`rgba[..., :3]`, and both call sites feed it directly).

Returning (h, w, 3) and compositing in row bands takes a 3200x2400 export from
117.2 MB to 27.4 MB of traced peak and makes it 30.8% faster. The arithmetic
is unchanged and elementwise: `rgb * a + 255 * (255 - a) + 127` peaks at 65152
for any 8-bit input, comfortably inside uint16, so the band size cannot move a
byte. Verified against the previous implementation over all 65,536
(value, alpha) pairs plus opaque, fully transparent, graded-alpha, 1x1 and
single-row images, comparing both the composited RGB and the encoded JPEG.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Alek99, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cda19597-36a2-4306-b14e-bbd7b08eb37e

📥 Commits

Reviewing files that changed from the base of the PR and between d35731d and a24521c.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • python/xy/_jpeg.py
  • python/xy/_webp.py
  • python/xy/export.py
  • tests/test_jpeg.py
  • tests/test_webp.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/mem-audit-2

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 103 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing alek/mem-audit-2 (a24521c) with main (d35731d)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Alek99
Alek99 merged commit 3dce698 into main Jul 26, 2026
28 checks passed
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