diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index 584c45d0..59bf178a 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -431,6 +431,8 @@ fn cmd_bench_open(args: &[String]) -> Result<(), String> { } let t = std::time::Instant::now(); let st = s.world_state(); + // As the desktop app prepares a design it opened. + st.warm_meshes(|| true); let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum(); println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles})); Ok(()) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 1ab8c824..fc31eb75 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -619,6 +619,17 @@ impl ModelState { pub fn body(&self, name: &str) -> Option<&ModelBody> { self.bodies.iter().find(|b| b.name == name) } + + /// Make every body's display mesh now, on all cores (bodies are meshed independently and + /// each keeps its own mesh, so the meshes are those `ModelBody::mesh` makes one by one). + /// Stops starting bodies once `go_on` says no. + pub fn warm_meshes(&self, go_on: impl Fn() -> bool + Sync) { + kernel::for_each_on_threads(&self.bodies, 16, |b| { + if go_on() { + let _ = b.mesh(); + } + }); + } pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> { self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s) } diff --git a/crates/doc/src/name_lookup.rs b/crates/doc/src/name_lookup.rs index 3b516391..0e0aae2a 100644 --- a/crates/doc/src/name_lookup.rs +++ b/crates/doc/src/name_lookup.rs @@ -260,6 +260,14 @@ impl EdgeHit { /// answering to it (merged into it, or the named piece told apart by the faces around it); else /// the piece of it nearest `p` (`split`). pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option { + // Every body's names are read below: work out the ones not known yet on all cores first + // (each body's names are its own, whichever thread works them out). + if legacy_name(name).is_none() { + let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect(); + solidcraft_kernel::for_each_on_threads(&bodies, 16, |b| { + let _ = naming::names(b); + }); + } locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p) } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 44e2a296..c68d1043 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -66,7 +66,12 @@ fn measure(s: &mut Session, p: &Value) -> Result { let want = string_list(p, "bodies"); let st = s.world_state(); let selected: Vec<_> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).collect(); - let mut bodies: Vec = selected.iter().map(|b| measure_json(b)).collect(); + // Each body is measured on its own: on all cores, in body order. + let mut bodies: Vec = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_json(b)) + .into_iter() + .zip(&selected) + .map(|(m, b)| m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"}))) + .collect(); // Mass from each body's material. for (b, body) in bodies.iter_mut().zip(selected) { if let Some(v) = b["volume_mm3"].as_f64() { diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 9ff27e2c..2818b3f0 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -56,6 +56,7 @@ mod numbered; mod offset; mod ops; mod par; +pub use par::{for_each_on_threads, map_on_threads}; #[cfg(test)] mod pinch_tests; mod plane_fit; diff --git a/crates/kernel/src/par.rs b/crates/kernel/src/par.rs index b0f281c8..5e08be76 100644 --- a/crates/kernel/src/par.rs +++ b/crates/kernel/src/par.rs @@ -22,11 +22,90 @@ pub(crate) fn map(what: &'static str, items: &[T], parallel: b items.iter().map(one).collect() } +/// Run `f` on every item, spread over plain threads (one per core, at most `max_threads`), +/// each pulling the next item; on the calling thread in the browser build or when threads +/// cannot be started. Each call runs under [`crate::guard`] (a panic is that item's loss). +/// +/// Plain threads rather than the worker pool: an item may wait on a lazily filled cell that +/// another item is filling, and a pool worker that picked up a second item while it waited +/// inside its own item's parallel work could wait on itself. +pub fn for_each_on_threads(items: &[T], max_threads: usize, f: impl Fn(&T) + Sync) { + let _ = map_on_threads(items, max_threads, f); +} + +/// `f` over `items` as [`for_each_on_threads`] runs it, results in item order (`None` where an +/// item panicked). +pub fn map_on_threads(items: &[T], max_threads: usize, f: impl Fn(&T) -> R + Sync) -> Vec> { + let one = |item: &T| guard("parallel item", || Ok(f(item))).ok(); + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::atomic::{AtomicUsize, Ordering}; + let threads = std::thread::available_parallelism().map(usize::from).unwrap_or(1).min(max_threads).min(items.len()); + if threads > 1 { + let next = AtomicUsize::new(0); + let work = || { + let mut done = Vec::new(); + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(i) else { break }; + done.push((i, one(item))); + } + done + }; + let mut out: Vec> = std::iter::repeat_with(|| None).take(items.len()).collect(); + std::thread::scope(|scope| { + // A thread that cannot start leaves its share to the others. + let helpers: Vec<_> = + (1..threads).filter_map(|_| std::thread::Builder::new().name("solidcraft-each".into()).spawn_scoped(scope, work).ok()).collect(); + let mut all = work(); + for h in helpers { + // `work` never panics (every item runs under the guard). + if let Ok(done) = h.join() { + all.extend(done); + } + } + for (i, r) in all { + if let Some(slot) = out.get_mut(i) { + *slot = r; + } + } + }); + return out; + } + } + let _ = max_threads; + items.iter().map(one).collect() +} + #[cfg(test)] mod tests { use super::*; use crate::KernelError; + #[test] + fn each_item_once_on_threads() { + let items: Vec = (0..1000).collect(); + let seen: Vec = (0..1000).map(|_| std::sync::atomic::AtomicU32::new(0)).collect(); + for_each_on_threads(&items, 8, |x| { + seen[*x as usize].fetch_add(1, std::sync::atomic::Ordering::Relaxed); + assert!(*x != 500, "a panicking item is that item's loss"); + }); + assert!(seen.iter().all(|s| s.load(std::sync::atomic::Ordering::Relaxed) == 1)); + } + + #[test] + fn map_on_threads_keeps_order() { + let items: Vec = (0..1000).collect(); + let out = map_on_threads(&items, 8, |x| { + assert!(*x != 7, "a panicking item is that item's loss"); + x * 3 + }); + assert_eq!(out.len(), 1000); + for (i, r) in out.iter().enumerate() { + assert_eq!(*r, (i != 7).then_some(3 * i as u32)); + } + } + #[test] fn keeps_order_and_errors() { let items: Vec = (0..1000).collect(); diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index ad490c2e..4e4b5eb7 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -370,11 +370,9 @@ fn compute(mut session: Session, command: &str, params: &Value, thumbnail: bool, let placement_ms = view_began.elapsed().as_secs_f64() * 1000.0; let mesh_began = std::time::Instant::now(); // Fit, picking and the first viewport frame must not trigger cold tessellation on the UI. - for b in &world.bodies { - if progress.is_cancelled() { - return cancelled(); - } - let _ = b.mesh(); + world.warm_meshes(|| !progress.is_cancelled()); + if progress.is_cancelled() { + return cancelled(); } let tessellation_ms = mesh_began.elapsed().as_secs_f64() * 1000.0; phase.store(2, Ordering::Relaxed);