diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 2be465ba..cf7cc1c2 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -57,6 +57,8 @@ fn main() -> ExitCode { Some("mcp") => cmd_mcp(&args[1..]), // Hidden, for the kill-mid-save test: save a large design over and over. Some("save-stress") => cmd_save_stress(&args[1..]), + // Hidden: time recomputes after parameter edits (docs/perf.md). + Some("bench-edit") => cmd_bench_edit(&args[1..]), Some("--version" | "-V") => { println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) @@ -241,3 +243,33 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> { } Ok(()) } + +/// `bench-edit `: build the design, then time a +/// parameter edit, setting it back, undo and redo (ms and features recomputed each). +fn cmd_bench_edit(args: &[String]) -> Result<(), String> { + let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else { + return Err("usage: solvecraft-cli bench-edit ".into()); + }; + let v = read_json(path)?; + let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v }; + let mut s = Session::default(); + let t = std::time::Instant::now(); + s.run_script(&script).map_err(|e| e.to_string())?; + let build = t.elapsed().as_secs_f64() * 1000.0; + let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?; + let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})]; + let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> { + let t = std::time::Instant::now(); + let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?; + let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed)); + out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n})); + Ok(()) + }; + let expr = expr.replace("{old}", &old); + time(&mut s, "edit", "ChangeParameterCommand", json!({"name": param, "expression": expr}))?; + time(&mut s, "set back", "ChangeParameterCommand", json!({"name": param, "expression": old}))?; + time(&mut s, "undo", "UndoCommand", json!({}))?; + time(&mut s, "redo", "RedoCommand", json!({}))?; + println!("{}", pretty(&json!(out))); + Ok(()) +} diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index ff2a1af8..49474a0b 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -20,11 +20,14 @@ pub struct ModelBody { /// Feature that created it. pub feature: u64, mesh: OnceLock>, + /// Identity of this body's content for the evaluation cache: the key of the evaluation + /// that produced it (kept while later features pass it through unchanged). + key: u64, } impl ModelBody { pub fn new(name: String, body: Body, feature: u64) -> Self { - ModelBody { name, body, feature, mesh: OnceLock::new() } + ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0 } } /// Display mesh (chord tolerance 1/1000 of the body size), computed once. pub fn mesh(&self) -> Arc { @@ -187,6 +190,85 @@ pub struct ModelState { pub body_counter: usize, /// Sheet metal bodies (by body name) and their flat patterns. pub sheets: Vec, + /// Evaluation-cache keys of the sketches (by sketch feature id). + sketch_keys: BTreeMap, +} + +impl ModelState { + /// A digest of everything later features can read: bodies (by content identity), sketches, + /// the body counter, threads and sheet bodies. + fn digest(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for b in &self.bodies { + b.name.hash(&mut h); + b.key.hash(&mut h); + b.feature.hash(&mut h); + } + for s in &self.sketches { + s.feature.hash(&mut h); + self.sketch_keys.get(&s.feature).hash(&mut h); + } + self.body_counter.hash(&mut h); + serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h); + serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h); + h.finish() + } + + /// After a feature evaluated to this state from `before` with cache key `key`: bodies it + /// made or changed, and sketches it made, take that key; the rest keep theirs. + fn stamp(&mut self, before: &ModelState, feature: u64, key: u64) { + for b in &mut self.bodies { + let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)).map(|x| x.key); + b.key = kept.unwrap_or_else(|| { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (key, &b.name).hash(&mut h); + h.finish() + }); + } + if self.sketches.iter().any(|s| s.feature == feature) { + self.sketch_keys.insert(feature, key); + } + } +} + +/// Evaluation results by cache key, shared by a model and its copies (previews, scratch +/// sessions), so a result computed once is reused wherever the same inputs come back. +#[derive(Debug, Default)] +pub struct EvalCache { + map: std::collections::HashMap, + order: std::collections::VecDeque, + pub hits: usize, +} + +#[derive(Clone, Debug)] +struct CachedEval { + error: Option, + warning: Option, + ms: f64, + state: Arc, +} + +/// Results kept (oldest dropped first). +const CACHE_ENTRIES: usize = 4096; + +impl EvalCache { + fn get(&mut self, key: u64) -> Option { + let r = self.map.get(&key).cloned(); + if r.is_some() { + self.hits += 1; + } + r + } + fn put(&mut self, key: u64, e: CachedEval) { + if self.map.insert(key, e).is_none() { + self.order.push_back(key); + } + while self.order.len() > CACHE_ENTRIES { + if let Some(k) = self.order.pop_front() { + self.map.remove(&k); + } + } + } } impl ModelState { @@ -221,6 +303,10 @@ pub struct Model { empty: Arc, /// Features recomputed by the last `evaluate`. pub last_recomputed: usize, + /// Results by inputs, shared with this model's copies. + pub cache: Arc>, + /// Evaluate everything from scratch (no reuse; tests compare the two). + pub no_cache: bool, } /// Parameters a feature uses directly (inputs and sketch dimensions), sorted. @@ -253,6 +339,56 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba h.finish() } +/// Document-level inputs any feature may read: construction planes (resolved by name), sheet +/// metal and plastic rules (and the parameters they use), components and body placement. +fn context_digest(doc: &Document, vals: &BTreeMap) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for f in doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) { + fingerprint(0, f, vals, false).hash(&mut h); + } + serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); + serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h); + serde_json::to_string(&doc.components).unwrap_or_default().hash(&mut h); + serde_json::to_string(&doc.body_components).unwrap_or_default().hash(&mut h); + for e in doc + .sheet + .rules + .iter() + .flat_map(|r| [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap]) + { + for n in expr::references(e) { + vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h); + } + } + for r in &doc.plastic.rules { + for (_, e, _) in r.fields() { + for n in expr::references(e) { + vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h); + } + } + } + h.finish() +} + +/// The cache key of a feature evaluation: its own inputs, the document context and the state it +/// starts from. Features that replay other features' definitions (patterns and mirrors of +/// features) also take the chain fingerprint, which covers every earlier definition. +fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelState) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + local.hash(&mut h); + ctx.hash(&mut h); + state.digest().hash(&mut h); + f.id.hash(&mut h); + let replays = match &f.kind { + FeatureKind::Pattern { features, .. } | FeatureKind::Mirror { features, .. } => !features.is_empty(), + _ => false, + }; + if replays { + chain_fp.hash(&mut h); + } + h.finish() +} + fn now() -> web_time::Instant { web_time::Instant::now() } @@ -284,9 +420,10 @@ impl Model { let mut prev_fp = 0u64; let mut state = self.empty.clone(); let mut out: Vec = Vec::with_capacity(doc.features.len()); - let mut reuse = true; + let mut reuse = !self.no_cache; let mut recomputed = 0; let marker = doc.marker.unwrap_or(usize::MAX); + let ctx = context_digest(doc, &vals); for (i, f) in doc.features.iter().enumerate() { let rolled_back = i >= marker; let mut fp = fingerprint(prev_fp, f, &vals, rolled_back); @@ -344,6 +481,26 @@ impl Model { }); continue; } + // The same feature on the same inputs: its earlier result. + let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state); + if !self.no_cache + && let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)) + { + if c.error.is_none() { + state = c.state.clone(); + } + out.push(FeatureResult { + id: f.id, + name: f.name.clone(), + error: c.error, + warning: c.warning, + ms: c.ms, + skipped: false, + fingerprint: fp, + state: state.clone(), + }); + continue; + } recomputed += 1; let t0 = now(); let mut next = (*state).clone(); @@ -359,11 +516,17 @@ impl Model { let ms = t0.elapsed().as_secs_f64() * 1000.0; let error = match r { Ok(()) => { + next.stamp(&state, f.id, key); state = Arc::new(next); None } Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()), }; + if !self.no_cache + && let Ok(mut m) = self.cache.lock() + { + m.put(key, CachedEval { error: error.clone(), warning: warning.clone(), ms, state: state.clone() }); + } out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() }); } self.results = out; diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index be1c0078..2ee161ad 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -83,9 +83,8 @@ pub fn read(s: &str) -> Result { return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); } } - if version < 2 { - v1_to_v2(&mut d)?; - } + // Fill in what older designs (and hand-edited files) may lack; a no-op otherwise. + normalise(&mut d)?; d.format = format_string(FORMAT); Ok(d) } @@ -134,8 +133,8 @@ fn rigid(m: &crate::Mat) -> bool { && t[..3].iter().all(|v| v.abs() <= MAX_REAL) } -/// Format 1 files may predate feature input names and occurrences. -fn v1_to_v2(d: &mut Document) -> Result<()> { +/// Feature input names and component occurrences, which format 1 files may predate. +fn normalise(d: &mut Document) -> Result<()> { let ids: Vec = d.features.iter().map(|f| f.id).collect(); for id in ids { d.name_feature_inputs(id); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f459dd30..d127ca49 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1074,3 +1074,73 @@ fn appearances_by_face_body_and_component() { assert!(s.execute("AppearanceCommand", &json!({"color": "#ffffff"})).is_err(), "no target"); assert!(s.execute("AppearanceCommand", &json!({"faces": [[500, 500, 500]], "color": "#ffffff"})).is_err(), "no face there"); } + +/// The evaluation cache gives exactly what evaluating from scratch gives, through edits, +/// suppression, roll-back, undo and redo. +#[test] +fn cached_evaluation_matches_from_scratch() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + run( + &mut s, + "PatternRectangular", + json!({"bodies": ["Plate"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 100, "dir2": [0, 1, 0], "count2": 2, "spacing2": 70}), + ); + run(&mut s, "PrimitiveBox", json!({"corner": [-50, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Side"})); + let same = |s: &Session, what: &str| { + let mut fresh = solvecraft_doc::Model::new(); + fresh.no_cache = true; + fresh.evaluate(&s.doc); + let errs = |m: &solvecraft_doc::Model| m.results.iter().map(|r| (r.id, r.error.clone(), r.skipped)).collect::>(); + assert_eq!(errs(&s.model), errs(&fresh), "{what}: feature results"); + let shape = |st: &solvecraft_doc::ModelState| { + st.bodies + .iter() + .map(|b| { + let m = solvecraft_kernel::measure(&b.body).unwrap(); + // Volume and area to 1e-9: two evaluations from scratch differ in the last + // bits too (the kernel's tessellation is not bit-for-bit repeatable). + (b.name.clone(), b.feature, (m.volume * 1e6).round(), (m.area * 1e6).round(), m.faces, m.edges, m.vertices, m.merged.faces) + }) + .collect::>() + }; + assert_eq!(shape(&s.model.state()), shape(&fresh.state()), "{what}: bodies"); + assert_eq!(s.model.state().sketches.len(), fresh.state().sketches.len(), "{what}: sketches"); + }; + // Two evaluations from scratch agree to the same precision. + let mut a = solvecraft_doc::Model::new(); + a.no_cache = true; + a.evaluate(&s.doc); + let mut b = solvecraft_doc::Model::new(); + b.no_cache = true; + b.evaluate(&s.doc); + let vols = |m: &solvecraft_doc::Model| { + m.state().bodies.iter().map(|x| (solvecraft_kernel::measure(&x.body).unwrap().volume * 1e6).round()).collect::>() + }; + assert_eq!(vols(&a), vols(&b)); + same(&s, "built"); + let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0); + for (step, (id, p)) in [ + ("ChangeParameterCommand", json!({"name": "width", "expression": "90 mm"})), + ("ChangeParameterCommand", json!({"name": "width", "expression": "80 mm"})), + ("ChangeParameterCommand", json!({"name": "thickness", "expression": "10 mm"})), + ("UndoCommand", json!({})), + ("RedoCommand", json!({})), + ("timeline.suppress", json!({"feature": "Holes", "suppressed": true})), + ("timeline.suppress", json!({"feature": "Holes", "suppressed": false})), + ("timeline.rollTo", json!({"position": 6})), + ("timeline.rollTo", json!({})), + ("UndoCommand", json!({})), + ("UndoCommand", json!({})), + ] + .into_iter() + .enumerate() + { + let before = hits(&s); + run(&mut s, id, p.clone()); + same(&s, &format!("step {step}: {id} {p}")); + if step == 1 { + assert!(hits(&s) > before, "setting the width back reuses the earlier results"); + } + } +} diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index b089e58e..72bbeae4 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -250,6 +250,17 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) { } impl Body { + /// The very same body (shared data), not just an equal-looking one. + pub fn same(&self, other: &Body) -> bool { + std::sync::Arc::ptr_eq(&self.solid, &other.solid) + && match (&self.mesh, &other.mesh) { + (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + } + && self.color == other.color + } + pub(crate) fn new(solid: Solid) -> Result { if solid.boundaries().is_empty() { return Err(KernelError::Failed("empty result".into())); diff --git a/docs/perf.md b/docs/perf.md index 228ad33c..ee864ceb 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -38,3 +38,46 @@ What changed: rows scrolled out of view only reserve their height. They register and paint nothing (no text layout, icons or eye/fold buttons), so the cost follows the rows on screen rather than the rows in the tree. Scrolling, Find in Browser and drag and drop still see every row's place. + +# Recompute performance + +`solvecraft-cli bench-edit