bench/examples/nornir-bench.rs
//! nornir Bencher harness for facett — render throughput, historized in the
//! warehouse + gated on regression by `nornir release` / `nornir bench run`.
//!
//! Ported from the `#[ignore]`'d perf tests in `facett/tests/perf.rs` (B-bucket:
//! bench-type tests that had no bench). Those tests are now FAST downscaled
//! correctness checks (`assert!(r.drew())` at small N); the full-scale throughput
//! measurement lives HERE so it lands in `bench_history` and the README.
//!
//! Run: `nornir bench run facett` (or `cargo run --release --example nornir-bench`).
use anyhow::{ensure, Result};
use nornir::bench::api::Bencher;
use nornir::bench::BenchResult;
use facett::graph::{hash_color, GraphView};
use facett::harness::headless_render;
use facett::map::MapView;
use facett::map3d::{scissor_report, Map3D, V3, ViewPreset};
use facett::table::Table;
use egui::{Color32, Pos2};
/// Headline scale — the largest size the perf tests swept.
const N: usize = 50_000;
/// Build the throughput `BenchResult`: `<unit>_per_sec` (the headline metric),
/// the vertex count actually drawn, and elapsed ms.
fn result(name: &str, unit: &str, units: usize, elapsed_s: f64, vertices: usize) -> BenchResult {
let mut metrics = serde_json::Map::new();
let per_sec = if elapsed_s > 0.0 { (units as f64 / elapsed_s).round() } else { 0.0 };
metrics.insert(format!("{unit}_per_sec"), serde_json::json!(per_sec));
metrics.insert("vertices".into(), serde_json::json!(vertices));
metrics.insert("elapsed_ms".into(), serde_json::json!((elapsed_s * 1000.0).round()));
metrics.insert("n".into(), serde_json::json!(units));
BenchResult::measured(name, metrics)
}
// ── force-layout SCALING benchers (Barnes–Hut O(n log n) proof) ─────────────
// These time the *layout* stage (not the render) at n = 1k / 10k / 30k to show the
// sub-quadratic scaling of the shared Barnes–Hut kernel. A quadratic regression
// would blow the 30k time by ~100× vs 1k×√ growth — the number the Bencher
// historizes catches it. Host-stamped PLAIN ms per size; authoritative run on Loki.
/// One `(nodes, elapsed_ms)` layout timing across the three scales, packed as a
/// single historized `BenchResult` (`ms_1k`/`ms_10k`/`ms_30k`, plus the headline
/// `nodes_per_sec` at 30k and a `ratio_30k_over_10k` sub-quadratic witness).
fn scale_result(name: &str, ms: [f64; 3]) -> BenchResult {
let mut metrics = serde_json::Map::new();
metrics.insert("ms_1k".into(), serde_json::json!(ms[0].round()));
metrics.insert("ms_10k".into(), serde_json::json!(ms[1].round()));
metrics.insert("ms_30k".into(), serde_json::json!(ms[2].round()));
let per_sec = if ms[2] > 0.0 { (30_000.0 / (ms[2] / 1000.0)).round() } else { 0.0 };
metrics.insert("nodes_per_sec".into(), serde_json::json!(per_sec));
// O(n log n): 30k/10k ≈ 3·(log-ratio) ≈ 3.1×; O(n²) would be ≈ 9×.
let ratio = if ms[1] > 0.0 { (ms[2] / ms[1] * 100.0).round() / 100.0 } else { 0.0 };
metrics.insert("ratio_30k_over_10k".into(), serde_json::json!(ratio));
metrics.insert("n".into(), serde_json::json!(30_000));
BenchResult::measured(name, metrics)
}
/// A ring + sparse-chord edge list over `n` nodes (O(n) edges).
fn ring_chords(n: usize) -> Vec<(usize, usize)> {
let mut e: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
for i in (0..n).step_by(11) {
e.push((i, (i + 313) % n));
}
e
}
/// 2-D force layout (the shared facett-core graph skin) scaling.
struct Force2dScale;
impl Bencher for Force2dScale {
fn id(&self) -> &'static str {
"facett.force_layout_2d"
}
fn run(&self) -> Result<BenchResult> {
let rect = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1024.0, 1024.0));
let mut ms = [0.0f64; 3];
for (slot, &n) in [1_000usize, 10_000, 30_000].iter().enumerate() {
let mut scene = facett_core::Scene::new();
for _ in 0..n {
scene.node("n", hash_color("n"));
}
for (a, b) in ring_chords(n) {
scene.edge(a, b);
}
let t = std::time::Instant::now();
let pos = facett_core::layout_positions(facett_core::Layout::Force, &scene, rect);
ms[slot] = t.elapsed().as_secs_f64() * 1000.0;
ensure!(pos.len() == n && pos.iter().all(|p| p.x.is_finite()), "2d layout produced a bad position set");
}
Ok(scale_result("force_layout_2d", ms))
}
}
/// 3-D force layout (facett-graph3d octree) scaling.
struct Force3dScale;
impl Bencher for Force3dScale {
fn id(&self) -> &'static str {
"facett.force_layout_3d"
}
fn run(&self) -> Result<BenchResult> {
use facett::graph3d::{Graph3D, Layout3D};
let mut ms = [0.0f64; 3];
for (slot, &n) in [1_000usize, 10_000, 30_000].iter().enumerate() {
let nodes: Vec<(String, Color32)> = (0..n).map(|i| (format!("n{i}"), Color32::from_rgb(0, 200, 220))).collect();
let edges = ring_chords(n);
let t = std::time::Instant::now();
let g = Graph3D::new(nodes, edges, Layout3D::Force);
ms[slot] = t.elapsed().as_secs_f64() * 1000.0;
ensure!(g.nodes.len() == n && g.nodes.iter().all(|nd| nd.pos.iter().all(|c| c.is_finite())), "3d layout produced a bad position set");
}
Ok(scale_result("force_layout_3d", ms))
}
}
/// Render a graph of N nodes + N edges and measure nodes/s.
struct GraphRender;
impl Bencher for GraphRender {
fn id(&self) -> &'static str {
"facett.graph_render"
}
fn run(&self) -> Result<BenchResult> {
let mut scene = facett::Scene::new();
for _ in 0..N {
scene.node("node", hash_color("node"));
}
for i in 0..N {
scene.edge(i, (i + 1) % N);
}
let mut gv = GraphView::new(scene);
let t = std::time::Instant::now();
let r = headless_render(&mut gv);
let s = t.elapsed().as_secs_f64();
ensure!(r.drew(), "graph render drew nothing");
Ok(result("graph_render", "nodes", N, s, r.vertices))
}
}
/// Render a map of N points and measure points/s.
struct MapRender;
impl Bencher for MapRender {
fn id(&self) -> &'static str {
"facett.map_render"
}
fn run(&self) -> Result<BenchResult> {
let points: Vec<(f64, f64)> = (0..N)
.map(|i| (9.4 + (i % 200) as f64 * 0.001, 47.0 + (i / 200) as f64 * 0.001))
.collect();
let mut mv = MapView::new("map", points);
let t = std::time::Instant::now();
let r = headless_render(&mut mv);
let s = t.elapsed().as_secs_f64();
ensure!(r.drew(), "map render drew nothing");
Ok(result("map_render", "points", N, s, r.vertices))
}
}
/// Render a 3-column table of N rows and measure rows/s.
struct TableRender;
impl Bencher for TableRender {
fn id(&self) -> &'static str {
"facett.table_render"
}
fn run(&self) -> Result<BenchResult> {
let mut tbl = Table::new("t", vec!["id".into(), "name".into(), "val".into()]);
for i in 0..N {
tbl.push_row(vec![i.to_string(), format!("row{i}"), (i * 3).to_string()]);
}
let t = std::time::Instant::now();
let r = headless_render(&mut tbl);
let s = t.elapsed().as_secs_f64();
Ok(result("table_render", "rows", N, s, r.vertices))
}
}
// ── Panel-trait native frame-time bencher (wasm-ui design §5, Phase 0) ──────
// The `Panel` trait is the constellation-wide UI-pane seam; backend (1) is native
// in-process (the blanket `impl<T: Facet> Panel for T`). This measures the native
// frame time of a TRIVIAL pane driven through a `Box<dyn Panel>` trait object —
// the foundation's perf floor (dynamic dispatch + the four-method seam), not a hot
// path. Reports frames/sec + ms/frame. Deliberately trivial so it isolates the
// trait-object overhead, per the design's "native frame time of a trivial pane".
/// A trivial pane: a title + a counter it mutates via the Elm `update_json` seam.
struct TrivialPane {
count: i64,
}
impl facett::Facet for TrivialPane {
fn title(&self) -> &str {
"trivial"
}
fn ui(&mut self, ui: &mut egui::Ui) {
ui.label(format!("count = {}", self.count));
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "count": self.count })
}
fn update_json(&mut self, msg_json: &str) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
if let Some(n) = v.get("add").and_then(|n| n.as_i64()) {
self.count += n;
}
}
}
}
/// Native frame time of a trivial `Box<dyn Panel>` — paint + one `update_json` per
/// frame through the trait object, over `FRAMES` frames.
struct PanelFrame;
impl Bencher for PanelFrame {
fn id(&self) -> &'static str {
"facett.panel_frame"
}
fn run(&self) -> Result<BenchResult> {
const FRAMES: usize = 20_000;
let mut pane: Box<dyn facett::Panel> = Box::new(TrivialPane { count: 0 });
let ctx = egui::Context::default();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0))),
..Default::default()
};
let t = std::time::Instant::now();
let mut last_verts = 0usize;
for _ in 0..FRAMES {
// Backend (1) paint through the trait object.
let mut out = ctx.run_ui(input.clone(), |ui| {
egui::CentralPanel::default().show(ui, |ui| pane.ui(ui));
});
out.textures_delta.clear();
last_verts = ctx
.tessellate(out.shapes, out.pixels_per_point)
.iter()
.map(|p| match &p.primitive {
egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
_ => 0,
})
.sum();
// Exercise the Elm mutation seam each frame too.
pane.update_json(r#"{"add": 1}"#);
}
let s = t.elapsed().as_secs_f64();
ensure!(last_verts > 0, "trivial pane drew nothing");
ensure!(pane.state_json()["count"].as_i64() == Some(FRAMES as i64), "update_json ran every frame");
let mut r = result("panel_frame", "frames", FRAMES, s, last_verts);
let ms_per_frame = if FRAMES > 0 { s * 1000.0 / FRAMES as f64 } else { 0.0 };
r.metrics.insert("ms_per_frame".into(), serde_json::json!((ms_per_frame * 1000.0).round() / 1000.0));
Ok(r)
}
}
// ── facett-map LAYER-TRAIT frame-prep bencher (consolidation §3.1) ──────────
// The selectable Map trait's per-frame CPU cost: for a heavy STACK of MapLayers
// under a ROTATED + TILTED car-nav camera, `MapFacet::enabled_features()` culls
// every layer to the viewport, projects each `Way` into a source-agnostic
// `DrawFeature`, and z-sorts the lot — the work the shared renderer paints each
// frame. Benched as the native frame time (ms/frame + features/sec). This is the
// perf gate for the "3D OSM facet, better than Garmin" native path.
/// A heavy synthetic city: `n` E–W roads × `n` N–S roads (a dense street grid),
/// each a 3-vertex polyline, classified through the OSM styler → real `Way`s.
fn city_roads(n: usize) -> Vec<facett::map::Way> {
let th = facett::Theme::default();
let (lon0, lat0, step) = (18.00_f64, 59.30_f64, 0.0009_f64);
let mut ways = Vec::with_capacity(n * 2);
let mut id = 1i64;
let class = |i: usize| if i % 8 == 0 { "primary" } else if i % 3 == 0 { "secondary" } else { "residential" };
for r in 0..n {
let lat = lat0 + r as f64 * step;
let line: Vec<(f64, f64)> = (0..n).map(|c| (lon0 + c as f64 * step, lat)).collect();
let tags = vec![("highway".to_string(), class(r).to_string())];
if let Some(w) = facett::map::Way::from_lonlat(id, &line, &tags, &th) {
ways.push(w);
id += 1;
}
}
for c in 0..n {
let lon = lon0 + c as f64 * step;
let line: Vec<(f64, f64)> = (0..n).map(|r| (lon, lat0 + r as f64 * step)).collect();
let tags = vec![("highway".to_string(), class(c).to_string())];
if let Some(w) = facett::map::Way::from_lonlat(id, &line, &tags, &th) {
ways.push(w);
id += 1;
}
}
ways
}
/// Frame-prep over a rotated + tilted layer stack (OSM streets + a Trafikverket
/// overlay + a korp collector point layer) — the native car-nav frame cost.
struct MapLayerFrame;
impl Bencher for MapLayerFrame {
fn id(&self) -> &'static str {
"facett.map.layer_frame"
}
fn run(&self) -> Result<BenchResult> {
use facett::map::layer::{LatLon, MapFacet, MapTheme, MapView, PointsLayer, WaysLayer};
let n = std::env::var("MAP_LAYER_BENCH_N").ok().and_then(|v| v.parse().ok()).unwrap_or(140usize);
let osm = city_roads(n);
let tv = city_roads(n); // a second selectable road source (Trafikverket overlay)
let ways = osm.len() + tv.len();
let verts: usize = osm.iter().map(|w| w.lonlat.len()).sum::<usize>() * 2;
// korp collector overlay points across the same extent.
let pts: Vec<(f64, f64)> = (0..5000).map(|i| (18.00 + (i % 130) as f64 * 0.0009, 59.30 + (i / 130) as f64 * 0.0009)).collect();
// The worst-case car-nav pose: heading 35°, pitched 55° back.
let view = MapView::new(LatLon::new(59.36, 18.06), 14.0, (1280.0, 800.0)).with_bearing(35.0).with_tilt(55.0);
let facet = MapFacet::new("bench", view, MapTheme::default())
.with_layer(Box::new(WaysLayer::new("osm-streets", osm)))
.with_layer(Box::new(WaysLayer::new("trafikverket-roads", tv)))
.with_layer(Box::new(PointsLayer::new("korp-hits", pts, Color32::RED)));
let iters = 60;
let t = std::time::Instant::now();
let mut features = 0usize;
for _ in 0..iters {
features = facet.enabled_features().len();
}
let s = t.elapsed().as_secs_f64();
ensure!(features > 0, "frame prep produced no features");
let mut metrics = serde_json::Map::new();
let per_frame_ms = s * 1000.0 / iters as f64;
metrics.insert("ms_per_frame".into(), serde_json::json!((per_frame_ms * 1000.0).round() / 1000.0));
metrics.insert("frames_per_sec".into(), serde_json::json!((iters as f64 / s).round()));
metrics.insert("features".into(), serde_json::json!(features));
metrics.insert("ways".into(), serde_json::json!(ways));
metrics.insert("vertices".into(), serde_json::json!(verts));
metrics.insert("iters".into(), serde_json::json!(iters));
Ok(BenchResult::measured("facett.map.layer_frame", metrics))
}
}
// ── facett-map3d PART-STEP benchers (the clip pipeline) ─────────────────────
// Bencher LAW: bench the PARTS, not just "draw map". Each part-step of
// `shade_faces` gets its own `facett.map3d.<step>` entry, fed real injected
// input sized to isolate that step, with the end-to-end `facett.map_render` as
// the rollup. The dolled-in street-level pose is the one the clip pipeline must
// stay fast under (it splits the most triangles at the near plane).
/// A sized synthetic town of real `building=yes` rings — N×N footprints → prisms.
fn map3d_town(n: usize) -> facett::map3d::Map3D {
let mut ways = Vec::with_capacity(n * n);
let mut id = 1i64;
for r in 0..n {
for c in 0..n {
let bx = 9.50 + c as f64 * 0.0015;
let by = 47.10 + r as f64 * 0.0015;
let ring = [(bx, by), (bx + 0.0008, by), (bx + 0.0008, by + 0.0008), (bx, by + 0.0008), (bx, by)];
let tags = vec![("building".to_string(), "yes".to_string())];
ways.push(facett::map::Way::from_lonlat(id, &ring, &tags, &facett::Theme::default()).unwrap());
id += 1;
}
}
let mut m = Map3D::from_ways("map3d-bench", &ways, 0.22);
// The dolled-in street-level pose: the worst case for the near-plane clip.
m.spin = false;
m.cam.target = V3::new(0.0, 0.02, 0.0);
m.cam.goal_target = m.cam.target;
m.cam.distance = 0.42;
m.cam.goal_distance = 0.42;
m.view_preset(ViewPreset::Front);
m.cam.elevation = 0.03;
m.cam.goal_elevation = 0.03;
m.cam.settle();
m
}
/// Build a `BenchResult` of `tris/sec` for a part-step over `tris` source tris.
fn step_result(name: &str, tris: usize, elapsed_s: f64, iters: usize) -> BenchResult {
let mut metrics = serde_json::Map::new();
let per_sec = if elapsed_s > 0.0 { ((tris * iters) as f64 / elapsed_s).round() } else { 0.0 };
metrics.insert("tris_per_sec".into(), serde_json::json!(per_sec));
metrics.insert("tris".into(), serde_json::json!(tris));
metrics.insert("iters".into(), serde_json::json!(iters));
metrics.insert("ns_per_op".into(), serde_json::json!(if tris * iters > 0 { (elapsed_s * 1e9 / (tris * iters) as f64).round() } else { 0.0 }));
metrics.insert("elapsed_ms".into(), serde_json::json!((elapsed_s * 1000.0).round()));
BenchResult::measured(name, metrics)
}
/// The viewport the part-steps project + scissor against.
const BCENTER: Pos2 = Pos2::new(360.0, 360.0);
const BHALF_H: f32 = 360.0;
fn brect() -> egui::Rect {
egui::Rect::from_center_size(BCENTER, egui::vec2(BHALF_H * 2.0, BHALF_H * 2.0))
}
/// Part-step 1 — view-space near-plane clip (the fix). Whole-pipeline timing with
/// the near clip ON (it is the costliest new stage at the straddling pose).
struct Map3dNearClip;
impl Bencher for Map3dNearClip {
fn id(&self) -> &'static str {
"facett.map3d.near_clip"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40); // 1600 prisms → a dense straddling town
let tris = m.triangle_count();
let iters = 50;
let t = std::time::Instant::now();
let mut acc = 0usize;
for _ in 0..iters {
acc += m.shade_faces_clip(BCENTER, BHALF_H, Color32::WHITE, true).len();
}
let s = t.elapsed().as_secs_f64();
ensure!(acc > 0, "near clip produced no faces");
Ok(step_result("facett.map3d.near_clip", tris, s, iters))
}
}
/// Part-step 2 — project. Cost of `cam.project` over every mesh vertex.
struct Map3dProject;
impl Bencher for Map3dProject {
fn id(&self) -> &'static str {
"facett.map3d.project"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40);
let cam = m.render_camera();
let verts: Vec<V3> = m.mesh().positions.iter().map(|p| V3::new(p[0], p[1], p[2])).collect();
let tris = m.triangle_count();
let iters = 50;
let t = std::time::Instant::now();
let mut acc = 0usize;
for _ in 0..iters {
for v in &verts {
if cam.project(*v, (BCENTER.x, BCENTER.y), BHALF_H).visible {
acc += 1;
}
}
}
let s = t.elapsed().as_secs_f64();
ensure!(acc > 0, "project saw no visible verts");
Ok(step_result("facett.map3d.project", tris, s, iters))
}
}
/// Part-step 3/4 — frustum + back-face cull. Measured as the whole shade pass
/// (cull is folded inline); the rollup compares against `near_clip`.
struct Map3dCull;
impl Bencher for Map3dCull {
fn id(&self) -> &'static str {
"facett.map3d.cull"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40);
let tris = m.triangle_count();
let iters = 50;
let t = std::time::Instant::now();
let mut visible = 0usize;
for _ in 0..iters {
visible = m.shade_faces_clip(BCENTER, BHALF_H, Color32::WHITE, true).len();
}
let s = t.elapsed().as_secs_f64();
ensure!(visible <= tris * 2, "cull is monotone (≤ 2× source tris)");
Ok(step_result("facett.map3d.cull", tris, s, iters))
}
}
/// Part-step 5 — shade. The lit + fog colouring over the visible faces.
struct Map3dShade;
impl Bencher for Map3dShade {
fn id(&self) -> &'static str {
"facett.map3d.shade"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40);
let tris = m.triangle_count();
let iters = 50;
let t = std::time::Instant::now();
let mut shades = 0usize;
for _ in 0..iters {
shades = m.shade_faces(BCENTER, BHALF_H, Color32::from_rgb(20, 24, 40)).len();
}
let s = t.elapsed().as_secs_f64();
ensure!(shades > 0, "shade produced no coloured faces");
Ok(step_result("facett.map3d.shade", tris, s, iters))
}
}
/// Part-step 6 — depth-sort. The painter's-algorithm far→near sort.
struct Map3dDepthSort;
impl Bencher for Map3dDepthSort {
fn id(&self) -> &'static str {
"facett.map3d.depth_sort"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40);
let mut faces = m.shade_faces(BCENTER, BHALF_H, Color32::WHITE);
let tris = faces.len();
let iters = 200;
let t = std::time::Instant::now();
for _ in 0..iters {
// Shuffle-ish perturb then re-sort to time the sort in isolation.
faces.rotate_left(1);
faces.sort_by(|l, r| r.0.partial_cmp(&l.0).unwrap_or(std::cmp::Ordering::Equal));
}
let s = t.elapsed().as_secs_f64();
ensure!(tris > 0, "no faces to sort");
Ok(step_result("facett.map3d.depth_sort", tris, s, iters))
}
}
/// Part-step 7 — 2D rect scissor. Clip every face to the widget rect (the confine).
struct Map3dScissor;
impl Bencher for Map3dScissor {
fn id(&self) -> &'static str {
"facett.map3d.scissor"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40);
let faces = m.shade_faces(BCENTER, BHALF_H, Color32::WHITE);
let rect = brect();
let tris = faces.len();
let iters = 100;
let t = std::time::Instant::now();
let mut escapes = 0usize;
for _ in 0..iters {
escapes = scissor_report(&faces, rect).1;
}
let s = t.elapsed().as_secs_f64();
ensure!(escapes == 0, "the scissor leaves no ink outside the rect");
Ok(step_result("facett.map3d.scissor", tris, s, iters))
}
}
/// Part-step 8 — composite. The full CPU shade → clip pass (the painter's whole
/// per-frame geometry cost; the pixel raster lives in the host renderer).
struct Map3dComposite;
impl Bencher for Map3dComposite {
fn id(&self) -> &'static str {
"facett.map3d.composite"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40);
let rect = brect();
let tris = m.triangle_count();
let iters = 30;
let t = std::time::Instant::now();
let mut painted = 0usize;
for _ in 0..iters {
let faces = m.shade_faces(BCENTER, BHALF_H, Color32::from_rgb(20, 24, 40));
for (_d, pts, _c) in &faces {
if facett::map3d::clip_poly_to_rect(pts, rect).len() >= 3 {
painted += 1;
}
}
}
let s = t.elapsed().as_secs_f64();
ensure!(painted > 0, "composite painted no polygons");
Ok(step_result("facett.map3d.composite", tris, s, iters))
}
}
/// **`facett.demo_frame`** — the WHOLE-frame CPU cost of the demo's heaviest 3D pane
/// (the OSM-3D cinematic-tour tab): a dense town of prisms shaded end-to-end at the
/// dolled-in street-level pose the tour holds. The rollup the part-steps above
/// decompose — the frame budget the interactive `facett-demo` must keep under
/// **16.6 ms** for a smooth 60 fps. The headline `ms_per_frame` / `fps` are the number
/// the lag fix (removing the per-frame full-deck `state_json` dump from the hot loop)
/// has to beat; the Loki bencher re-runs it on every push to guard against a
/// regression back into per-frame stutter.
struct DemoFrame;
impl Bencher for DemoFrame {
fn id(&self) -> &'static str {
"facett.demo_frame"
}
fn run(&self) -> Result<BenchResult> {
let m = map3d_town(40); // 1600 prisms → a dense demo town at street level
let tris = m.triangle_count();
let iters = 60; // ~1 s of frames
let t = std::time::Instant::now();
let mut acc = 0usize;
for _ in 0..iters {
// The full CPU paint pipeline for one frame (near-clip → project → cull →
// shade → depth-sort), the residual per-frame cost that decides smoothness.
acc += m.shade_faces(BCENTER, BHALF_H, Color32::from_rgb(20, 24, 40)).len();
}
let s = t.elapsed().as_secs_f64();
ensure!(acc > 0, "demo frame drew no faces");
let frame_ms = s * 1000.0 / iters as f64;
let mut metrics = serde_json::Map::new();
metrics.insert("ms_per_frame".into(), serde_json::json!((frame_ms * 1000.0).round() / 1000.0));
metrics.insert("fps".into(), serde_json::json!(if frame_ms > 0.0 { (1000.0 / frame_ms).round() } else { 0.0 }));
metrics.insert("smooth_60fps".into(), serde_json::json!(frame_ms <= 16.6));
metrics.insert("triangles".into(), serde_json::json!(tris));
metrics.insert("iters".into(), serde_json::json!(iters));
Ok(BenchResult::measured("facett.demo_frame", metrics))
}
}
// ── facett-core L0 SDF PART-STEP benchers (CONS-CORE Phase B) ────────────────
// Bencher LAW: bench the PARTS, not just "draw". Each L0 SDF primitive + the CPU
// raster + the GPU upload gets its own `facett.core.<step>` entry, fed injected
// sized input, sweeping instance counts (the galileo bar = ≥3.6M points; the rollup
// sweep reaches 10M). The coverage math is the same the GPU runs, so these time the
// CPU floor's per-instance cost — the number that matters on the no-GPU row.
//
// NOTE: this bench crate currently fails to COMPILE because `nornir`'s `skade`
// dependency pulls a conflicting arrow 57/58 (the same pre-existing conflict that
// blocks the map3d part-benchers from running). The benchers are registered in the
// correct pattern so they run as soon as that conflict is resolved.
use facett::core::render::cpu::sdf;
use facett::core::render::prim::{shape, CircleInstance, LineInstance, MarkerInstance, QuadInstance, RingInstance};
use facett::core::render::{Camera, Canvas, CpuCanvas};
/// The instance-count sweep each SDF part-step is timed over (1k → 10M).
const SDF_SWEEP: [usize; 5] = [1_000, 10_000, 100_000, 1_000_000, 10_000_000];
/// A `BenchResult` of `instances/sec` for an SDF part-step at the headline (largest)
/// sweep size, carrying the full sweep grid in the metrics.
fn sdf_result(name: &str, per_size_ns: &[(usize, f64)]) -> BenchResult {
let mut metrics = serde_json::Map::new();
// Headline: throughput at the largest size.
if let Some((n, ns)) = per_size_ns.last() {
let per_sec = if *ns > 0.0 { (*n as f64 / (*ns / 1e9)).round() } else { 0.0 };
metrics.insert("instances_per_sec".into(), serde_json::json!(per_sec));
metrics.insert("n".into(), serde_json::json!(n));
metrics.insert("ns_per_op".into(), serde_json::json!((ns / *n as f64).round()));
}
// The full sweep grid (size → ns/op) — the part-bench parameter sweep.
let grid: serde_json::Map<String, serde_json::Value> = per_size_ns
.iter()
.map(|(n, ns)| (n.to_string(), serde_json::json!((ns / *n as f64))))
.collect();
metrics.insert("sweep_ns_per_op".into(), serde_json::Value::Object(grid));
BenchResult::measured(name, metrics)
}
/// Time a per-instance coverage closure over the count sweep — the inner SDF kernel
/// (the work the fragment shader does per covered pixel), evaluated at a fixed
/// sample point so it isolates the math, not the raster loop.
fn sweep_coverage(name: &str, make: impl Fn(usize) -> QuadInstance) -> Result<BenchResult> {
let mut grid = Vec::new();
for &n in &SDF_SWEEP {
let inst = make(n);
let t = std::time::Instant::now();
let mut acc = 0.0f32;
for i in 0..n {
// Vary the sample so the optimiser can't hoist the call.
let f = (i & 63) as f32 * 0.25;
acc += sdf::quad_coverage(&inst, f, f * 0.5);
}
let ns = t.elapsed().as_nanos() as f64;
ensure!(acc.is_finite(), "{name} produced non-finite coverage");
grid.push((n, ns));
}
Ok(sdf_result(name, &grid))
}
struct SdfCircle;
impl Bencher for SdfCircle {
fn id(&self) -> &'static str { "facett.core.sdf_circle" }
fn run(&self) -> Result<BenchResult> {
sweep_coverage("facett.core.sdf_circle", |_| {
CircleInstance { center: [0.0, 0.0], radius: 16.0, color: [1.0; 4], aa: 1.5 }.lower()
})
}
}
struct SdfRing;
impl Bencher for SdfRing {
fn id(&self) -> &'static str { "facett.core.sdf_ring" }
fn run(&self) -> Result<BenchResult> {
sweep_coverage("facett.core.sdf_ring", |_| {
RingInstance { center: [0.0; 2], radius: 16.0, inner: 9.0, color: [1.0; 4], aa: 1.5 }.lower()
})
}
}
struct SdfMarker;
impl Bencher for SdfMarker {
fn id(&self) -> &'static str { "facett.core.sdf_marker" }
fn run(&self) -> Result<BenchResult> {
sweep_coverage("facett.core.sdf_marker", |_| {
MarkerInstance { center: [0.0; 2], radius: 14.0, corner: 2.0, color: [1.0; 4], aa: 1.0, shape: shape::DIAMOND }.lower()
})
}
}
struct ThickLine;
impl Bencher for ThickLine {
fn id(&self) -> &'static str { "facett.core.thick_line" }
fn run(&self) -> Result<BenchResult> {
let mut grid = Vec::new();
for &n in &SDF_SWEEP {
let l = LineInstance::round([10.0, 50.0], [200.0, 80.0], 4.0, 1.5, [1.0; 4]);
let t = std::time::Instant::now();
let mut acc = 0.0f32;
for i in 0..n {
let f = (i & 127) as f32 * 0.5;
acc += sdf::line_coverage(&l, [f, 50.0 + (i & 31) as f32]);
}
let ns = t.elapsed().as_nanos() as f64;
ensure!(acc.is_finite(), "thick_line non-finite");
grid.push((n, ns));
}
Ok(sdf_result("facett.core.thick_line", &grid))
}
}
/// CPU raster: the whole `CpuCanvas` → vello_cpu pixmap path.
///
/// **Scaling-honest bench (rewritten):** the old version timed `new` (a 1M-px serial
/// clear) + `frame` (a single-threaded un-premultiply `collect`) + `lit_px` inside
/// the window, swamping the one parallel call with serial tail and capping the
/// measured speedup at ~6.3× / 15.78 cores-busy. Now:
/// - The canvas is built **once** and the framebuffer is **reused** across passes
/// (no per-pass alloc; the per-frame bg clear runs inside the parallel raster).
/// - Only `rasterize()` is timed; the correctness `lit_px()` check is outside the
/// timer (a separate untimed pass).
/// - A large 4096×4096 frame + many discs makes the parallel region dominate, and
/// the **same** raster is timed at `workers = N` (all cores) vs `workers = 1`
/// (forced sequential) so cores-busy is a measured ratio, not a guess.
/// - Reported **per-n**: `cores_busy_<n>` and `speedup_<n>` for the big case, not a
/// sweep average that drowns the heavy case in serial-light small ones.
struct CpuRaster;
impl CpuRaster {
/// Median wall-time (ns) of `passes` rasters of `quads` onto a reused
/// `dim×dim` canvas, all at `workers` gatling workers. Reusing the canvas drops
/// per-pass alloc; the parallel raster repaints the bg each pass.
fn time_raster(dim: u32, quads: &[QuadInstance], workers: usize, passes: usize) -> f64 {
let mut samples = Vec::with_capacity(passes);
let mut canvas = CpuCanvas::new(dim, dim, Camera::default(), [8, 10, 14, 255]);
for _ in 0..passes {
canvas.push_quads(quads);
let t = std::time::Instant::now();
let _frame = canvas.rasterize_with_workers(workers);
samples.push(t.elapsed().as_nanos() as f64);
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
samples[samples.len() / 2]
}
/// Median wall-time (ns) of the **compositing raster only** — `raster_only`
/// (bg clear + y-bucketed SDF blend, the region the gatling kernel governs),
/// excluding the memory-bandwidth-bound un-premultiply (`frame`). This isolates
/// the part that *should* scale with cores from the part that is capped by
/// RAM bandwidth, so the report is honest about which is which.
fn time_composite(dim: u32, quads: &[QuadInstance], workers: usize, passes: usize) -> f64 {
let mut samples = Vec::with_capacity(passes);
let mut canvas = CpuCanvas::new(dim, dim, Camera::default(), [8, 10, 14, 255]);
for _ in 0..passes {
canvas.push_quads(quads);
let t = std::time::Instant::now();
canvas.raster_only(workers);
samples.push(t.elapsed().as_nanos() as f64);
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
samples[samples.len() / 2]
}
}
impl Bencher for CpuRaster {
fn id(&self) -> &'static str { "facett.core.cpu_raster" }
fn run(&self) -> Result<BenchResult> {
const DIM: u32 = 4096;
let cores = std::thread::available_parallelism().map(|c| c.get()).unwrap_or(1);
let mut grid = Vec::new();
let mut metrics = serde_json::Map::new();
for &n in &[10_000usize, 50_000, 200_000] {
// Spread discs across the whole 4096² frame so every scanline carries
// real work (the gatling fan-out has nothing to starve on).
let quads: Vec<QuadInstance> = (0..n)
.map(|i| {
let x = (i * 131 % DIM as usize) as f32;
let y = (i * 197 % DIM as usize) as f32;
CircleInstance { center: [x, y], radius: 9.0, color: [0.2, 0.7, 1.0, 0.8], aa: 1.5 }.lower()
})
.collect();
// Correctness OUTSIDE the timer: prove it actually drew.
{
let mut c = CpuCanvas::new(DIM, DIM, Camera::default(), [8, 10, 14, 255]);
c.push_quads(&quads);
ensure!(c.rasterize().lit_px() > 0, "cpu_raster drew nothing");
}
// More passes on the heavy case so the parallel window dominates.
let passes = if n >= 200_000 { 16 } else { 24 };
// Optional serial-tail breakdown (env-gated, to stderr) — proves what
// does/doesn't scale, so the ceiling report is honest, not guessed.
if std::env::var_os("FACETT_RASTER_PROBE").is_some() {
use facett::core::render::cpu::CpuCanvas as C;
let c = C::new(DIM, DIM, Camera::default(), [8, 10, 14, 255]);
let probe = |label: &str, f: &mut dyn FnMut()| {
let t = std::time::Instant::now();
for _ in 0..passes { f(); }
eprintln!("PROBE n={n} {label} = {:.3} ms", t.elapsed().as_secs_f64() * 1000.0 / passes as f64);
};
probe("new(4096)", &mut || { let _ = C::new(DIM, DIM, Camera::default(), [8,10,14,255]); });
probe("frame_par", &mut || { let _ = c.frame_with_workers(0); });
probe("frame_seq", &mut || { let _ = c.frame_with_workers(1); });
{
let mut cc = C::new(DIM, DIM, Camera::default(), [8,10,14,255]);
probe("composite_par", &mut || { cc.push_quads(&quads); cc.raster_only(0); });
probe("composite_seq", &mut || { cc.push_quads(&quads); cc.raster_only(1); });
}
}
// Whole rasterize() (composite + un-premultiply), all cores vs 1.
let par_ns = Self::time_raster(DIM, &quads, 0, passes); // all cores
let seq_ns = Self::time_raster(DIM, &quads, 1, passes); // forced 1 worker
let speedup = if par_ns > 0.0 { seq_ns / par_ns } else { 0.0 };
// Compositing raster ONLY (the gatling-governed region), all cores vs 1.
let cpar = Self::time_composite(DIM, &quads, 0, passes);
let cseq = Self::time_composite(DIM, &quads, 1, passes);
let cspeedup = if cpar > 0.0 { cseq / cpar } else { 0.0 };
let r2 = |x: f64| (x * 100.0).round() / 100.0;
metrics.insert(format!("par_ns_{n}"), serde_json::json!(par_ns.round()));
metrics.insert(format!("seq_ns_{n}"), serde_json::json!(seq_ns.round()));
metrics.insert(format!("speedup_{n}"), serde_json::json!(r2(speedup)));
metrics.insert(format!("cores_busy_{n}"), serde_json::json!(r2(speedup.min(cores as f64))));
metrics.insert(format!("composite_speedup_{n}"), serde_json::json!(r2(cspeedup)));
metrics.insert(format!("composite_cores_busy_{n}"), serde_json::json!(r2(cspeedup.min(cores as f64))));
grid.push((n, par_ns));
}
metrics.insert("cores".into(), serde_json::json!(cores));
// Roll the per-n cores-busy/speedup into the standard sdf_result envelope so
// it still historizes the headline throughput, then merge the per-n metrics.
let mut res = sdf_result("facett.core.cpu_raster", &grid);
for (k, v) in metrics {
res.metrics.insert(k, v);
}
Ok(res)
}
}
/// GPU upload + offscreen draw: builds the instance buffer + runs the L0 SDF
/// pipeline headlessly, timed by total instances uploaded. Requires the `wgpu`
/// feature AND a usable adapter; otherwise reports a zero result with `skipped=1`
/// (honest — no GPU lane on this box).
struct GpuUpload;
impl Bencher for GpuUpload {
fn id(&self) -> &'static str { "facett.core.gpu_upload" }
fn run(&self) -> Result<BenchResult> {
#[cfg(feature = "wgpu")]
{
use facett::core::render::gpu::offscreen_render;
let mut grid = Vec::new();
for &n in &[10_000usize, 100_000, 1_000_000] {
let quads: Vec<QuadInstance> = (0..n)
.map(|i| {
let x = (i % 512) as f32;
let y = ((i / 512) % 512) as f32;
CircleInstance { center: [x, y], radius: 2.0, color: [1.0, 0.5, 0.2, 1.0], aa: 1.0 }.lower()
})
.collect();
let t = std::time::Instant::now();
let frame = offscreen_render(512, 512, [0.03, 0.04, 0.05, 1.0], &quads, &[]);
let ns = t.elapsed().as_nanos() as f64;
match frame {
Some(_) => grid.push((n, ns)),
None => {
let mut metrics = serde_json::Map::new();
metrics.insert("skipped".into(), serde_json::json!(1));
metrics.insert("reason".into(), serde_json::json!("no wgpu adapter"));
return Ok(BenchResult::measured("facett.core.gpu_upload", metrics));
}
}
}
Ok(sdf_result("facett.core.gpu_upload", &grid))
}
#[cfg(not(feature = "wgpu"))]
{
let mut metrics = serde_json::Map::new();
metrics.insert("skipped".into(), serde_json::json!(1));
metrics.insert("reason".into(), serde_json::json!("wgpu feature off"));
Ok(BenchResult::measured("facett.core.gpu_upload", metrics))
}
}
}
// ── scene WINDOW-CHROME frame cost (L2 non-regression for a default-ON feature) ──
// Every pane in a `facett_app::scene` now draws a title bar with fold / minimise /
// maximise. That is ON BY DEFAULT, so it must be proven not to cost a frame: this arm
// renders the SAME deep pane tree twice — once with the default chrome, once with every
// pane opted out — and reports both frame times plus the overhead between them. A
// regression shows up as `chrome_overhead_pct` climbing, not as a vague "feels slower".
/// A balanced binary split tree `depth` levels deep ⇒ `2^depth` leaves,
/// `2^(depth+1) - 1` panes. `chrome` false ⇒ every pane opts out (the pre-chrome look).
fn chrome_tree(depth: u32, chrome: bool) -> facett_app::scene::Scene {
use facett_app::scene::{Axis, Pane, Scene};
fn build(prefix: &str, depth: u32, chrome: bool) -> Pane {
if depth == 0 {
let p = Pane::slot(format!("leaf{prefix}"), "cell").title("Leaf");
return if chrome { p } else { p.no_chrome() };
}
let axis = if depth % 2 == 0 { Axis::Horizontal } else { Axis::Vertical };
let kids = vec![build(&format!("{prefix}0"), depth - 1, chrome), build(&format!("{prefix}1"), depth - 1, chrome)];
let p = Pane::split(format!("n{prefix}"), axis, 0.5, kids);
if chrome { p } else { p.no_chrome() }
}
Scene::new("chrome-bench", build("", depth, chrome))
}
/// A slot host that draws one cheap label, so the frame cost is the SCENE's, not a
/// component's.
struct CellHost;
impl facett_app::scene::SceneHost for CellHost {
fn slot(&mut self, _id: &str, ui: &mut egui::Ui) -> serde_json::Value {
ui.label("·");
serde_json::Value::Null
}
}
/// Median ns per rendered frame for one scene, over `iters` frames after a warmup.
fn scene_frame_ns(scene: &facett_app::scene::Scene, iters: usize) -> (f64, usize) {
let ctx = egui::Context::default();
let rect = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1920.0, 1080.0));
let mut nav = facett_app::scene::NavState::new(scene);
let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() };
let mut drawn = 0usize;
// Warm the font atlas / layout caches so the measured frames are steady-state.
for _ in 0..3 {
// egui 0.35: `Context::run` is gone and `Panel::show` now takes a `&mut Ui`
// rather than a `&Context` (that is the same rename the `show_inside`
// deprecation warnings point at). `run_ui` is the one-for-one replacement —
// it builds the root viewport `Ui` and runs a full pass around it, so the
// CentralPanel this bench exists to time is still in the frame.
ctx.run_ui(raw(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
let st = facett_app::scene::render(scene, &mut nav, ui, &mut CellHost);
drawn = st["panes"].as_object().map(|o| o.len()).unwrap_or(0);
});
}).textures_delta.clear();
}
let mut samples: Vec<f64> = Vec::with_capacity(iters);
for _ in 0..iters {
let t = std::time::Instant::now();
ctx.run_ui(raw(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
facett_app::scene::render(scene, &mut nav, ui, &mut CellHost);
});
}).textures_delta.clear();
samples.push(t.elapsed().as_secs_f64() * 1e9);
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
(samples[samples.len() / 2], drawn)
}
/// The chrome frame-cost arm: `scene::render` with the default chrome vs. every pane
/// opted out, at a REALISTIC shell size (31 panes) and at a pathological one (1023).
/// The realistic number is the one that decides whether default-ON was affordable; the
/// pathological one is the regression tripwire.
struct SceneChromeFrame;
impl Bencher for SceneChromeFrame {
fn id(&self) -> &'static str {
"facett.scene_chrome_frame"
}
fn run(&self) -> Result<BenchResult> {
const ITERS: usize = 40;
let mut metrics = serde_json::Map::new();
for (depth, tag) in [(4u32, "shell"), (9u32, "deep")] {
let (on_ns, panes) = scene_frame_ns(&chrome_tree(depth, true), ITERS);
let (off_ns, off_panes) = scene_frame_ns(&chrome_tree(depth, false), ITERS);
ensure!(panes == off_panes && panes > 1, "both trees must lay out the same {panes} panes (off: {off_panes})");
metrics.insert(format!("{tag}_panes"), serde_json::json!(panes));
metrics.insert(format!("{tag}_chrome_on_ns"), serde_json::json!(on_ns.round()));
metrics.insert(format!("{tag}_chrome_off_ns"), serde_json::json!(off_ns.round()));
metrics.insert(format!("{tag}_ns_per_pane"), serde_json::json!((on_ns / panes as f64).round()));
let pct = if off_ns > 0.0 { ((on_ns - off_ns) / off_ns * 1000.0).round() / 10.0 } else { 0.0 };
metrics.insert(format!("{tag}_chrome_overhead_pct"), serde_json::json!(pct));
let fps = if on_ns > 0.0 { (1e9 / on_ns).round() } else { 0.0 };
metrics.insert(format!("{tag}_frames_per_sec"), serde_json::json!(fps));
// The number that actually decides affordability: the share of a 60fps frame
// budget (16666667 ns) the scene walk costs with the chrome on.
let budget = (on_ns / 16_666_667.0 * 1000.0).round() / 10.0;
metrics.insert(format!("{tag}_pct_of_60fps_budget"), serde_json::json!(budget));
if tag == "shell" {
metrics.insert("n".into(), serde_json::json!(panes));
}
}
Ok(BenchResult::measured("facett.scene_chrome_frame", metrics))
}
}
nornir::register_bench!(Force2dScale);
nornir::register_bench!(Force3dScale);
nornir::register_bench!(GraphRender);
nornir::register_bench!(MapRender);
nornir::register_bench!(PanelFrame);
nornir::register_bench!(MapLayerFrame);
nornir::register_bench!(TableRender);
nornir::register_bench!(Map3dNearClip);
nornir::register_bench!(Map3dProject);
nornir::register_bench!(Map3dCull);
nornir::register_bench!(Map3dShade);
nornir::register_bench!(Map3dDepthSort);
nornir::register_bench!(Map3dScissor);
nornir::register_bench!(Map3dComposite);
nornir::register_bench!(DemoFrame);
nornir::register_bench!(SdfCircle);
nornir::register_bench!(SdfRing);
nornir::register_bench!(SdfMarker);
nornir::register_bench!(ThickLine);
nornir::register_bench!(CpuRaster);
nornir::register_bench!(GpuUpload);
nornir::register_bench!(SceneChromeFrame);
fn main() -> Result<()> {
nornir::bench::api::run_main_json()
}