From 564725d29a65c3ee172939aef4b4c1373699fec8 Mon Sep 17 00:00:00 2001 From: VladPr <6140568+VladPr@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:05:20 +0300 Subject: [PATCH] bench(customize): large-grid parallel scaling ladder + conclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in large-grid customize bench (CCH_BENCH_SIDE selects sizes; builds each CCH once and times customize under a 1-thread vs all-cores rayon pool). Measured on 18 cores: ~1.0x at 16k nodes, 1.85x at 65k, 2.79x at 656k — parallel customization is a large-graph win, sub-linear in cores, no small-graph regression. Full method/numbers in docs/customize-parallel-scaling.md; README performance section updated with the measured scaling. --- README.md | 2 + benches/cch.rs | 59 ++++++++++++++++++++++++++ docs/customize-parallel-scaling.md | 68 ++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 docs/customize-parallel-scaling.md diff --git a/README.md b/README.md index f0d084b..fe7d048 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ Every operation is at parity with (or faster than) RoutingKit. The query paths r A `customize_reuse` bench compares a fresh `Cch::customize` per call against a reused `Customizer::customize_into` on the same grid; the reused path avoids re-deriving the level partition and re-allocating output buffers, so it is never slower and is typically a few percent faster (`cargo bench --bench cch -- customize` to reproduce). The gain grows with structure size and call frequency; on this modest 24×24 fixture the parallel overhead largely offsets the savings. +Parallel customization is a **large-graph** win: on nested-dissection-ordered grids, `customize` runs ~1.85× faster at 65k nodes and ~2.8× at 656k nodes on 18 cores, with no benefit — and no measurable regression — below ~tens of thousands of nodes. The efficiency is sub-linear in cores: level-synchronized customization has a barrier per elimination-tree level, and the top of the hierarchy has too few nodes to fill many cores. Grids are a pessimistic proxy (worse separators than real road networks), so treat these as a lower bound. Full method and numbers: [docs/customize-parallel-scaling.md](docs/customize-parallel-scaling.md). + ## Correctness The reference C++ RoutingKit is vendored as a **dev-only** differential-test oracle, and each stage of the pipeline is gated against it: diff --git a/benches/cch.rs b/benches/cch.rs index b84208d..feb2426 100644 --- a/benches/cch.rs +++ b/benches/cch.rs @@ -217,6 +217,64 @@ fn bench_customize_reuse(c: &mut Criterion) { g.finish(); } +// --------------------------------------------------------------------------- +// Bench: customize on LARGE grids (parallel scaling). Run with +// RAYON_NUM_THREADS=1 vs all cores to isolate the parallel speedup. Uses +// inertial_order (nested dissection) for a realistic, low-fill-in order. +// --------------------------------------------------------------------------- + +#[allow( + clippy::cast_precision_loss, + reason = "grid coords are exact in f32 at these sizes" +)] +fn bench_customize_large(c: &mut Criterion) { + // Grid sides come from CCH_BENCH_SIDE (comma-separated) so a plain + // `cargo bench` stays fast and OOM-free; the big sizes build a large CCH + // (slow inertial_order + lots of RAM) and are opt-in, e.g.: + // CCH_BENCH_SIDE=810 cargo bench --bench cch -- customize_large + // Node counts: 128->16k, 256->65k, 810->656k, 2561->6.56M, 8101->65.6M. + let sides: Vec = std::env::var("CCH_BENCH_SIDE").ok().map_or_else( + || vec![128u32, 256u32], + |s| { + s.split(',') + .filter_map(|x| x.trim().parse::().ok()) + .collect() + }, + ); + + let max_threads = rayon::current_num_threads(); + let thread_counts: Vec = if max_threads > 1 { + vec![1, max_threads] + } else { + vec![1] + }; + + for &side in &sides { + let (n, tail, head, weights) = make_grid(side, side); + let graph = csr_from_arcs(n, &tail, &head); + let lat: Vec = (0..n).map(|v| (v / side) as f32).collect(); + let lon: Vec = (0..n).map(|v| (v % side) as f32).collect(); + let order = cch::inertial_order(n, &tail, &head, &lat, &lon); + let cch = cch::Cch::build(&graph, &order); + + let mut g: BenchmarkGroup = + c.benchmark_group(format!("customize_large/{side}x{side}")); + g.sample_size(10); + // Build the CCH once; time customize under a single-thread and an + // all-cores rayon pool to isolate the parallel speedup. + for &threads in &thread_counts { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("build rayon pool"); + g.bench_function(BenchmarkId::new("threads", threads), |b| { + b.iter(|| pool.install(|| black_box(cch.customize(black_box(&weights))))); + }); + } + g.finish(); + } +} + // --------------------------------------------------------------------------- // Bench: distance_matrix (all 576 nodes as sources AND targets = 576×576) // --------------------------------------------------------------------------- @@ -539,6 +597,7 @@ criterion_group!( bench_build, bench_customize, bench_customize_reuse, + bench_customize_large, bench_distance_matrix, bench_node_path, bench_path_query, diff --git a/docs/customize-parallel-scaling.md b/docs/customize-parallel-scaling.md new file mode 100644 index 0000000..6a194f1 --- /dev/null +++ b/docs/customize-parallel-scaling.md @@ -0,0 +1,68 @@ +# Parallel customization — scaling benchmark + +Measures how `Cch::customize` (the parallel, rayon-based customization added in +0.2.0) scales with graph size and core count. The goal is to answer honestly: +*does the parallelism actually help, and from what size?* + +## Method + +- Bidirectional square grids of increasing size, ordered with `inertial_order` + (nested dissection) for a realistic, low-fill-in hierarchy. +- Each CCH is built once; `customize` is then timed under a **1-thread** and an + **all-cores** rayon pool (`ThreadPoolBuilder`), so the only variable is core + count. Criterion, `--warm-up-time 2 --measurement-time 4`, `sample_size(10)`. +- Hardware: Apple Silicon, 18 logical cores. Numbers are machine-specific — run + `CCH_BENCH_SIDE= cargo bench --bench cch -- customize_large` to reproduce. + +## Results + +| Grid | Nodes | 1 thread | 18 threads | Speedup | +|--------|------------|-------------|------------|---------| +| 128² | 16,384 | 20.2 ms | 20.7 ms | ~1.0× (none) | +| 256² | 65,536 | 145.8 ms | 78.7 ms | 1.85× | +| 810² | 656,100 | 4.77 s | 1.71 s | 2.79× | +| 2561² | 6,558,721 | ~165 s/iter*| not measured | — | +| 8101² | 65,626,201 | not attempted | — | — | + +\* Single-thread per-iteration cost taken from criterion's own estimate; the run +was stopped before completion because the process was ~12.5 GB resident and the +machine was swapping. 66M nodes (~125 GB projected) is infeasible on this box. + +## Conclusions + +1. **The parallel speedup grows with graph size, but only pays off at scale.** + No gain at 16k nodes (parallel == serial within noise), 1.85× at 65k, 2.79× + at 656k. Below ~tens of thousands of nodes, the per-level barrier plus rayon + dispatch overhead roughly cancels the work — so parallelism is not a blanket + win, it is a large-graph win. + +2. **Efficiency is low and stays low** — ~15% (2.79× / 18 cores) at 656k. This is + inherent to level-synchronized customization: the upper elimination-tree + levels contain very few nodes, so most cores sit idle there, and there is a + barrier per level. This matches the known behavior of RoutingKit's parallel + customization; it is not a defect in the implementation. + +3. **No regression on small graphs.** At 16k nodes the parallel path is within + noise of single-threaded, so small-graph users pay nothing measurable for the + always-on parallelism. + +4. **Single-thread cost scales ~super-linearly on grids** (≈ n^1.5): 146 ms → + 4.77 s → ~165 s for 65k → 656k → 6.6M (roughly ×33 per 10× nodes). Grids have + much worse separators than real road networks, so this is a *pessimistic* + proxy — a real continental road graph (near-planar, good separators) should + scale closer to n·log n, with better absolute times and likely better + parallel efficiency. These grid numbers bound the bad case, not the expected + road-network case. + +5. **Practical ceiling on this hardware:** 656k is comfortable; 6.6M runs but is + memory-bound (~12.5 GB, swapping); 66M is out of reach. A true + continental-scale figure needs a larger-memory machine and, better, the real + road corpus — see the road-network benchmark follow-up (improvement #3). + +## Implication for the 0.2.0 "parallel customization" claim + +Present it as what it is: a **real but modest win that materializes at scale** +(≈1.85× at 65k, ≈2.8× at 656k on 18 cores), negligible on small graphs, and +sub-linear in cores. Avoid implying a blanket or near-linear speedup. The +headline value for a routing service is that customization of a large region +gets ~2–3× faster on a many-core host while small regions are unaffected.