Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
59 changes: 59 additions & 0 deletions benches/cch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> = std::env::var("CCH_BENCH_SIDE").ok().map_or_else(
|| vec![128u32, 256u32],
|s| {
s.split(',')
.filter_map(|x| x.trim().parse::<u32>().ok())
.collect()
},
);

let max_threads = rayon::current_num_threads();
let thread_counts: Vec<usize> = 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<f32> = (0..n).map(|v| (v / side) as f32).collect();
let lon: Vec<f32> = (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<WallTime> =
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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions docs/customize-parallel-scaling.md
Original file line number Diff line number Diff line change
@@ -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=<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.
Loading