pdfrum_render/walkprofile.rs
1//! Where the *engine* half of a render goes, split finer than the backend
2//! seam.
3//!
4//! Records, per render, the time spent in each of the walk's own phases —
5//! clip resolution, colour resolution, glyph placement, image preparation,
6//! and the dispatch left over — and the allocations at each site in the walk
7//! that makes one, counted and sized.
8//!
9//! **Costs nothing when the `profiling` feature is off**: every entry
10//! point compiles to an empty inline function. The accumulator is a
11//! thread-local, so a rayon render reports per-thread totals rather than a
12//! contended one, and [`take`] resets it.
13
14// Counters rather than a `GlobalAlloc` shim, which would need `unsafe impl`
15// and `unsafe_code = "forbid"` is workspace-wide. Counting at the sites is
16// enough for the question asked: the walk's allocation sites are enumerable
17// by reading it.
18//
19// The reporting half — `Profile` itself, and `Phase`/`Site`'s
20// `index`/`name`/`ALL` — carries `#[cfg(feature = "profiling")]` because
21// with the feature off nothing ever produces a `Profile` to report. `Site`
22// and `Phase`'s *variants* are unconditional: the recording half names them
23// at every call site whether or not the feature is on.
24
25#[cfg(feature = "profiling")]
26use core::time::Duration;
27
28/// One phase of the walk's engine-side work.
29///
30/// The set is closed and each variant names a span of code rather than a
31/// function, because the question is where the *cost* is and several of these
32/// are spread over more than one call site.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Phase {
35 /// `crate::clip::resolve`: a clip stack reduced to the device calls it
36 /// becomes, per object. Allocates a `Vec<Clip>` and, for a non-rectangular
37 /// entry, a transformed `BezPath`.
38 Clip,
39 /// `crate::color::resolve_argb` and the transfer function, per object.
40 Color,
41 /// `crate::text::place_glyphs` and its type-3 sibling: the advance
42 /// arithmetic, the cache lookups, and the per-glyph `PlacedGlyph`.
43 Glyphs,
44 /// The image path's geometry, cache key and pixmap production — everything
45 /// `crate::walk`'s image arms do that is not a device call.
46 Image,
47 /// The shading path's own evaluation, likewise.
48 Shading,
49 /// `crate::paint::draw_path`'s decision tree: the two-point test, the
50 /// axis-aligned-rectangle test, the zero-area sub-path scan and the stroke
51 /// split — everything a path object costs above the device call.
52 PathPrep,
53 /// The per-object cull test in `crate::walk::render_object_list`:
54 /// `crate::walk`'s `object_bbox` and the four comparisons against the
55 /// list's object-space clip box.
56 Cull,
57 /// `crate::path::path_rect` and `snap_rect` — `draw_path`'s case 2, the
58 /// axis-aligned-rectangle fast path, which runs on every fill-only path
59 /// object whether or not it is a rectangle.
60 RectTest,
61 /// `crate::zero_area::scan_into` — `draw_path`'s case 3, which runs on
62 /// every fill-only non-glyph path object and on the corpus almost never
63 /// finds anything.
64 ZeroScan,
65 /// The geometry `draw_path`'s ordinary case hands the device: the path
66 /// transformed into device space and clamped by
67 /// `crate::path::hard_clip`. One reserved `BezPath` per fill, per
68 /// object — `crate::path::transform_hard_clip` fuses the transform and
69 /// the clamp into a single pass.
70 PathXform,
71 /// `crate::shading::draw_patches` — the Coons and tensor mesh half of a
72 /// shading, which rasterizes patch by patch through a scratch device
73 /// rather than writing a buffer.
74 ///
75 /// It sits beside `shading`, not inside it: `Phase::Shading` wraps
76 /// `draw_to_pixmap`, which the mesh kinds never reach. Like `PathPrep`,
77 /// the span covers the device calls it makes.
78 Patches,
79}
80
81/// One allocation site in the walk, named by what it allocates.
82///
83/// These are the sites an arena could plausibly serve: each produces a value
84/// that does not outlive the object being drawn. A site whose value escapes the
85/// walk is deliberately absent, because an arena cannot help it.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Site {
88 /// The `Vec<Clip>` `crate::clip::resolve` returns, once per object.
89 ClipVec,
90 /// A clip entry's transformed `BezPath`, for a clip that is not an
91 /// axis-aligned rectangle.
92 ClipPath,
93 /// The `Vec<PlacedGlyph>` `crate::text::place_glyphs` returns, once per
94 /// text object.
95 GlyphVec,
96 /// A `RenderOptions` cloned into a nested context — a form, a char proc, a
97 /// tile cell, a soft mask.
98 OptionsClone,
99 /// A `RenderCtx` cloned by `crate::ctx::RenderCtx::deeper`.
100 CtxClone,
101 /// The device-space `BezPath`s `crate::paint::draw_path`'s ordinary case
102 /// builds for a fill or a stroke: one per fill, since
103 /// `crate::path::transform_hard_clip` fuses the transform and the clamp;
104 /// three on the stroke arm, which still composes a nudge between them.
105 PathGeometry,
106}
107
108/// Everything one render's walk accumulated.
109///
110/// A record of facts: the counters are public and the reporting lives in
111/// whoever reads them.
112#[cfg(feature = "profiling")]
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub struct Profile {
115 /// Time in each phase, indexed as [`Phase`] orders them.
116 pub phase_time: [Duration; 11],
117 /// How many times each phase was entered.
118 pub phase_calls: [u64; 11],
119 /// How many allocations each site made, indexed as [`Site`] orders them.
120 pub site_count: [u64; 6],
121 /// How many bytes those allocations asked for, where the size is knowable
122 /// from the value itself (a `Vec`'s capacity times its element size, a
123 /// `BezPath`'s element count times a `PathEl`).
124 pub site_bytes: [u64; 6],
125}
126
127impl Phase {
128 /// The index this phase occupies in [`Profile::phase_time`].
129 #[cfg(feature = "profiling")]
130 #[must_use]
131 pub const fn index(self) -> usize {
132 match self {
133 Phase::Clip => 0,
134 Phase::Color => 1,
135 Phase::Glyphs => 2,
136 Phase::Image => 3,
137 Phase::Shading => 4,
138 Phase::PathPrep => 5,
139 Phase::Cull => 6,
140 Phase::RectTest => 7,
141 Phase::ZeroScan => 8,
142 Phase::PathXform => 9,
143 Phase::Patches => 10,
144 }
145 }
146
147 /// The phases in the order the arrays index them.
148 #[cfg(feature = "profiling")]
149 pub const ALL: [Phase; 11] = [
150 Phase::Clip,
151 Phase::Color,
152 Phase::Glyphs,
153 Phase::Image,
154 Phase::Shading,
155 Phase::PathPrep,
156 Phase::Cull,
157 Phase::RectTest,
158 Phase::ZeroScan,
159 Phase::PathXform,
160 Phase::Patches,
161 ];
162
163 /// A short name for a report column.
164 #[cfg(feature = "profiling")]
165 #[must_use]
166 pub const fn name(self) -> &'static str {
167 match self {
168 Phase::Clip => "clip",
169 Phase::Color => "color",
170 Phase::Glyphs => "glyphs",
171 Phase::Image => "image",
172 Phase::Shading => "shading",
173 Phase::PathPrep => "path prep",
174 Phase::Cull => "cull",
175 Phase::RectTest => "rect test",
176 Phase::ZeroScan => "zero scan",
177 Phase::PathXform => "path xform",
178 Phase::Patches => "patches",
179 }
180 }
181}
182
183impl Site {
184 /// The index this site occupies in [`Profile::site_count`].
185 #[cfg(feature = "profiling")]
186 #[must_use]
187 pub const fn index(self) -> usize {
188 match self {
189 Site::ClipVec => 0,
190 Site::ClipPath => 1,
191 Site::GlyphVec => 2,
192 Site::OptionsClone => 3,
193 Site::CtxClone => 4,
194 Site::PathGeometry => 5,
195 }
196 }
197
198 /// The sites in the order the arrays index them.
199 #[cfg(feature = "profiling")]
200 pub const ALL: [Site; 6] = [
201 Site::ClipVec,
202 Site::ClipPath,
203 Site::GlyphVec,
204 Site::OptionsClone,
205 Site::CtxClone,
206 Site::PathGeometry,
207 ];
208
209 /// A short name for a report row.
210 #[cfg(feature = "profiling")]
211 #[must_use]
212 pub const fn name(self) -> &'static str {
213 match self {
214 Site::ClipVec => "clip Vec<Clip>",
215 Site::ClipPath => "clip BezPath",
216 Site::GlyphVec => "Vec<PlacedGlyph>",
217 Site::OptionsClone => "RenderOptions clone",
218 Site::CtxClone => "RenderCtx clone",
219 Site::PathGeometry => "draw_path BezPath",
220 }
221 }
222}
223
224#[cfg(feature = "profiling")]
225mod imp {
226 use core::cell::Cell;
227 use core::time::Duration;
228
229 use super::{Phase, Profile, Site};
230
231 thread_local! {
232 /// The accumulator. A `Cell<Profile>` rather than a `RefCell`: the
233 /// record is `Copy`, so a read-modify-write needs no borrow and cannot
234 /// panic on a re-entrant one — and the walk *is* re-entrant, since a
235 /// form's objects are walked from inside the phase timing an enclosing
236 /// object.
237 static PROFILE: Cell<Profile> = const { Cell::new(Profile {
238 phase_time: [Duration::ZERO; 11],
239 phase_calls: [0; 11],
240 site_count: [0; 6],
241 site_bytes: [0; 6],
242 }) };
243 }
244
245 /// Read the accumulator and clear it.
246 pub fn take() -> Profile {
247 PROFILE.replace(Profile::default())
248 }
249
250 /// Record one allocation of `bytes` at `site`.
251 pub fn alloc(site: Site, bytes: usize) {
252 let mut p = PROFILE.get();
253 let i = site.index();
254 if let (Some(c), Some(b)) = (p.site_count.get_mut(i), p.site_bytes.get_mut(i)) {
255 *c = c.saturating_add(1);
256 *b = b.saturating_add(bytes as u64);
257 }
258 PROFILE.set(p);
259 }
260
261 /// Run `body` and charge its elapsed time to `phase`.
262 ///
263 /// **Nested phases are not double-counted into each other**, because each
264 /// variant is charged to its own bucket and the buckets are summed rather
265 /// than nested. A phase entered from inside another — glyph placement
266 /// inside a clip resolution, which is what a text clip does — therefore
267 /// appears in both, and the report says so rather than pretending the
268 /// buckets partition the engine half.
269 pub fn phase<T>(phase: Phase, body: impl FnOnce() -> T) -> T {
270 let started = std::time::Instant::now();
271 let out = body();
272 let elapsed = started.elapsed();
273 let mut p = PROFILE.get();
274 let i = phase.index();
275 if let (Some(t), Some(c)) = (p.phase_time.get_mut(i), p.phase_calls.get_mut(i)) {
276 *t = t.saturating_add(elapsed);
277 *c = c.saturating_add(1);
278 }
279 PROFILE.set(p);
280 out
281 }
282
283 /// The moment a phase began, for a caller that cannot wrap its body in a
284 /// closure — `crate::zero_area::scan_into`, whose result borrows the
285 /// scratch it was handed, so a closure would have to hand that borrow back
286 /// out of itself or run the scan twice.
287 #[derive(Debug, Clone, Copy)]
288 pub struct Started(std::time::Instant);
289
290 impl Started {
291 /// Charge the time since this was taken to `phase`.
292 pub fn end(self, phase: Phase) {
293 let elapsed = self.0.elapsed();
294 let mut p = PROFILE.get();
295 let i = phase.index();
296 if let (Some(t), Some(c)) = (p.phase_time.get_mut(i), p.phase_calls.get_mut(i)) {
297 *t = t.saturating_add(elapsed);
298 *c = c.saturating_add(1);
299 }
300 PROFILE.set(p);
301 }
302 }
303
304 /// Start a phase, closed by the returned guard's `end`.
305 pub fn phase_start() -> Started {
306 Started(std::time::Instant::now())
307 }
308}
309
310#[cfg(not(feature = "profiling"))]
311mod imp {
312 #[cfg(feature = "profiling")]
313 use super::Profile;
314 use super::{Phase, Site};
315
316 /// Nothing was recorded, because nothing is recording.
317 #[cfg(feature = "profiling")]
318 #[inline]
319 pub fn take() -> Profile {
320 Profile::default()
321 }
322
323 /// A no-op without the feature.
324 #[inline]
325 pub fn alloc(_site: Site, _bytes: usize) {}
326
327 /// The body, unclocked, without the feature.
328 #[inline]
329 pub fn phase<T>(_phase: Phase, body: impl FnOnce() -> T) -> T {
330 body()
331 }
332
333 /// A phase's start, which without the feature carries nothing.
334 #[derive(Debug, Clone, Copy)]
335 pub struct Started;
336
337 impl Started {
338 /// A no-op without the feature.
339 #[inline]
340 #[expect(
341 clippy::unused_self,
342 reason = "the feature-on twin takes `self` — the `Instant` it holds — and the two must have one signature"
343 )]
344 pub fn end(self, _phase: Phase) {}
345 }
346
347 /// A no-op without the feature.
348 #[inline]
349 pub fn phase_start() -> Started {
350 Started
351 }
352}
353
354#[cfg(feature = "profiling")]
355pub use imp::take;
356pub use imp::{alloc, phase, phase_start};
357
358/// Record one allocation whose size is the capacity of a slice-shaped value.
359///
360/// A convenience over [`alloc`] for the common `Vec`-and-element-size case, so
361/// a call site reads as the fact it is recording rather than as arithmetic.
362#[inline]
363pub fn alloc_items(site: Site, items: usize, item_size: usize) {
364 alloc(site, items.saturating_mul(item_size));
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 /// The arrays exist only with the feature on, so their indexing does too.
372 #[cfg(feature = "profiling")]
373 #[test]
374 fn the_indices_are_dense_and_distinct() {
375 // The arrays are indexed by these, so a duplicate or a gap would
376 // silently merge two rows of a report.
377 let phases: Vec<usize> = Phase::ALL.iter().map(|p| p.index()).collect();
378 assert_eq!(phases, (0..Phase::ALL.len()).collect::<Vec<_>>());
379 let sites: Vec<usize> = Site::ALL.iter().map(|s| s.index()).collect();
380 assert_eq!(sites, (0..Site::ALL.len()).collect::<Vec<_>>());
381 }
382
383 #[test]
384 fn the_phase_wrapper_returns_the_body_s_value_either_way() {
385 // The feature-off build must be transparent, not merely cheap.
386 assert_eq!(phase(Phase::Color, || 7_u32), 7);
387 alloc_items(Site::ClipVec, 3, 8);
388 }
389
390 #[cfg(feature = "profiling")]
391 #[test]
392 fn taking_the_profile_clears_it() {
393 let _ = take();
394 alloc_items(Site::CtxClone, 4, 16);
395 let first = take();
396 assert_eq!(first.site_count.get(Site::CtxClone.index()), Some(&1));
397 assert_eq!(first.site_bytes.get(Site::CtxClone.index()), Some(&64));
398 assert_eq!(take(), Profile::default());
399 }
400}