Skip to content

Commit d5ed740

Browse files
committed
feat: add benchmarking infrastructure for Moving AI datasets
The project now includes: - mapf-bench crate: A tool for running multi-agent negotiation benchmarks on Moving AI grid maps. - scripts/benchmark.py: An automated script that downloads maps/scenarios, runs benchmarks for 2-50 agents, and generates reports with strict timeouts. - .gitignore update: The cache/ directory used for benchmarking data is now ignored. Generated-by: Gemini-CLI Signed-off-by: Arjo Chakravarty <arjoc@intrinsic.ai>
1 parent 6f0970f commit d5ed740

6 files changed

Lines changed: 377 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
/target
22
Cargo.lock
3+
cache/
4+
benchmark_report.json

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
members = [
33
"mapf",
44
"mapf-viz",
5+
"mapf-bench",
56
]
67

78
resolver = "2"

mapf-bench/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "mapf-bench"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
mapf = { path = "../mapf" }
8+
movingai = "0.2"
9+
anyhow = "1.0"
10+
clap = { version = "4.0", features = ["derive"] }
11+
serde = { version = "1.0", features = ["derive"] }
12+
serde_json = "1.0"
13+
indicatif = "0.17"

mapf-bench/src/main.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
mod movingai;
2+
3+
use anyhow::Result;
4+
use clap::Parser;
5+
use std::path::PathBuf;
6+
use std::time::Instant;
7+
8+
#[derive(Parser, Debug)]
9+
#[command(author, version, about, long_about = None)]
10+
struct Args {
11+
#[arg(short, long)]
12+
map: PathBuf,
13+
14+
#[arg(short, long)]
15+
scen: PathBuf,
16+
17+
#[arg(short, long, default_value_t = 0.45)]
18+
radius: f64,
19+
20+
#[arg(short, long, default_value_t = 1.0)]
21+
speed: f64,
22+
23+
#[arg(short, long, default_value_t = 10)]
24+
num_agents: usize,
25+
26+
#[arg(short, long, default_value_t = 30)]
27+
timeout: u64,
28+
}
29+
30+
fn main() -> Result<()> {
31+
let args = Args::parse();
32+
33+
println!("Loading map: {:?}", args.map);
34+
let map = movingai::Map::from_file(&args.map)?;
35+
36+
println!("Loading scenario: {:?}", args.scen);
37+
let scenario = movingai::MovingAIScenario::from_file(&args.scen)?;
38+
39+
let negotiation_scenario = scenario.to_negotiation_scenario(
40+
&map,
41+
args.num_agents,
42+
args.radius,
43+
args.speed,
44+
60_f64.to_radians(),
45+
);
46+
47+
println!("Running negotiation with {} agents", args.num_agents);
48+
let start_time = Instant::now();
49+
// We don't have a clean way to pass wall-clock timeout into negotiate yet,
50+
// so we'll rely on the parent script to enforce strict timeouts for now,
51+
// or we could add a QueueLengthLimit as a proxy.
52+
let result = mapf::negotiation::negotiate(&negotiation_scenario, None);
53+
let duration = start_time.elapsed();
54+
55+
match result {
56+
Ok((_solution, _arena, _name_map)) => {
57+
println!("Negotiation successful in {:?}", duration);
58+
}
59+
Err(e) => {
60+
println!("Negotiation failed: {:?}", e);
61+
}
62+
}
63+
64+
Ok(())
65+
}

mapf-bench/src/movingai.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use std::fs::File;
2+
use std::io::{BufRead, BufReader};
3+
use std::path::Path;
4+
use anyhow::Result;
5+
use std::collections::HashMap;
6+
7+
use mapf::negotiation::{Agent, Scenario};
8+
use std::collections::BTreeMap;
9+
10+
pub struct Map {
11+
pub width: usize,
12+
pub height: usize,
13+
pub grid: Vec<Vec<char>>,
14+
}
15+
16+
impl Map {
17+
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
18+
let file = File::open(path)?;
19+
let reader = BufReader::new(file);
20+
let mut lines = reader.lines();
21+
22+
let mut width = 0;
23+
let mut height = 0;
24+
25+
// Parse header
26+
while let Some(line) = lines.next() {
27+
let line = line?;
28+
if line.starts_with("type") {
29+
continue;
30+
} else if line.starts_with("height") {
31+
height = line.split_whitespace().nth(1).unwrap().parse()?;
32+
} else if line.starts_with("width") {
33+
width = line.split_whitespace().nth(1).unwrap().parse()?;
34+
} else if line.starts_with("map") {
35+
break;
36+
}
37+
}
38+
39+
let mut grid = Vec::with_capacity(height);
40+
for _ in 0..height {
41+
if let Some(line) = lines.next() {
42+
let line = line?;
43+
grid.push(line.chars().collect());
44+
}
45+
}
46+
47+
Ok(Map { width, height, grid })
48+
}
49+
50+
pub fn to_occupancy_map(&self) -> HashMap<i64, Vec<i64>> {
51+
let mut occupancy = HashMap::new();
52+
for y in 0..self.height {
53+
let mut row = Vec::new();
54+
for x in 0..self.width {
55+
let c = self.grid[y][x];
56+
if c != '.' && c != 'G' && c != 'S' {
57+
row.push(x as i64);
58+
}
59+
}
60+
if !row.is_empty() {
61+
occupancy.insert(y as i64, row);
62+
}
63+
}
64+
occupancy
65+
}
66+
}
67+
68+
pub struct ScenarioEntry {
69+
pub bucket: usize,
70+
pub map_file: String,
71+
pub map_width: usize,
72+
pub map_height: usize,
73+
pub start_x: usize,
74+
pub start_y: usize,
75+
pub goal_x: usize,
76+
pub goal_y: usize,
77+
pub optimal_length: f64,
78+
}
79+
80+
pub struct MovingAIScenario {
81+
pub entries: Vec<ScenarioEntry>,
82+
}
83+
84+
impl MovingAIScenario {
85+
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
86+
let file = File::open(path)?;
87+
let reader = BufReader::new(file);
88+
let mut lines = reader.lines();
89+
90+
// Skip version line
91+
lines.next();
92+
93+
let mut entries = Vec::new();
94+
for line in lines {
95+
let line = line?;
96+
let parts: Vec<&str> = line.split_whitespace().collect();
97+
if parts.len() < 9 {
98+
continue;
99+
}
100+
101+
entries.push(ScenarioEntry {
102+
bucket: parts[0].parse()?,
103+
map_file: parts[1].to_string(),
104+
map_width: parts[2].parse()?,
105+
map_height: parts[3].parse()?,
106+
start_x: parts[4].parse()?,
107+
start_y: parts[5].parse()?,
108+
goal_x: parts[6].parse()?,
109+
goal_y: parts[7].parse()?,
110+
optimal_length: parts[8].parse()?,
111+
});
112+
}
113+
114+
Ok(MovingAIScenario { entries })
115+
}
116+
117+
pub fn to_negotiation_scenario(
118+
&self,
119+
map: &Map,
120+
num_agents: usize,
121+
radius: f64,
122+
speed: f64,
123+
spin: f64,
124+
) -> Scenario {
125+
let mut agents = BTreeMap::new();
126+
for i in 0..num_agents.min(self.entries.len()) {
127+
let entry = &self.entries[i];
128+
agents.insert(
129+
format!("agent_{}", i),
130+
Agent {
131+
start: [entry.start_x as i64, entry.start_y as i64],
132+
yaw: 0.0,
133+
goal: [entry.goal_x as i64, entry.goal_y as i64],
134+
radius,
135+
speed,
136+
spin,
137+
},
138+
);
139+
}
140+
141+
Scenario {
142+
agents,
143+
obstacles: Vec::new(),
144+
occupancy: map.to_occupancy_map(),
145+
cell_size: 1.0,
146+
camera_bounds: None,
147+
}
148+
}
149+
}

scripts/benchmark.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import subprocess
5+
import zipfile
6+
import urllib.request
7+
import json
8+
import time
9+
import argparse
10+
from pathlib import Path
11+
12+
# Configuration
13+
CACHE_DIR = Path("cache")
14+
MAPS_ZIP_URL = "https://www.movingai.com/benchmarks/mapf/mapf-map.zip"
15+
SCEN_ZIP_URL = "https://www.movingai.com/benchmarks/mapf/mapf-scen-random.zip"
16+
DEFAULT_AGENT_COUNTS = [2, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]
17+
18+
def download_file(url, dest):
19+
if dest.exists():
20+
return
21+
print(f"Downloading {url} to {dest}...")
22+
urllib.request.urlretrieve(url, dest)
23+
24+
def unzip_file(zip_path, extract_to):
25+
print(f"Unzipping {zip_path} to {extract_to}...")
26+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
27+
zip_ref.extractall(extract_to)
28+
29+
def run_benchmark(map_path, scen_path, num_agents, timeout):
30+
cmd = [
31+
"cargo", "run", "-p", "mapf-bench", "--release", "--",
32+
"--map", str(map_path),
33+
"--scen", str(scen_path),
34+
"--num-agents", str(num_agents),
35+
"--timeout", str(timeout)
36+
]
37+
38+
start_time = time.time()
39+
try:
40+
# Strict timeout enforcement via subprocess
41+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
42+
duration = time.time() - start_time
43+
44+
success = "Negotiation successful" in result.stdout
45+
return {
46+
"success": success,
47+
"duration": duration,
48+
"stdout": result.stdout,
49+
"stderr": result.stderr
50+
}
51+
except subprocess.TimeoutExpired:
52+
return {
53+
"success": False,
54+
"duration": timeout,
55+
"error": "Timeout"
56+
}
57+
except Exception as e:
58+
return {
59+
"success": False,
60+
"error": str(e)
61+
}
62+
63+
def main():
64+
parser = argparse.ArgumentParser()
65+
parser.add_argument("--timeout", type=int, default=30, help="Timeout in seconds per run")
66+
parser.add_argument("--max-scenarios", type=int, default=1, help="Max random scenarios per map")
67+
parser.add_argument("--maps", nargs="+", default=["empty-32-32.map", "room-32-32-4.map", "maze-32-32-2.map"])
68+
args = parser.parse_args()
69+
70+
CACHE_DIR.mkdir(exist_ok=True)
71+
72+
maps_zip = CACHE_DIR / "mapf-map.zip"
73+
scen_zip = CACHE_DIR / "mapf-scen-random.zip"
74+
75+
download_file(MAPS_ZIP_URL, maps_zip)
76+
download_file(SCEN_ZIP_URL, scen_zip)
77+
78+
maps_dir = CACHE_DIR / "maps"
79+
scen_dir = CACHE_DIR / "scenarios"
80+
81+
if not maps_dir.exists():
82+
unzip_file(maps_zip, maps_dir)
83+
if not scen_dir.exists():
84+
unzip_file(scen_zip, scen_dir)
85+
86+
report = {}
87+
88+
print("Building mapf-bench in release mode...")
89+
subprocess.run(["cargo", "build", "-p", "mapf-bench", "--release"], check=True)
90+
91+
for map_name in args.maps:
92+
map_path = maps_dir / map_name
93+
if not map_path.exists():
94+
print(f"Map {map_name} not found.")
95+
continue
96+
97+
base_name = map_name.replace(".map", "")
98+
99+
# Find all matching scenario files
100+
scen_pattern = f"{base_name}-random-*.scen"
101+
scen_files = list((scen_dir / "scen-random").glob(scen_pattern))
102+
scen_files.sort()
103+
104+
if not scen_files:
105+
print(f"No scenarios found for {map_name} with pattern {scen_pattern}")
106+
continue
107+
108+
selected_scens = scen_files[:args.max_scenarios]
109+
110+
for scen_path in selected_scens:
111+
scen_name = scen_path.name
112+
print(f"\nBenchmarking {map_name} with scenario {scen_name}...")
113+
114+
key = f"{map_name}:{scen_name}"
115+
report[key] = []
116+
117+
for count in DEFAULT_AGENT_COUNTS:
118+
print(f" Agents: {count}", end=" ", flush=True)
119+
res = run_benchmark(map_path, scen_path, count, args.timeout)
120+
if res["success"]:
121+
print(f"✅ ({res['duration']:.2f}s)")
122+
else:
123+
error_msg = res.get("error", "FAILED")
124+
print(f"❌ ({error_msg})")
125+
126+
report[key].append({
127+
"agents": count,
128+
"success": res["success"],
129+
"duration": res.get("duration", 0),
130+
"error": res.get("error")
131+
})
132+
133+
# Save report
134+
with open("benchmark_report.json", "w") as f:
135+
json.dump(report, f, indent=2)
136+
137+
# Print summary table
138+
print("\nBenchmark Summary:")
139+
print(f"{'Scenario':<40} | {'Agents':<6} | {'Status':<8} | {'Time':<8}")
140+
print("-" * 75)
141+
for key, results in report.items():
142+
for res in results:
143+
status = "SUCCESS" if res["success"] else (res.get("error") if res.get("error") else "FAILED")
144+
print(f"{key[:40]:<40} | {res['agents']:<6} | {status:<8} | {res['duration']:>7.2f}s")
145+
146+
if __name__ == "__main__":
147+
main()

0 commit comments

Comments
 (0)