Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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.");
}