Skip to content

Stop keeping a second framebuffer, and a second count grid - #322

Merged
Alek99 merged 1 commit into
mainfrom
alek/mem-audit-2-rust
Jul 26, 2026
Merged

Stop keeping a second framebuffer, and a second count grid#322
Alek99 merged 1 commit into
mainfrom
alek/mem-audit-2-rust

Conversation

@Alek99

@Alek99 Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member

Two Rust-core allocations that existed only to be copied out of. From the second
memory audit pass (follow-up to #309); sibling PRs carry the encode paths (#320),
the Python paths (#321), the selection kernel, and the legend cache.

The rasterizer kept a second framebuffer

Canvas owned its pixels. rasterize_spans_into allocated w * h * 4, zeroed
it, painted it, and then copy_from_slice'd the result into the caller's
identically shaped output. Two complete framebuffers were live for the whole
paint — 4 bytes per device pixel of pure surplus, 33 MB at 3840x2160 — plus an
allocation, a zero-fill and a memcpy nobody asked for.

Canvas now borrows its framebuffer (px: &'a mut [u8]), so the RGBA entry
points paint straight into the caller's buffer and the export step is nothing at
all. Canvas::wrap zeroes the destination exactly as the owning
vec![0; w * h * 4] did, and straight alpha is already the canvas's own layout,
so to_rgba8 had no work left to do and is gone. paint_banded
(split_at_mut) and paint_image_bands (chunks_mut) already operated on
&mut [u8] and needed no change.

Measured on a 3840x2160 RGBA raster in a fresh process:

peak RSS growth frame
main 104.9 MB 64.8 MB 31.6 MB
this PR 73.3 MB 33.4 MB 31.6 MB

Exactly one framebuffer's difference.

rasterize_png_spans_into still owns a buffer, because its destination is the
encoded stream rather than a caller frame — but that one is 3-channel.

Failure semantics are unchanged in substance. A malformed display list already
left the output undefined, and every Python entry point raises rather than
returning it — _native.rasterize's docstring says so explicitly ("the Rust side
returns 0 = output undefined"). The difference is only that the undefined bytes
now live in the caller's buffer instead of a discarded one.

bin_2d_counts allocated a merge target it then overwrote

After collecting threads full worker grids, the parallel branch allocated
another full w * h u32 grid and summed into it — so peak was
(threads + 1) grids where threads was enough. It is the only member of the
bin_2d family that does not merge into a caller-owned buffer, and it is the one
on the pyramid build path, i.e. the largest grid in the codebase.

That is 16.8 MB of surplus on a 2048^2 base, and considerably more out-of-core:
_pyramid_base_dim_for rounds sqrt(n / 16) up to a power of two, which keeps
n / cells in roughly [4, 16] and therefore keeps the parallel branch live at
much larger grids (a memmapped 500M-row trace builds at base_dim 8192, where the
surplus is 268 MB).

Fold into the last worker's grid instead. Integer addition is associative and the
single saturation point is unchanged, so the output is bitwise identical for any
thread count
— the invariant the doc comment and bin_2d_threads_grid_aware
care about.

Verification

  • 118 Rust tests pass. Canvas borrows now, so tests that keep a result past the
    call use a new rasterize_to_vec helper that owns the buffer; the comparisons
    themselves are unchanged.
  • 2412 Python tests pass.
  • A 97-artifact output fingerprint sweep — payloads, SVG/PNG/JPEG/WebP/HTML
    exports, selections, density views, append, pyplot — is identical to main.

Perf

This is the tranche with real perf exposure, so it was gated on its own. Focused
interleaved A/B against main over the raster and binning regions, 16 alternations
in drift-cancelling ABBA blocks, native core swapped per arm:

min-basis median  -0.31%      worse >5%: 0      better >5%: 0
native_png_export_scatter              -2.35%
native_png_export_categorical_scatter  -1.18%
native_png_export_heatmap              +0.79%
pyramid_build                          -3.68%
bin_2d / bin_2d_indices / compose      -0.31% / -0.12% / -0.36%

A first full-suite run read pyramid_build +14.9%, which did not survive
scrutiny: the control kernels (normalize_f32, min_max, range_indices — code
this PR cannot reach) moved by the same amount, and pyramid_build showed 54-74%
intra-arm spread with monotonic drift across the run. Re-gated on a quieter
machine with balanced round ordering it is -7.7%.

@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: 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: 3b5bd570-5bd6-4551-862b-921f6876c134

📥 Commits

Reviewing files that changed from the base of the PR and between 3dce698 and 51f37e3.

📒 Files selected for processing (2)
  • src/kernels.rs
  • src/raster.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/mem-audit-2-rust

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-rust (51f37e3) with main (3dce698)

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.

Two Rust-core allocations that existed only to be copied out of.

`Canvas` owned its pixels: `rasterize_spans_into` allocated `w * h * 4`, zeroed
it, painted it, then `copy_from_slice`'d the result into the caller's
identically shaped output. Two full framebuffers were live for the whole paint —
4 bytes per device pixel of pure surplus, 33 MB at 3840x2160 — plus an
allocation, a zero-fill and a memcpy. `Canvas` now borrows its framebuffer, so
the RGBA entry points paint straight into the caller's buffer and the export
step is nothing at all. Measured on a 3840x2160 RGBA raster in a fresh process:
peak RSS 73.3 MB, growth 33.4 MB, against 104.9 MB and 64.8 MB before — exactly
one framebuffer's difference. `rasterize_png_spans_into` still owns a buffer
because its destination is the encoded stream, but that one is 3-channel.

Failure semantics are unchanged in substance: a malformed display list already
left the output undefined and every Python entry point raises rather than
returning it, which the docstrings say ("the Rust side returns 0 = output
undefined").

`bin_2d_counts` allocated a fresh `w * h` u32 grid to merge its worker grids
into, and then overwrote every cell of it. It is the only member of the bin_2d
family that does not merge into a caller-owned buffer, and it is the one on the
pyramid build path, so peak was (threads + 1) grids where threads was enough:
16.8 MB of surplus on a 2048^2 base and considerably more out-of-core, since an
adaptive base_dim keeps n/cells in roughly [4, 16] and therefore keeps the
parallel branch live at much larger grids. Fold into the last worker's grid
instead. Integer addition is associative and the single saturation point is
unchanged, so the output is bitwise identical for any thread count.

118 Rust tests and 2412 Python tests pass; a 97-artifact output fingerprint
sweep is identical to main.
@Alek99
Alek99 force-pushed the alek/mem-audit-2-rust branch from 3d840ff to 51f37e3 Compare July 26, 2026 21:24
@Alek99
Alek99 merged commit ceaefec 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