Prepare independent STEP bodies concurrently on desktop (#312)

This commit is contained in:
selune authored and GitHub committed 2026-10-12 11:46:32 -04:00
1 parent a9e4830ded
commit 65a99608d9
5 files changed
+417 -79

No files matched your search

+5
View File
@@ -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
+104 -79
View File
@@ -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<u64>,
bodies: Vec<ImportedBody>,
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<String>) {
*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<std::sync::Arc<StepImport>> {
/// Read a STEP file: its solids (as bodies, placed by the assembly structure) and the tree.
pub fn step_import(text: &str) -> Result<StepImport> {
step_import_with_workers(text, parallel::workers())
}
fn step_import_with_workers(text: &str, workers: usize) -> Result<StepImport> {
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<u64> =
+60
View File
@@ -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<Prepared> {
#[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::<Vec<_>>())
.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()
}
+196
View File
@@ -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::<usize>().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::<Vec<_>>(), "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), (&parallel.schema, &parallel.system));
assert_eq!(serial.bodies.len(), parallel.bodies.len());
assert_eq!(
step_export(&serial.bodies.iter().map(|b| &b.body).collect::<Vec<_>>(), "equivalence").unwrap(),
step_export(&parallel.bodies.iter().map(|b| &b.body).collect::<Vec<_>>(), "equivalence").unwrap()
);
for (a, b) in serial.bodies.iter().zip(&parallel.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::<Vec<_>>(), "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();
+52
View File
@@ -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