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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
147 changes: 147 additions & 0 deletions benches/real_corpus.rs
Original file line number Diff line number Diff line change
@@ -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<u32> {
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<usize> = if max_threads > 1 {
vec![1, max_threads]
} else {
vec![1]
};

let mut g: BenchmarkGroup<WallTime> = 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<WallTime> = 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);
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
91 changes: 91 additions & 0 deletions src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> {
distance_matrix(cch, metric, &[source], targets)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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());
}
}
Loading