facett-graph3d/examples/rotate_bench.rs
//! **Rotate/spin decomposition benchmark** — PROVES where the single CPU core goes
//! while you rotate a 3D graph, and that the gatling projection lane makes it multicore.
//!
//! The graph LAYOUT is frozen (cached) during rotation — it does NOT recompute. What
//! runs EVERY FRAME while the camera spins is the **per-node projection + paint**. This
//! bench spins the camera yaw across [`FRAMES`] frames for n ∈ {3k, 30k, 100k} and
//! measures the per-frame cost, **decomposed** so we can see the single core:
//!
//! (a) **projection** — the ortho Y-rotation of every node to screen+depth, timed
//! BOTH single-threaded (today's `.iter().map().collect()`) AND through the
//! gatling lane (`Graph3D::project_all`, one worker per core);
//! (b) **depth sort** — the back-to-front paint order;
//! (c) **paint / tessellation** — egui's CPU tessellation of the 30k orbs + edges
//! into meshes (the UI-thread cost when the wgpu instanced lane is NOT carrying
//! the draw — the argument for the GPU lane at scale);
//! (d) **total frame** = projection + sort + paint.
//!
//! For the projection it derives **CPU core-saturation** from `/proc/self/stat`
//! (`cpu_seconds / wall_seconds` = average cores busy across the spin) for BOTH lanes —
//! serial ≈ 1 core (the wall the owner saw), gatling ≈ all cores (the fix). This is a
//! measurement, not a claim (CORE-SATURATION LAW evidence).
//!
//! Run:
//! cargo run --release -p facett-graph3d --example rotate_bench --features wgpu
use std::time::Instant;
use egui::{Color32, Pos2, Shape, Stroke, epaint};
use facett_graph3d::{Graph3D, Layout3D, Msg};
/// Frames of camera spin measured per size (a full-ish yaw sweep).
const FRAMES: usize = 120;
/// Read this process's consumed CPU time (user + system) in seconds from
/// `/proc/self/stat` (fields 14 `utime` + 15 `stime`, in clock ticks). The delta across
/// a run divided by wall time is the average number of cores kept busy.
fn cpu_seconds() -> f64 {
let s = std::fs::read_to_string("/proc/self/stat").unwrap_or_default();
let rest = s.rsplit_once(')').map(|(_, r)| r).unwrap_or(&s);
let fields: Vec<&str> = rest.split_whitespace().collect();
let utime: u64 = fields.get(11).and_then(|v| v.parse().ok()).unwrap_or(0);
let stime: u64 = fields.get(12).and_then(|v| v.parse().ok()).unwrap_or(0);
(utime + stime) as f64 / 100.0 // _SC_CLK_TCK = 100 on this kernel
}
/// A deterministic sphere-shell graph of `n` nodes + a ring of edges (~1 edge/node).
/// Sphere layout is cheap + frozen — exactly the "cached layout, just rotating" case.
fn gen_graph(n: usize) -> Graph3D {
let nodes: Vec<(String, Color32)> = (0..n).map(|i| (format!("n{i}"), Color32::from_rgb(40, 200, 220))).collect();
let mut edges: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
for i in (0..n).step_by(9) {
edges.push((i, (i + 257) % n)); // a few long chords → real edge paint
}
Graph3D::new(nodes, edges, Layout3D::Sphere)
}
/// The SERIAL projection reference — the exact orthographic Y-rotation the single
/// -threaded `.iter().map().collect()` did (byte-identical kernel to `project_all`).
fn project_serial(g: &Graph3D, center: Pos2, scale: f32, yaw: f32) -> Vec<(Pos2, f32)> {
let (s, c) = yaw.sin_cos();
g.nodes
.iter()
.map(|n| {
let p = n.pos;
let x = p[0] * c + p[2] * s;
let z = -p[0] * s + p[2] * c;
(Pos2::new(center.x + x * scale, center.y - p[1] * scale), z)
})
.collect()
}
/// Tessellate the depth-sorted cloud (edges + node discs) into egui meshes — the
/// real per-frame CPU paint cost when egui draws the cloud (no GPU instanced lane).
fn tessellate_cloud(g: &Graph3D, proj: &[(Pos2, f32)], order: &[usize]) -> usize {
let mut tess = epaint::Tessellator::new(1.0, epaint::TessellationOptions::default(), [1024, 1024], vec![]);
let mut mesh = epaint::Mesh::default();
// Edges as line segments (faded by depth), matching the view's edge paint.
for &(a, b) in &g.edges {
if a < proj.len() && b < proj.len() {
tess.tessellate_shape(Shape::line_segment([proj[a].0, proj[b].0], Stroke::new(0.6_f32, Color32::from_gray(90))), &mut mesh);
}
}
// Nodes back-to-front, bigger+brighter when nearer — the view's circle_filled cloud.
for &i in order {
let (p, z) = proj[i];
let r = 3.0 + (z + 1.0) * 2.5;
tess.tessellate_shape(Shape::circle_filled(p, r, g.nodes[i].color), &mut mesh);
}
mesh.indices.len() / 3 // triangles tessellated this frame
}
/// The box this run actually happened on, read at RUNTIME.
///
/// It used to be the string literal `"Loki (Ryzen 9 7900, 12c/24t, 64GB, RTX 5080)"`,
/// and on 2026-09-05 that line printed itself on **oden** — a Threadripper PRO 3975WX
/// with 32 threads — in the same breath as a live `gatling workers = 32` that
/// contradicted its own "12c/24t". A number carries its box or it is not a number
/// (LAW 7), and a hardcoded box means every figure ever copied out of this bench was
/// attributed to hardware it may never have touched.
fn machine() -> String {
let host = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap_or_default().trim().to_string();
let cpu = std::fs::read_to_string("/proc/cpuinfo")
.unwrap_or_default()
.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split_once(':').map(|(_, v)| v.trim().to_string()))
.unwrap_or_else(|| "unknown cpu".into());
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
let host = if host.is_empty() { "unknown host".into() } else { host };
format!("{host} ({cpu}, {threads} threads)")
}
fn main() {
let workers = znippy_zoomies::gatling_forkjoin::default_workers();
println!("# facett-graph3d rotate/spin decomposition benchmark");
println!("machine = {} gatling workers = {workers} frames/size = {FRAMES}", machine());
println!("date = {}", std::process::Command::new("date").arg("-Is").output().ok()
.and_then(|o| String::from_utf8(o.stdout).ok()).map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".into()));
#[cfg(feature = "wgpu")]
println!("gpu_scene lane = compiled (wgpu) — CPU-side scene build (projection + instance buffers) timed below");
#[cfg(not(feature = "wgpu"))]
println!("gpu_scene lane = NOT compiled (build --features wgpu)");
println!();
println!(
"{:>8} {:>13} {:>13} {:>8} {:>10} {:>10} {:>9} {:>10} {:>11} {:>9}",
"n", "proj serial", "proj gatling", "speedup", "ser cores", "par cores", "sort ms", "paint ms", "total ms/f", "tris/f"
);
// A representative host pane (roughly what korp gives the 3D canvas).
let (w, h) = (1280.0f32, 800.0f32);
let center = Pos2::new(w * 0.5, h * 0.5);
let scale = w.min(h) * 0.40;
for &n in &[3_000usize, 30_000, 100_000] {
let mut g = gen_graph(n);
// Spin the camera across FRAMES yaws, timing each decomposed stage. Warm one
// frame first (allocation / first-touch), then measure.
let mut proj_serial_ms = 0.0f64;
let mut proj_gatling_ms = 0.0f64;
let mut sort_ms = 0.0f64;
let mut paint_ms = 0.0f64;
let mut tris = 0usize;
// (a2) serial projection lane — measure core saturation (expect ≈ 1 core).
let sc0 = cpu_seconds();
let s_t0 = Instant::now();
for f in 0..FRAMES {
let yaw = f as f32 * (std::f32::consts::TAU / FRAMES as f32);
let p = project_serial(&g, center, scale, yaw);
std::hint::black_box(&p);
}
proj_serial_ms += s_t0.elapsed().as_secs_f64() * 1000.0;
let ser_cores = (cpu_seconds() - sc0) / (proj_serial_ms / 1000.0).max(1e-9);
// (a1) gatling projection lane — measure core saturation (expect ≈ all cores).
let pc0 = cpu_seconds();
let g_t0 = Instant::now();
let mut last_proj = Vec::new();
for f in 0..FRAMES {
let yaw = f as f32 * (std::f32::consts::TAU / FRAMES as f32);
// NOTE: no `update(Msg::SetYaw)` here. It used to sit inside this timed loop
// and NOT inside the serial one above, so the two lanes were never timed on
// the same work. It is one float store — it does not explain the gap below —
// but an unfair comparison cannot be used to argue either way, so it is out.
last_proj = g.project_all(center, scale, yaw);
std::hint::black_box(&last_proj);
}
proj_gatling_ms += g_t0.elapsed().as_secs_f64() * 1000.0;
let par_cores = (cpu_seconds() - pc0) / (proj_gatling_ms / 1000.0).max(1e-9);
// (b) depth sort + (c) paint tessellation, per frame (on the parallel proj).
for f in 0..FRAMES {
let yaw = f as f32 * (std::f32::consts::TAU / FRAMES as f32);
let proj = g.project_all(center, scale, yaw);
let t_sort = Instant::now();
let mut order: Vec<usize> = (0..proj.len()).collect();
order.sort_by(|&i, &j| proj[i].1.partial_cmp(&proj[j].1).unwrap_or(std::cmp::Ordering::Equal));
sort_ms += t_sort.elapsed().as_secs_f64() * 1000.0;
let t_paint = Instant::now();
tris = tessellate_cloud(&g, &proj, &order);
paint_ms += t_paint.elapsed().as_secs_f64() * 1000.0;
}
let per = |ms: f64| ms / FRAMES as f64;
let proj_ser = per(proj_serial_ms);
let proj_par = per(proj_gatling_ms);
let sort = per(sort_ms);
let paint = per(paint_ms);
// Total per-frame with the PARALLEL projection lane (the shipped path).
let total_par = proj_par + sort + paint;
let speedup = proj_ser / proj_par.max(1e-9);
println!(
"{:>8} {:>10.3} ms {:>10.3} ms {:>7.2}x {:>9.1} {:>10.1} {:>7.3} {:>8.3} {:>9.3} {:>9}",
n, proj_ser, proj_par, speedup, ser_cores, par_cores, sort, paint, total_par, tris
);
// The GPU-lane CPU-side scene build (projection + instance buffers) — proves the
// wgpu lane also projects through the parallel `project_all` now, not one core.
#[cfg(feature = "wgpu")]
{
let mut scene_ms = 0.0f64;
let cc0 = cpu_seconds();
let t0 = Instant::now();
for f in 0..FRAMES {
let yaw = f as f32 * (std::f32::consts::TAU / FRAMES as f32);
g.update(Msg::SetYaw(yaw));
let sc = g.gpu_scene(w, h, 3.0);
std::hint::black_box(&sc);
}
scene_ms += t0.elapsed().as_secs_f64() * 1000.0;
let scene_cores = (cpu_seconds() - cc0) / (scene_ms / 1000.0).max(1e-9);
println!(
" gpu_scene() CPU-side build (projection+instances): {:.3} ms/frame ({:.1} cores busy)",
per(scene_ms), scene_cores
);
}
let _ = &last_proj;
}
println!();
println!("Read: `proj serial` is the single-threaded projection kernel; `proj gatling` is the");
println!("shipped `project_all`, which fans the SAME kernel across every core once the cloud");
println!("reaches PROJ_PARALLEL_MIN (2048 nodes).");
println!();
println!("`speedup` = serial / gatling, so ABOVE 1.00x the fan-out paid and BELOW 1.00x it cost.");
println!("Read that column before quoting `par cores`: cores kept busy is a measure of work");
println!("dispatched, not of work saved, and a fork/join that loses still lights up every core.");
println!("This read-out used to call `par cores`≈all 'the CORE-SATURATION proof' regardless of");
println!("what `speedup` said — a conclusion printed beside a number that could contradict it");
println!("and never did, because nobody read them together.");
println!();
println!("`paint ms` is egui's CPU tessellation of the whole cloud. When it dominates — and at");
println!("30k+ it dominates everything else by two orders of magnitude — the frame is the PAINT,");
println!("and the wgpu instanced lane (gpu_scene -> graph_gpu) is what carries the draw instead.");
println!("`gpu_scene()` below each row is the CPU-side cost of feeding that lane: the number a");
println!("host should budget when the GPU carries the cloud.");
}