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 9c14a6c69..390750c6e 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -562,13 +562,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) } @@ -586,6 +595,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; @@ -604,11 +614,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); @@ -628,19 +644,23 @@ 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)); - } - // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. - if e.checkpoints.len() > 256 { - e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0); + // 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. The + // state just asked for keeps its slot, so asking for it again is answered from the cache. + if e.checkpoints.len() > MAX_SIM_CHECKPOINTS { + e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0 && *s != target); e.checkpoints.sort_by_key(|(s, _)| *s); - let mut k = 0; + // Drop only the excess, evenly spaced, so the rest keep their seek distance. + let (len, excess) = (e.checkpoints.len(), (e.checkpoints.len() + 1).saturating_sub(MAX_SIM_CHECKPOINTS)); + let mut i = 0; e.checkpoints.retain(|_| { - k += 1; - k % 2 == 1 + i += 1; + (i * excess) / len == ((i - 1) * excess) / len }); + e.checkpoints.push((target, out.clone())); } out } @@ -818,6 +838,45 @@ 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); + } + + /// A request that leaves the cache over its checkpoint cap still finds its own state there. + #[test] + fn sim_cache_keeps_the_requested_state_when_trimming() { + let c: SimCache = SimCache::new(2); + let steps = std::cell::Cell::new(0u64); + let count = |s: &mut u64, _: u64| { + steps.set(steps.get() + 1); + *s += 1; + }; + let target = SIM_CHECKPOINT * (MAX_SIM_CHECKPOINTS as u64 - 1) + SIM_CHECKPOINT - 1; + assert_eq!(*c.run(1, target, || 0, count), target); + 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, "{n} checkpoints"); + steps.set(0); + assert_eq!(*c.run(1, target, || 0, count), target); + assert_eq!(steps.get(), 0, "the second request is answered from the cache"); + // Only the excess is dropped: the other aligned checkpoints stay. + let kept = c.inner.lock().map(|g| g[0].checkpoints.len()).unwrap_or(0); + assert_eq!(kept, MAX_SIM_CHECKPOINTS, "trimmed to the cap, not halved"); + } + #[test] fn poly_helpers() { let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];