Skip to content
Open
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
23 changes: 23 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,26 @@ versions, and Criterion configuration.

Memory is not reported until Roadrunner adopts a controlled allocator or profiler configuration.
An unavailable measurement is recorded explicitly rather than estimated.

## A* comparison

Run the Phase 6 comparison with:

```bash
cargo bench -p roadrunner-core --bench astar --locked
```

The benchmark builds deterministic geographic graphs with a directed eastbound backbone and
dead-end northern branches. Both algorithms return the same optimal backbone route. Dijkstra
finalizes the lower-cost dead ends before reaching the destination, while the scaled Haversine
heuristic lets A* exclude them from its search.

Cases cover 1K, 10K, and 100K nodes. Benchmark identifiers include graph size and observed
finalized-node count. Criterion measures the complete routing call, including A* heuristic
preparation; graph construction and correctness comparisons happen outside the timed closure.

See [`../docs/benchmarks.md`](../docs/benchmarks.md) for findings and the linked structured
result snapshot.

The initial result is
[`2026-09-04-apple-m3-astar-comparison.json`](results/2026-09-04-apple-m3-astar-comparison.json).
147 changes: 147 additions & 0 deletions benchmarks/results/2026-09-04-apple-m3-astar-comparison.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
{
"schema_version": 1,
"benchmark": "astar_comparison",
"recorded_at": "2026-09-04T07:09:59+01:00",
"hardware": {
"model": "MacBook Pro Mac15,3",
"cpu": "Apple M3",
"architecture": "arm64",
"cpu_cores": 8,
"performance_cores": 4,
"efficiency_cores": 4,
"memory_bytes": 17179869184
},
"software": {
"operating_system": "macOS 26.5 (25F71)",
"rustc": "1.96.0 (ac68faa20 2026-05-25)",
"cargo": "1.96.0 (30a34c682 2026-05-25)",
"criterion": "0.7.0",
"build_profile": "bench"
},
"dataset": {
"name": "geographic_backbone_with_northern_dead_ends",
"provenance": "deterministically generated by crates/roadrunner-core/benches/astar.rs",
"source_node": 0,
"destination": "last_backbone_node",
"backbone_node_fraction": 0.1,
"dead_end_node_fraction": 0.9,
"graph_construction_timed": false,
"correctness_check_timed": false
},
"algorithms": [
{
"name": "dijkstra",
"cost_model": "distance",
"heuristic": null,
"priority_queue": "std::collections::BinaryHeap",
"best_cost_storage": "std::collections::HashMap",
"predecessor_storage": "std::collections::HashMap"
},
{
"name": "astar",
"cost_model": "distance",
"heuristic": "Haversine scaled by the graph minimum cost per geodesic meter",
"heuristic_preparation_timed": true,
"priority_queue": "std::collections::BinaryHeap",
"best_cost_storage": "std::collections::HashMap",
"predecessor_storage": "std::collections::HashMap"
}
],
"configuration": {
"benchmark_invocations": 1,
"samples_per_case": 20,
"warm_up_seconds": 3.0,
"measurement_seconds": 5.0,
"percentile_method": "R type 7 linear interpolation over per-iteration sample times"
},
"results": [
{
"nodes": 1000,
"edges": 999,
"optimal_path_nodes": 100,
"same_optimal_path_and_cost": true,
"search_space_reduction_percent": 90.0,
"dijkstra": {
"visited_nodes": 1000,
"measured_route_iterations": 41790,
"latency_nanoseconds": {
"median": 120527.243,
"p95": 122810.305,
"p99": 139814.875
},
"memory_bytes": null
},
"astar": {
"visited_nodes": 100,
"measured_route_iterations": 28560,
"median_latency_overhead_percent": 44.3,
"latency_nanoseconds": {
"median": 173959.656,
"p95": 176030.169,
"p99": 176698.292
},
"memory_bytes": null
}
},
{
"nodes": 10000,
"edges": 9999,
"optimal_path_nodes": 1000,
"same_optimal_path_and_cost": true,
"search_space_reduction_percent": 90.0,
"dijkstra": {
"visited_nodes": 10000,
"measured_route_iterations": 4200,
"latency_nanoseconds": {
"median": 1242228.313,
"p95": 1261505.533,
"p99": 1296747.607
},
"memory_bytes": null
},
"astar": {
"visited_nodes": 1000,
"measured_route_iterations": 2940,
"median_latency_overhead_percent": 40.8,
"latency_nanoseconds": {
"median": 1748876.286,
"p95": 1774971.69,
"p99": 1784335.605
},
"memory_bytes": null
}
},
{
"nodes": 100000,
"edges": 99999,
"optimal_path_nodes": 10000,
"same_optimal_path_and_cost": true,
"search_space_reduction_percent": 90.0,
"dijkstra": {
"visited_nodes": 100000,
"measured_route_iterations": 420,
"latency_nanoseconds": {
"median": 17776201.563,
"p95": 18465051.255,
"p99": 18970599.537
},
"memory_bytes": null
},
"astar": {
"visited_nodes": 10000,
"measured_route_iterations": 420,
"median_latency_overhead_percent": 23.7,
"latency_nanoseconds": {
"median": 21987747.846,
"p95": 23876468.586,
"p99": 26653078.59
},
"memory_bytes": null
}
}
],
"memory_measurement": {
"status": "not_measured",
"reason": "No controlled allocator or profiler configuration is available yet."
}
}
4 changes: 4 additions & 0 deletions crates/roadrunner-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ harness = false
[[bench]]
name = "dijkstra"
harness = false

[[bench]]
name = "astar"
harness = false
167 changes: 167 additions & 0 deletions crates/roadrunner-core/benches/astar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//! A* and Dijkstra comparison over deterministic geographic graphs.
//!
//! Criterion records end-to-end search time, including A* heuristic preparation.
//! Graph construction and correctness checks happen outside timed closures.

use std::hint::black_box;

use criterion::{BenchmarkId, Criterion, Throughput};
use roadrunner_core::cost::{DistanceCost, RoutingContext};
use roadrunner_core::geo::{Coordinate, Seconds, haversine_distance};
use roadrunner_core::graph::{Edge, EdgeId, Graph, Node, NodeId};
use roadrunner_core::routing::{RouteResult, astar, dijkstra};

const BACKBONE_DIVISOR: u32 = 10;
const SAMPLE_SIZE: usize = 20;

fn coordinate(latitude: f64, longitude: f64) -> Coordinate {
match Coordinate::new(latitude, longitude) {
Ok(coordinate) => coordinate,
Err(error) => panic!("generated coordinate is invalid: {error}"),
}
}

fn seconds(value: f64) -> Seconds {
match Seconds::new(value) {
Ok(seconds) => seconds,
Err(error) => panic!("generated duration is invalid: {error}"),
}
}

fn add_node(graph: &mut Graph, id: u32, coordinate: Coordinate) {
let result = graph.add_node(Node::new(NodeId::new(id), coordinate));
assert!(
result.is_ok(),
"generated node insertion failed: {result:?}"
);
}

fn add_edge(graph: &mut Graph, id: u64, from: NodeId, to: NodeId) {
let from_coordinate = match graph.node(from) {
Some(node) => node.coordinate(),
None => panic!("generated source node {from} is missing"),
};
let to_coordinate = match graph.node(to) {
Some(node) => node.coordinate(),
None => panic!("generated destination node {to} is missing"),
};
let distance = haversine_distance(from_coordinate, to_coordinate);
let edge = Edge::new(
EdgeId::new(id),
from,
to,
distance,
seconds(distance.value()),
);
let result = graph.add_edge(edge);
assert!(
result.is_ok(),
"generated edge insertion failed: {result:?}"
);
}

fn generated_graph(node_count: u32) -> (Graph, NodeId) {
assert!(node_count >= BACKBONE_DIVISOR * 2);
let backbone_count = node_count / BACKBONE_DIVISOR;
let destination = NodeId::new(backbone_count - 1);
let mut graph = Graph::new();

for id in 0..backbone_count {
let longitude = f64::from(id) / f64::from(backbone_count - 1);
add_node(&mut graph, id, coordinate(0.0, longitude));
}
for id in backbone_count..node_count {
add_node(&mut graph, id, coordinate(0.25, 0.0));
}

let mut edge_id = 0_u64;
for from in 0..(backbone_count - 1) {
add_edge(
&mut graph,
edge_id,
NodeId::new(from),
NodeId::new(from + 1),
);
edge_id += 1;
}
for decoy in backbone_count..node_count {
add_edge(&mut graph, edge_id, NodeId::new(0), NodeId::new(decoy));
edge_id += 1;
}

(graph, destination)
}

fn dijkstra_route(graph: &Graph, destination: NodeId) -> RouteResult {
let result = dijkstra(
graph,
NodeId::new(0),
destination,
&DistanceCost,
&RoutingContext::new(),
);
match result {
Ok(route) => route,
Err(error) => panic!("generated graph Dijkstra search failed: {error}"),
}
}

fn astar_route(graph: &Graph, destination: NodeId) -> RouteResult {
let result = astar(
graph,
NodeId::new(0),
destination,
&DistanceCost,
&RoutingContext::new(),
);
match result {
Ok(route) => route,
Err(error) => panic!("generated graph A* search failed: {error}"),
}
}

fn astar_comparison(criterion: &mut Criterion) {
let mut group = criterion.benchmark_group("astar_comparison");
group.sample_size(SAMPLE_SIZE);

for node_count in [1_000_u32, 10_000, 100_000] {
let (graph, destination) = generated_graph(node_count);
let dijkstra_result = dijkstra_route(&graph, destination);
let astar_result = astar_route(&graph, destination);
assert_eq!(astar_result.total_cost(), dijkstra_result.total_cost());
assert_eq!(astar_result.path(), dijkstra_result.path());

group.throughput(Throughput::Elements(u64::from(node_count)));
let dijkstra_parameter = format!(
"{node_count}_nodes_{}_visited",
dijkstra_result.visited_nodes()
);
group.bench_with_input(
BenchmarkId::new("dijkstra_distance", dijkstra_parameter),
&graph,
|bencher, graph| {
bencher.iter(|| black_box(dijkstra_route(black_box(graph), destination)));
},
);

let astar_parameter = format!(
"{node_count}_nodes_{}_visited",
astar_result.visited_nodes()
);
group.bench_with_input(
BenchmarkId::new("astar_distance", astar_parameter),
&graph,
|bencher, graph| {
bencher.iter(|| black_box(astar_route(black_box(graph), destination)));
},
);
}

group.finish();
}

fn main() {
let mut criterion = Criterion::default().configure_from_args();
astar_comparison(&mut criterion);
criterion.final_summary();
}
8 changes: 8 additions & 0 deletions crates/roadrunner-core/src/graph/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ impl Graph {
.find(|edge| edge.id() == id)
}

/// Iterates over every directed edge in unspecified order.
///
/// Algorithms that expose deterministic results must apply their own stable
/// ordering rather than depend on this iterator's hash-map traversal order.
pub fn edges(&self) -> impl Iterator<Item = &Edge> {
self.outgoing.values().flat_map(|edges| edges.iter())
}

/// Returns the outgoing edges for a node in insertion order.
///
/// # Errors
Expand Down
Loading
Loading