Skip to content

Cut peak memory across the encode and payload paths - #309

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

Cut peak memory across the encode and payload paths#309
Alek99 merged 5 commits into
mainfrom
alek/mem-audit

Conversation

@Alek99

@Alek99 Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member

Audit of engine memory, plus the wins from it that survived a perf gate.

Instrumented peak RSS and tracemalloc per workload in fresh processes. The finding that
shaped the PR: the payload path is already lean — a 10M-point density build costs
13.8 MB over the 160 MB of canonical data, and a 10M-point M4 line costs 0.9 MB. Nothing
there needed fixing. Every real problem was a transient, and almost all of them were in
the encoders. Since freed memory is not returned to the OS, each one-shot peak
permanently raises the process high-water, so cutting a peak cuts steady-state RSS too.

Three commits, kept unsquashed — they are independently validated and independently
revertable.

Memory: before → after

path before after
JPEG encode, photographic 1800×840 1515.9 MB 399.9 MB −74%
JPEG encode, gradient 1800×840 127.2 MB 48.4 MB −62%
JPEG encode, chart 1800×840 108.2 MB 48.4 MB −55%
PNG encode, indexed 3840×2160 273.7 MB 33.2 MB −88%
PNG encode, indexed 1800×840 49.9 MB 6.1 MB −88%
PNG encode, truecolor 1800×840 36.3 MB 30.3 MB −17%
2.1M-row continuous color channel resolve 44.1 MB 7.3 MB −83%
colored 2.1M-point first paint (RSS) 181.4 MB 145.8 MB −19%
to_svg, 100k-point scatter 38.7 MB (100.1 MB RSS) 26.9 MB (84.8 MB RSS) −30%
to_html, 1M-point scatter 41.3 MB 34.0 MB −18%
streamed figure's reported RAM 1.92 of a real 3.20 MB exact 40% was hidden

Speed: the same changes, measured in isolation (min-of-N, interleaved)

path before after
PNG encode, indexed 3840×2160 86.9 ms 40.2 ms
PNG encode, indexed 1800×840 15.3 ms 8.6 ms
direct-tier scatter payload emit, 100k 0.058 ms 0.038 ms
JPEG encode, gradient 1800×840 51.0 ms 41.5 ms
JPEG encode, chart 1800×840 40.3 ms 38.3 ms
to_html, 1M 8.36 ms 8.00 ms

What was actually wrong

Every one of these was "copying what the caller already holds":

  • The JPEG entropy packer built two int64 index vectors per output bit — 17 bytes per
    bit — so a 2.8 MB stream peaked over 1.5 GB. It now packs in bounded bit passes, carrying
    each pass's sub-byte remainder so the emitted bytes are exactly those of the whole-image
    pass (0xFF stuffing is byte-local, so it stays per-pass). Around it, planes are released
    as consumed, the quantize chain runs in place, and token fields are freed as they are
    gathered into MCU order. The rounding rule is now trunc(x + copysign(0.5, x)): same
    round-half-away-from-zero in three in-place passes instead of four passes and four
    full-size temporaries, and exact rather than approximate — IEEE addition is
    sign-symmetric, so for x < 0 the sum is the exact negation of |x| + 0.5.
  • The indexed-palette PNG encoder held five full frames: an intp-per-pixel
    np.unique inverse (twice the image itself), a redundant astype, a list of per-row
    bytes, its join, and a tobytes. Now: one (h, stride+1) staging buffer,
    searchsorted over the ≤256-entry palette in row blocks, buffer handed straight to zlib.
  • Color quantization was nominally chunk-bounded at 2²² = 4M rows, so every real column
    still ran as one shot with three simultaneous full-length f64 temporaries. Chunked at 2¹⁸
    with in-place stages; direct_rgba shipping (previously unchunked) routed through the
    same helper.
  • Direct-tier scatter/line/area rebuilt an all-visible row mask on every build — two
    isfinite passes, an &, an np.all, two N-byte temporaries. Zone maps count NaN and
    ±inf as null (§22/§19), so with linear axes and no nulls the mask cannot drop a row. Area
    additionally built an identity index vector and gathered animation keys through it.
  • SVG joined the mark markup twice (once inside the return f-string) and kept one Python
    str per point, ~50 bytes of object header each. One flat join; markers collapse into
    4096-marker blocks.
  • HTML export copied the base64 payload twice more than needed. Chunks are appended as
    their own document parts and joined once. The newline stays between blocks, so a
    payload-free document keeps its exact byte layout.
  • Small ones: the rasterizer's serial point/segment passes no longer collect 0..n into
    a Vec<u32> just to iterate it in order (the band painters take an item iterator, so
    fanned-out callers pass bucketed indices and serial callers pass a range);
    render_raster borrows the display list instead of freezing a bytes copy of it; facet
    PNG export drops two full-frame tobytes(); the density quantizer keeps one temporary.

Report accuracy (§27)

memory_report() now reports capacity_bytes per column and canonical_capacity_bytes per
store, and builds resident_array_bytes from the capacity total. A streamed column's
values is a prefix view of its capacity-doubling growth buffer, so up to half of what the
process holds was invisible to the report — a 20-append figure reported 1.92 MB of a real
3.20 MB. Continuous channels already reported their growth buffers this way; columns now
match. Figures that never appended report exactly what they did before. Dossier §27 updated.

Validation

Output is byte-identical everywhere. Fingerprints hashed on origin/main and on this
branch and diffed:

  • 89 payload / split-payload / density-view / drill-cycle / selection / append-frame /
    SVG / PNG / PDF / JPEG / WebP / HTML fingerprints across 30 chart configurations,
    including NaN and ±inf rows, log and symlog axes, wide categorical, and direct-RGBA
    channels
  • 90 JPEG encodes (5 qualities × 18 images: flat/noise/gradient at five sizes down to 1×1,
    non-multiple-of-8 dimensions, RGB-vs-RGBA input, all-black, all-white)
  • 30 HTML documents, including animation-progress and custom-CSS variants and an empty figure
  • animation/transition-key paths for scatter, line and area, with and without non-finite rows

The only intentional difference is memory_report, which gained fields.

pytest -q: 2392 passed, 4 skipped. cargo test: 118 passed. clippy, ruff check,
ruff format --check clean; ty check reports the same 13 pre-existing diagnostics as the
base commit.

Perf gate: three interleaved A/B campaigns over the 89-benchmark CodSpeed suite in
walltime mode — arms alternated (ref mem mem ref ref mem), full suite per arm,
min-of-runs per arm, 16 suite runs total, and the native dylib swapped per arm alongside the
Python tree. Final campaign: median +0.24%, mean −0.49%, 0 of 89 rows slower by >5%.

Note for the CodSpeed comment

Expect noise on rows this branch cannot touch. Every single-campaign outlier above +5%
failed to reproduce:

  • contour_core_2d read +9.3% in one campaign and +2.1% in another; it has ~17% intra-arm
    spread, and in an isolated in-process interleaved benchmark this branch produced the
    fastest run of all six (3.43 ms vs the baseline's best 4.65 ms).
  • weighted_ecdf_native (+6.4% in one campaign) and sample_mask (+4.7%) are untouched
    native kernels running from the same dylib in both arms — noise by construction.
  • native_png_export_scatter read +0.79% in-suite while an in-process interleaved
    micro-benchmark showed equal-or-better medians and the segments path reproducibly −0.8%.
    Reporting it rather than hiding it.
  • select_lasso_message_1m is the repo's known bimodal row.

Deliberately not in this PR

Documented rather than coded, because each is a trade or a bigger change than a memory fix
should smuggle in:

  • The colored pyramid accumulator is the largest single number in the engine.
    MeanColorCell is 40 B/cell (kernels.rs), and tiles::build_color holds it alive while
    materializing the count and color planes: a 168 MB transient at the 2048² default for a
    2.1M-point trace, and MEAN_COLOR_ACCUM_BUDGET_BYTES (1 GB) permits 4 workers — 672 MB on
    a row-heavy trace. The exact u64 sums are a documented bitwise-determinism guarantee, so
    the cell cannot shrink without changing that contract. Both available levers (lower the
    worker budget; pick base_dim from row count) regress real-world build wall-clock that no
    benchmark covers, so they are trades, not wins.
  • range_indices/bin_2d_indices allocate 4N regardless of result size — a 400 MB
    transient at 100M rows to return a few KB for a deep-zoom window. Chunking adds two extra
    copies of the result exactly when the result is the whole column; count-then-fill doubles
    a memory-bound scan. The sound fix is structural: interaction.py scans the view window
    and _padded_drill_window then scans up to three superset windows, discarding all but
    the winner — one scan of the widest rung plus O(rows) filters could replace k+1 full O(N)
    scans, a CPU win as well as a memory win. It needs a rows-restricted range kernel (only
    polygon_select takes rows today), i.e. a new C-ABI entry point and an ABI_VERSION
    bump, and it lands on the two already-red drill rows. Worth its own PR with its own A/B.
  • to_pdf inflates the SVG into an ElementTree: 29.4 MB peak for a 1.07 MB PDF at only
    20k points, versus 5.8 MB for the same figure's SVG. Needs a streaming converter or
    conversion from the payload instead of from serialized SVG.
  • to_html(path=...) still materializes the whole document before writing it; streaming
    parts to the file would remove one full copy but changes the return contract.
  • Screen-grid mean-color worker accumulators cost 32 MB per colored figure (4 workers ×
    196k cells × 40 B); capping the fan-out at 2 halves it and costs wall-clock.
  • The direct-tier raster scratch materializes xs/ys/radii/fills at 16 B/mark even when
    serial, partly because the fan-out estimate needs a full pass first.
  • memory_report should surface the transient high-water. After a colored 2.1M drill
    session RSS is 382 MB while resident_array_bytes reports 120 MB; most of the gap is
    one-shot transients the allocator keeps rather than anything retained. §27's promise
    argues for an observed-RSS / peak-transient line.

Summary by CodeRabbit

  • Performance
    • Improved memory usage and speed for PNG, JPEG, SVG, and HTML exports, especially for large figures and images.
    • Reduced peak allocations during raster rendering, payload generation, and color/palette handling.
  • Enhancements
    • Memory reports now include per-column capacity details and more accurate resident-memory totals.
    • Rasterization accepts a wider range of read-only buffer types without extra copies.
  • Bug Fixes
    • Correct log-axis row rejection behavior in shipped payloads (with symlog unchanged) and improved handling for baseline-null dropping in area-like traces.
  • Documentation
    • Updated the changelog and memory-model documentation accordingly.

Alek99 added 3 commits July 25, 2026 19:38
Audit of engine memory with peak-RSS and tracemalloc probes per workload. The
core payload path is already lean (a 10M-point density build costs 13.8 MB over
the data; a 10M M4 line costs 0.9 MB), so this targets the four places where
transient allocation was many multiples of the result — and on this allocator a
one-shot transient sets the process high-water permanently, so shrinking peaks
shrinks steady-state RSS.

- Color-source quantization was nominally chunk-bounded at 2^22 = 4M rows, which
  meant every real column still ran as one shot with three full-length f64
  temporaries live at once. Chunk at 2^18 and run the scale/round stages in
  place; route direct-RGBA shipping (previously unchunked) through the same
  helper. Resolving a 2.1M-row continuous color channel drops 44.1 -> 7.3 MB,
  taking a colored 2.1M-point first paint from 181 -> 147 MB RSS.
- The indexed-palette PNG encoder held five full frames: an intp-per-pixel
  np.unique inverse, a redundant astype, a list of per-row bytes, its join, and
  a tobytes. Stage one (h, stride+1) scanline buffer, resolve indices with
  searchsorted over the <=256-entry palette in row blocks, and hand the buffer
  straight to zlib. 1800x840 peaks at 6.1 MB instead of 49.9 and encodes in
  8.6 ms instead of 15.3; at 4K it is 33 MB instead of 274 and 40 ms instead of
  87. Truecolor loses one frame (-17%) at unchanged speed.
- Direct-tier scatter/line/area rebuilt an all-visible row mask on every build:
  two isfinite passes, an &, an np.all, two N-byte temporaries -- and area also
  materialized an identity index vector and gathered animation keys through it.
  Zone maps count NaN *and* +-inf as null, so with linear axes and no nulls the
  mask cannot drop a row; skip it unless a log axis or a baseline column outside
  the x/y zone maps can. Scatter emit -35%, categorical -33%, continuous
  channels -16%, keyed animation payloads -19 to -35%.
- SVG documents joined the mark markup twice (once inside the return f-string)
  and kept one Python str per point, ~50 bytes of object header each. One flat
  join for the document, 4096-marker blocks for the markers: a 100k-point export
  peaks at 26.9 MB instead of 38.7. The native rasterizer now borrows the
  display list instead of freezing a bytes copy of it, and facet PNG export
  stops copying the whole frame twice.
- memory_report() reports capacity_bytes per column and canonical_capacity_bytes
  per store, and builds resident_array_bytes from the capacity total. A streamed
  column's values is a prefix view of its capacity-doubling buffer, so up to half
  of what it holds was invisible: a 20-append figure reported 1.92 of a real
  3.20 MB. Continuous channels already reported their growth buffers this way.
  Figures that never appended report exactly what they did before.

Every output stays byte-identical: 89 fingerprints over payloads, split
payloads, density views, drill cycles, selections, append frames, and
SVG/PNG/PDF/JPEG/WebP/HTML exports match origin/main, as do the
animation-key paths with and without non-finite rows. Perf gate is two
interleaved A/B campaigns over the 89-benchmark CodSpeed suite (arms alternated,
min-of-runs, 10 full suite runs total): median +0.36%, mean -0.24%, no
reproducible regression -- the two rows that crossed +5% in a single campaign
(contour, weighted_ecdf) are high-variance and land in favour of this branch
when measured in isolation, and weighted_ecdf is untouched native code running
from the same dylib in both arms.
`_pack_entropy` built one int64 index *per output bit* twice over, so a 2.8 MB
entropy stream cost over 1.5 GB of peak memory for a 6 MB image — enough to make
a 4K JPEG export a plausible OOM. Pack in bounded bit passes instead, carrying
each pass's sub-byte remainder into the next so the emitted bytes are exactly
those of the whole-image pass (0xFF stuffing is byte-local, so it stays
per-pass).

Around it, the pipeline now hands memory back as it goes: the interleaved f32
source dies once the three planes exist, each plane dies when its blocks are
built, the quantize chain runs in place, and the per-component token fields are
released as they are gathered into MCU order.

The rounding rule is now `trunc(x + copysign(0.5, x))` rather than
`sign(x) * floor(|x| + 0.5)`: same round-half-away-from-zero, three in-place
passes and one temporary instead of four passes and four full-size temporaries.
It is exact rather than approximate — IEEE addition is sign-symmetric, so for
x < 0 the sum is the exact negation of |x| + 0.5 and truncation toward zero
matches that magnitude's floor; ±0 lands on ±0.0 either way and casts to 0.

1800x840: peak 108.2 -> 48.4 MB, 40.3 -> 38.3 ms. Photographic content:
1515.9 -> 399.9 MB, 292 -> 285 ms. Gradient: 127.2 -> 48.4 MB, 51.0 -> 41.5 ms.
Byte-identical across 90 encodes (5 qualities x 18 images: flat/noise/gradient
at five sizes down to 1x1, odd non-multiple-of-8 dimensions, RGB-vs-RGBA input,
all-black, all-white), and the 89 payload/export fingerprints are unchanged.
2392 passed.
Three bookkeeping wins found by the same audit, none of which changes a byte of
output.

Standalone HTML export interpolated every base64 payload chunk into a wrapper
string and then folded a second joined copy into the document f-string. The
encoded payload *is* the bulk of the document, so that was two extra full copies
of it. Append the chunk as its own part and join once: a 1M-point export peaks at
34.0 MB instead of 41.3 and is marginally faster. The newline stays *between*
blocks, so a payload-free document keeps its exact byte layout too (checked
against an empty figure).

The rasterizer's serial point and segment passes collected `0..n` into a Vec<u32>
purely to iterate it in order — 4 bytes per mark, and the serial path is the one
CodSpeed measures, since raster_fanout is pinned serial under CODSPEED_ENV. The
band painters now take any item iterator, so the fanned-out callers pass their
bucketed indices and the serial callers pass a range. Monomorphization keeps both
at the same cost per mark.

`interaction._quantize_dval` clipped, scaled, and rounded through three
full-window temporaries; it now reuses the clip's copy.

Also drops a stale `_cpu.color`/`_cpu.size` reference from the `ship_channels`
docstring — those attributes no longer exist.

Validated: 89 payload/export fingerprints, 90 JPEG encodes, 30 HTML documents
(including animation-progress and custom-CSS variants and an empty figure), and
the animation-key paths all byte-identical; 2392 passed; 118 Rust tests pass;
clippy, ruff, and ty clean and unchanged.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 1 minute

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: 9bbcd93a-543d-4dce-bf56-f1cf426fc956

📥 Commits

Reviewing files that changed from the base of the PR and between 17960c1 and 84e0fc2.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • python/xy/_jpeg.py
  • python/xy/export.py
📝 Walkthrough

Walkthrough

Rendering and export paths now use bounded buffers, incremental assembly, and reduced intermediate allocations across PNG, JPEG, SVG, HTML, rasterization, payload generation, and quantization. Memory reports include streamed-column capacity, and native raster APIs accept buffer-like command inputs.

Changes

Performance and memory optimizations

Layer / File(s) Summary
Capacity-aware memory reporting
python/xy/columns.py, python/xy/_figure.py, spec/design-dossier.md, tests/test_figure.py, CHANGELOG.md
Memory reporting now includes column capacity and aggregates canonical_capacity_bytes; figure resident-array accounting uses capacity values, with regression coverage.
Chunked export encoding
python/xy/_png.py, python/xy/export.py, python/xy/_svg.py, python/xy/facets.py, python/xy/_raster.py, python/xy/_native.py
PNG, HTML, SVG, facet, and fast raster export paths use shared buffers, bounded chunks, and buffer-like native command inputs.
Chunked JPEG encoding
python/xy/_jpeg.py, tests/test_jpeg.py
JPEG token gathering, intermediate-plane cleanup, quantization rounding, and entropy packing were reworked for bounded intermediate storage, with bit-level and end-to-end output tests.
Bounded payload and quantization
python/xy/_payload.py, python/xy/channels.py, python/xy/interaction.py, tests/test_figure.py
Visibility masks are conditionally applied, while color and density quantization process data in chunks with in-place operations and payload filtering tests.
Iterator-based raster painting
src/raster.rs
Point and segment painting now consumes ranges or iterators instead of materialized index vectors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • reflex-dev/xy issue 167 — Both changes update standalone HTML export base64 chunking and document assembly to reduce peak memory.

Suggested reviewers: farhanaliraza

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main theme: reducing peak memory, especially in encode and payload paths, even though the PR also touches other export and reporting code.
Docstring Coverage ✅ Passed Docstring coverage is 93.55% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/mem-audit

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 improve performance by 16.63%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 101 untouched benchmarks
⏩ 2 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
test_animation_plain_payload_100k 2.2 ms 1.8 ms +20.49%
test_animation_keyed_payload_100k 3.3 ms 2.9 ms +12.9%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing alek/mem-audit (84e0fc2) with main (62462ec)

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.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
python/xy/_jpeg.py (1)

357-369: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

parts stays alive through the return expression, undermining the documented savings.

The docstring claims this costs "one joined copy plus the gathered result rather than both plus the parts," but parts is a named local that CPython keeps alive for the full duration of return np.concatenate(parts)[order] (locals aren't freed until the frame returns). So during the [order] fancy-index step, parts, the concatenate result, and the final indexed result all coexist — effectively 3x the field size at peak, not 2x. An explicit del parts before indexing would actually realize the claimed savings.

♻️ Proposed fix to release `parts` before indexing
 def _gathered(fields: list[list[np.ndarray]], index: int, order: np.ndarray) -> np.ndarray:
     parts = [f[index] for f in fields]
     for f in fields:
         f[index] = _EMPTY_I64
-    return np.concatenate(parts)[order]
+    joined = np.concatenate(parts)
+    del parts
+    return joined[order]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/xy/_jpeg.py` around lines 357 - 369, Update _gathered to concatenate
the collected parts into a temporary result, explicitly release parts before
applying order-based indexing, then return the indexed result. Preserve the
existing component-release behavior and output ordering while ensuring the
original parts do not remain alive during the fancy-index operation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@python/xy/_jpeg.py`:
- Around line 357-369: Update _gathered to concatenate the collected parts into
a temporary result, explicitly release parts before applying order-based
indexing, then return the indexed result. Preserve the existing
component-release behavior and output ordering while ensuring the original parts
do not remain alive during the fancy-index operation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 33fca85a-8dd9-4cf1-b15c-480701eb9791

📥 Commits

Reviewing files that changed from the base of the PR and between 62462ec and f5ea7fe.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • python/xy/_figure.py
  • python/xy/_jpeg.py
  • python/xy/_native.py
  • python/xy/_payload.py
  • python/xy/_png.py
  • python/xy/_raster.py
  • python/xy/_svg.py
  • python/xy/channels.py
  • python/xy/columns.py
  • python/xy/export.py
  • python/xy/facets.py
  • python/xy/interaction.py
  • spec/design-dossier.md
  • src/raster.rs

…capacity

Review of #309 asked for in-repo coverage of the behaviors these commits
introduced. Mutation-testing each candidate first showed the gaps were real:

- The JPEG entropy packer's chunk-boundary carry was dead code in the suite.
  Every image in tests/test_jpeg.py has an entropy stream orders of magnitude
  shorter than one 2^18-bit group, so dropping the carry, padding a non-final
  group, or skipping its byte stuffing all kept CI green while corrupting every
  large export. Now checked against a bit-by-bit statement of the T.81 rule at
  five tiny group sizes (every byte alignment), at the production group size
  over a stream crossing it twice, on degenerate streams (empty, one sub-byte
  token, consecutive 0xFF), and end-to-end: a noisy 320x400 image encodes
  identically with the packer forced into a single pass. The tests pin the
  emitted bytes, not the grouping — moving a split point by one token is a
  refactor and stays green by design.
- `_visible_mask_needed`'s log-axis and baseline-null conditions were invisible:
  removing either left all 2405 tests passing. Both are now pinned (x and y log
  axes, log combined with non-finite rows, symlog explicitly keeping
  non-positive rows, and an error_band whose baseline carries NaN/inf), so the
  predicate cannot be tightened past what the mask actually rejects. The
  null-rows condition was already covered by test_area_hostile.
- The memory report's capacity accounting had assertions only for the
  never-appended case — the case the change deliberately leaves alone. Now the
  appended case is pinned end to end: capacity exceeds live length, per-column
  capacity matches `Column.capacity_bytes`, `resident_array_bytes` is built from
  the capacity total, and growth stays inside the 2x amortized bound.

Each new test was verified to fail against a targeted mutation of the code it
covers. Two mutations survive and should: shifting an entropy group split by one
token, and treating symlog as log (which computes a mask that then rejects
nothing) — both are output-preserving.

Also: `_log_visible_mask` now documents the invariant it shares with
`_visible_mask_needed` in both directions, and `png_truecolor` takes a real
buffer union instead of `object`, with the C-contiguity requirement stated.

2412 passed; 89 payload/export fingerprints and 90 JPEG encodes unchanged;
ruff and ty clean.
@Alek99

Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Addressed the review. Before writing anything I mutation-tested each ask, which changed what got done:

The entropy-packer gap was real and worse than described. Not only does no test cross a group boundary — I confirmed that dropping the carry, padding a non-final group, or skipping its byte stuffing each left the whole suite green. Now covered against a bit-by-bit statement of the T.81 rule: five tiny group sizes (so passes end at every byte alignment), the production group size over a stream crossing it twice, degenerate streams (empty, a single sub-byte token, consecutive 0xFF), and end-to-end — a noisy 320×400 image encodes identically with the packer forced into one pass. All four failure modes above now fail the suite.

The tests deliberately pin the emitted bytes, not the grouping: packing is a concatenation with carries, so moving a split point by a token is output-equivalent. That mutation survives on purpose, and the test file says so, so nobody later reads it as a hole.

The mask conditions: two of three were genuinely unpinned, and I checked rather than assumed. Removing the log-axis condition, or the baseline-null condition, left all 2405 tests passing. Removing the null-rows condition failed test_property_figure.py::test_area_hostile, so that one was already covered. Added tests for the two that weren't: x and y log axes dropping non-positive rows, log combined with non-finite rows, symlog explicitly keeping non-positive rows, and an error_band whose baseline carries NaN/inf. Each was verified to fail against removal of the condition it covers.

Worth noting which direction is dangerous: weakening the predicate only recomputes a mask that rejects nothing (a wasted pass, not a wrong payload), while tightening it silently ships rows that should have been dropped. The new tests pin the tightening direction. The symlog-treated-as-log mutation therefore survives correctly.

Report assertions now cover the appended case — the case the change actually ships. Capacity exceeds live length, per-column capacity matches Column.capacity_bytes, resident_array_bytes is built from the capacity total, and growth stays inside the 2× amortized bound. Verified against two mutations (capacity ignoring _grow; resident_array_bytes reverting to live length).

Both minor items taken. _log_visible_mask now states the invariant it shares with _visible_mask_needed in both directions and points at the tests. png_truecolor takes bytes | bytearray | memoryview | np.ndarray instead of object, with the C-contiguity requirement spelled out — you were right that object was worse than the bytes it replaced.

17960c1: 2412 passed, and the 89 payload/export fingerprints plus 90 JPEG encodes are still byte-identical to main. Since this commit is tests plus comments plus one annotation, it cannot move the perf gate; the three A/B campaigns on the code commits stand.

One correction to the review's framing, for the record: capacity_bytes staleness via memmap isn't prevented only by the append-migration path — the report also never accumulates capacity in the memmapped branch, so both would have to break together.

CodSpeed caught a real regression from f5ea7fe: `test_html_export_line` −10.1%.
That benchmark's document *is* the ~330 KB client bundle (a 100k-point line
M4-decimates to a ~30 KB payload), and the new parts list interpolated
`client_js` into its header part, so the join copied the bundle a second time.
Locally reproducible as +1.9% wall-clock on that shape — ~16 µs, which is one
memcpy of 330 KB and confirms the mechanism rather than guessing at it.

The rule the assembler now follows: any string that is large relative to the
document is its own part, never interpolated. That covers the client bundle, the
spec JSON, the inline decoder, and each base64 chunk — so each is copied exactly
once by the join, which is strictly fewer copies than main did (main duplicated
the base64; this duplicated the bundle).

`test_html_export_line` shape: 0.730 → 0.726 ms best-of-three-runs, medians
0.792/0.803/0.846 → 0.785/0.787/0.794. A 1M-point export is 1.248 → 1.205 ms and
now peaks at 33.3 MB rather than 34.0 (main: 41.3). The empty-payload byte layout
is still pinned by the 30-document check.

Also takes the CodeRabbit note on `_jpeg._gathered`: a local stays alive until
its frame returns, so `return np.concatenate(parts)[order]` held the pieces, the
join and the gather simultaneously. Dropping `parts` and `joined` explicitly
makes the docstring's claim true. Measured peak on the probe images is unchanged
— their binding peak is in token construction, not the gather — but the
three-way coexistence was real.

89 payload/export fingerprints, 90 JPEG encodes and 30 HTML documents unchanged;
2412 passed; ruff and ty clean.
@Alek99

Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Both items fixed in 84e0fc2.

CodSpeed's test_html_export_line −10.1% was a real regression, not runner noise. That benchmark's document is the ~330 KB client bundle — a 100k-point line M4-decimates to a ~30 KB payload — and my parts list interpolated client_js into its header part, so the join copied the bundle a second time. It reproduces locally as +1.9% wall-clock on that exact shape, which is ~16 µs, i.e. one memcpy of 330 KB. That arithmetic is why I'm confident in the cause rather than just the correlation.

The assembler now follows one rule: any string that is large relative to the document is its own part and is never interpolated — the client bundle, the spec JSON, the inline decoder, and each base64 chunk. Each is then copied exactly once by the join, which is strictly fewer copies than main (main duplicated the base64 text; my first attempt traded that for duplicating the bundle).

After the fix, on the benchmark's shape: 0.730 → 0.726 ms best-of-three-runs, medians 0.792/0.803/0.846 → 0.785/0.787/0.794. A 1M-point export is 1.248 → 1.205 ms and peaks at 33.3 MB versus 34.0 before and 41.3 on main — so the fix improved both axes over my own earlier version.

Two notes on reading the rest of that report. The ⚠️ Different runtime environments flag applies to the improvements too, and the two flagged there (animation_plain_payload_100k +20.7%, animation_keyed_payload_100k +13.0%) match my local interleaved A/B closely (−34.7% and −18.9% wall-clock), so I'd treat those as real. And the row I pre-emptively flagged in the PR description, native_png_export_scatter, came back untouched here — consistent with it having been local noise.

@coderabbitai's _gathered note was correct and I took it. A local stays alive until its frame returns, so return np.concatenate(parts)[order] held the pieces, the join, and the gather at once — three times the field size, not two, exactly as described. parts and joined are now dropped explicitly, so the docstring's claim is actually true.

One honest qualification: the measured peak on my probe images did not move (48.4 / 399.9 / 48.4 MB). Those images' binding peak is in _component_tokens construction rather than the gather, so this fix removes a real three-way coexistence that simply is not the high-water mark for them. It will matter for an image whose token count dominates, and the code no longer claims something it does not do.

All other 24 checks were green before this push.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --no-ext-diff --format=fuller --stat 84e0fc2
git diff --no-ext-diff 84e0fc2^ 84e0fc2 -- python/xy/export.py python/xy/_jpeg.py

ast-grep outline python/xy/export.py --items all --match 'to_html|_iter_base64_chunks|_base64_chunks'
ast-grep outline python/xy/_jpeg.py --items all --match '_gathered|encode'

rg -n -A70 -B15 'def to_html\b|def _gathered\b' python/xy/export.py python/xy/_jpeg.py

Length of output: 19318


@Alek99, verified in 84e0fc2.

  • to_html() now keeps client_js, _DECODE_B64_JS, spec_js, and every base64 chunk as standalone parts entries, avoiding interpolation-created copies before the final join.
  • _gathered() explicitly releases parts before creating the gathered array, then releases joined before returning. The implementation now matches its documented two-array peak behavior.

Both findings are addressed. 🐇


✏️ Learnings added
Learnt from: Alek99
URL: https://github.com/reflex-dev/xy/pull/309

Timestamp: 2026-07-26T07:19:57.239Z
Learning: In `python/xy/export.py:to_html`, large document strings such as the standalone client bundle, serialized spec, inline decoder, and base64 payload chunks must be separate entries in the final `parts` list rather than interpolated into formatted strings, so each is copied only by the final join.

Learnt from: Alek99
URL: https://github.com/reflex-dev/xy/pull/309

Timestamp: 2026-07-26T07:19:57.239Z
Learning: In `python/xy/_jpeg.py:_gathered`, explicitly delete `parts` after `np.concatenate(parts)` and delete `joined` after applying `order`; Python function locals otherwise remain alive until return and can create a three-way coexistence of component pieces, joined data, and gathered result.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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