Skip to main content

perf_probe/
perf_probe.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `perf_probe` - one command to attribute load time across the whole native
6//! pipeline (the same Rust code the browser runs through WASM), so a lever can
7//! be found and re-measured instead of guessed.
8//!
9//! It drains the timings the pipeline already publishes
10//! (`ProcessingStats.{parse,entity_scan,lookup,preprocess,geometry,total}_time_ms`,
11//! the faceted-brep point cache, CSG-failure counts) plus an isolated
12//! `build_entity_index` scan, and reports the parse-vs-geometry split with
13//! sub-phase breakdown per fixture. It also drains the always-on CSG op census
14//! (`--census`) so boolean workload is visible next to wall time.
15//!
16//! ```text
17//! # human table (best-of-N, N=3 by default):
18//! cargo run --profile profiling -p ifc-lite-processing --example perf_probe -- \
19//!     tests/models/ara3d/schependomlaan.ifc --iters 5 --census
20//!
21//! # the default suite (every catalogued heavy fixture that is on disk):
22//! cargo run --profile profiling -p ifc-lite-processing --example perf_probe -- --suite
23//!
24//! # machine-readable (JSON to stdout, table to stderr):
25//! cargo run --profile profiling -p ifc-lite-processing --example perf_probe -- \
26//!     --suite --json > /tmp/perf.json
27//! ```
28//!
29//! Build with `--profile profiling` (release-grade opt + symbols + panic=unwind)
30//! so a `samply record` on the produced binary yields a symbolized flamegraph;
31//! `--features observability` additionally fills `faceted_brep_time_ms`.
32//!
33//! This is a measurement harness, NOT a regression gate: run on a quiet machine,
34//! it already reports best-of-N to shave scheduler/GC noise, but treat single
35//! runs as noisy and compare medians across runs.
36//!
37//! WASM parity note: `std::time::Instant` traps on wasm32, so in the browser
38//! only `geometry_ms`/`total_ms` are self-timed; the parse/scan phases run in
39//! JS workers and are timed there (viewer `ifc_model_loaded` PostHog milestones
40//! and the console `[stream]` timeline). The *algorithmic* hotspots this probe
41//! surfaces are identical on both targets because the Rust code is shared; the
42//! WASM-only concerns (per-worker file re-decode, no-threads, memory bandwidth)
43//! are orchestration-level and covered by the viewer benchmark, not here. See
44//! `scripts/perf/README.md`.
45
46use std::time::Instant;
47
48use ifc_lite_core::build_entity_index;
49use ifc_lite_geometry::csg::{reset_csg_census, take_csg_census};
50use ifc_lite_processing::{process_geometry, ProcessingStats};
51
52/// One fixture's best-of-N measurement plus the isolated scan.
53struct Probe {
54    path: String,
55    file_mb: f64,
56    entities: usize,
57    index_build_ms: f64,
58    // Best-of-N run (selected by minimum total_time_ms).
59    stats: ProcessingStats,
60    all_totals_ms: Vec<u64>,
61    census: Option<CensusSummary>,
62}
63
64/// Aggregate of the always-on CSG op census for one run.
65#[derive(Default)]
66struct CensusSummary {
67    subtract: u64,
68    union: u64,
69    intersection: u64,
70    clip: u64,
71    /// Sum of operand triangle counts across every recorded boolean - the real
72    /// heavy-path kernel workload (analytic box clips never reach the census).
73    operand_tris: u64,
74}
75
76// CSG op codes as recorded in `CsgOpRecord.op` (a `u8`, not an exported enum).
77// Mirrors ifc_lite_geometry's census numbering; kept as named constants so a
78// reorder there surfaces as a one-line change here rather than silently
79// swapping the reported counts.
80const OP_SUBTRACT: u8 = 0;
81const OP_UNION: u8 = 1;
82const OP_INTERSECTION: u8 = 2;
83const OP_CLIP: u8 = 3;
84
85fn summarize_census() -> CensusSummary {
86    let mut s = CensusSummary::default();
87    for r in take_csg_census() {
88        match r.op {
89            OP_SUBTRACT => s.subtract += 1,
90            OP_UNION => s.union += 1,
91            OP_INTERSECTION => s.intersection += 1,
92            OP_CLIP => s.clip += 1,
93            _ => {}
94        }
95        s.operand_tris += r.a_tris as u64 + r.b_tris as u64;
96    }
97    s
98}
99
100fn run(path: &str, iters: usize, want_census: bool) -> Option<Probe> {
101    let content = match std::fs::read(path) {
102        Ok(c) => c,
103        Err(e) => {
104            eprintln!("skip {path}: {e}");
105            return None;
106        }
107    };
108    let file_mb = content.len() as f64 / 1.048_576e6;
109
110    // Isolated scan: build_entity_index alone times the pure structural scan
111    // that the pipeline otherwise folds into entity_scan_ms. Best-of-3.
112    let mut index_build_ms = f64::INFINITY;
113    let mut entities = 0usize;
114    for _ in 0..3 {
115        let t = Instant::now();
116        let idx = build_entity_index(&content);
117        let ms = t.elapsed().as_secs_f64() * 1e3;
118        entities = idx.len();
119        index_build_ms = index_build_ms.min(ms);
120    }
121
122    // Full pipeline, best-of-N by total_time_ms. Census (if requested) is
123    // drained from the run that was kept, so the op counts match the timing.
124    let mut best: Option<ProcessingStats> = None;
125    let mut best_total = u64::MAX;
126    let mut best_census: Option<CensusSummary> = None;
127    let mut all_totals_ms = Vec::with_capacity(iters);
128    for _ in 0..iters.max(1) {
129        if want_census {
130            reset_csg_census();
131        }
132        let result = process_geometry(&content);
133        let census = if want_census {
134            Some(summarize_census())
135        } else {
136            None
137        };
138        all_totals_ms.push(result.stats.total_time_ms);
139        if result.stats.total_time_ms <= best_total {
140            best_total = result.stats.total_time_ms;
141            best = Some(result.stats);
142            best_census = census;
143        }
144    }
145
146    Some(Probe {
147        path: path.to_string(),
148        file_mb,
149        entities,
150        index_build_ms,
151        stats: best?,
152        all_totals_ms,
153        census: best_census,
154    })
155}
156
157fn pct(part: u64, whole: u64) -> f64 {
158    if whole == 0 {
159        0.0
160    } else {
161        part as f64 / whole as f64 * 100.0
162    }
163}
164
165fn print_human(p: &Probe) {
166    let s = &p.stats;
167    let total = s.total_time_ms.max(1);
168    let parse = s.parse_time_ms;
169    let geom = s.geometry_time_ms;
170    let tris = s.total_triangles;
171    let mtris_s = if geom > 0 {
172        tris as f64 / (geom as f64 / 1e3) / 1e6
173    } else {
174        0.0
175    };
176    let cache_refs = s.point_cache_hits + s.point_cache_misses;
177    let hit_rate = pct(s.point_cache_hits, cache_refs);
178
179    eprintln!("\n=== {} ===", p.path);
180    eprintln!(
181        "  {:.1} MB | {} entities | {} meshes | {} verts | {} tris | {:.2} Mtris/s (geom)",
182        p.file_mb, p.entities, s.total_meshes, s.total_vertices, tris, mtris_s,
183    );
184    eprintln!(
185        "  best total {} ms  (runs: {:?} ms)",
186        s.total_time_ms, p.all_totals_ms
187    );
188    eprintln!("  phase                    ms        % total");
189    eprintln!(
190        "  parse (pre-geometry)  {:>8}   {:>5.1}%",
191        parse,
192        pct(parse, total)
193    );
194    eprintln!(
195        "    - index-scan alone  {:>8.1}   {:>5.1}%   (isolated build_entity_index)",
196        p.index_build_ms,
197        pct(p.index_build_ms as u64, total)
198    );
199    eprintln!(
200        "    - entity_scan       {:>8}   {:>5.1}%",
201        s.entity_scan_time_ms,
202        pct(s.entity_scan_time_ms, total)
203    );
204    eprintln!(
205        "    - lookup/styles     {:>8}   {:>5.1}%",
206        s.lookup_time_ms,
207        pct(s.lookup_time_ms, total)
208    );
209    eprintln!(
210        "    - preprocess        {:>8}   {:>5.1}%",
211        s.preprocess_time_ms,
212        pct(s.preprocess_time_ms, total)
213    );
214    eprintln!(
215        "  geometry              {:>8}   {:>5.1}%",
216        geom,
217        pct(geom, total)
218    );
219    if s.faceted_brep_time_ms > 0 {
220        eprintln!(
221            "    - faceted-brep      {:>8}   {:>5.1}%   (observability build)",
222            s.faceted_brep_time_ms,
223            pct(s.faceted_brep_time_ms, total)
224        );
225    }
226    if cache_refs > 0 {
227        eprintln!(
228            "  brep point-cache      {} hits / {} misses ({:.1}% memoized)",
229            s.point_cache_hits, s.point_cache_misses, hit_rate
230        );
231    }
232    if s.total_csg_failures > 0 {
233        eprintln!(
234            "  csg failures          {} across {} products",
235            s.total_csg_failures, s.products_with_failures
236        );
237    }
238    if s.degenerate_triangles_dropped > 0 {
239        eprintln!(
240            "  degenerate dropped    {}",
241            s.degenerate_triangles_dropped
242        );
243    }
244    if let Some(c) = &p.census {
245        eprintln!(
246            "  csg census            {} subtract / {} union / {} intersect / {} clip | {} operand-tris",
247            c.subtract, c.union, c.intersection, c.clip, c.operand_tris
248        );
249    }
250}
251
252fn print_json(probes: &[Probe]) {
253    // Hand-rolled to avoid pulling serde_json into an example; the shape is
254    // small and stable. Emits one object per fixture.
255    let mut out = String::from("[\n");
256    for (i, p) in probes.iter().enumerate() {
257        let s = &p.stats;
258        let census = p
259            .census
260            .as_ref()
261            .map(|c| {
262                format!(
263                    r#","csg":{{"subtract":{},"union":{},"intersection":{},"clip":{},"operandTris":{}}}"#,
264                    c.subtract, c.union, c.intersection, c.clip, c.operand_tris
265                )
266            })
267            .unwrap_or_default();
268        out.push_str(&format!(
269            concat!(
270                "  {{",
271                r#""path":{:?},"fileMb":{:.3},"entities":{},"meshes":{},"vertices":{},"triangles":{},"#,
272                r#""indexBuildMs":{:.2},"parseMs":{},"entityScanMs":{},"lookupMs":{},"preprocessMs":{},"#,
273                r#""geometryMs":{},"facetedBrepMs":{},"totalMs":{},"allTotalsMs":{:?},"#,
274                r#""pointCacheHits":{},"pointCacheMisses":{},"csgFailures":{},"degenerateDropped":{}{}}}"#,
275            ),
276            p.path,
277            p.file_mb,
278            p.entities,
279            s.total_meshes,
280            s.total_vertices,
281            s.total_triangles,
282            p.index_build_ms,
283            s.parse_time_ms,
284            s.entity_scan_time_ms,
285            s.lookup_time_ms,
286            s.preprocess_time_ms,
287            s.geometry_time_ms,
288            s.faceted_brep_time_ms,
289            s.total_time_ms,
290            p.all_totals_ms,
291            s.point_cache_hits,
292            s.point_cache_misses,
293            s.total_csg_failures,
294            s.degenerate_triangles_dropped,
295            census,
296        ));
297        out.push_str(if i + 1 < probes.len() { ",\n" } else { "\n" });
298    }
299    out.push(']');
300    println!("{out}");
301}
302
303/// Catalogued public manifest fixtures worth profiling, in rough phase-stress
304/// order. All are STEP `.ifc` (the probe drives `process_geometry`, the STEP
305/// path; IFCX/IFC5 use a separate pipeline and would report zero here) and all
306/// are fetchable with `pnpm fixtures <path>`; each is skipped silently when not
307/// on disk.
308const SUITE: &[&str] = &[
309    "tests/models/ara3d/AC20-FZK-Haus.ifc",              // small arch
310    "tests/models/various/01_Snowdon_Towers_Sample_Structural(1).ifc", // structural
311    "tests/models/various/01_BIMcollab_Example_ARC.ifc", // mid arch
312    "tests/models/ara3d/schependomlaan.ifc",            // arch, void-CSG, parse-heavy
313    "tests/models/ara3d/ISSUE_053_20181220Holter_Tower_10.ifc", // big parse
314    "tests/models/various/O-S1-BWK-BIM architectural - BIM bouwkundig.ifc", // largest
315];
316
317fn main() {
318    let mut iters = 3usize;
319    let mut json = false;
320    let mut census = false;
321    let mut suite = false;
322    let mut fixtures: Vec<String> = Vec::new();
323
324    let mut args = std::env::args().skip(1);
325    while let Some(a) = args.next() {
326        match a.as_str() {
327            "--iters" => {
328                iters = args
329                    .next()
330                    .and_then(|v| v.parse().ok())
331                    .filter(|n| *n >= 1)
332                    .unwrap_or_else(|| {
333                        eprintln!("--iters expects a positive integer");
334                        std::process::exit(2);
335                    });
336            }
337            "--json" => json = true,
338            "--census" => census = true,
339            "--suite" => suite = true,
340            other if other.starts_with("--") => {
341                eprintln!("unknown flag: {other}");
342                eprintln!("usage: perf_probe [<file.ifc>...] [--suite] [--iters N] [--census] [--json]");
343                std::process::exit(2);
344            }
345            other => fixtures.push(other.to_string()),
346        }
347    }
348    if suite {
349        for f in SUITE {
350            fixtures.push((*f).to_string());
351        }
352    }
353    if fixtures.is_empty() {
354        eprintln!("usage: perf_probe [<file.ifc>...] [--suite] [--iters N] [--census] [--json]");
355        eprintln!("  no fixtures given; try --suite (uses catalogued models on disk)");
356        std::process::exit(2);
357    }
358
359    eprintln!(
360        "perf_probe: {} fixture(s), best-of-{}{}",
361        fixtures.len(),
362        iters,
363        if census { ", +csg-census" } else { "" }
364    );
365
366    let mut probes = Vec::new();
367    for f in &fixtures {
368        if let Some(p) = run(f, iters, census) {
369            print_human(&p);
370            probes.push(p);
371        }
372    }
373
374    if json {
375        print_json(&probes);
376    }
377
378    if probes.is_empty() {
379        eprintln!("\nno fixtures measured (all missing?). Fetch with: pnpm fixtures <path>");
380        std::process::exit(1);
381    }
382}