M3: rendered pixels are moved out of the pixmap, not copied (#404)

`render_page` copied every raster out of vello_cpu's pixmap with
`data_as_u8_slice().to_vec()`. That is a second buffer the size of the raster
(up to 256 MB for a 64 MP page), alive alongside the first for as long as the
copy runs. `Pixmap::take` hands over the pixel vector, and `bytemuck`
reinterprets its four one-byte channels as bytes in place: the same bytes, in
the same buffer.

On a 6182×8000 px page, peak RSS goes from about 425 MB to 246 MB and render
time from 102 ms to 81 ms. Pixels are identical: every page of #260's test
file, at two scales and as a tile, hashes the same before and after.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cyfung1031 2026-10-09 22:15:55 +09:00 committed by GitHub
parent e130af2ef4
commit d6c2a694ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 24 additions and 1 deletions

1
Cargo.lock generated
View File

@ -3745,6 +3745,7 @@ dependencies = [
name = "pdfcraft-render"
version = "0.4.0"
dependencies = [
"bytemuck",
"getrandom 0.4.3",
"hayro",
"hayro-syntax",

View File

@ -16,6 +16,8 @@ lopdf = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# Moves rendered pixels out of the renderer's pixmap without copying; already in the tree through hayro.
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
# lopdf (bootstrap) draws random bytes for encryption; on the web they come from the browser.
[target.'cfg(target_arch = "wasm32")'.dependencies]

View File

@ -17,6 +17,7 @@ use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_interpret::font::{FontData, FontQuery};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::Pixmap;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{RenderCache, RenderSettings, render};
@ -222,7 +223,7 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
}
};
let pixmap = render(page, cache, settings, &rs);
Ok((pixmap.width() as u32, pixmap.height() as u32, pixmap.data_as_u8_slice().to_vec(), None))
Ok((pixmap.width() as u32, pixmap.height() as u32, into_bytes(pixmap), None))
}));
match result {
Ok(Ok(v)) => Ok(v),
@ -231,6 +232,13 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
}
}
/// The pixmap's premultiplied RGBA bytes, moved out rather than copied (a page raster can be
/// hundreds of megabytes). Four one-byte channels always recast to bytes in place; the copy is
/// only there because the cast's signature allows failure.
fn into_bytes(pixmap: Pixmap) -> Vec<u8> {
bytemuck::allocation::try_cast_vec(pixmap.take()).unwrap_or_else(|(_, pixels)| pixels.iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect())
}
fn finish(req: RenderRequest, start: Stopwatch, r: Result<Output, (String, bool)>) -> RenderedPage {
let millis = start.millis();
match r {
@ -732,6 +740,18 @@ trailer << /Root 1 0 R >>
assert_eq!(px(80, 5), &[255, 255, 255, 255]);
}
#[test]
fn rendered_pixels_are_moved_out_of_the_pixmap() {
let mut pixmap = Pixmap::new(3, 2);
for (i, b) in pixmap.data_as_u8_slice_mut().iter_mut().enumerate() {
*b = i as u8;
}
let (at, expected) = (pixmap.data_as_u8_slice().as_ptr(), pixmap.data_as_u8_slice().to_vec());
let bytes = into_bytes(pixmap);
assert_eq!(bytes, expected);
assert_eq!(bytes.as_ptr(), at, "the pixmap's own buffer, not a copy");
}
#[test]
fn missing_page_and_garbage_input_fail_gracefully() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());