mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
perf(engine): export page images on worker threads
Export > Image spent its time in the PNG, JPEG and TIFF encoders, one page after another: on a 55-page manual at 150 dpi, drawing every page took about 0.34 s and encoding it 1.1 s (PNG) to 2.0 s (JPEG). Exporter::images exports a list of pages, drawing and encoding several at once on worker threads, each with its own PageRenderer (as automation's extract_parallel does), and hands the files to a sink in page order on the calling thread. Workers draw at most one page ahead of the sink and fewer run when 1 GiB can't hold that many of the largest page. The first page or sink error stops the export with every earlier page handed over, as before. A page panic becomes that page's error, and a panicking sink stops the workers. One page, one core or wasm keeps the per-page loop. Export > Image and the doc_export_images tool use it. Files are byte-identical. One core is left for the interface (at most 8 workers). With examples/export_bench, on a Ryzen with 16 threads (shared), mean of two medians of 21 runs: PNG 150 dpi 1874 -> 340 ms (-82%) JPEG 150 dpi 2106 -> 340 ms (-84%) TIFF 150 dpi 2032 -> 375 ms (-82%) PNG 300 dpi 3804 -> 590 ms (-84%) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1dd2737b47
commit
37bf4ee986
@@ -539,11 +539,23 @@ impl Automation {
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
let mut lowered = Vec::new();
|
||||
for p in pages {
|
||||
let img = ex.image(p, dpi, format).map_err(failed)?;
|
||||
// A write error stops the export as it is, not as a page error.
|
||||
let mut write_error = None;
|
||||
let exported = ex.images(&pages, dpi, format, |p, img| {
|
||||
let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension()));
|
||||
write_atomic(&path, &img)?;
|
||||
write_atomic(&path, &img).map_err(|e| {
|
||||
let message = e.to_string();
|
||||
write_error = Some(e);
|
||||
message
|
||||
})?;
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
});
|
||||
if let Some(e) = write_error {
|
||||
return Err(e);
|
||||
}
|
||||
exported.map_err(failed)?;
|
||||
for &p in &pages {
|
||||
// A page too large for the renderer at `dpi` is drawn at the most it allows.
|
||||
let used = ex.dpi_used(p, dpi);
|
||||
if used < dpi.clamp(18.0, 1200.0) - 0.5 {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Times Export ▸ Image over every page of a PDF, for before and after comparisons.
|
||||
//!
|
||||
//! Usage: `export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]` (defaults: 150 dpi, PNG, 7 runs). Each
|
||||
//! run exports every page through [`Exporter::images`] from a fresh exporter, as Export ▸ Image
|
||||
//! does, and prints its wall time; the summary gives the median and the minimum, the total size
|
||||
//! and an FNV-1a hash of every file in page order. Equal hashes mean equal output. Build with
|
||||
//! `--release`; timings are diagnostic, never a CI deadline.
|
||||
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use pdfcraft_engine::Session;
|
||||
use pdfcraft_engine::export::{Exporter, ImageFormat};
|
||||
|
||||
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let path = args.next().ok_or("usage: export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]")?;
|
||||
let dpi: f64 = args.next().map_or(Ok(150.0), |s| s.parse())?;
|
||||
let format = match args.next().as_deref() {
|
||||
None | Some("png") => ImageFormat::Png,
|
||||
Some("jpeg" | "jpg") => ImageFormat::Jpeg { quality: 85 },
|
||||
Some("tiff" | "tif") => ImageFormat::Tiff,
|
||||
Some(other) => return Err(format!("unknown format {other:?}").into()),
|
||||
};
|
||||
let runs: usize = args.next().map_or(Ok(7), |s| s.parse())?;
|
||||
let mut session = Session::new();
|
||||
let id = session.open("bench.pdf", None, Arc::new(std::fs::read(&path)?), None)?;
|
||||
let src = session.get(id).ok_or("the document did not open")?.export_source();
|
||||
let pages: Vec<usize> = (0..src.pages).collect();
|
||||
let mut times = Vec::new();
|
||||
let mut summary = None;
|
||||
for run in 0..runs {
|
||||
let start = Instant::now();
|
||||
let mut exporter = Exporter::from_source(src.clone());
|
||||
let (mut hash, mut bytes) = (FNV_OFFSET, 0usize);
|
||||
exporter.images(&pages, dpi, format, |_, file| {
|
||||
bytes += file.len();
|
||||
for b in file {
|
||||
hash = (hash ^ u64::from(b)).wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("run {}: {ms:.1} ms", run + 1);
|
||||
times.push(ms);
|
||||
summary = Some((bytes, hash));
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
if let (Some(median), Some(min), Some((bytes, hash))) = (times.get(times.len() / 2), times.first(), summary) {
|
||||
println!(
|
||||
"{} pages, {} at {dpi} dpi: median {median:.1} ms, min {min:.1} ms ({runs} runs), {bytes} bytes, hash {hash:016x}",
|
||||
pages.len(),
|
||||
format.label()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+185
-5
@@ -4,13 +4,52 @@
|
||||
//! text, from a
|
||||
//! document's working file (so unsaved edits and hidden layers are respected, as on screen).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Condvar, Mutex, PoisonError, mpsc};
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// At most this many pages are drawn at once by [`Exporter::images`].
|
||||
const MAX_IMAGE_WORKERS: usize = 8;
|
||||
/// Bytes of rasters [`Exporter::images`] may hold at once: 1 GiB, about 16 Letter pages at 300 dpi.
|
||||
/// Each worker holds one page (the raster, its RGB copy and the encoded file), so large pages run
|
||||
/// on fewer workers; a page over the cap still exports, on one worker, as it did before.
|
||||
const IMAGE_MEMORY: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// Page `page` drawn at `dpi` and encoded as `format`, saying it was drawn at `used` dpi.
|
||||
fn draw_image(renderer: &mut PageRenderer, page: usize, dpi: f64, format: ImageFormat, used: f64) -> Result<Vec<u8>, String> {
|
||||
let r = renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
}
|
||||
encode_image(r.width, r.height, &r.rgba, format, used)
|
||||
}
|
||||
|
||||
/// Tells the workers of [`Exporter::images`] to stop when dropped.
|
||||
struct StopWorkers<'a> {
|
||||
stop: &'a AtomicBool,
|
||||
handed_over: &'a (Mutex<usize>, Condvar),
|
||||
}
|
||||
|
||||
impl Drop for StopWorkers<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
// Taken under the lock, so a worker between its check and its wait still sees it.
|
||||
drop(self.handed_over.0.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
self.handed_over.1.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders and extracts pages of one document state.
|
||||
pub struct Exporter {
|
||||
renderer: PageRenderer,
|
||||
/// What the renderer draws, for the extra renderers [`Exporter::images`] starts.
|
||||
bytes: std::sync::Arc<Vec<u8>>,
|
||||
config: pdfcraft_render::RenderConfig,
|
||||
pages: usize,
|
||||
/// Each page's displayed size in points, for [`Exporter::dpi_used`].
|
||||
sizes: Vec<(f32, f32)>,
|
||||
@@ -65,7 +104,13 @@ impl Exporter {
|
||||
/// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what.
|
||||
/// A source whose `config.reject_oversize` is set refuses such pages instead.
|
||||
pub fn from_source(src: ExportSource) -> Self {
|
||||
Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages, sizes: src.sizes }
|
||||
Self {
|
||||
renderer: PageRenderer::new(src.bytes.clone(), src.config.clone()),
|
||||
bytes: src.bytes,
|
||||
config: src.config,
|
||||
pages: src.pages,
|
||||
sizes: src.sizes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked
|
||||
@@ -96,11 +141,146 @@ impl Exporter {
|
||||
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
|
||||
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
self.check(page)?;
|
||||
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
let used = self.dpi_used(page, dpi);
|
||||
draw_image(&mut self.renderer, page, dpi, format, used)
|
||||
}
|
||||
|
||||
/// Pages `pages` as image files of `format`, handed to `sink` in order with their page
|
||||
/// number: the same files as [`Exporter::image`] for each page in turn. Encoding a page costs
|
||||
/// several times what drawing it does, so several pages are drawn and encoded at once on
|
||||
/// worker threads, each with its own renderer, while `sink` runs on the calling thread.
|
||||
/// Workers draw at most one page each ahead of `sink`, and fewer run when the pages are
|
||||
/// large, so memory stays bounded. Stops at the first page that fails or the first error `sink`
|
||||
/// returns, and returns it; every page before it has been handed to `sink`.
|
||||
pub fn images(
|
||||
&mut self,
|
||||
pages: &[usize],
|
||||
dpi: f64,
|
||||
format: ImageFormat,
|
||||
sink: impl FnMut(usize, Vec<u8>) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let workers = self.image_workers(pages, dpi);
|
||||
self.images_on(workers, pages, dpi, format, sink)
|
||||
}
|
||||
|
||||
/// [`Exporter::images`] with `workers` pages drawn at once (one: in turn, on this thread).
|
||||
pub(crate) fn images_on(
|
||||
&mut self,
|
||||
workers: usize,
|
||||
pages: &[usize],
|
||||
dpi: f64,
|
||||
format: ImageFormat,
|
||||
mut sink: impl FnMut(usize, Vec<u8>) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
if workers <= 1 {
|
||||
for &p in pages {
|
||||
let img = self.image(p, dpi, format)?;
|
||||
sink(p, img)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
|
||||
let used: Vec<f64> = pages.iter().map(|p| self.dpi_used(*p, dpi)).collect();
|
||||
let count = self.pages;
|
||||
let (bytes, config) = (&self.bytes, &self.config);
|
||||
let next = AtomicUsize::new(0);
|
||||
let stop = AtomicBool::new(false);
|
||||
// How many pages `sink` has taken; a worker waits while its page is `workers` past it.
|
||||
let handed_over = (Mutex::new(0usize), Condvar::new());
|
||||
let (tx, rx) = mpsc::channel::<(usize, Result<Vec<u8>, String>)>();
|
||||
let mut first = Some(&mut self.renderer);
|
||||
std::thread::scope(|s| {
|
||||
let handles: Vec<_> = (0..workers)
|
||||
.map(|_| {
|
||||
let tx = tx.clone();
|
||||
let (first, used, next, stop, handed_over) = (first.take(), &used, &next, &stop, &handed_over);
|
||||
s.spawn(move || {
|
||||
let mut own = None;
|
||||
let renderer = match first {
|
||||
Some(r) => r,
|
||||
None => own.insert(PageRenderer::new(bytes.clone(), config.clone())),
|
||||
};
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let (Some(&page), Some(&used)) = (pages.get(i), used.get(i)) else { break };
|
||||
let mut taken = handed_over.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while i >= taken.saturating_add(workers) && !stop.load(Ordering::Relaxed) {
|
||||
taken = handed_over.1.wait(taken).unwrap_or_else(PoisonError::into_inner);
|
||||
}
|
||||
drop(taken);
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let img = if page < count {
|
||||
catch_unwind(AssertUnwindSafe(|| draw_image(renderer, page, dpi, format, used)))
|
||||
.unwrap_or_else(|_| Err(format!("page {}: the export crashed", page + 1)))
|
||||
} else {
|
||||
Err(format!("page {} does not exist", page + 1))
|
||||
};
|
||||
if tx.send((i, img)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
drop(tx);
|
||||
// Stops the workers however this ends, a panicking `sink` included: waiting ones
|
||||
// would otherwise keep the scope from returning.
|
||||
let stopper = StopWorkers { stop: &stop, handed_over: &handed_over };
|
||||
let mut ready = BTreeMap::new();
|
||||
let mut result = Ok(());
|
||||
let mut taken = 0;
|
||||
while let Some(&page) = pages.get(taken) {
|
||||
let Some(img) = ready.remove(&taken) else {
|
||||
match rx.recv() {
|
||||
Ok((i, img)) => {
|
||||
ready.insert(i, img);
|
||||
}
|
||||
Err(_) => {
|
||||
result = Err(format!("page {}: the export stopped unexpectedly", page + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = img.and_then(|img| sink(page, img)) {
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
taken += 1;
|
||||
*handed_over.0.lock().unwrap_or_else(PoisonError::into_inner) = taken;
|
||||
handed_over.1.notify_all();
|
||||
}
|
||||
drop(stopper);
|
||||
// Joined here, so a worker that panicked outside a page is not re-raised by the scope.
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// How many pages [`Exporter::images`] draws at once: one per core but one, up to
|
||||
/// [`MAX_IMAGE_WORKERS`], as many as [`IMAGE_MEMORY`] holds of the largest page, and one
|
||||
/// when there is nothing to share or no threads (wasm).
|
||||
fn image_workers(&self, pages: &[usize], dpi: f64) -> usize {
|
||||
if cfg!(target_arch = "wasm32") || pages.len() < 2 {
|
||||
return 1;
|
||||
}
|
||||
// One core is left for the interface, which keeps drawing while an export runs.
|
||||
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).saturating_sub(1).min(MAX_IMAGE_WORKERS);
|
||||
// The raster (RGBA), the RGB copy the encoders take, and room for the encoded file.
|
||||
let largest = pages
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let (w, h) = *self.sizes.get(*p)?;
|
||||
let k = self.dpi_used(*p, dpi) / 72.0;
|
||||
Some((f64::from(w) * k).ceil() * (f64::from(h) * k).ceil() * 8.0)
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
// A float-to-integer cast saturates (and NaN is 0).
|
||||
let by_memory = if largest > 0.0 { (IMAGE_MEMORY / largest) as usize } else { cores };
|
||||
cores.min(by_memory).min(pages.len()).max(1)
|
||||
}
|
||||
|
||||
/// The reading-order text of a page.
|
||||
|
||||
@@ -3410,6 +3410,70 @@ fn pages_export_as_png_jpeg_and_tiff() {
|
||||
assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
|
||||
}
|
||||
|
||||
/// Export ▸ Image draws several pages at once: each file is the one exporting that page alone
|
||||
/// writes, handed over in the order asked for, whatever the number of pages drawn at once.
|
||||
#[test]
|
||||
fn pages_exported_at_once_are_the_files_each_page_exports_alone_in_order() {
|
||||
use crate::export::{Exporter, ImageFormat};
|
||||
let (s, id) = session_with(12);
|
||||
let pages = [3, 0, 11, 5, 5, 7, 1, 2, 10, 9, 8, 6, 4];
|
||||
for format in [ImageFormat::Png, ImageFormat::Jpeg { quality: 85 }, ImageFormat::Tiff] {
|
||||
let alone: Vec<(usize, Vec<u8>)> = pages.iter().map(|&p| (p, Exporter::new(s.get(id).unwrap()).image(p, 96.0, format).unwrap())).collect();
|
||||
for workers in [1, 2, 4, 8] {
|
||||
let mut got = Vec::new();
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
ex.images_on(workers, &pages, 96.0, format, |p, img| {
|
||||
got.push((p, img));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(got == alone, "{} with {workers} workers", format.label());
|
||||
}
|
||||
let mut got = Vec::new();
|
||||
Exporter::new(s.get(id).unwrap())
|
||||
.images(&pages, 96.0, format, |p, img| {
|
||||
got.push((p, img));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(got == alone, "{}", format.label());
|
||||
}
|
||||
}
|
||||
|
||||
/// A page that can't be exported, or a file that can't be written, stops the export there with
|
||||
/// its error: every page before it is handed over, none after it.
|
||||
#[test]
|
||||
fn exporting_pages_at_once_stops_at_the_first_error() {
|
||||
use crate::export::{Exporter, ImageFormat};
|
||||
let (s, id) = session_with(6);
|
||||
for workers in [1, 4] {
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
let mut got = Vec::new();
|
||||
let result = ex.images_on(workers, &[0, 1, 99, 2, 3], 72.0, ImageFormat::Png, |p, _| {
|
||||
got.push(p);
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(result, Err("page 100 does not exist".to_string()), "{workers} workers");
|
||||
assert_eq!(got, [0, 1], "{workers} workers");
|
||||
|
||||
let mut got = Vec::new();
|
||||
let result = ex.images_on(workers, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| {
|
||||
got.push(p);
|
||||
if p == 2 { Err("disk full".to_string()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(result, Err("disk full".to_string()), "{workers} workers");
|
||||
assert_eq!(got, [0, 1, 2], "{workers} workers");
|
||||
// The exporter is still usable afterwards.
|
||||
assert!(ex.image(5, 72.0, ImageFormat::Png).is_ok());
|
||||
}
|
||||
// A panicking sink stops the workers rather than leaving them waiting for it.
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
ex.images_on(4, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| if p == 1 { panic!("sink") } else { Ok(()) })
|
||||
}));
|
||||
assert!(panicked.is_err());
|
||||
}
|
||||
|
||||
/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
|
||||
fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
|
||||
use tiff::{decoder::ifd::Value, tags::Tag};
|
||||
|
||||
@@ -176,12 +176,18 @@ fn run(
|
||||
// Handled above.
|
||||
ExportKind::AllImages => String::new(),
|
||||
ExportKind::Image => {
|
||||
for (k, p) in pages.iter().enumerate() {
|
||||
set(k, None);
|
||||
let result = ex.image(*p, dpi, format).and_then(|img| sink(&format!("{stem}_page_{}.{}", p + 1, format.extension()), img));
|
||||
if let Err(e) = result {
|
||||
return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]);
|
||||
}
|
||||
set(0, None);
|
||||
// Pages are drawn several at once and handed over in order: the progress counts the
|
||||
// pages written so far.
|
||||
let mut done = 0;
|
||||
let result = ex.images(&pages, dpi, format, |p, img| {
|
||||
sink(&format!("{stem}_page_{}.{}", p + 1, format.extension()), img)?;
|
||||
done += 1;
|
||||
set(done, None);
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = result {
|
||||
return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]);
|
||||
}
|
||||
let mut msg = if total == 1 {
|
||||
crate::i18n::fmt(tl!("Exported 1 image"), &[])
|
||||
|
||||
Reference in New Issue
Block a user