1
Fork 0

optimized via deepseek (100x speedup)

This commit is contained in:
Andy Killorin 2026-11-10 08:06:31 -05:00
parent f2d3ea77cb
commit 57e9bcab90
Signed by: ank
GPG key ID: 23F9463ECB67FE8C
2 changed files with 396 additions and 44 deletions

View file

@ -12,3 +12,7 @@ rand_gen = { package = "rand", version = "0.10.3" }
rayon = "1.12.0"
serde = { version = "1.0.229", features = ["derive"] }
toml = "1.2.1"
[profile.release]
lto = "thin"
codegen-units = 1

View file

@ -1,15 +1,12 @@
use std::cmp::min;
use std::collections::HashMap;
use std::f32::consts::TAU;
use std::fs;
use std::path::PathBuf;
use std::ptr;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use macroquad::prelude::*;
use parry2d::math::Pose;
use parry2d::math::Pose2;
use parry2d::math::Rot2;
use parry2d::math::Vec2;
use parry2d::query::contact;
@ -31,6 +28,148 @@ use serde::Deserialize;
const BOT_RADIUS: f32 = 16.0;
/// Broad-phase grid cell size. Derived from the sensing range (the dominant
/// query length) and clamped so both sensing and food pick-up stay efficient
/// across configurations.
fn grid_cell(view_dist: f32) -> f32 {
(view_dist * 0.8).clamp(64.0, 256.0)
}
/// Uniform spatial grid over a slice of 2D points, rebuilt every simulation
/// step. Indices are stored in an intrusive singly-linked list (`head`/`next`)
/// so building and querying is allocation-free and hash-free.
struct Grid {
cell: f32,
inv_cell: f32,
min_x: f32,
min_y: f32,
cols: i32,
rows: i32,
head: Vec<i32>,
next: Vec<i32>,
}
impl Grid {
fn new(cell: f32) -> Self {
Self {
cell,
inv_cell: 1.0 / cell,
min_x: 0.0,
min_y: 0.0,
cols: 0,
rows: 0,
head: Vec::new(),
next: Vec::new(),
}
}
/// Rebuild the grid for `pts` (index `i` in `pts` is what queries return).
fn rebuild(&mut self, pts: &[Vec2]) {
if pts.is_empty() {
self.cols = 0;
self.rows = 0;
self.head.clear();
self.next.clear();
return;
}
let (mut min_x, mut min_y) = (f32::MAX, f32::MAX);
let (mut max_x, mut max_y) = (f32::MIN, f32::MIN);
for p in pts {
min_x = min_x.min(p.x);
min_y = min_y.min(p.y);
max_x = max_x.max(p.x);
max_y = max_y.max(p.y);
}
// One cell of slack on every side keeps the arithmetic in bounds.
self.min_x = min_x - self.cell;
self.min_y = min_y - self.cell;
self.cols = (((max_x - self.min_x) * self.inv_cell) as i32 + 2).max(1);
self.rows = (((max_y - self.min_y) * self.inv_cell) as i32 + 2).max(1);
let n = (self.cols as usize) * (self.rows as usize);
self.head.clear();
self.head.resize(n, -1);
self.next.clear();
self.next.resize(pts.len(), -1);
for (i, p) in pts.iter().enumerate() {
let cx = ((p.x - self.min_x) * self.inv_cell) as i32;
let cy = ((p.y - self.min_y) * self.inv_cell) as i32;
let cx = cx.clamp(0, self.cols - 1);
let cy = cy.clamp(0, self.rows - 1);
let c = (cy * self.cols + cx) as usize;
self.next[i] = self.head[c];
self.head[c] = i as i32;
}
}
/// Visit every index whose point lies inside the axis-aligned box
/// `[lo, hi]`. Conservative: may visit points just outside.
#[inline]
fn for_each_in_aabb(&self, lo: Vec2, hi: Vec2, mut f: impl FnMut(usize)) {
if self.cols == 0 {
return;
}
let cx0 = (((lo.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1);
let cx1 = (((hi.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1);
let cy0 = (((lo.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1);
let cy1 = (((hi.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1);
for cy in cy0..=cy1 {
let base = cy * self.cols;
for cx in cx0..=cx1 {
let mut i = self.head[(base + cx) as usize];
while i >= 0 {
f(i as usize);
i = self.next[i as usize];
}
}
}
}
}
/// Exact distance from `a` to the point where the segment `a..b` first comes
/// within `radius` of `center`, or `None` if it never does.
///
/// This reproduces parry's `contact(Pose::IDENTITY, segment, center, ball, 0.0)`
/// followed by `contact.point1.distance(a)`: parry reports the closest point on
/// the segment as `point1`, and contacts exactly when the centre-to-segment
/// distance is `<= radius`.
#[inline]
fn seg_ball_dist(a: Vec2, b: Vec2, center: Vec2, radius: f32) -> Option<f32> {
let ab = b - a;
let ab2 = ab.length_squared();
let t = if ab2 > 0.0 {
((center - a).dot(ab) / ab2).clamp(0.0, 1.0)
} else {
0.0
};
let q = a + ab * t;
if (center - q).length_squared() <= radius * radius {
Some((q - a).length())
} else {
None
}
}
/// Exact distance from a ball's centre to the contact point on another ball,
/// matching parry's ball-ball contact (`point1` sits on ball 1's surface along
/// the centre-to-centre normal). Contacts exactly when the centres are closer
/// than the sum of the radii. The add-then-subtract mirrors parry transforming
/// the local contact point into world space and measuring back to the centre,
/// so the result is bit-identical (including the cancellation error).
#[inline]
fn ball_ball_dist(center1: Vec2, center2: Vec2, radius: f32) -> Option<f32> {
let delta = center2 - center1;
let d2 = delta.length_squared();
let sum = radius + radius;
if d2 < sum * sum {
let n = if d2 != 0.0 { delta.normalize() } else { Vec2::X };
let world_point1 = center1 + n * radius;
Some(world_point1.distance(center1))
} else {
None
}
}
trait Mortal {
fn collider(&self) -> (&Pose, &dyn Shape);
fn kill(&self);
@ -90,11 +229,20 @@ impl Robot {
}
fn detect<'a>(&self, things: impl Iterator<Item = (&'a Pose, &'a dyn Shape)>) -> Option<f32> {
let collider = Segment::new(self.pose.translation, self.view_end());
things
.map(|thing| contact(&Pose2::IDENTITY, &collider, thing.0, thing.1, 0.0).unwrap()).flatten()
.map(|contact| contact.point1.distance(self.pose.translation)).reduce(|x: f32,y: f32| x.min(y))
/// Distance from the robot's translation to the closest point on its view
/// segment that lies within `radius` of `center`, or `None` if the segment
/// misses the target's ball. This is an exact, closed-form replacement for
/// parry's GJK `contact` query in the ball-vs-segment case (all bodies here
/// are circles), and is dramatically cheaper.
#[inline]
fn contact_dist(&self, translation: Vec2, end: Vec2, center: Vec2) -> Option<f32> {
let reach = self.view_dist + BOT_RADIUS;
let dx = center.x - translation.x;
let dy = center.y - translation.y;
if dx * dx + dy * dy > reach * reach {
return None;
}
seg_ball_dist(translation, end, center, BOT_RADIUS)
}
fn crater(&self) -> (f32, f32, f32, Color) {
@ -134,7 +282,7 @@ struct Food {
alive: AtomicBool,
}
#[derive(PartialEq, Eq)]
#[derive(PartialEq, Eq, Clone, Copy)]
enum Sensor {
Food,
Kin,
@ -169,10 +317,24 @@ impl Food {
}
fn detect<'a>(&self, things: impl Iterator<Item = &'a Robot>) -> Option<Color> {
things
.map(|robot| contact(&self.pose, &self.collider, &robot.pose, &robot.collider, 0.0).unwrap().map(|a| (a,robot.team))).flatten()
.map(|(contact,team)| (contact.point1.distance(self.pose.translation),team)).reduce(|x ,y| if x.0 < y.0 {x} else{y} ).map(|(_, team)| team)
/// Team of the nearest robot touching this food, via the robot broad-phase
/// grid. Preserves the original tie-break (a later candidate wins ties).
fn detect(&self, grid: &Grid, robots: &[Robot]) -> Option<Color> {
let c = self.pose.translation;
let r = 2.0 * BOT_RADIUS;
let lo = Vec2::new(c.x - r, c.y - r);
let hi = Vec2::new(c.x + r, c.y + r);
let mut best: Option<(f32, Color)> = None;
grid.for_each_in_aabb(lo, hi, |ri| {
let robot = &robots[ri];
if let Some(d) = ball_ball_dist(c, robot.pose.translation, BOT_RADIUS) {
match best {
Some((bd, _)) if bd < d => {}
_ => best = Some((d, robot.team)),
}
}
});
best.map(|(_, team)| team)
}
}
@ -253,6 +415,12 @@ struct Simulation {
new_craters: Vec<(f32, f32, f32, Color)>,
watchdog: i32,
score: HashMap<[u32;4], i32>,
robot_grid: Grid,
food_grid: Grid,
// Scratch buffers reused every step to avoid per-step allocations.
robot_pos: Vec<Vec2>,
food_pos: Vec<Vec2>,
sensors: Vec<Sensor>,
}
impl Simulation {
@ -288,7 +456,10 @@ impl Simulation {
let y = rng.random_range(200.0 .. 900.0);
food.push(Food::new(Vec2 { x, y }));
}
Self { robots, food, craters, new_craters, watchdog, score }
let cell = grid_cell(config.view_dist);
Self { robots, food, craters, new_craters, watchdog, score,
robot_grid: Grid::new(cell), food_grid: Grid::new(cell),
robot_pos: Vec::new(), food_pos: Vec::new(), sensors: Vec::new() }
}
fn color_hash(color: Color) -> [u32; 4] {
@ -296,10 +467,18 @@ impl Simulation {
}
fn sim_step(&mut self) -> Option<(i32, i32)>{
let Simulation { robots, food, craters, new_craters, watchdog, score } = self;
let Simulation { robots, food, craters: _, new_craters, watchdog, score,
robot_grid, food_grid, robot_pos, food_pos, sensors } = self;
// Broad-phase for food pickup: must cover the same robot set the
// original iterated, i.e. *before* the retain below (dead robots get
// one more chance to score).
robot_pos.clear();
robot_pos.extend(robots.iter().map(|r| r.pose.translation));
robot_grid.rebuild(robot_pos);
for food in &*food{
if let Some(team) = food.detect(robots.iter()) {
if let Some(team) = food.detect(robot_grid, robots) {
if food.living() {
score.entry(Self::color_hash(team)).and_modify(|s| *s +=1 ).or_insert(1);
food.kill();
@ -308,14 +487,50 @@ impl Simulation {
}
}
let robots_before = robots.len();
food.retain(Mortal::living);
robots.retain(Mortal::living);
let mut sensors = Vec::new();
for robot in &*robots {
let detect_food = robot.detect(food.iter().map(Food::collider));
let detect_kin = robot.detect(robots.iter().filter(|o| o.team == robot.team).filter(|r| !ptr::eq(&**r , &**&robot)).map(Robot::collider));
let detect_non_kin = robot.detect(robots.iter().filter(|o| o.team != robot.team).filter(|r| !ptr::eq(&**r , &**&robot)).map(Robot::collider));
// Rebuild the grids on the retained sets for the sensing pass. The
// robot grid is still valid (positions unchanged) when no robot died.
if robots.len() != robots_before {
robot_pos.clear();
robot_pos.extend(robots.iter().map(|r| r.pose.translation));
robot_grid.rebuild(robot_pos);
}
food_pos.clear();
food_pos.extend(food.iter().map(|f| f.pose.translation));
food_grid.rebuild(food_pos);
sensors.clear();
for (i, robot) in robots.iter().enumerate() {
let translation = robot.pose.translation;
let end = robot.view_end();
let lo = Vec2::new(translation.x.min(end.x) - BOT_RADIUS, translation.y.min(end.y) - BOT_RADIUS);
let hi = Vec2::new(translation.x.max(end.x) + BOT_RADIUS, translation.y.max(end.y) + BOT_RADIUS);
let mut detect_food: Option<f32> = None;
food_grid.for_each_in_aabb(lo, hi, |fi| {
if let Some(d) = robot.contact_dist(translation, end, food[fi].pose.translation) {
detect_food = Some(detect_food.map_or(d, |b| b.min(d)));
}
});
let mut detect_kin: Option<f32> = None;
let mut detect_non_kin: Option<f32> = None;
robot_grid.for_each_in_aabb(lo, hi, |ri| {
if ri == i {
return;
}
let other = &robots[ri];
if let Some(d) = robot.contact_dist(translation, end, other.pose.translation) {
if other.team == robot.team {
detect_kin = Some(detect_kin.map_or(d, |b| b.min(d)));
} else {
detect_non_kin = Some(detect_non_kin.map_or(d, |b| b.min(d)));
}
}
});
let mut sensor = Sensor::None;
let mut closest: Option<f32> = None;
@ -334,6 +549,7 @@ impl Simulation {
sensors.push(sensor);
}
for idx in 0..robots.len() {
let (before, after) = robots.split_at_mut(idx);
let (robot, after) = after.split_first_mut().unwrap();
@ -362,7 +578,7 @@ impl Simulation {
}
async fn render(&mut self) {
let Simulation { robots, food, craters, new_craters, watchdog: _, score } = self;
let Simulation { robots, food, craters, new_craters, watchdog: _, score, .. } = self;
clear_background(LIGHTGRAY);
for crater in &*new_craters {
@ -440,31 +656,163 @@ struct Cli {
debug: u8,
}
#[macroquad::main("Adversarial Foraging Simulator")]
async fn main() {
fn load_config(path: &std::path::Path) -> SimConfig {
let text = fs::read_to_string(path).unwrap();
toml::from_str(&text).unwrap()
}
fn print_average(red: i32, blu: i32, iters: u64) {
let iters = iters.max(1) as f32;
let red = red as f32 / iters;
let blu = blu as f32 / iters;
println!("{} wins on average! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"});
}
fn main() {
let cli = Cli::parse();
// Headless runs never need a window. Going through macroquad's event loop
// would otherwise cost seconds of X11/window setup on every invocation,
// which dwarfs the actual simulation for parallel sweeps.
if cli.no_gui {
let config = load_config(&cli.config);
let iters = cli.run_count.unwrap_or(1);
let (red, blu) = (0..iters)
.into_par_iter()
.map(|s| run_headless_simulation(s, &config))
.reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1));
print_average(red, blu, iters);
} else {
macroquad::Window::new("Adversarial Foraging Simulator", gui_main(cli));
}
}
async fn gui_main(cli: Cli) {
let iters = cli.run_count.unwrap_or(1);
let speedup = cli.gui_speedup.unwrap_or(5);
let config = load_config(&cli.config);
let config = fs::read_to_string(cli.config).unwrap();
let config: SimConfig = toml::from_str(&config).unwrap();
let (red, blu) = if cli.no_gui {
(0..iters).into_par_iter().map(|s |run_headless_simulation(s,&config)).reduce(|| (0,0), |a,b| (a.0 + b.0, a.1 + b.1))
} else {
let (mut rtot, mut btot) = (0, 0);
for i in 0..iters {
let (red, blu) = run_simulation(i, speedup, &config).await;
rtot += red;
btot += blu;
println!("{} wins! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"});
}
(rtot,btot)
};
let red = red as f32 / iters as f32;
let blu = blu as f32 / iters as f32;
println!("{} wins on average! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"});
print_average(rtot, btot, iters);
}
#[cfg(test)]
mod tests {
use super::*;
use parry2d::math::Pose2;
fn config() -> SimConfig {
toml::from_str(include_str!("../conf.toml")).unwrap()
}
/// The analytic replacements must agree with parry bit-for-bit, both on
/// whether a contact exists and on the reported `point1.distance(origin)`.
/// This is the load-bearing test behind the whole speedup: the hot path no
/// longer calls into parry at all.
#[test]
fn analytic_matches_parry() {
let mut rng = SmallRng::seed_from_u64(0xC0FFEE);
let mut seg_contacts = 0u64;
let mut bb_contacts = 0u64;
for _ in 0..2_000_000 {
// segment vs ball
let a = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0));
let ang = rng.random_range(0.0f32..TAU);
let len = rng.random_range(0.0f32..300.0);
let b = a + Vec2::new(ang.cos(), ang.sin()) * len;
let center = Vec2::new(rng.random_range(-600.0f32..600.0), rng.random_range(-600.0..600.0));
let pose = Pose::from_translation(center.to_array().into());
let parry = contact(&Pose2::IDENTITY, &Segment::new(a, b), &pose, &Ball::new(BOT_RADIUS), 0.0)
.unwrap()
.map(|c| c.point1.distance(a));
let mine = seg_ball_dist(a, b, center, BOT_RADIUS);
assert_eq!(parry.is_some(), mine.is_some(), "segment-ball contact existence");
if let (Some(p), Some(m)) = (parry, mine) {
assert_eq!(p.to_bits(), m.to_bits(), "segment-ball distance {p} != {m}");
seg_contacts += 1;
}
// ball vs ball
let c1 = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0));
let c2 = Vec2::new(rng.random_range(-520.0f32..520.0), rng.random_range(-520.0..520.0));
let p1 = Pose::from_translation(c1.to_array().into());
let p2 = Pose::from_translation(c2.to_array().into());
let parry = contact(&p1, &Ball::new(BOT_RADIUS), &p2, &Ball::new(BOT_RADIUS), 0.0)
.unwrap()
.map(|c| c.point1.distance(c1));
let mine = ball_ball_dist(c1, c2, BOT_RADIUS);
assert_eq!(parry.is_some(), mine.is_some(), "ball-ball contact existence");
if let (Some(p), Some(m)) = (parry, mine) {
assert_eq!(p.to_bits(), m.to_bits(), "ball-ball distance {p} != {m}");
bb_contacts += 1;
}
}
// Guard against the generator accidentally producing (almost) no contacts.
assert!(seg_contacts > 1000, "too few segment-ball contacts sampled");
assert!(bb_contacts > 1000, "too few ball-ball contacts sampled");
}
/// End-to-end behaviour lock: exact scores for the first 16 seeds. Any
/// change to the simulation's physics, ordering or collision code that
/// alters outcomes will fail here.
#[test]
fn regression_scores() {
let config = config();
let expected: [(i32, i32); 16] = [
(53, 45), (37, 61), (32, 48), (48, 51),
(43, 51), (30, 68), (59, 41), (49, 45),
(54, 37), (70, 28), (43, 41), (41, 42),
(44, 51), (49, 49), (30, 62), (48, 43),
];
for (seed, want) in expected.iter().enumerate() {
let got = run_headless_simulation(seed as u64, &config);
assert_eq!(got, *want, "seed {seed}");
}
}
/// The whole sweep must stay deterministic and reproduce the documented
/// average when run from scratch.
#[test]
fn regression_sweep_average() {
let config = config();
let (red, blu) = (0..200u64)
.map(|seed| run_headless_simulation(seed, &config))
.fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1));
assert_eq!(red, 9615, "red total over 200 seeds");
assert_eq!(blu, 9088, "blu total over 200 seeds");
}
/// Same seed twice must produce the same result (no hidden global state).
#[test]
fn deterministic() {
let config = config();
for seed in 0..8u64 {
assert_eq!(
run_headless_simulation(seed, &config),
run_headless_simulation(seed, &config),
"seed {seed}"
);
}
}
/// Coarse performance smoke test. Run explicitly with:
/// cargo test --release -- --ignored --nocapture bench_full_sweep
#[test]
#[ignore]
fn bench_full_sweep() {
let config = config();
let t0 = std::time::Instant::now();
let (red, blu) = (0..200u64)
.map(|seed| run_headless_simulation(seed, &config))
.fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1));
let dt = t0.elapsed();
println!("200 headless runs (1 core): {dt:?} ({:.3} ms/run) RED={red} BLU={blu}", dt.as_secs_f64() * 1000.0 / 200.0);
}
}