From 1518c3b6405cdba5b58afff0db62cf4100a75cc6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:33:37 -0500 Subject: [PATCH] Simulations: cap the steps a layer time can ask for and bound checkpoints as they are made --- crates/effects/src/sim/engine.rs | 6 ++-- crates/effects/src/util.rs | 62 +++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index beda8c318..8d9914e31 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -8,9 +8,11 @@ use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, /// Steps per second of the stepped particle simulations. pub(crate) const SPS: f64 = 60.0; -/// Number of whole steps reached at layer time `t`. +/// Number of whole steps reached at layer time `t` (at most [`crate::util::MAX_SIM_STEPS`]: the +/// per-step histories built from it, such as [`rate_history`], stay bounded however late a +/// project places the layer's time). pub(crate) fn steps_at(t: f64) -> u64 { - if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 } + if t <= 0.0 { 0 } else { ((t * SPS + 1e-6).floor() as u64).min(crate::util::MAX_SIM_STEPS) } } #[derive(Clone, Debug)] diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9c81dab10..d1eed6855 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -545,13 +545,22 @@ struct SimEntry { /// Checkpoint every this many steps. pub const SIM_CHECKPOINT: u64 = 15; +/// Most steps a simulation runs: three hours of layer time at 60 steps per second. The layer +/// time comes from the project (start time, stretch), so without a limit a tiny file could ask +/// for years of steps; past this the state stays where it is. +pub const MAX_SIM_STEPS: u64 = 60 * 60 * 60 * 3; + +/// Most checkpoints kept per simulation (and made by one run, which spaces them further apart +/// as it goes). +const MAX_SIM_CHECKPOINTS: usize = 256; + impl SimCache { pub const fn new(cap: usize) -> SimCache { SimCache { inner: std::sync::Mutex::new(Vec::new()), cap } } - /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step - /// `i` to `i + 1`. + /// State after `target` steps (at most [`MAX_SIM_STEPS`]). `init` builds step 0; + /// `step(state, i)` advances from step `i` to `i + 1`. pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { self.run_where(key, target, init, step, |_, _| true) } @@ -569,6 +578,7 @@ impl SimCache { valid: impl Fn(u64, &S) -> bool, ) -> std::sync::Arc { use std::sync::Arc; + let target = target.min(MAX_SIM_STEPS); let start = { let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; @@ -587,11 +597,17 @@ impl SimCache { if !had_start { fresh.push((0, Arc::new(st.clone()))); } + let mut every = SIM_CHECKPOINT; while i < target { step(&mut st, i); i += 1; - if i % SIM_CHECKPOINT == 0 && i != target { + if i % every == 0 && i != target { fresh.push((i, Arc::new(st.clone()))); + // A long run keeps a bounded number of states: spread them twice as far apart. + if fresh.len() > MAX_SIM_CHECKPOINTS { + every = every.saturating_mul(2); + fresh.retain(|(s, _)| s % every == 0); + } } } let out = Arc::new(st); @@ -611,19 +627,21 @@ impl SimCache { }; let e = &mut g[idx]; e.used = tick; - for (s, st) in fresh { - e.checkpoints.retain(|(c, _)| *c != s); - e.checkpoints.push((s, st)); - } + // Fresh checkpoints replace those at the same step (one pass, not one per checkpoint). + let steps: std::collections::HashSet = fresh.iter().map(|(s, _)| *s).collect(); + e.checkpoints.retain(|(c, _)| !steps.contains(c)); + e.checkpoints.extend(fresh); // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. - if e.checkpoints.len() > 256 { + if e.checkpoints.len() > MAX_SIM_CHECKPOINTS { e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0); e.checkpoints.sort_by_key(|(s, _)| *s); - let mut k = 0; - e.checkpoints.retain(|_| { - k += 1; - k % 2 == 1 - }); + while e.checkpoints.len() > MAX_SIM_CHECKPOINTS { + let mut k = 0; + e.checkpoints.retain(|_| { + k += 1; + k % 2 == 1 + }); + } } out } @@ -801,6 +819,24 @@ mod tests { assert_eq!(d, e); } + /// Layer time from a project cannot run a simulation without end, and a long run keeps a + /// bounded number of checkpoints that still give the same answer when seeking. + #[test] + fn sim_cache_bounds_steps_and_checkpoints() { + let c: SimCache = SimCache::new(2); + let count = |s: &mut u64, _: u64| *s += 1; + assert_eq!(*c.run(1, u64::MAX, || 0, count), MAX_SIM_STEPS); + let n = c.inner.lock().map(|g| g.iter().map(|e| e.checkpoints.len()).max().unwrap_or(0)).unwrap_or(0); + assert!(n <= MAX_SIM_CHECKPOINTS + 1, "{n} checkpoints"); + let fresh: SimCache = SimCache::new(2); + let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i); + let far = *fresh.run(1, 100_000, || 7, step); + let mid = *fresh.run(1, 54_321, || 7, step); + let again: SimCache = SimCache::new(2); + assert_eq!(*again.run(1, 54_321, || 7, step), mid); + assert_eq!(*again.run(1, 100_000, || 7, step), far); + } + #[test] fn poly_helpers() { let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];