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