diff --git a/Cargo.lock b/Cargo.lock index 480d016e..e222d0c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,6 +693,7 @@ dependencies = [ "cadcraft-fonts", "cadcraft-geom", "lyon_tessellation", + "rayon", "serde_json", "tiny-skia", ] diff --git a/crates/engine/tests/parallel_display_list.rs b/crates/engine/tests/parallel_display_list.rs new file mode 100644 index 00000000..80ffe1f9 --- /dev/null +++ b/crates/engine/tests/parallel_display_list.rs @@ -0,0 +1,52 @@ +//! Model space is built on all cores for the screen: the chunks are joined in drawing order, so +//! the display list is exactly what one thread builds (vertex and triangle indices included). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::Space; +use cadcraft_engine::render::{DisplayList, Options, build, build_entities}; +use serde_json::json; + +fn same(a: &DisplayList, b: &DisplayList) { + assert_eq!(a.prims.len(), b.prims.len()); + assert!(a.prims == b.prims, "primitives differ"); + assert!(a.verts == b.verts, "vertices differ"); + assert!(a.tris == b.tris, "triangles differ"); + assert_eq!((a.bounds.min, a.bounds.max), (b.bounds.min, b.bounds.max)); + assert_eq!(a.expanded, b.expanded); +} + +#[test] +fn parallel_model_space_matches_one_thread() { + let mut s = Session::new(); + // A block with a hatch in it, inserted many times: block expansion in every chunk. + let r = s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 3]})).unwrap(); + let h = r["handle"].clone(); + s.execute("hatch", &json!({ "handles": [h.clone()] })).unwrap(); + s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": [h], "keep": "delete"})).unwrap(); + // Well over two chunks of mixed entities: lines, circles (curves), text, inserts, points, + // solid fills (triangles), so both vertex and triangle offsets are exercised. + for i in 0..700 { + let x = f64::from(i) * 10.0; + match i % 7 { + 0 => s.execute("line", &json!({"points": [[x, 0], [x + 5.0, 5]]})), + 1 => s.execute("circle", &json!({"center": [x, 20], "radius": 3})), + 2 => s.execute("text", &json!({"at": [x, 40], "height": 2, "text": format!("T{i}")})), + 3 => s.execute("insert", &json!({"name": "B", "at": [x, 60]})), + 4 => s.execute("point", &json!({"at": [x, 80]})), + 5 => { + let r = s.execute("rectang", &json!({"p1": [x, 100], "p2": [x + 4.0, 104]})).unwrap(); + s.execute("hatch", &json!({ "handles": [r["handle"].clone()], "pattern": "SOLID" })) + } + _ => s.execute("line", &json!({"points": [[x, 120], [x, 125]]})), + } + .unwrap(); + } + let st = s.state().unwrap(); + let d = &st.doc; + let n = d.space(&Space::Model).map(|m| m.len()).unwrap_or(0); + assert!(n > 600, "{n} entities: enough for several parallel chunks"); + let one_thread = build_entities(d, d.space(&Space::Model).unwrap().iter().map(|e| e.as_ref()), &Options::default()); + let parallel = build(d, &Space::Model, &Options::default()); + assert!(!parallel.tris.is_empty() && !parallel.verts.is_empty()); + same(¶llel, &one_thread); +} diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 1858084d..a8ca8f4c 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock}; use cadcraft_geom::Vec2; use skrifa::instance::{LocationRef, Size}; @@ -598,12 +598,13 @@ enum GlyphKey { } /// Cached glyph outlines keyed by (font bytes address, length, face, glyph). Fonts live for the -/// whole process in the font table, so the address identifies them. +/// whole process in the font table, so the address identifies them. A read-write lock: display +/// lists are built on all cores and nearly every lookup is a hit, which a mutex serialized. type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc>; -fn glyph_cache() -> &'static Mutex { - static C: OnceLock> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +fn glyph_cache() -> &'static RwLock { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| RwLock::new(HashMap::new())) } const MAX_CACHED_GLYPHS: usize = 40_000; @@ -699,7 +700,7 @@ pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec]> { fn outline(face: &Face, k: GlyphKey) -> Arc { let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k); - if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) { + if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).get(&key) { return g.clone(); } let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) }; @@ -716,7 +717,7 @@ fn outline(face: &Face, k: GlyphKey) -> Arc { let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5)); let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32()); let g = Arc::new(GlyphOutline { contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) }); - let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner); + let mut cache = glyph_cache().write().unwrap_or_else(PoisonError::into_inner); if cache.len() > MAX_CACHED_GLYPHS { cache.clear(); } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 01e3633c..d62af23b 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -16,5 +16,9 @@ cadcraft-fonts = { workspace = true } tiny-skia = { workspace = true } lyon_tessellation = { workspace = true } +# Display lists of large drawings are built on all cores (not on the web, which has no threads). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 65a964b2..ce75c86d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -250,6 +250,8 @@ struct Builder<'a> { styles: Option<&'a plotstyle::Styler>, /// Named plot style (table index) of the entity being drawn. named: Option, + /// The block-expansion budget shared by the parts of a list built in parallel. + shared_budget: Option<&'a std::sync::atomic::AtomicUsize>, } /// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy @@ -368,7 +370,10 @@ impl Builder<'_> { /// Take one unit of the block-expansion budget; false once it is spent. fn expand(&mut self) -> bool { self.list.expanded = self.list.expanded.saturating_add(1); - self.list.expanded <= MAX_BLOCK_EXPANSION + match self.shared_budget { + Some(spent) => spent.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < MAX_BLOCK_EXPANSION, + None => self.list.expanded <= MAX_BLOCK_EXPANSION, + } } } @@ -411,12 +416,17 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di _ => None, }); let styler = table.map(|t| plotstyle::Styler::new(d, t)); - let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None }; + let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None }; if let Space::Paper(name) = space { b.list.sheet = paper::sheet(d, name); } if let Some(store) = d.space(space) { let paper = matches!(space, Space::Paper(_)); + #[cfg(not(target_arch = "wasm32"))] + if !paper && !plotting && b.styles.is_none() && store.len() >= 2 * PARALLEL_CHUNK { + build_parallel(&mut b, d, store); + return b.list; + } let mut viewports = 0usize; for e in store.iter() { if paper && let EntityKind::Viewport(vp) = &e.kind { @@ -439,6 +449,66 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di b.list } +/// Top-level entities per task when a display list is built on all cores. +#[cfg(not(target_arch = "wasm32"))] +const PARALLEL_CHUNK: usize = 256; + +/// Build model space for the screen on all cores: the entities are split into chunks built +/// side by side, then joined in drawing order, so the list is the same as a one-thread build. +/// The canvas rebuilds the whole list at every 2x zoom step, on the UI thread; one core made +/// that a visible stall on real drawings. Plots, layouts and plot styles keep the one-thread +/// path (they run once, and their pens and per-primitive layers are built along the way). +#[cfg(not(target_arch = "wasm32"))] +fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStore) { + use rayon::prelude::*; + let ents: Vec<&std::sync::Arc> = store.iter().collect(); + let budget = std::sync::atomic::AtomicUsize::new(0); + let (opts, plotting) = (b.opts, b.plotting); + let parts: Vec = ents + .par_chunks(PARALLEL_CHUNK) + .map(|chunk| { + let mut part = Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget) }; + for e in chunk { + entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); + } + part.list + }) + .collect(); + // One allocation per array for the joined list. + b.list.prims.reserve(parts.iter().map(|p| p.prims.len()).sum()); + b.list.verts.reserve(parts.iter().map(|p| p.verts.len()).sum()); + b.list.tris.reserve(parts.iter().map(|p| p.tris.len()).sum()); + for part in parts { + append(&mut b.list, part); + } +} + +/// Append `src` to `dst`: its primitives index into `dst`'s vertex and triangle arrays. +#[cfg(not(target_arch = "wasm32"))] +fn append(dst: &mut DisplayList, src: DisplayList) { + let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32); + // Pens are parallel to the primitives, with missing entries meaning "use object". + if !src.pens.is_empty() { + dst.pens.resize(dst.prims.len(), Pen::default()); + } + dst.prims.extend(src.prims.into_iter().map(|mut p| { + p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts }; + p + })); + dst.verts.extend(src.verts); + dst.tris.extend(src.tris); + dst.prim_layers.extend(src.prim_layers); + dst.texts.extend(src.texts.into_iter().map(|mut t| { + if t.prim < PlotText::CLIPPED { + t.prim += prims; + } + t + })); + dst.pens.extend(src.pens); + dst.bounds = dst.bounds.union(&src.bounds); + dst.expanded = dst.expanded.saturating_add(src.expanded); +} + /// `opts` with a view height for relative point sizes: the extents height when none is given /// and points draw as figures sized relative to the view. fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options { @@ -493,7 +563,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor let mcenter = vp.view_center + (rect.center() - center) / s; // Model window with slack: entity bounds are approximate (text, dimensions). let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1); - let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None }; + let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, shared_budget: None }; // PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in // paper space, whatever the viewport scale. let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 }; @@ -631,7 +701,7 @@ pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: } else { opts }; - let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None }; + let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None }; for e in ents { entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 4ddf091f..6595e145 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1,7 +1,7 @@ //! Filled font glyphs: bounded font-unit meshes shared across placements and zoom rebuilds. use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::sync::{Arc, OnceLock, PoisonError, RwLock}; use cadcraft_fonts::{TextGlyph, ttf}; use cadcraft_geom::{Bounds2, Mat3, Vec2}; @@ -43,9 +43,10 @@ struct Cache { bytes: usize, } -fn cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(Cache::default())) +/// A read-write lock: display lists are built on all cores and nearly every lookup is a hit. +fn cache() -> &'static RwLock { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| RwLock::new(Cache::default())) } /// World-space placement of font units, including the block/viewport transform. @@ -70,7 +71,7 @@ fn detail(xf: Mat3, pixel: f64) -> Detail { pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc { let detail = detail(xf, pixel); let key = (g.face.bytes.as_ptr() as usize, g.face.bytes.len(), g.face.index, g.gid, detail); - if let Some(entry) = cache().lock().unwrap_or_else(PoisonError::into_inner).entries.get(&key) { + if let Some(entry) = cache().read().unwrap_or_else(PoisonError::into_inner).entries.get(&key) { return entry.mesh.clone(); } let contours = ttf::glyph_contours(&g.face, g.gid); @@ -98,7 +99,7 @@ pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc { .saturating_add(mesh.contours.len().saturating_mul(std::mem::size_of::>())) .saturating_add(std::mem::size_of::() + std::mem::size_of::() + std::mem::size_of::()); if bytes <= MAX_CACHE_BYTES { - let mut c = cache().lock().unwrap_or_else(PoisonError::into_inner); + let mut c = cache().write().unwrap_or_else(PoisonError::into_inner); if c.entries.len() >= MAX_ENTRIES || c.bytes.saturating_add(bytes) > MAX_CACHE_BYTES { c.entries.clear(); c.bytes = 0;