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

nordisk/facett

facett-graphnav/examples/force_bench.rs

//! **Force-layout two-lane benchmark** — PROVES the 30 000-node force layout is snappy.
//!
//! Measures a FULL Fruchterman–Reingold force layout (all [`FORCE_ITERS`] cooled
//! iterations, to convergence) for n ∈ {10k, 30k, 100k} across the three lanes:
//!
//!   (a) **serial** — today's baseline (parallel Barnes–Hut repulsion, but the
//!       attraction + integrate phases single-core), i.e. `force_directed(.., false)`;
//!   (b) **gatling** — the CPU lane: the whole step fanned across every core via
//!       znippy `gatling_for_each`, i.e. `force_directed(.., true)`;
//!   (c) **GPU** — the wgpu `cs_layout` compute lane iterating in VRAM (only when built
//!       `--features gpu` on a box with a usable adapter; else reported as skipped).
//!
//! For the gatling lane it also derives **CPU core-saturation** from `/proc/self/stat`:
//! `cpu_seconds / wall_seconds` = the average number of cores kept busy across the run
//! (the CORE-SATURATION LAW evidence — not a claim, a measurement).
//!
//! Run (CPU lanes only):
//!   cargo run --release --example force_bench
//! Run (all three lanes, needs a GPU):
//!   cargo run --release --features gpu --example force_bench

use std::time::Instant;

use facett_graphnav::layout::{force_directed, CpuLane, FORCE_ITERS};

/// Deterministic synthetic graph: `tris` triangles chained into a ring, plus a
/// deterministic long-range chord per triangle — ~1.7 edges/node, every node incident
/// (real attraction work), reproducible with no RNG.
fn gen_graph(n: usize) -> Vec<(usize, usize)> {
    let tris = n / 3;
    let mut edges = Vec::with_capacity(tris * 5);
    for t in 0..tris {
        let (a, b, c) = (t * 3, t * 3 + 1, t * 3 + 2);
        edges.push((a, b));
        edges.push((b, c));
        edges.push((c, a));
        edges.push((a, (a + 3) % n)); // chain to the next triangle
        // A deterministic long chord (hop by a golden-ish stride) → small-world links.
        let stride = 1 + (t * 2_654_435_761usize) % (n / 2).max(1);
        edges.push((b, (b + stride) % n));
    }
    edges
}

/// 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();
    // The comm field can contain spaces/parens; split after the final ')'.
    let rest = s.rsplit_once(')').map(|(_, r)| r).unwrap_or(&s);
    let fields: Vec<&str> = rest.split_whitespace().collect();
    // After ')': index 0 = state (field 3). utime = field 14 ⇒ index 11; stime ⇒ 12.
    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);
    let hz = 100.0; // _SC_CLK_TCK is 100 on this kernel
    (utime + stime) as f64 / hz
}

/// The CPU this run is actually on, read from `/proc/cpuinfo`. The header used to
/// hard-code one developer box, which then printed a false machine name on every
/// other box the benchmark ran on — a number is only evidence next to the hardware
/// that produced it.
fn cpu_model() -> String {
    let s = std::fs::read_to_string("/proc/cpuinfo").unwrap_or_default();
    let name = s
        .lines()
        .find_map(|l| l.strip_prefix("model name").and_then(|r| r.split_once(':')))
        .map(|(_, v)| v.trim().to_string())
        .unwrap_or_else(|| "unknown".to_string());
    let threads = s.lines().filter(|l| l.starts_with("processor")).count();
    format!("{name} ({threads} threads)")
}

fn time_ms<F: FnOnce() -> Vec<[f32; 2]>>(f: F) -> (f64, Vec<[f32; 2]>) {
    let t0 = Instant::now();
    let out = f();
    (t0.elapsed().as_secs_f64() * 1000.0, out)
}

fn main() {
    let workers = znippy_zoomies::gatling_forkjoin::default_workers();
    println!("# facett force-layout two-lane benchmark");
    println!("cpu = {}   gatling workers = {workers}   iters = {FORCE_ITERS}", cpu_model());
    #[cfg(feature = "gpu")]
    let gpu_iters = facett_graphnav::layout::FORCE_ITERS_GPU;
    // Create the wgpu device ONCE (a real wgpu host already holds a live device), so the
    // GPU column reflects the warm per-layout cost, not a per-call device init.
    #[cfg(feature = "gpu")]
    let gpu_dev = {
        let t0 = Instant::now();
        let d = facett_graphview::gpu_layout::compute_device();
        if let Some((_, _)) = &d {
            println!("GPU lane compiled = yes   one-time device init = {:.1}ms   gpu iters = {gpu_iters}", t0.elapsed().as_secs_f64() * 1000.0);
        } else {
            println!("GPU lane compiled = yes   adapter available = false (no usable device)");
        }
        d
    };
    #[cfg(not(feature = "gpu"))]
    println!("GPU lane compiled = no (build --features gpu on a box with an adapter to measure it)");
    println!();
    println!(
        "{:>8}  {:>10}  {:>10}  {:>8}  {:>12}  {:>10}  {:>10}",
        "n", "serial ms", "gatling ms", "speedup", "cores busy", "scatter ms", "gpu ms"
    );

    for &n in &[10_000usize, 30_000, 100_000] {
        let edges = gen_graph(n);

        // (a) single-core serial baseline
        let (serial_ms, serial_pos) = time_ms(|| force_directed(n, &edges, CpuLane::Serial));

        // (b) gatling CPU lane (gather attraction) — measure core saturation over the run.
        let c0 = cpu_seconds();
        let (gatling_ms, gatling_pos) = time_ms(|| force_directed(n, &edges, CpuLane::Gatling));
        let c1 = cpu_seconds();
        let cores_busy = (c1 - c0) / (gatling_ms / 1000.0).max(1e-9);
        let speedup = serial_ms / gatling_ms.max(1e-9);

        // (b2) scatter-reduce lane (attraction via the znippy gatling_reduce fold) — also
        // measure its core saturation, to prove the new fold primitive saturates cores.
        let s0 = cpu_seconds();
        let (scatter_ms, _) = time_ms(|| force_directed(n, &edges, CpuLane::GatlingScatter));
        let scatter_cores = (cpu_seconds() - s0) / (scatter_ms / 1000.0).max(1e-9);

        // Sanity: the two CPU lanes agree within tolerance (not part of timing).
        let max_err = serial_pos
            .iter()
            .zip(&gatling_pos)
            .map(|(s, g)| (s[0] - g[0]).abs().max((s[1] - g[1]).abs()))
            .fold(0.0f32, f32::max);

        // (c) GPU lane — warm device (VRAM iterate + readback, no per-call device init).
        #[cfg_attr(not(feature = "gpu"), allow(unused_mut))]
        let mut gpu_ms_s = String::from("  skipped");
        #[cfg(feature = "gpu")]
        {
            if let Some((device, queue)) = &gpu_dev {
                // Warm up once (shader compile / pipeline cache), then time.
                let _ = facett_graphview::gpu_layout::force_layout_gpu_on(device, queue, n, &edges, gpu_iters);
                let t0 = Instant::now();
                let _ = facett_graphview::gpu_layout::force_layout_gpu_on(device, queue, n, &edges, gpu_iters);
                gpu_ms_s = format!("{:.1}", t0.elapsed().as_secs_f64() * 1000.0);
            }
        }

        println!(
            "{:>8}  {:>10.1}  {:>10.1}  {:>7.2}x  {:>12.1}  {:>8.1} ({:.1}c)  {:>10}   (cpu-lane max_err={:.1e})",
            n, serial_ms, gatling_ms, speedup, cores_busy, scatter_ms, scatter_cores, gpu_ms_s, max_err
        );
    }
}