Skip to main content

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}