diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ff3f4..d8fb484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this crate are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-07-14 + +### Added + +- `distance` — point-to-point shortest-path distance convenience wrapper. +- `distances_from` — one-to-many shortest-path distances convenience wrapper. +- Env-gated real-corpus benchmark (`benches/real_corpus.rs`, `CCH_CORPUS` / + `CCH_CORPUS_METRIC`) measuring customize parallel scaling and query latency on + a genuine road network. + ## [0.2.0] - 2026-07-09 ### Added @@ -50,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 distance / distance-matrix / shortest-path queries — validated bit-for-bit against RoutingKit. +[0.3.0]: https://github.com/Rodeapps/cch/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/Rodeapps/cch/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/Rodeapps/cch/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/Rodeapps/cch/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index dcbd3b9..0cf0817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cch" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" description = "Pure-Rust Customizable Contraction Hierarchies (CCH): build, customize in parallel, and serve fast shortest-path distance, many-to-many matrix, and path queries on road networks" @@ -30,6 +30,10 @@ criterion = { version = "0.5", features = ["html_reports"] } name = "cch" harness = false +[[bench]] +name = "real_corpus" +harness = false + [lints.clippy] all = "deny" pedantic = "warn" diff --git a/README.md b/README.md index fe7d048..6841ed6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It is a from-scratch Rust reimplementation of [RoutingKit](https://github.com/Ro ```toml [dependencies] -cch = "0.2" +cch = "0.3" ``` ## Quick start @@ -83,7 +83,7 @@ The expensive build is amortized across many cheap customizations, which is exac | Customize a metric | [`Cch::customize`](https://docs.rs/cch/latest/cch/struct.Cch.html#method.customize) → [`Metric`](https://docs.rs/cch/latest/cch/struct.Metric.html) | | Serialize / load | [`Cch::save_struct`](https://docs.rs/cch/latest/cch/struct.Cch.html#method.save_struct), [`Cch::load_struct`](https://docs.rs/cch/latest/cch/struct.Cch.html), [`Metric::save`](https://docs.rs/cch/latest/cch/struct.Metric.html#method.save) | | Open bundles (mmap) | [`CchBundle`](https://docs.rs/cch/latest/cch/struct.CchBundle.html), [`MetricBundle`](https://docs.rs/cch/latest/cch/struct.MetricBundle.html) | -| Query | [`distance_matrix`](https://docs.rs/cch/latest/cch/fn.distance_matrix.html), [`node_path`](https://docs.rs/cch/latest/cch/fn.node_path.html), [`ElimTreeQuery`](https://docs.rs/cch/latest/cch/struct.ElimTreeQuery.html) | +| Query | [`distance`](https://docs.rs/cch/latest/cch/fn.distance.html), [`distances_from`](https://docs.rs/cch/latest/cch/fn.distances_from.html), [`distance_matrix`](https://docs.rs/cch/latest/cch/fn.distance_matrix.html), [`node_path`](https://docs.rs/cch/latest/cch/fn.node_path.html), [`ElimTreeQuery`](https://docs.rs/cch/latest/cch/struct.ElimTreeQuery.html) | Unreachable distances are reported as [`cch::INF_WEIGHT`](https://docs.rs/cch/latest/cch/constant.INF_WEIGHT.html) (`2_147_483_647`). Queries take borrowed `CchView` / `MetricView`s, so you can query a freshly-built `Cch`/`Metric` in memory (`.view()`) or a memory-mapped bundle (`.view()` on `CchBundle`/`MetricBundle`) through the same functions. @@ -120,7 +120,7 @@ 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). +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). On a real road network — Albania, ~6.29M nodes — `customize` runs 366.87 ms single-threaded and 80.7 ms on 18 threads (~4.55×), a stronger scaling result than the synthetic grids above, consistent with real road networks having better separators than grids. ## Correctness diff --git a/benches/real_corpus.rs b/benches/real_corpus.rs new file mode 100644 index 0000000..f0c514c --- /dev/null +++ b/benches/real_corpus.rs @@ -0,0 +1,147 @@ +//! Real-corpus benchmark (opt-in via env vars — nothing is committed). +//! +//! Measures parallel customize scaling and query latency on a genuine road +//! network in the crate's own bundle format. Point it at a corpus, e.g.: +//! +//! `CCH_CORPUS`=~/workspace/osm-data/cch-artifacts/albania.cch-struct \ +//! `CCH_CORPUS_METRIC`=~/workspace/osm-data/cch-artifacts/albania.cch-metric-distance \ +//! cargo bench --bench `real_corpus` +//! +//! - `CCH_CORPUS` (a .cch-struct): enables the customize parallel-scaling bench. +//! - `CCH_CORPUS_METRIC` (a .cch-metric, optional): enables the query benches. +//! +//! When `CCH_CORPUS` is unset the whole bench registers no cases and exits. + +use std::path::{Path, PathBuf}; + +use criterion::measurement::WallTime; +use criterion::{ + BenchmarkGroup, BenchmarkId, Criterion, black_box, criterion_group, criterion_main, +}; + +/// Tiny deterministic LCG so sampled node ids are stable across runs. +fn lcg(state: &mut u64) -> u64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + *state +} + +/// A deterministic sample of `count` node ids in `0..node_count`. +#[allow( + clippy::cast_possible_truncation, + reason = "node id < node_count fits u32" +)] +fn sample_nodes(node_count: u32, count: usize, seed: u64) -> Vec { + let mut s = seed; + (0..count) + .map(|_| (lcg(&mut s) % u64::from(node_count)) as u32) + .collect() +} + +fn bench_customize_scaling(c: &mut Criterion, struct_path: &Path) { + let cch = cch::Cch::load_struct(struct_path).expect("load_struct CCH_CORPUS"); + let weights = vec![1u32; cch.input_arc_to_cch_arc.len()]; + let n = cch.node_count(); + let arcs = cch.input_arc_to_cch_arc.len(); + + let max_threads = rayon::current_num_threads(); + let thread_counts: Vec = if max_threads > 1 { + vec![1, max_threads] + } else { + vec![1] + }; + + let mut g: BenchmarkGroup = c.benchmark_group("real_corpus/customize"); + g.sample_size(10); + println!("real_corpus: {n} nodes, {arcs} input arcs"); + 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(); +} + +#[allow(clippy::many_single_char_names)] // s,t,n,i,c,g: conventional short names for a query bench +fn bench_queries(c: &mut Criterion, struct_path: &Path, metric_path: &Path) { + let bundle = cch::CchBundle::open(struct_path).expect("open CCH_CORPUS"); + let metric = cch::MetricBundle::open(metric_path).expect("open CCH_CORPUS_METRIC"); + let cv = bundle.view(); + let mv = metric.view(); + let n = cv.node_count(); + + let srcs = sample_nodes(n, 200, 0x1234_5678); + let tgts = sample_nodes(n, 1_000, 0x9abc_def0); + let matrix_nodes = sample_nodes(n, 100, 0x0f0f_0f0f); + + let mut g: BenchmarkGroup = c.benchmark_group("real_corpus/query"); + g.sample_size(20); + + g.bench_function("distance", |b| { + let mut i = 0usize; + b.iter(|| { + let s = srcs[i % srcs.len()]; + let t = tgts[i % tgts.len()]; + i += 1; + black_box(cch::distance(black_box(&cv), black_box(&mv), s, t)) + }); + }); + + g.bench_function("distances_from_1k", |b| { + let mut i = 0usize; + b.iter(|| { + let s = srcs[i % srcs.len()]; + i += 1; + black_box(cch::distances_from( + black_box(&cv), + black_box(&mv), + s, + black_box(&tgts), + )) + }); + }); + + g.bench_function("distance_matrix_100x100", |b| { + b.iter(|| { + black_box(cch::distance_matrix( + black_box(&cv), + black_box(&mv), + black_box(&matrix_nodes), + black_box(&matrix_nodes), + )) + }); + }); + + g.bench_function("node_path", |b| { + let mut i = 0usize; + b.iter(|| { + let s = srcs[i % srcs.len()]; + let t = tgts[i % tgts.len()]; + i += 1; + black_box(cch::node_path(black_box(&cv), black_box(&mv), s, t)) + }); + }); + + g.finish(); +} + +fn real_corpus(c: &mut Criterion) { + let Some(struct_path) = std::env::var_os("CCH_CORPUS").map(PathBuf::from) else { + println!("real_corpus: CCH_CORPUS unset — skipping (see file header to enable)."); + return; + }; + bench_customize_scaling(c, &struct_path); + + match std::env::var_os("CCH_CORPUS_METRIC").map(PathBuf::from) { + Some(metric_path) => bench_queries(c, &struct_path, &metric_path), + None => println!("real_corpus: CCH_CORPUS_METRIC unset — skipping query benches."), + } +} + +criterion_group!(benches, real_corpus); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs index 0a2f51d..50b943e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,5 +87,5 @@ pub use bundle::{CchBundle, CchView, MetricBundle, MetricView}; pub use customize::{Customizer, Metric}; pub use order::{degree_order, inertial_order}; pub use path::{PathQuery, node_path}; -pub use query::{ElimTreeQuery, distance_matrix}; +pub use query::{ElimTreeQuery, distance, distance_matrix, distances_from}; pub use structure::Cch; diff --git a/src/query.rs b/src/query.rs index 5373637..cde53c5 100644 --- a/src/query.rs +++ b/src/query.rs @@ -315,6 +315,39 @@ pub fn distance_matrix( out } +/// Point-to-point shortest-path distance from `source` to `target`. +/// +/// Returns [`INF_WEIGHT`] if `target` is unreachable, and `0` +/// when `source == target`. Convenience wrapper over [`distance_matrix`] with a +/// single source and target. +/// +/// # Panics +/// Panics if `source` or `target` is `>= node_count` (same as [`distance_matrix`]). +#[must_use] +pub fn distance(cch: &CchView, metric: &MetricView, source: u32, target: u32) -> u32 { + distance_matrix(cch, metric, &[source], &[target])[0] +} + +/// One-to-many shortest-path distances from `source` to each of `targets`. +/// +/// Returns one entry per target, in input order; [`INF_WEIGHT`] +/// for unreachable targets. Returns an empty `Vec` when `targets` is empty. +/// Convenience wrapper over [`distance_matrix`] with a single source (which pins +/// the target set once and runs one forward search — the optimal one-to-many path). +/// +/// # Panics +/// Panics if `source` or any target is `>= node_count` (same as [`distance_matrix`]). +/// An empty `targets` slice returns an empty `Vec` without checking `source`. +#[must_use] +pub fn distances_from( + cch: &CchView, + metric: &MetricView, + source: u32, + targets: &[u32], +) -> Vec { + distance_matrix(cch, metric, &[source], targets) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -588,4 +621,62 @@ mod tests { q.get_distances_to_targets(&mut out); assert_eq!(out[0], crate::INF_WEIGHT); } + + // --------------------------------------------------------------------------- + // distance / distances_from + // --------------------------------------------------------------------------- + + /// Small fixture for the `distance` / `distances_from` wrapper tests: a + /// pure-Rust (no C++ oracle) directed 4-node path 0->1->2->3 with + /// unit-weight arcs, built the same way as the crate-level doc example. + /// Because only the forward direction has arcs, the reverse direction + /// (e.g. 3->0) is unreachable — giving an `INF_WEIGHT` case for free. + fn build_query_fixture() -> (crate::Cch, crate::Metric) { + use crate::graph::Graph; + use crate::order::degree_order; + + let graph = Graph { + first_out: vec![0, 1, 2, 3, 3], + head: vec![1, 2, 3], + weight: vec![1, 1, 1], + }; + let order = degree_order(&graph); + let cch = crate::Cch::build(&graph, &order); + let metric = cch.customize(&graph.weight); + (cch, metric) + } + + #[test] + fn distance_matches_matrix_and_self_is_zero() { + // Build the same small fixture the distance_matrix tests use. + let (cch, metric) = build_query_fixture(); + let cv = cch.view(); + let mv = metric.view(); + + // point-to-point equals the 1x1 matrix cell + for &(s, t) in &[(0u32, 3u32), (3, 0), (1, 2)] { + let expected = distance_matrix(&cv, &mv, &[s], &[t])[0]; + assert_eq!(distance(&cv, &mv, s, t), expected); + } + // self-distance is zero + assert_eq!(distance(&cv, &mv, 2, 2), 0); + + // reverse direction has no arcs in the fixture -> unreachable. + assert_eq!(distance(&cv, &mv, 3, 0), INF_WEIGHT); + } + + #[test] + fn distances_from_matches_matrix_row_and_handles_empty() { + let (cch, metric) = build_query_fixture(); + let cv = cch.view(); + let mv = metric.view(); + + let targets = [0u32, 1, 2, 3]; + let expected = distance_matrix(&cv, &mv, &[0], &targets); // single-source row + assert_eq!(distances_from(&cv, &mv, 0, &targets), expected); + assert_eq!(distances_from(&cv, &mv, 0, &targets).len(), targets.len()); + + // empty targets -> empty vec (matches distance_matrix) + assert!(distances_from(&cv, &mv, 0, &[]).is_empty()); + } }