facett-graph3d/benches/heavy_graph3d.rs
//! **HEAVY benchmark — the Graph3D cloud at scale.**
//!
//! "Fill it with more data. MUCH MORE." is a performance claim as much as a
//! content one, so the sizes it is asked to hold are measured rather than hoped
//! for. Three costs, and they have very different shapes:
//!
//! | arm | when it is paid | shape |
//! |---|---|---|
//! | `dataset/generate` | once, at construction | O(n), pure arithmetic |
//! | `layout/force` | once, at construction | O(n log n) per pass × passes — the startup wall |
//! | `frame/project_all` | EVERY FRAME while spinning | O(n), gatling fan-out over all cores |
//!
//! Only the third is in the frame budget; the first two decide how long a host
//! waits before the pane appears. Both are swept so a demo default can be chosen
//! from numbers instead of taste.
//!
//! Run: `cargo bench -p facett-graph3d`
use std::hint::black_box;
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use facett_graph3d::{dataset, Graph3D, Layout3D};
/// Node budgets swept. `GRAPH3D_BENCH_N` overrides (comma list).
fn sizes() -> Vec<usize> {
match std::env::var("GRAPH3D_BENCH_N") {
Ok(v) => v.split(',').filter_map(|s| s.trim().parse().ok()).collect(),
Err(_) => vec![10_000, 30_000, 100_000],
}
}
/// Building the dataset itself — the cheap part, but it must stay cheap because it
/// is what makes the size a knob rather than a fixture.
fn bench_generate(c: &mut Criterion) {
let mut g = c.benchmark_group("dataset");
for n in sizes() {
let spec = dataset::GraphSpec::sized(n);
g.throughput(Throughput::Elements(spec.node_count() as u64));
g.bench_with_input(BenchmarkId::new("generate", n), &spec, |b, spec| {
b.iter(|| black_box(dataset::generate(black_box(spec))).nodes.len())
});
}
g.finish();
}
/// The **force layout** — the one-off startup cost of turning the topology into a
/// volume. Barnes–Hut repulsion (parallel) plus a serial edge-attraction pass, over
/// the relaxation budget (`LARGE_GRAPH_ITERS` above `LARGE_GRAPH_ITER_N`).
fn bench_layout(c: &mut Criterion) {
let mut g = c.benchmark_group("layout");
g.sample_size(10);
for n in sizes() {
let d = dataset::generate(&dataset::GraphSpec::sized(n));
let count = d.nodes.len();
g.throughput(Throughput::Elements(count as u64));
g.bench_with_input(BenchmarkId::new("force", n), &n, |b, _| {
b.iter(|| {
let g = Graph3D::new(
black_box(d.nodes.clone()),
black_box(d.edges.clone()),
Layout3D::Force,
);
black_box(g.nodes.len())
})
});
}
g.finish();
}
/// **THE FRAME ARM.** `project_all` is what a spinning cloud pays every frame: the
/// yaw rotation + perspective divide for every node, fanned out over all cores
/// through the znippy gatling engine. This is the number that decides how big the
/// demo's default can be.
fn bench_frame_projection(c: &mut Criterion) {
let mut g = c.benchmark_group("frame");
for n in sizes() {
let d = dataset::generate(&dataset::GraphSpec::sized(n));
let count = d.nodes.len();
let graph = Graph3D::new(d.nodes, d.edges, Layout3D::Force);
let center = egui::pos2(800.0, 500.0);
g.throughput(Throughput::Elements(count as u64));
g.bench_with_input(BenchmarkId::new("project_all", n), &n, |b, _| {
b.iter(|| black_box(graph.project_all(center, 420.0, 0.7)).len())
});
}
g.finish();
}
/// The **GPU scene build** — lowering the projected cloud into instance buffers
/// (node discs + edge filaments). What the instanced lane pays per frame on the CPU
/// side before the draw call; the alternative is tessellating every disc, which is
/// the 33.75 ms/frame wall the instanced lane exists to remove.
#[cfg(feature = "wgpu")]
fn bench_gpu_scene(c: &mut Criterion) {
let mut g = c.benchmark_group("frame");
for n in sizes() {
let d = dataset::generate(&dataset::GraphSpec::sized(n));
let count = d.nodes.len();
let graph = Graph3D::new(d.nodes, d.edges, Layout3D::Force);
g.throughput(Throughput::Elements(count as u64));
g.bench_with_input(BenchmarkId::new("gpu_scene_at", n), &n, |b, _| {
b.iter(|| {
let s = black_box(graph.gpu_scene_at(1600.0, 1000.0, 1.0, 0.7));
black_box(s.nodes.len() + s.edges.len())
})
});
}
g.finish();
}
#[cfg(feature = "wgpu")]
criterion_group!(benches, bench_generate, bench_layout, bench_frame_projection, bench_gpu_scene, bench_frame_alloc);
#[cfg(not(feature = "wgpu"))]
criterion_group!(benches, bench_generate, bench_layout, bench_frame_projection, bench_frame_alloc);
criterion_main!(benches);
/// **What the per-frame ALLOCATION costs** — `project_all` vs `project_all_into`.
///
/// `frame/project_all` above measures wall time and is silent about allocation, which is
/// not the same as being free of it. `project_all` returns a fresh `Vec` every frame: at
/// 30 000 nodes that is 360 KB allocated and dropped per frame, roughly 21 MB/s of churn
/// at 60 fps, for a buffer whose length never changes.
///
/// `project_all_into` reuses a caller-owned buffer. It is not yet ZERO-allocation on the
/// parallel path — `gatling_for_each` returns a `Vec`, so the allocation moves inside and
/// is followed by a memcpy — and the honest question this arm answers is whether removing
/// the caller-side allocation is worth adding a `gatling_for_each_into` to the engine.
/// Measure first, then decide.
fn bench_frame_alloc(c: &mut Criterion) {
let mut g = c.benchmark_group("frame_alloc");
for &n in &sizes() {
let d = dataset::generate(&dataset::GraphSpec::sized(n));
let count = d.nodes.len();
let graph = Graph3D::new(d.nodes, d.edges, Layout3D::Force);
let center = egui::Pos2::new(400.0, 300.0);
g.throughput(Throughput::Elements(count as u64));
g.bench_with_input(BenchmarkId::new("allocating", count), &count, |b, _| {
b.iter(|| black_box(graph.project_all(black_box(center), 1.0, 0.7)).len())
});
// The buffer lives ACROSS iterations, which is the point — a spinning pane keeps
// it between frames.
let mut buf: Vec<(egui::Pos2, f32)> = Vec::new();
g.bench_with_input(BenchmarkId::new("into_buffer", count), &count, |b, _| {
b.iter(|| {
graph.project_all_into(black_box(center), 1.0, 0.7, &mut buf);
black_box(buf.len())
})
});
}
g.finish();
}