facett-graph3d/examples/split_probe.rs
//! **Where does the projection time actually go?** — five lanes, same kernel, same data.
//!
//! `rotate_bench` proved the shipped `gatling_for_each` fan-out LOSES to the serial map
//! by 45-400x. It could not say WHY, so it could not say what to do instead. This probe
//! separates the three candidate costs:
//!
//! 1. **grain** — `gatling_for_each` claims ONE index per contended `fetch_add`, so a
//! 100k projection pays 100 000 atomics on one cache line across every core.
//! 2. **spawn** — `std::thread::scope` + `s.spawn` creates fresh OS threads on EVERY
//! call. There is no pool. That cost is paid whatever the grain.
//! 3. **layout** — `project_pt(nodes[i].pos, ..)` reads 12 useful bytes out of a
//! `Node3` (String + Color32 + [f32;3]), so it strides a struct it mostly ignores.
//!
//! The lanes isolate them: `static split` keeps the threads but removes the per-item
//! atomic (grain = n/workers, contiguous, zero atomics in the loop); the `SoA` lanes
//! feed the same kernel from a packed `Vec<[f32;3]>` instead of the node array.
//!
//! Run: cargo run --release -p facett-graph3d --example split_probe
use std::time::Instant;
use egui::Pos2;
use facett_graph3d::{dataset, Graph3D, Layout3D};
const FRAMES: usize = 120;
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 t = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
format!("{host} ({cpu}, {t} threads)")
}
/// The kernel, spelled once. Byte-identical to `facett_graph3d`'s `project_pt`.
#[inline]
fn project_pt(p: [f32; 3], center: Pos2, scale: f32, s: f32, c: f32) -> (Pos2, f32) {
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)
}
fn built(n: usize) -> Graph3D {
let d = dataset::generate(&dataset::GraphSpec::sized(n));
Graph3D::new(d.nodes, d.edges, Layout3D::Force)
}
/// **STATIC CONTIGUOUS SPLIT** — the split a fixed-stride array actually wants: carve
/// `0..n` into `workers` contiguous ranges, hand one to each worker, **zero atomics in
/// the loop**. No border scan is needed because every index of a `Vec<Fixed>` is already
/// a valid boundary (that machinery is for variable-length records in a byte stream).
fn project_static_split(pos: &[[f32; 3]], center: Pos2, scale: f32, yaw: f32, workers: usize) -> Vec<(Pos2, f32)> {
let (s, c) = yaw.sin_cos();
let n = pos.len();
let mut out: Vec<(Pos2, f32)> = vec![(Pos2::ZERO, 0.0); n];
if workers <= 1 {
for (o, p) in out.iter_mut().zip(pos) {
*o = project_pt(*p, center, scale, s, c);
}
return out;
}
let chunk = n.div_ceil(workers);
std::thread::scope(|sc| {
for (pi, oi) in pos.chunks(chunk).zip(out.chunks_mut(chunk)) {
sc.spawn(move || {
for (o, p) in oi.iter_mut().zip(pi) {
*o = project_pt(*p, center, scale, s, c);
}
});
}
});
out
}
fn main() {
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
println!("# facett-graph3d split probe — where the projection time goes");
println!("machine = {} workers = {workers} frames/size = {FRAMES}", machine());
println!();
println!(
"{:>8} {:>12} {:>12} {:>13} {:>14} {:>14} {:>12}",
"n", "serial AoS", "serial SoA", "gatling(g=1)", "static split", "POOLED", "bare spawn"
);
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 g = built(n);
// The SoA lane: positions packed contiguously, 12 bytes per node, nothing else.
let soa: Vec<[f32; 3]> = g.nodes.iter().map(|nd| nd.pos).collect();
let yaws: Vec<f32> = (0..FRAMES).map(|f| f as f32 * (std::f32::consts::TAU / FRAMES as f32)).collect();
let per = |ms: f64| ms / FRAMES as f64;
// 1) serial over the NODE array (today's serial arm).
let t = Instant::now();
for &yaw in &yaws {
let (s, c) = yaw.sin_cos();
let v: Vec<(Pos2, f32)> = g.nodes.iter().map(|nd| project_pt(nd.pos, center, scale, s, c)).collect();
std::hint::black_box(&v);
}
let ser_aos = per(t.elapsed().as_secs_f64() * 1000.0);
// 2) serial over the PACKED positions (same kernel, 12 bytes/node instead of ~40).
let t = Instant::now();
for &yaw in &yaws {
let (s, c) = yaw.sin_cos();
let v: Vec<(Pos2, f32)> = soa.iter().map(|p| project_pt(*p, center, scale, s, c)).collect();
std::hint::black_box(&v);
}
let ser_soa = per(t.elapsed().as_secs_f64() * 1000.0);
// 3) the SHIPPED lane: gatling grain-1.
let t = Instant::now();
for &yaw in &yaws {
let v = g.project_all(center, scale, yaw);
std::hint::black_box(&v);
}
let gat = per(t.elapsed().as_secs_f64() * 1000.0);
// 4/5) static contiguous split — over the node array, then over packed positions.
let aos: Vec<[f32; 3]> = g.nodes.iter().map(|nd| nd.pos).collect(); // same bytes; lane 4 vs 5 differ only in source below
let t = Instant::now();
for &yaw in &yaws {
let v = project_static_split(&aos, center, scale, yaw, workers);
std::hint::black_box(&v);
}
let st_aos = per(t.elapsed().as_secs_f64() * 1000.0);
let t = Instant::now();
for &yaw in &yaws {
let v = project_static_split(&soa, center, scale, yaw, workers);
std::hint::black_box(&v);
}
let st_soa = per(t.elapsed().as_secs_f64() * 1000.0);
// 6b) THE PARKED POOL: workers spawned ONCE, outside the loop, woken per frame.
// Same static contiguous split as lane 4/5 — the only thing removed is the
// per-call thread creation.
let pool = znippy_zoomies::gatling_map::Pool::new(0);
let mut out: Vec<(Pos2, f32)> = vec![(Pos2::ZERO, 0.0); soa.len()];
let t = Instant::now();
for &yaw in &yaws {
let (sy, cy) = yaw.sin_cos();
pool.map_slice(&soa, &mut out, |p| project_pt(*p, center, scale, sy, cy));
std::hint::black_box(&out);
}
let pooled = per(t.elapsed().as_secs_f64() * 1000.0);
// 6) THE FLOOR: spawn `workers` threads that do NOTHING and join. Whatever this
// costs, no per-call fork/join lane can beat it — the price of the threads alone.
let t = Instant::now();
for _ in 0..FRAMES {
std::thread::scope(|sc| {
for _ in 0..workers {
sc.spawn(|| std::hint::black_box(0u8));
}
});
}
let spawn = per(t.elapsed().as_secs_f64() * 1000.0);
let _ = st_aos;
println!(
"{:>8} {:>9.3} ms {:>9.3} ms {:>10.3} ms {:>11.3} ms {:>11.3} ms {:>9.3} ms",
n, ser_aos, ser_soa, gat, st_soa, pooled, spawn
);
}
println!();
println!("`bare spawn` is the FLOOR for any lane that creates threads per call: N threads");
println!("spawned and joined doing nothing. A fan-out can only pay if the serial column");
println!("exceeds it — otherwise the threads cost more than the whole job, whatever the split.");
}