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