Fix late-game slowdown from boxed-in wanderers; A* scratch buffers; bench mode
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 13:32 UTC
- commit
- bef876c2ecbf985ded94e492a04da5b256ea1baa
- parent
- 4acafd45be
- tree
- browse at this commit
5 files changed +106 -26
| CLAUDE.md | +5 -1 | |
| src/animals.rs | +52 -12 | |
| src/jobs/mod.rs | +16 -7 | |
| src/sim.rs | +32 -6 | |
| tasks.md | +1 -0 |
CLAUDE.md +5 -1
| @@ -55,11 +55,15 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 55 | 55 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 56 | 56 | |
| 57 | 57 | ## Performance notes |
| 58 | -- Native profiling: temporarily add a Startup system writing `Action::SetSpeed(SimSpeed::Max)` in sim.rs, `CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release`, run, `sample <pid> 15 -file out.txt`, aggregate `simulator::` symbols (revert the hack). | |
| 58 | +- Benchmark: `SIM_BENCH=1 ./target/release/simulator` (native only) loads `simulator-save.json` (F9 path), sets Max and logs `bench: N us per tick, M game days per 10 s` every 10 s after a 5 s warmup. Use µs/tick: game days per 10 s depends on frame pacing, and macOS throttles the window once it's covered. Profile with `CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release` + `sample <pid> 10 -file out.txt` (rerun if it captures only a handful of samples); sample late in a run too — slowdowns can build up. | |
| 59 | 59 | - Hot spots fixed so far: predicate BFS scanning items per tile (use `nearest_of` with precollected sets), searches that cannot match (pre-check wanted kinds, empty farm sets), choose_jobs setup skipped unless someone is ready (idle timer ticked in a pre-pass), periodic passes over only `WorldMap::worn_tiles` / `growing_plants`, light-driven tilemap re-tints rate-limited to 100 ms, `BuildContext.occupied` set. |
| 60 | 60 | - `BuildKind::Floor` (1 wood, 90 work): not blocked by structures (like Roof), sets Terrain::WoodFloor; Pave/Floor blueprints don't hold back plan_shelter. |
| 61 | 61 | - Lighting recomputes on `WorldMap::shape_version()` (bumped only by set_wall/set_roof changes), not on every TileChanged — flooring 100+ tiles made it a full-map pass per tile. |
| 62 | 62 | - `search_reach` is the flood's half-width (√max/2); it was the full width, so nearest_of's precheck let far targets through and every haul search swept its budget for map-edge stacks. `ColonyReach` resource (Option<Reach>, refreshed by plan_homes every 300 ticks, cleared on WorldGenerated) filters find_haul stacks and blueprint sites (`allows`, `allows_near`). Animal migration uses `zones.home` instead of `spawn_tiles` (full largest-region scan). |
| 63 | +- Fixed schedules (FixedFirst..FixedLast) use `SingleThreadedExecutor`: with hundreds of tiny ticks per frame the multi-threaded executor spent most time parking/handing off between threads (5.6× faster natively; wasm was already single-threaded). | |
| 64 | +- A* (`astar_limited`) reuses thread-local buffers with generation stamps (no 800 KB clear per call); `find_nearby_path(.., WANDER_SEARCH 800)` caps wander searches. Failed wander attempts (animals, colonists) still restart the idle timer and failed flees wait FLEE_RETRY_SECS — retrying every tick for boxed-in animals made late games ~15× slower per tick. | |
| 65 | +- plan_homes caches its reach flood by (`shape_version`, colony home, size); `WorldMap::set` bumps `shape_version` when walkability changes. `GatherMemo` also memoises forage (food mask) and haul failures (HAUL_RETRY_TICKS 120, reset when Zones change — checked before choose_jobs' early return). choose_jobs builds `occupied` lazily; run_jobs only collects mover positions while someone builds a wall. | |
| 66 | +- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. | |
| 63 | 67 | - Browser timings are only valid while the pane is visible: a hidden/popped-out-behind pane throttles rAF to ~1 Hz (check `tabs_context`); otherwise measure natively (temporary Update system logging SimTick per 10 s real time). |
| 64 | 68 | - Searches and A* share `can_step` (no corner cutting): a target the BFS could reach diagonally but A* couldn't made A* exhaust the map (~10 ms) many times a second on a 50-day save. Mined yields drop on the miner's tile when the site is only diagonally reachable. |
| 65 | 69 | - Profiling a saved colony: copy the browser save to `simulator-save.json` (gitignored; the native build reads it with F9), temporarily add a Startup system writing `Action::Load` + `SetSpeed(Max)`. `sample` sometimes captures only a handful of samples on the first try — rerun. |
src/animals.rs +52 -12
| @@ -38,6 +38,7 @@ const BREED_CHANCE: f32 = 0.5; | ||
| 38 | 38 | const BREED_INTERVAL_TICKS: u64 = 24 * TICKS_PER_HOUR; |
| 39 | 39 | pub const FLEE_RANGE: i32 = 4; |
| 40 | 40 | const FLEE_DISTANCE: f32 = 7.0; |
| 41 | +const FLEE_RETRY_SECS: f32 = 1.0; | |
| 41 | 42 | const IDLE_SECS: Range<f32> = 2.0..8.0; |
| 42 | 43 | const MIN_DISTANCE_FROM_COLONY: f32 = 25.0; |
| 43 | 44 | const MIGRATION_INTERVAL_TICKS: u64 = 2 * TICKS_PER_HOUR; |
| @@ -123,11 +124,11 @@ pub struct Animal { | ||
| 123 | 124 | pub struct Tame; |
| 124 | 125 | |
| 125 | 126 | #[derive(Component, Debug)] |
| 126 | -pub struct AnimalIdle(Timer); | |
| 127 | +pub struct AnimalIdle(Timer, f32); | |
| 127 | 128 | |
| 128 | 129 | impl Default for AnimalIdle { |
| 129 | 130 | fn default() -> Self { |
| 130 | - Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once)) | |
| 131 | + Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once), 0.0) | |
| 131 | 132 | } |
| 132 | 133 | } |
| 133 | 134 | |
| @@ -394,23 +395,28 @@ pub fn wander( | ||
| 394 | 395 | && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH) |
| 395 | 396 | { |
| 396 | 397 | path.0 = route.into(); |
| 397 | - idle.0 | |
| 398 | - .set_duration(Duration::from_secs_f32(rng.range_f32(IDLE_SECS))); | |
| 399 | - idle.0.reset(); | |
| 400 | 398 | } |
| 399 | + idle.0 | |
| 400 | + .set_duration(Duration::from_secs_f32(rng.range_f32(IDLE_SECS))); | |
| 401 | + idle.0.reset(); | |
| 401 | 402 | continue; |
| 402 | 403 | } |
| 403 | 404 | let threats: &[UVec2] = if predator.is_some() { &[] } else { &threats }; |
| 404 | - let heading_into_danger = path | |
| 405 | - .0 | |
| 406 | - .back() | |
| 407 | - .is_none_or(|end| flee_goal(*end, threats).is_some()); | |
| 405 | + idle.1 = (idle.1 - time.delta_secs()).max(0.0); | |
| 406 | + let ready = idle.1 <= 0.0; | |
| 407 | + let heading_into_danger = ready | |
| 408 | + && path | |
| 409 | + .0 | |
| 410 | + .back() | |
| 411 | + .is_none_or(|end| flee_goal(*end, threats).is_some()); | |
| 408 | 412 | if heading_into_danger && let Some(goal) = flee_goal(tile.0, threats) { |
| 409 | 413 | let goal = goal.clamp(IVec2::ZERO, map.size.as_ivec2() - 1).as_uvec2(); |
| 410 | 414 | if let Some(route) = find_nearby_path(&map, tile.0, goal, WANDER_SEARCH) { |
| 411 | 415 | path.0 = route.into(); |
| 412 | 416 | continue; |
| 413 | 417 | } |
| 418 | + path.0.truncate(1); | |
| 419 | + idle.1 = FLEE_RETRY_SECS; | |
| 414 | 420 | } |
| 415 | 421 | if !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() { |
| 416 | 422 | continue; |
| @@ -424,10 +430,10 @@ pub fn wander( | ||
| 424 | 430 | && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH) |
| 425 | 431 | { |
| 426 | 432 | path.0 = route.into(); |
| 427 | - let secs = rng.range_f32(IDLE_SECS); | |
| 428 | - idle.0.set_duration(Duration::from_secs_f32(secs)); | |
| 429 | - idle.0.reset(); | |
| 430 | 433 | } |
| 434 | + let secs = rng.range_f32(IDLE_SECS); | |
| 435 | + idle.0.set_duration(Duration::from_secs_f32(secs)); | |
| 436 | + idle.0.reset(); | |
| 431 | 437 | } |
| 432 | 438 | } |
| 433 | 439 | |
| @@ -507,4 +513,38 @@ mod tests { | ||
| 507 | 513 | } |
| 508 | 514 | assert!(!app.world().get::<Path>(idle).unwrap().0.is_empty()); |
| 509 | 515 | } |
| 516 | + | |
| 517 | + // A boxed-in animal can't reach any wander goal. It used to retry on the very next tick, | |
| 518 | + // forever; once the colony walled in a few animals, those failing searches ran every tick and | |
| 519 | + // made late games ten times slower. Failed attempts now wait out the idle time too. | |
| 520 | + #[test] | |
| 521 | + fn boxed_in_animals_wait_before_trying_again() { | |
| 522 | + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); | |
| 523 | + for d in [-1, 0, 1] { | |
| 524 | + for (x, y) in [(d, -1), (d, 1), (-1, d), (1, d)] { | |
| 525 | + map.set_wall(UVec2::new((10 + x) as u32, (10 + y) as u32), true); | |
| 526 | + } | |
| 527 | + } | |
| 528 | + let mut app = App::new(); | |
| 529 | + app.insert_resource(map) | |
| 530 | + .insert_resource(SimRng::new(1)) | |
| 531 | + .init_resource::<Time>() | |
| 532 | + .init_resource::<Zones>() | |
| 533 | + .add_systems(Update, wander); | |
| 534 | + let deer = app | |
| 535 | + .world_mut() | |
| 536 | + .spawn(( | |
| 537 | + Animal { | |
| 538 | + kind: AnimalKind::Deer, | |
| 539 | + }, | |
| 540 | + TilePos(UVec2::splat(10)), | |
| 541 | + )) | |
| 542 | + .id(); | |
| 543 | + app.world_mut() | |
| 544 | + .resource_mut::<Time>() | |
| 545 | + .advance_by(Duration::from_secs(10)); | |
| 546 | + app.update(); | |
| 547 | + let idle = app.world().get::<AnimalIdle>(deer).unwrap(); | |
| 548 | + assert!(!idle.0.is_finished()); | |
| 549 | + } | |
| 510 | 550 | } |
src/jobs/mod.rs +16 -7
| @@ -1308,11 +1308,7 @@ fn choose_jobs( | ||
| 1308 | 1308 | .map(|(_, _, pos)| pos.0) |
| 1309 | 1309 | .collect(); |
| 1310 | 1310 | let hour = hour_of_day(*tick); |
| 1311 | - let mut occupied: HashSet<UVec2> = structures_list | |
| 1312 | - .iter() | |
| 1313 | - .map(|(pos, ..)| *pos) | |
| 1314 | - .chain(items.iter().map(|(_, _, pos)| pos.0)) | |
| 1315 | - .collect(); | |
| 1311 | + let mut occupied: Option<HashSet<UVec2>> = None; | |
| 1316 | 1312 | for ( |
| 1317 | 1313 | entity, |
| 1318 | 1314 | tile, |
| @@ -1439,6 +1435,13 @@ fn choose_jobs( | ||
| 1439 | 1435 | { |
| 1440 | 1436 | Some(Task::Equip { pickup }) |
| 1441 | 1437 | } else { |
| 1438 | + let occupied = occupied.get_or_insert_with(|| { | |
| 1439 | + structures_list | |
| 1440 | + .iter() | |
| 1441 | + .map(|(pos, ..)| *pos) | |
| 1442 | + .chain(items.iter().map(|(_, _, pos)| pos.0)) | |
| 1443 | + .collect() | |
| 1444 | + }); | |
| 1442 | 1445 | occupied.extend(planned.iter().map(|(pos, _)| *pos)); |
| 1443 | 1446 | let build = BuildContext { |
| 1444 | 1447 | reach: &reach, |
| @@ -1449,7 +1452,7 @@ fn choose_jobs( | ||
| 1449 | 1452 | structures: &structures_list, |
| 1450 | 1453 | planned: &planned, |
| 1451 | 1454 | blueprints: &blueprints, |
| 1452 | - occupied: &occupied, | |
| 1455 | + occupied, | |
| 1453 | 1456 | }; |
| 1454 | 1457 | let cooking = || { |
| 1455 | 1458 | (wanted(ItemKind::Meal) && input_source(&build, tile.0, Recipe::Meal).is_some()) |
| @@ -1622,8 +1625,8 @@ fn choose_jobs( | ||
| 1622 | 1625 | find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH) |
| 1623 | 1626 | { |
| 1624 | 1627 | path.0 = wander_path.into(); |
| 1625 | - idle.restart(rng.range_f32(IDLE_SECS)); | |
| 1626 | 1628 | } |
| 1629 | + idle.restart(rng.range_f32(IDLE_SECS)); | |
| 1627 | 1630 | } |
| 1628 | 1631 | } |
| 1629 | 1632 | |
| @@ -1678,12 +1681,18 @@ fn run_jobs( | ||
| 1678 | 1681 | ) { |
| 1679 | 1682 | let (mut changed, mut ate) = messages; |
| 1680 | 1683 | let hour = hour_of_day(*tick); |
| 1684 | + let walling = colonists.iter().any(|c| { | |
| 1685 | + c.3.0 | |
| 1686 | + .is_some_and(|j| matches!(j.task, Task::Build { kind, .. } if kind.blocks_tile())) | |
| 1687 | + }); | |
| 1681 | 1688 | let occupied: Vec<UVec2> = colonists |
| 1682 | 1689 | .iter() |
| 1690 | + .filter(|_| walling) | |
| 1683 | 1691 | .flat_map(|c| [Some(c.1.0), c.2.0.front().copied()]) |
| 1684 | 1692 | .chain( |
| 1685 | 1693 | animals |
| 1686 | 1694 | .iter() |
| 1695 | + .filter(|_| walling) | |
| 1687 | 1696 | .flat_map(|(t, p)| [Some(t.0), p.0.front().copied()]), |
| 1688 | 1697 | ) |
| 1689 | 1698 | .flatten() |
src/sim.rs +32 -6
| @@ -62,16 +62,42 @@ fn start_bench(mut actions: MessageWriter<Action>) { | ||
| 62 | 62 | } |
| 63 | 63 | |
| 64 | 64 | #[cfg(not(target_arch = "wasm32"))] |
| 65 | -fn report_bench(tick: Res<SimTick>, real: Res<Time<Real>>, mut last: Local<Option<(f32, u64)>>) { | |
| 65 | +#[derive(Default)] | |
| 66 | +struct BenchWindow { | |
| 67 | + since: f32, | |
| 68 | + ticks: u64, | |
| 69 | + sim_secs: f32, | |
| 70 | + last_tick: u64, | |
| 71 | +} | |
| 72 | + | |
| 73 | +#[cfg(not(target_arch = "wasm32"))] | |
| 74 | +fn report_bench( | |
| 75 | + tick: Res<SimTick>, | |
| 76 | + real: Res<Time<Real>>, | |
| 77 | + clock: Res<SimClock>, | |
| 78 | + mut window: Local<Option<BenchWindow>>, | |
| 79 | +) { | |
| 66 | 80 | let now = real.elapsed_secs(); |
| 67 | 81 | if now < BENCH_WARMUP_SECS { |
| 68 | 82 | return; |
| 69 | 83 | } |
| 70 | - let (since, from) = *last.get_or_insert((now, tick.0)); | |
| 71 | - if now - since >= BENCH_REPORT_SECS { | |
| 72 | - let days = (tick.0 - from) as f32 / (HOURS_PER_DAY * TICKS_PER_HOUR) as f32; | |
| 73 | - info!("bench: {days:.2} game days per {BENCH_REPORT_SECS} s"); | |
| 74 | - *last = Some((now, tick.0)); | |
| 84 | + let window = window.get_or_insert_with(|| BenchWindow { | |
| 85 | + since: now, | |
| 86 | + last_tick: tick.0, | |
| 87 | + ..default() | |
| 88 | + }); | |
| 89 | + window.ticks += tick.0 - window.last_tick; | |
| 90 | + window.last_tick = tick.0; | |
| 91 | + window.sim_secs += clock.last_sim_secs; | |
| 92 | + if now - window.since >= BENCH_REPORT_SECS { | |
| 93 | + let days = window.ticks as f32 / (HOURS_PER_DAY * TICKS_PER_HOUR) as f32; | |
| 94 | + let micros = window.sim_secs * 1e6 / window.ticks.max(1) as f32; | |
| 95 | + info!("bench: {micros:.1} us per tick, {days:.2} game days per {BENCH_REPORT_SECS} s"); | |
| 96 | + *window = BenchWindow { | |
| 97 | + since: now, | |
| 98 | + last_tick: tick.0, | |
| 99 | + ..default() | |
| 100 | + }; | |
| 75 | 101 | } |
| 76 | 102 | } |
| 77 | 103 | |
tasks.md +1 -0
| @@ -47,6 +47,7 @@ | ||
| 47 | 47 | - [x] Connected wall rendering (wall tops join neighbouring walls and doors) |
| 48 | 48 | - [x] Skills: eight work skills set work speed, improve with practice, newcomers have a talent; poor-skill work is tried last |
| 49 | 49 | - [x] Wolves: predators hunt animals and colonists; health, bites, healing; adults fight wolves near the colony, children and the hurt flee; newcomers now walk to the colony instead of stranding at the edge |
| 50 | +- [x] Optimization pass: single-threaded fixed schedules, A* scratch buffers, capped wander searches, no retry-every-tick for boxed-in animals, cached reach, more search memos; native SIM_BENCH mode | |
| 50 | 51 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 51 | 52 | |
| 52 | 53 | ## Planned |