nigig-org/REVIEWS/PDF_DRAWCALL_STRATEGY_ANALYSIS.md
andodeki 087965f17d
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
docs(pdf): does the PDF viewport have a minimal-drawcall strategy? No -- and the renderer is unwired
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.
2026-08-21 05:04:41 +00:00

12 KiB
Raw Permalink Blame History

Does the PDF viewport have a minimal-drawcall strategy?

Short answer: no — and unlike CAD, the question is currently moot, because the renderer that would issue those draw calls is never called by the widget. The page content is not drawn at all.

That is the finding. Everything else below is secondary to it.

This note answers the same question asked of the CAD codebase in REVIEWS/CAD_DRAWCALL_STRATEGY_ANALYSIS.md, prompted by a datagrid brief asking for "virtual viewport on both axes" and "an optimal minimal drawcall strategy". Those are two distinct techniques. PDF has a partial version of the first and none of the second.

Read at commit 06a3de6. Sources: pdf-makepad/src/renderer.rs (710 lines), page_view.rs (585), pdf-graphics/src/cache.rs, and Makepad draw_vector.rs / draw_text.rs at the pinned rev ecf5a572ab62a1c1598909971f602f99083671cc.

Makepad's batching model was read from source, not inferred. The CAD note records getting exactly this wrong (§0.1 there), so the two new_draw_call sites in draw_vector.rs were checked by grep before any number below was computed.

0. The finding that precedes the others

PdfRenderer is exported from lib.rs and referenced by nothing in the widget's draw path:

$ grep -rn "PdfRenderer" crates/apps/pdf/ --include=*.rs
pdf-makepad/src/lib.rs:15:pub use renderer::PdfRenderer;

PdfPageWidget::draw_walk draws a background, then either a placeholder or draw_affordances — link underlines and form fields. It never constructs a PdfRenderer and never replays a RenderCommand. The 710 lines of renderer are reachable only from outside the crate.

So the honest statement of today's cost is: the page content costs zero draw calls because it is not drawn. Every number in §2 is a projection of what will happen the moment the renderer is wired in — which is precisely when a drawcall strategy stops being theoretical.

This also explains why ClipRect carries #[allow(dead_code)] // retained for the scissor-rect work in Phase 7: clipping is modelled but unused, because nothing renders.

1. Batching: Makepad provides it, the PDF renderer uses neither mechanism

Two batching mechanisms exist at the pinned rev, and the renderer opts out of both.

1.1 DrawVector — one draw call per end()

In draw/src/shader/draw_vector.rs, cx.new_draw_call(...) appears twice, both inside end(). begin() only clears acc_verts/acc_indices. stroke() and fill() tessellate into the accumulator and issue no draw call. So an arbitrary number of fills and strokes 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, ClipEvenOdd, ClipWinding — plus once at the end of render().

Save/Restore are q/Q. In real PDFs they are extremely frequent and they are graphics-state operations, not clip operations. Flushing the vector batch on q/Q is the defect: colour, stroke width and the CTM are all baked into vertices on the CPU by to_screen_with_ctm before tessellation, so a state change needs no draw-call boundary. Only a clip change genuinely does, because clipping is a GPU scissor/stencil concern.

1.2 DrawText — an explicit instance-batch API, unused

draw_text.rs exposes begin_many_instances / end_many_instances, and draw_rasterized_glyphs_abs documents the cost of not using them:

An already-open batch ran update_draw_vars when it began; running it again per call dominates CPU when thousands of glyphs share a batch.

A bare draw_abs opens and closes its own instance batch. renderer.rs contains no reference to many_instances or begin_deferred_slug_flush, so every text run is its own batch. It also alternates between three separate DrawText objects (draw_text, draw_text_bold, draw_text_code), and switching draw objects breaks a batch even when the API is used — TextFlow hits this, which is why begin_deferred_slug_flush exists.

2. Measured: replaying the corpus through the renderer's own state machine

pdf_drawcall_census.rs (kept out of tree; see §6) replays the exact finish_path rules above over every parseable page in crates/apps/pdf/tests/corpus, counting what the renderer would issue.

165 pages, 15,185 render commands.

count
DrawVector::end() 2,578
DrawText::draw_abs() 1,331
DrawImage::draw_abs() 2
total draw calls 3,911 (23.7 / page)

What forced the vector breaks:

cause count
Save 1,230
Restore 1,230
clip ops 2
fill/stroke paint ops 2,457

2,460 of the 2,578 vector draw calls are caused by q/Q. Two are caused by clipping. That is the whole finding in one line: the renderer flushes on the operation that does not need a flush, and the operation that does need one barely occurs.

Counterfactual — flush only when a clip actually changes, and coalesce contiguous text runs:

count
DrawVector::end() 166 (≈ one per page)
DrawText batches 1,318
total 1,486 (9.0 / page)
reduction 2.6×

Two honest caveats on that 2.6×:

  • The vector side improves 15.5× (2,578 → 166). The blended figure is dragged down by text, which dominates once vectors are fixed.
  • The text figure is barely moved by contiguity alone (1,331 → 1,318) because this corpus interleaves text with vector paints. Text needs the many_instances API, not run-merging. Fixing vectors alone gets 3,911 → 1,499; the text work is a separate, larger job.

3. Virtual viewport: partially present, and better than CAD's

This is where PDF is genuinely ahead of CAD.

Present. pdf-graphics/src/cache.rs is a real LRU page cache with a byte budget rather than an entry count — the docstring gives the right reason ("one image-heavy page can cost more than fifty text pages"). It carries generation tagging so a page from an abandoned document is never shown. phase7_exit_criterion.rs asserts the behaviours by name: opening_a_large_document_does_not_render_every_page, scrolling_evicts_pages_that_left_the_viewport, a_page_from_an_abandoned_document_is_never_shown. That is a virtual viewport on the page axis, tested.

Absent. There is no culling within a page:

  • draw_affordances loops every annotation on the page, filtering only by page index and visibility flags — never against the viewport rect, which it already holds as self.interaction.viewport.
  • PdfRenderer::render walks the whole command list. Nothing skips a command whose geometry lies outside the visible rect. A grep for cull/offscreen/intersect in renderer.rs returns nothing.

So: virtual on the page axis, not on the intra-page axes. For a zoomed-in A0 drawing or a page with thousands of annotations, everything is submitted.

4. Do the datagrid techniques transfer?

Yes — with one that transfers better than it does for a grid, and one that transfers worse.

Technique Transfers? Notes
Minimal draw calls / batching Yes, directly. Strongest fit. A PDF page is a flat command list over one coordinate space; that is exactly what DrawVector's accumulator wants. Requires only removing flushes, not adding structure.
Virtual viewport, page axis Already done. cache.rs, with tests.
Virtual viewport, intra-page Yes, but harder than a grid. A grid computes visible rows by division: first = scroll / row_height. A PDF command list has no such index — the CTM is stateful, so you cannot know a command's screen rect without interpreting everything before it. Needs a precomputed per-command bbox built once at record time and cached alongside the commands.
Cell-hosted child widgets Partially — as annotations. Form fields and links are already the analogue. render_fields is the equivalent of a cell renderer, and it is where intra-page culling should land first: it is a flat list with known rects, so it is the easy half.
Proper clipping Modelled, unimplemented. ClipRect is dead_code; apply_clip only calls finish_path() and sets no scissor rect. Nested /BBox clips from Phase 7's render_form are therefore not honoured on the GPU path.

The important asymmetry: a datagrid's virtual viewport is cheap because cell geometry is derivable. A PDF's is expensive because geometry is accumulated. The bbox index is the price of entry, and it should be built during recording — RecordingDevice already tracks the CTM, so it is the one place that can compute a screen rect per command without a second interpretation pass.

5. What is not measured

Stated plainly, because §2 is a static replay and not a profile.

  • No GPU profiling. No frame times, no counters from a live app. The ui.rs suite that would host such a benchmark is #[ignore]d on the missing Makepad headless backend, unchanged since Phase 1.
  • Draw calls are a proxy, not a cost. 3,911 draw calls spread over 165 pages is ~24 per visible page, which is not alarming on a desktop GPU. The argument for fixing it is that the count scales with document complexity, not viewport size — a dense vector page multiplies q/Q without bound.
  • The census counts what the renderer would do. Since the renderer is unwired (§0), these are projections. They will become real on the commit that wires it in, which is the right moment to add the guard.
  • CPU tessellation cost is not measured, only draw-call boundaries. Batching removes submission overhead; it does not remove tessellation.

6. Suggested order

Cheapest first, each independently testable. No code was changed for this note.

  1. Stop flushing on q/Q. Remove finish_path() from Save and Restore; keep it on the clip commands. Projected 2,578 → 166 vector draw calls. Guard with a test asserting the count for a fixture page, in the spirit of CAD's param_hash_is_not_a_shape_key... pin.
  2. Cull annotations against the viewport rect in draw_affordances. The rect is already in hand; this is a few lines and is the flat, easy half of intra-page virtualisation.
  3. Wire PdfRenderer into draw_walk so page content is actually drawn — with 1 and 2 already in place, so the strategy lands with the feature rather than after it.
  4. Batch text via begin_many_instances, and collapse the three DrawText objects to one where the style permits, or use begin_deferred_slug_flush where it does not.
  5. Implement clipping properly — give apply_clip a real scissor rect and drop the dead_code allow.
  6. Per-command bbox index at record time, then cull the command list. Only worth doing after 15, and only if profiling justifies it.

Step 1 is the whole of the drawcall answer. Steps 2 and 6 are the whole of the virtual-viewport answer.

7. Cross-reference with CAD

CAD PDF
Vector batching used correctly yes, in the 2D path no — flushes on q/Q
Draw call per item yes, in 3D (one per part) no — but 2,460 flushes from state ops
Viewport culling none page-level yes (tested); intra-page none
Culling data structure exists yes, benchmarked, unused on the hot path page bboxes yes; per-command no
Renderer actually wired up yes no

The two codebases fail the same question in opposite directions. CAD renders everything and batches some of it. PDF batches nothing and renders none of it.