From 65a99608d9724df8284bb81ec7faec96d92afab9 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:46:32 +0200 Subject: [PATCH] Prepare independent STEP bodies concurrently on desktop (#312) --- ROADMAP.md | 5 + crates/kernel/src/step_in/mod.rs | 183 +++++++++++++----------- crates/kernel/src/step_in/parallel.rs | 60 ++++++++ crates/kernel/src/step_in/tests.rs | 196 ++++++++++++++++++++++++++ docs/perf.md | 52 +++++++ 5 files changed, 417 insertions(+), 79 deletions(-) create mode 100644 crates/kernel/src/step_in/parallel.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0480d352..90dc6e68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -330,6 +330,11 @@ and reference-object placement remain separate work, as do loft rails and center ## Document-opening performance +- Desktop STEP import prepares consecutive independent solids in bounded batches with up + to eight workers, retaining serial traversal, result order and persistent source identities. + The web path remains serial. Original multi-solid fixtures show 64–81% lower complete + import times; single-solid assembly parts are not scheduled across nodes. See + [measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12). - Cold rebuild profiling separates parsing, geometry, placement, display meshing and thumbnails. Straight-line closest-point projection and exact curved-surface evaluation reuse reduce repeated work without relaxing geometry checks. Three alternating optimized diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 794fdac3..6fa7eb57 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -10,6 +10,7 @@ mod brep; pub(crate) mod geom; pub(crate) mod p21; +mod parallel; mod split; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -243,9 +244,32 @@ struct Reader<'a> { /// Items whose meshing was checked. unmeshed_checked: HashSet, bodies: Vec, + workers: usize, } impl<'a> Reader<'a> { + fn new(ex: &'a Exchange, workers: usize) -> Self { + Self { + ex, + units: HashMap::new(), + warnings: BTreeMap::new(), + reps: HashMap::new(), + rep_links: HashMap::new(), + pd_reps: HashMap::new(), + children: HashMap::new(), + placements: HashMap::new(), + colors: HashMap::new(), + opacity: HashMap::new(), + solids: HashMap::new(), + used_items: HashSet::new(), + node_items: HashSet::new(), + visits: 0, + unmeshed_checked: HashSet::new(), + bodies: Vec::new(), + workers, + } + } + fn warn(&mut self, msg: impl Into) { *self.warnings.entry(msg.into()).or_default() += 1; } @@ -468,71 +492,85 @@ impl<'a> Reader<'a> { return; } let u = self.units_of(rep); - let items = self.reps.get(&rep).map(|r| r.0.clone()).unwrap_or_default(); - for item in items { + let mut items = self.reps.get(&rep).map(|r| r.0.clone()).unwrap_or_default().into_iter().peekable(); + while let Some(item) = items.next() { let Some(e) = self.ex.get(item) else { continue }; match e.name() { "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL" => { - self.used_items.insert(item); - if !self.node_items.insert(item) { - continue; - } - if self.bodies.len() >= MAX_BODIES { - self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out")); - return; - } - let Some((solid, closed, file_faces, face_origin)) = self.item_solid(item, u) else { continue }; - let identity = *m == mt::Matrix4::from_scale(1.0); - let placed = if identity { - Ok(solid) - } else { - let mm = *m; - guard("step placement", || Ok(mt::builder::transformed(&solid, mm))) - }; - let body = placed.and_then(Body::new); - match body { - Ok(body) => { - let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string(); - let name = if own.is_empty() { product.to_string() } else { own }; - // Faces the mesher gives up on (or that the budget cut short) are reported. - if self.unmeshed_checked.insert(item) { - let n = body.unmeshed_faces(); - if n > 0 { - self.warn(format!("solid '{name}': {n} face(s) could not be meshed and are not shown")); - } - } - out.push(self.bodies.len()); - let color = self.colors.get(&item).copied(); - // See-through bodies and faces with colours of their own. - let faces = face_origin - .iter() - .enumerate() - .filter_map(|(i, f)| { - let c = self.colors.get(f)?; - Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) }) - }) - .collect(); - let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces }; - let body = body.with_color(color).with_paint(Some(paint)); - let identities = face_origin - .iter() - .map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None })) - .collect(); - let body = body.with_face_sources(Some(std::sync::Arc::new(identities))); - self.bodies.push(ImportedBody { - name, - body, - color, - path: path.to_vec(), - closed, - file_faces, - source_item: item, - face_origin, - }); + // Only batch consecutive solids: mapped items must retain traversal and + // first-use unit/cache order. Bound temporary geometry independently of file size. + let mut batch = vec![item]; + let capacity = MAX_BODIES.saturating_sub(self.bodies.len()).min(64); + while batch.len() < capacity + && items.peek().is_some_and(|id| { + self.ex.get(*id).is_some_and(|e| { + matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL") + }) + }) + { + if let Some(next) = items.next() { + batch.push(next); } - Err(err) => { - let label = self.label(item); - self.warn(format!("{label}: not imported: {err}")); + } + let mut sources = Vec::new(); + let mut solids = Vec::new(); + for item in batch { + self.used_items.insert(item); + if !self.node_items.insert(item) { + continue; + } + if self.bodies.len() >= MAX_BODIES { + self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out")); + return; + } + let Some((solid, closed, file_faces, face_origin)) = self.item_solid(item, u) else { continue }; + sources.push((item, closed, file_faces, face_origin)); + solids.push(parallel::Job { solid, check_mesh: !self.unmeshed_checked.contains(&item) }); + } + let prepared = parallel::bodies(&solids, m, self.workers); + for ((item, closed, file_faces, face_origin), body) in sources.into_iter().zip(prepared) { + let Some(e) = self.ex.get(item) else { continue }; + match body { + Ok((body, missing)) => { + let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string(); + let name = if own.is_empty() { product.to_string() } else { own }; + // Faces the mesher gives up on (or that the budget cut short) are reported. + if self.unmeshed_checked.insert(item) && missing > 0 { + self.warn(format!("solid '{name}': {missing} face(s) could not be meshed and are not shown")); + } + out.push(self.bodies.len()); + let color = self.colors.get(&item).copied(); + // See-through bodies and faces with colours of their own. + let faces = face_origin + .iter() + .enumerate() + .filter_map(|(i, f)| { + let c = self.colors.get(f)?; + Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) }) + }) + .collect(); + let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces }; + let body = body.with_color(color).with_paint(Some(paint)); + let identities = face_origin + .iter() + .map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None })) + .collect(); + let body = body.with_face_sources(Some(std::sync::Arc::new(identities))); + self.bodies.push(ImportedBody { + name, + body, + color, + path: path.to_vec(), + closed, + file_faces, + source_item: item, + face_origin, + }); + } + Err(err) => { + let label = self.label(item); + self.warn(format!("{label}: not imported: {err}")); + } } } } @@ -826,25 +864,12 @@ pub fn step_import_shared(text: &str) -> Result> { /// Read a STEP file: its solids (as bodies, placed by the assembly structure) and the tree. pub fn step_import(text: &str) -> Result { + step_import_with_workers(text, parallel::workers()) +} + +fn step_import_with_workers(text: &str, workers: usize) -> Result { let ex = p21::parse(text).map_err(KernelError::Invalid)?; - let mut rd = Reader { - ex: &ex, - units: HashMap::new(), - warnings: BTreeMap::new(), - reps: HashMap::new(), - rep_links: HashMap::new(), - pd_reps: HashMap::new(), - children: HashMap::new(), - placements: HashMap::new(), - colors: HashMap::new(), - opacity: HashMap::new(), - solids: HashMap::new(), - used_items: HashSet::new(), - node_items: HashSet::new(), - visits: 0, - unmeshed_checked: HashSet::new(), - bodies: Vec::new(), - }; + let mut rd = Reader::new(&ex, workers); rd.index(); // Roots: product definitions that are nobody's component. let child_pds: HashSet = diff --git a/crates/kernel/src/step_in/parallel.rs b/crates/kernel/src/step_in/parallel.rs new file mode 100644 index 00000000..4883a1d7 --- /dev/null +++ b/crates/kernel/src/step_in/parallel.rs @@ -0,0 +1,60 @@ +//! Bounded desktop preparation of independent imported bodies, collected in file order. +use super::{Body, Result, guard, mt}; + +pub(super) struct Job { + pub solid: mt::Solid, + pub check_mesh: bool, +} + +type Prepared = Result<(Body, usize)>; + +pub(super) fn workers() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::available_parallelism().map_or(1, |n| n.get().min(8)) + } + #[cfg(target_arch = "wasm32")] + { + 1 + } +} + +fn prepare(job: &Job, placement: &mt::Matrix4) -> Prepared { + guard("STEP body preparation", || { + let solid = if *placement == mt::Matrix4::from_scale(1.0) { job.solid.clone() } else { mt::builder::transformed(&job.solid, *placement) }; + let body = Body::new(solid)?; + let missing = if job.check_mesh { body.unmeshed_faces() } else { 0 }; + Ok((body, missing)) + }) +} + +pub(super) fn bodies(solids: &[Job], placement: &mt::Matrix4, workers: usize) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if solids.len() >= 4 && workers > 1 { + return std::thread::scope(|scope| { + let size = solids.len().div_ceil(workers.min(8)); + let mut pending = Vec::new(); + for chunk in solids.chunks(size) { + let handle = std::thread::Builder::new() + .name("step-body".into()) + .spawn_scoped(scope, move || chunk.iter().map(|solid| prepare(solid, placement)).collect::>()) + .ok(); + pending.push((chunk, handle)); + } + let mut output = Vec::with_capacity(solids.len()); + for (chunk, handle) in pending { + match handle { + Some(handle) => match handle.join() { + Ok(results) => output.extend(results), + Err(_) => output.extend(chunk.iter().map(|_| Err(crate::KernelError::Internal("STEP body worker panicked".into())))), + }, + // Resource exhaustion must not lose an import: use the serial path. + None => output.extend(chunk.iter().map(|solid| prepare(solid, placement))), + } + } + output + }); + } + let _ = workers; + solids.iter().map(|solid| prepare(solid, placement)).collect() +} diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index b8a92dfc..4a142ff4 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -7,6 +7,195 @@ fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-9) } +#[test] +#[ignore = "manual STEP import timing; run without concurrent builds"] +fn benchmark_independent_step_solids() { + let count = std::env::var("SOLVECRAFT_STEP_BENCH_COUNT").ok().map(|s| s.parse::().unwrap().clamp(1, 512)).unwrap_or(48); + let kind = std::env::var("SOLVECRAFT_STEP_BENCH_KIND").unwrap_or_else(|_| "cylinder".into()); + let bodies: Vec<_> = (0..count) + .map(|i| { + let at = Vec3::new((i % 8) as f64 * 60.0, (i / 8) as f64 * 60.0, 0.0); + match kind.as_str() { + "torus" => torus(at, 20.0, 4.0).unwrap(), + "box" => box_solid(at, at + Vec3::new(10.0, 15.0, 12.0)).unwrap(), + _ => cylinder(at, Vec3::Z, 5.0, 12.0).unwrap(), + } + }) + .collect(); + let text = step_export(&bodies.iter().collect::>(), "independent bodies").unwrap(); + if let Ok(path) = std::env::var("SOLVECRAFT_STEP_BENCH_FIXTURE") { + std::fs::write(path, &text).unwrap(); + } + let start = std::time::Instant::now(); + let ex = p21::parse(&text).unwrap(); + let parse = start.elapsed(); + let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 0.01 }; + let mut items: Vec<_> = ex.entities.iter().filter(|(_, e)| e.name() == "MANIFOLD_SOLID_BREP").map(|(id, _)| *id).collect(); + items.sort(); + let start = std::time::Instant::now(); + let solids: Vec<_> = items.iter().map(|id| guard("benchmark read", || brep::read_item(&cx, *id).map_err(KernelError::Failed)).unwrap()).collect(); + let read = start.elapsed(); + let start = std::time::Instant::now(); + for solid in solids { + std::hint::black_box(Body::new(solid.solid).unwrap().unmeshed_faces()); + } + println!( + "STEP_PHASE parse_ms={:.3} brep_ms={:.3} body_ms={:.3}", + parse.as_secs_f64() * 1000.0, + read.as_secs_f64() * 1000.0, + start.elapsed().as_secs_f64() * 1000.0 + ); + for run in 0..5 { + for workers in if run % 2 == 0 { [1, 8] } else { [8, 1] } { + let start = std::time::Instant::now(); + let imported = step_import_with_workers(&text, workers).unwrap(); + let elapsed = start.elapsed(); + assert_eq!(imported.bodies.len(), count); + assert!(imported.warnings.is_empty(), "{:?}", imported.warnings); + println!( + "STEP_BENCH kind={kind} run={run} workers={workers} bodies={count} bytes={} ms={:.3}", + text.len(), + elapsed.as_secs_f64() * 1000.0 + ); + std::hint::black_box(imported); + } + } +} + +fn assert_same_import(serial: &StepImport, parallel: &StepImport) { + assert_eq!(serial.tree, parallel.tree); + assert_eq!(serial.warnings, parallel.warnings); + assert_eq!((&serial.schema, &serial.system), (¶llel.schema, ¶llel.system)); + assert_eq!(serial.bodies.len(), parallel.bodies.len()); + assert_eq!( + step_export(&serial.bodies.iter().map(|b| &b.body).collect::>(), "equivalence").unwrap(), + step_export(¶llel.bodies.iter().map(|b| &b.body).collect::>(), "equivalence").unwrap() + ); + for (a, b) in serial.bodies.iter().zip(¶llel.bodies) { + assert_eq!( + (&a.name, &a.path, a.source_item, &a.face_origin, a.closed, a.file_faces, a.color), + (&b.name, &b.path, b.source_item, &b.face_origin, b.closed, b.file_faces, b.color) + ); + assert_eq!(a.body.paint(), b.body.paint()); + assert_eq!(a.body.face_sources, b.body.face_sources); + assert_same_measure(&measure(&a.body).unwrap(), &measure(&b.body).unwrap()); + let (a, b) = (a.body.display_mesh(0.05).unwrap(), b.body.display_mesh(0.05).unwrap()); + assert_eq!(a.positions, b.positions); + assert_eq!(a.normals, b.normals); + assert_eq!(a.triangles, b.triangles); + assert_eq!(a.tri_face, b.tri_face); + assert_eq!(a.edges, b.edges); + assert_eq!(a.seams, b.seams); + assert_eq!(a.smooth_edges, b.smooth_edges); + assert_eq!(a.sharp_edges, b.sharp_edges); + assert_eq!(a.edge_faces, b.edge_faces); + } +} + +fn assert_same_measure(a: &crate::BodyMeasure, b: &crate::BodyMeasure) { + assert_eq!((a.volume, a.area, a.centroid, a.bbox.min, a.bbox.max), (b.volume, b.area, b.centroid, b.bbox.min, b.bbox.max)); + assert_eq!((a.faces, a.edges, a.vertices, a.shells, &a.merged), (b.faces, b.edges, b.vertices, b.shells, &b.merged)); +} + +#[test] +fn parallel_import_retains_units_order_sources_and_partial_failure_across_batches() { + let mut text = String::from(HEAD); + text += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n"; + text += &cube_entities(); + text += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))"); + text += &product(50, "Batch"); + let mut refs = Vec::new(); + for i in 0..70 { + let id = 2000 + i; + text += &format!("#{id}=MANIFOLD_SOLID_BREP('Cube{i}',#100);\n"); + refs.push(format!("#{id}")); + } + refs.insert(3, "#3000".into()); + refs.insert(8, "#2000".into()); + text += "#3000=MANIFOLD_SOLID_BREP('Broken',#999999);\n"; + text += &format!("#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',({}),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n", refs.join(",")); + text += TAIL; + let serial = step_import_with_workers(&text, 1).unwrap(); + assert_eq!(serial.bodies.len(), 70); + assert!(serial.warnings.iter().any(|warning| warning.contains("Broken"))); + assert_eq!(serial.bodies.first().unwrap().name, "Cube0"); + assert_eq!(serial.bodies.last().unwrap().name, "Cube69"); + assert!((measure(&serial.bodies[0].body).unwrap().volume - 1000.0).abs() < 1e-9); + for workers in [2, 3, 8, usize::MAX] { + assert_same_import(&serial, &step_import_with_workers(&text, workers).unwrap()); + } +} + +#[test] +fn parallel_body_preparation_reports_errors_in_place_and_preserves_placement() { + let body = cylinder(Vec3::new(2.0, 2.0, 0.0), Vec3::Z, 2.0, 4.0).unwrap(); + let solids = vec![body.solid.as_ref().clone(), mt::Solid::new(Vec::new()), body.solid.as_ref().clone(), body.solid.as_ref().clone()]; + let placement = mt::Matrix4::from_translation(mt::Vector3::new(10.0, 20.0, 30.0)); + let jobs: Vec<_> = solids.into_iter().map(|solid| parallel::Job { solid, check_mesh: true }).collect(); + let serial = parallel::bodies(&jobs, &placement, 1); + let threaded = parallel::bodies(&jobs, &placement, 4); + assert_eq!(serial.len(), 4); + assert_eq!(threaded.len(), 4); + for (index, (a, b)) in serial.iter().zip(&threaded).enumerate() { + if index == 1 { + assert_eq!(a.as_ref().unwrap_err(), b.as_ref().unwrap_err()); + } else { + assert_eq!(a.as_ref().unwrap().1, b.as_ref().unwrap().1); + let a = measure(&a.as_ref().unwrap().0).unwrap(); + let b = measure(&b.as_ref().unwrap().0).unwrap(); + assert_same_measure(&a, &b); + // Curved bounds come from the measurement tessellation, not analytic extrema. + assert!(a.bbox.min.dist(Vec3::new(10.0, 20.0, 30.0)) < 1e-3); + } + } +} + +#[test] +fn parallel_curved_import_keeps_face_colours_and_exact_display_meshes() { + let bodies: Vec<_> = (0..8) + .map(|i| { + let body = cylinder(Vec3::new(i as f64 * 20.0, 0.0, 0.0), Vec3::Z, 5.0, 12.0).unwrap(); + body.with_color(Some([0.2, 0.4, 0.6])) + .with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.1, 0.3], opacity: 0.5 }] })) + }) + .collect(); + let text = step_export(&bodies.iter().collect::>(), "coloured cylinders").unwrap(); + let serial = step_import_with_workers(&text, 1).unwrap(); + assert_eq!(serial.bodies.len(), 8); + assert!(serial.bodies.iter().all(|b| b.color == Some([0.2, 0.4, 0.6]))); + assert!(serial.bodies.iter().all(|b| b.body.paint().is_some_and(|p| !p.faces.is_empty()))); + for workers in [2, 3, 8] { + assert_same_import(&serial, &step_import_with_workers(&text, workers).unwrap()); + } +} + +#[test] +fn parallel_import_does_not_prepare_past_body_budget_with_duplicates_or_mapped_items() { + let text = format!( + "{HEAD}{}#2000=MANIFOLD_SOLID_BREP('Duplicate',#100);\n#2001=MANIFOLD_SOLID_BREP('First',#100);\n#2002=MANIFOLD_SOLID_BREP('Second',#100);\n#2003=MANIFOLD_SOLID_BREP('Beyond budget',#999999);\n#3000=MAPPED_ITEM('',#999998,#999997);\n#60=SHAPE_REPRESENTATION('',(#2000,#2000,#2001,#3000,#2002,#2003),$);\n{TAIL}", + cube_entities() + ); + let ex = p21::parse(&text).unwrap(); + let seed = + step_import_with_workers(&format!("{HEAD}{}#60=SHAPE_REPRESENTATION('',(#101),$);\n{TAIL}", cube_entities()), 1).unwrap().bodies.remove(0); + for workers in [1, 8] { + let mut reader = Reader::new(&ex, workers); + reader.index(); + reader.bodies = vec![seed.clone(); MAX_BODIES - 2]; + reader.node_items.insert(2000); + let mut out = Vec::new(); + reader.rep_bodies(60, &mt::Matrix4::from_scale(1.0), &[], "Budget", &mut out, 0); + assert_eq!(reader.bodies.len(), MAX_BODIES); + assert_eq!(out, vec![MAX_BODIES - 2, MAX_BODIES - 1]); + assert_eq!(reader.bodies.last().unwrap().name, "Second"); + assert_eq!(reader.solids.len(), 2); + assert!(!reader.solids.contains_key(&2003)); + assert!(!reader.used_items.contains(&3000)); + assert_eq!(reader.warnings.len(), 1); + assert!(reader.warnings.keys().next().unwrap().contains("more than")); + } +} + /// Export → import → same volume, area and face count. fn round_trip(b: &Body) -> StepImport { let text = step_export(&[b], "test").unwrap(); @@ -301,6 +490,13 @@ fn hand_written_assembly() { } t += TAIL; let imp = step_import(&t).unwrap(); + assert_same_import(&step_import_with_workers(&t, 1).unwrap(), &step_import_with_workers(&t, 8).unwrap()); + let expanded = t.replace("(#101,#65)", "(#101,#2000,#2001,#2002,#2003,#65)"); + let extra: String = (0..4).map(|i| format!("#{}=MANIFOLD_SOLID_BREP('Extra{i}',#100);\n", 2000 + i)).collect(); + let expanded = expanded.replace(TAIL, &(extra + TAIL)); + let serial = step_import_with_workers(&expanded, 1).unwrap(); + assert_eq!(serial.bodies.len(), 10); + assert_same_import(&serial, &step_import_with_workers(&expanded, 8).unwrap()); assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); assert_eq!(imp.bodies.len(), 2); let boxes: Vec<_> = imp.bodies.iter().map(|b| measure(&b.body).unwrap()).collect(); diff --git a/docs/perf.md b/docs/perf.md index f9969f2d..7baa026b 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -1,5 +1,57 @@ # Design loading +## Parallel STEP body preparation (2026-10-12) + +Profiling an original 48-cylinder STEP fixture before changing the importer measured +3.17 ms parsing, 6.06 ms reconstructing B-reps and 45.58 ms preparing/orienting bodies. +The five complete imports had a median of 59.21 ms. The expensive orientation mesh, +rather than the text parser, was the first parallelization target for #287. + +Desktop import now prepares consecutive independent solids in a representation with +up to eight workers. Each batch contains at most 64 solids. Parsing, B-rep reconstruction, +assembly traversal, first-use unit resolution, naming and result collection remain ordered. +Mapped items end a batch. Fewer than four solids use the serial path; wasm always uses it. +Thread creation failure falls back to serial work, and kernel/worker failures remain +per-body errors reported by the importer. + +Windows x64, AMD Ryzen AI Max+ 395 (16 cores / 32 logical processors), optimized `ci` +profile, no concurrent build: three fresh test processes per fixture, five alternating +serial/parallel pairs in each process. These are complete uncached kernel imports, +including parsing and body preparation; fixture generation/export and later desktop +display meshing are outside the timer. Both paths use the same input text and code. + +| Original generated fixture | Bodies | Serial median | 8 workers median | Reduction | +|---|---:|---:|---:|---:| +| Cylinders, radius 5 mm, height 12 mm | 48 | 57.54 ms | 17.38 ms | 69.8% | +| Cylinders, radius 5 mm, height 12 mm | 512 | 896.31 ms | 313.06 ms | 65.1% | +| Tori, major radius 20 mm, minor radius 4 mm | 48 | 269.05 ms | 51.90 ms | 80.7% | +| Boxes, 10 × 15 × 12 mm | 512 | 247.97 ms | 90.15 ms | 63.6% | +| Boxes, 10 × 15 × 12 mm | 4 | 1.49 ms | 1.02 ms | 31.5% | +| One cylinder (serial fallback) | 1 | 1.22 ms | 1.29 ms | serial fallback | + +There are 15 samples per cell. The single-cylinder modes run identical serial code; their +0.07 ms median difference is within the overlapping 1.11–1.57 / 1.13–1.52 ms ranges. The 512-cylinder runs ranged from 567.40–983.61 ms +serial and 197.72–345.51 ms parallel; scheduling/thermal variation is material. These +synthetic results do not establish a speedup for every assembly or complete desktop Open. +In particular, parts containing only one solid are not parallelized across assembly nodes. +Cross-part scheduling, parallel B-rep reconstruction and later display meshes remain open. + +Reproduce after compiling, without other builds running: + +```sh +cargo test -p solvecraft-kernel --profile ci benchmark_independent_step_solids --no-run +SOLVECRAFT_STEP_BENCH_COUNT=512 SOLVECRAFT_STEP_BENCH_KIND=cylinder \ + cargo test -p solvecraft-kernel --profile ci benchmark_independent_step_solids \ + -- --ignored --nocapture --test-threads=1 +``` + +Use `torus` or `box` for the other fixtures and repeat in three fresh processes. +`SOLVECRAFT_STEP_BENCH_FIXTURE` optionally writes the original STEP for native inspection. +No timing threshold runs in CI. Regressions compare serial and threaded imports with +different worker counts: exact display meshes, measures, body/face source identities, +names, colours/opacity, units, rotated assembly placements, duplicates and partial failures +across the batch boundary. The public analytic/FreeCAD reference suite also checks STEP +export/import; its current results are reported in docs/oracle.md. ## Analytic extrusion source preparation New-body extrusion certificates now prepare the sketch's bounded analytic segments once