Prepare independent STEP bodies concurrently on desktop (#312)
This commit is contained in:
1 parent
a9e4830ded
commit
65a99608d9
5 files changed
+417
-79
No files matched your search
@@ -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
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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), (¶llel.schema, ¶llel.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(¶llel.bodies.iter().map(|b| &b.body).collect::<Vec<_>>(), "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::<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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user