Some checks failed
repo hygiene / hygiene (push) Has been cancelled
The same question CAD_DRAWCALL_STRATEGY_ANALYSIS.md asked of the CAD viewport, asked of pdf-makepad, and prompted by the same datagrid brief: "virtual viewport on both axes" and "an optimal minimal drawcall strategy". Those are two techniques, and PDF has a partial version of the first and none of the second. The finding that precedes every other one: `PdfRenderer` is exported from lib.rs and referenced by nothing in the widget's draw path. grep returns the `pub use` and nothing else. `PdfPageWidget::draw_walk` draws a background, then a placeholder or link/field affordances -- it never constructs a renderer and never replays a RenderCommand. Page content costs zero draw calls because page content is not drawn. That also explains the `#[allow(dead_code)]` on ClipRect "retained for the scissor-rect work in Phase 7": clipping is modelled but unreachable. So the numbers below are projections of what happens the moment the renderer is wired in, which is exactly when a strategy stops being theoretical. They are stated as projections in the document. Makepad's batching model was read from source rather than inferred, because the CAD note records getting precisely this wrong in its own first draft (its section 0.1). In draw_vector.rs at the pinned rev, `cx.new_draw_call` appears exactly twice, both inside `end()`. `begin()` clears accumulation buffers; `stroke()` and `fill()` tessellate and issue no draw call. An unbounded number of paints therefore cost one draw call provided nothing calls `end()` between them. renderer.rs calls `end()` from `finish_path()`, and `finish_path()` runs on Save, Restore, PushClip, PopClip and the two Clip ops. Save/Restore are `q`/`Q`: graphics-state operations, not clip operations, and very frequent in real files. Measured by replaying the corpus through that exact state machine -- tools/analysis/pdf_drawcall_census.rs, so the numbers can be reproduced instead of trusted. 165 pages, 15,185 commands, 3,911 draw calls, 23.7 per page. Of the 2,578 vector draw calls, 2,460 are caused by q/Q and **two** by clipping. The renderer flushes on the operation that does not need a flush, and the operation that does need one barely occurs. Colour, stroke width and the CTM are all baked into vertices on the CPU before tessellation, so a state change needs no draw-call boundary; only a clip does, being a GPU scissor concern. Flushing only on clip change takes the vector side from 2,578 to 166 -- about one per page, 15.5x. The blended figure is a more modest 2.6x and the document leads with that rather than the flattering one, because text then dominates: DrawText exposes begin_many_instances, renderer.rs uses neither it nor begin_deferred_slug_flush, so every run is its own batch, and the renderer alternates between three DrawText objects which breaks a batch even when the API is used. On virtual viewports PDF is genuinely ahead of CAD, and the document says so: cache.rs is a real LRU with a byte budget rather than an entry count, generation-tagged, and phase7_exit_criterion.rs asserts the behaviours by name. That is a tested virtual viewport on the page axis. There is none within a page -- draw_affordances loops every annotation filtering only by page index and visibility, never against the viewport rect it already holds, and nothing skips an offscreen command. The asymmetry worth recording for anyone porting the datagrid approach: a grid's virtual viewport is cheap because cell geometry is derivable by division. A PDF's is expensive because geometry is accumulated through a stateful CTM, so a command's screen rect is unknowable without interpreting everything before it. The bbox index is the price of entry and belongs in RecordingDevice, which already tracks the CTM. What is not measured is stated plainly: no GPU profiling, no frame times, because the ui.rs suite that would host a benchmark is still #[ignore]d on the missing Makepad headless backend. Draw calls are a proxy for cost, not cost. 24 per page is not alarming on a desktop GPU; the argument is that the count scales with document complexity rather than viewport size. No source was changed. The suggested order puts "stop flushing on q/Q" first because it is a deletion, and puts wiring the renderer third so the strategy lands with the feature instead of after it.
194 lines
7.2 KiB
Rust
194 lines
7.2 KiB
Rust
//! Count the draw calls the Makepad PDF renderer would issue for every page
|
|
//! in the corpus, and what a batched strategy would issue instead.
|
|
//!
|
|
//! This replays the *exact* state machine in `pdf-makepad/src/renderer.rs`:
|
|
//! - `render()` opens with `begin()`.
|
|
//! - `finish_path()` calls `DrawVector::end()` (a real draw call) whenever
|
|
//! `has_path` is set, and is invoked on Save, Restore, PushClip, PopClip
|
|
//! and Clip*.
|
|
//! - Stroke / Fill* end the accumulation and `begin()` a fresh one, but do
|
|
//! NOT call `end()` themselves.
|
|
//! - Text and images go through DrawText / DrawImage, which are separate
|
|
//! draw objects and so break the vector batch in submission order.
|
|
|
|
use nigig_pdf_graphics::content::parse_content_stream;
|
|
use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Default, Debug, Clone)]
|
|
struct Census {
|
|
commands: usize,
|
|
vector_end_calls: usize,
|
|
text_draws: usize,
|
|
image_draws: usize,
|
|
saves: usize,
|
|
restores: usize,
|
|
clips: usize,
|
|
paint_ops: usize,
|
|
// Counterfactual: flush only when a clip changes, since colour and CTM
|
|
// are baked into vertices on the CPU and need no draw-call boundary.
|
|
batched_vector_ends: usize,
|
|
// Text runs that are contiguous (no intervening vector paint), which a
|
|
// single DrawText batch could absorb.
|
|
text_batches: usize,
|
|
}
|
|
|
|
fn census(commands: &[RenderCommand]) -> Census {
|
|
let mut c = Census::default();
|
|
c.commands = commands.len();
|
|
|
|
// Mirrors PdfRenderer::render: begin() then has_path = true.
|
|
let mut has_path = true;
|
|
let mut batched_open = true;
|
|
let mut last_was_text = false;
|
|
|
|
let finish_path = |has_path: &mut bool, c: &mut Census| {
|
|
if *has_path {
|
|
c.vector_end_calls += 1;
|
|
*has_path = false;
|
|
}
|
|
};
|
|
|
|
for cmd in commands {
|
|
match cmd {
|
|
RenderCommand::Save => {
|
|
finish_path(&mut has_path, &mut c);
|
|
c.saves += 1;
|
|
}
|
|
RenderCommand::Restore => {
|
|
finish_path(&mut has_path, &mut c);
|
|
c.restores += 1;
|
|
}
|
|
RenderCommand::PushClip | RenderCommand::PopClip => {
|
|
finish_path(&mut has_path, &mut c);
|
|
c.clips += 1;
|
|
if batched_open { c.batched_vector_ends += 1; batched_open = false; }
|
|
}
|
|
RenderCommand::ClipEvenOdd | RenderCommand::ClipWinding => {
|
|
finish_path(&mut has_path, &mut c);
|
|
c.clips += 1;
|
|
if batched_open { c.batched_vector_ends += 1; batched_open = false; }
|
|
}
|
|
RenderCommand::MoveTo(..)
|
|
| RenderCommand::LineTo(..)
|
|
| RenderCommand::CurveTo(..)
|
|
| RenderCommand::Rectangle(..) => {
|
|
has_path = true;
|
|
}
|
|
RenderCommand::Stroke
|
|
| RenderCommand::FillEvenOdd
|
|
| RenderCommand::FillWinding
|
|
| RenderCommand::FillStrokeEvenOdd
|
|
| RenderCommand::FillStrokeWinding => {
|
|
// tessellates into the accumulator; begins a fresh one
|
|
c.paint_ops += 1;
|
|
has_path = true;
|
|
batched_open = true;
|
|
last_was_text = false;
|
|
}
|
|
RenderCommand::ShowText(..) | RenderCommand::ShowTextWithMetrics { .. } => {
|
|
c.text_draws += 1;
|
|
if !last_was_text { c.text_batches += 1; }
|
|
last_was_text = true;
|
|
}
|
|
RenderCommand::DrawImage(..) | RenderCommand::DrawInlineImage { .. } => {
|
|
c.image_draws += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
// final finish_path() at the end of render()
|
|
finish_path(&mut has_path, &mut c);
|
|
if batched_open { c.batched_vector_ends += 1; }
|
|
c
|
|
}
|
|
|
|
fn collect_pdfs(dir: &Path, out: &mut Vec<PathBuf>) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
collect_pdfs(&p, out);
|
|
} else if p.extension().and_then(|s| s.to_str()) == Some("pdf") {
|
|
out.push(p);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let root = std::env::args()
|
|
.nth(1)
|
|
.unwrap_or_else(|| "tests/corpus".to_string());
|
|
let mut pdfs = Vec::new();
|
|
collect_pdfs(Path::new(&root), &mut pdfs);
|
|
pdfs.sort();
|
|
|
|
let mut total = Census::default();
|
|
let mut pages = 0usize;
|
|
let mut worst: Vec<(usize, String)> = Vec::new();
|
|
|
|
for path in &pdfs {
|
|
let Ok(bytes) = std::fs::read(path) else {
|
|
continue;
|
|
};
|
|
let Ok(mut doc) = nigig_pdf_document::PdfDocument::parse(&bytes) else {
|
|
continue;
|
|
};
|
|
for i in 0..doc.page_count() {
|
|
let Ok(page) = doc.page(i) else { continue };
|
|
let content = page.content_data.clone();
|
|
let Ok(ops) = parse_content_stream(&content) else {
|
|
continue;
|
|
};
|
|
let cmds = RecordingDevice::from_ops(&ops);
|
|
let c = census(&cmds);
|
|
if c.commands == 0 {
|
|
continue;
|
|
}
|
|
pages += 1;
|
|
total.commands += c.commands;
|
|
total.vector_end_calls += c.vector_end_calls;
|
|
total.text_draws += c.text_draws;
|
|
total.image_draws += c.image_draws;
|
|
total.saves += c.saves;
|
|
total.restores += c.restores;
|
|
total.clips += c.clips;
|
|
total.paint_ops += c.paint_ops;
|
|
total.batched_vector_ends += c.batched_vector_ends;
|
|
total.text_batches += c.text_batches;
|
|
|
|
let dc = c.vector_end_calls + c.text_draws + c.image_draws;
|
|
worst.push((dc, format!("{}#{}", path.display(), i)));
|
|
}
|
|
}
|
|
|
|
worst.sort_by(|a, b| b.0.cmp(&a.0));
|
|
|
|
let dc = total.vector_end_calls + total.text_draws + total.image_draws;
|
|
println!("pages measured : {pages}");
|
|
println!("render commands : {}", total.commands);
|
|
println!("--- draw calls, as the renderer stands ---");
|
|
println!("DrawVector::end() calls : {}", total.vector_end_calls);
|
|
println!("DrawText::draw_abs() : {}", total.text_draws);
|
|
println!("DrawImage::draw_abs() : {}", total.image_draws);
|
|
println!("TOTAL draw calls : {dc}");
|
|
println!(" per page (mean) : {:.1}", dc as f64 / pages as f64);
|
|
println!("--- what forced the breaks ---");
|
|
println!("Save : {}", total.saves);
|
|
println!("Restore : {}", total.restores);
|
|
println!("clip ops : {}", total.clips);
|
|
println!("fill/stroke paint ops : {}", total.paint_ops);
|
|
let batched = total.batched_vector_ends + total.text_batches + total.image_draws;
|
|
println!("--- counterfactual: flush only on clip change ---");
|
|
println!("DrawVector::end() calls : {}", total.batched_vector_ends);
|
|
println!("DrawText batches : {}", total.text_batches);
|
|
println!("TOTAL draw calls : {batched}");
|
|
println!(" per page (mean) : {:.1}", batched as f64 / pages as f64);
|
|
println!(" reduction : {:.1}x", dc as f64 / batched as f64);
|
|
println!("--- worst pages ---");
|
|
for (n, name) in worst.iter().take(10) {
|
|
println!("{n:>7} {name}");
|
|
}
|
|
}
|