ifc_lite_processing/parallel_scan.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Parallel entity-index construction.
6//!
7//! [`build_entity_index_parallel`] returns a **byte-identical**
8//! [`EntityIndex`](ifc_lite_core::EntityIndex) to the serial
9//! [`ifc_lite_core::build_entity_index`], but scans the STEP DATA section on all
10//! cores. The STEP scan (entity offsets) is otherwise 100% single-threaded and
11//! is a large fraction of load on big models.
12//!
13//! ## Why byte-identical is achievable despite splitting mid-record
14//!
15//! The serial builder walks `EntityScanner::next_entity()` from the header-skip
16//! to EOF and does `index.insert(id, (start, end))` per entity, so the contract
17//! we must reproduce is: **the same key set, the same spans, and last-wins on a
18//! duplicate id in file order.**
19//!
20//! We split the file into N byte ranges and scan them concurrently. Only chunk 0
21//! starts at a known-good boundary (`EntityScanner::new`, header-aware); every
22//! other chunk starts at an arbitrary byte via `EntityScanner::new_at`, which may
23//! land inside a quoted string or a `/* … */` comment. A speculative scan from
24//! there can emit garbage "records" until it re-synchronises to the real STEP
25//! record grid (STEP is self-synchronising: after the next real `;` terminator
26//! the misaligned scanner produces exactly the records an aligned scanner would).
27//!
28//! The **handoff-stitch** makes this exact, not heuristic:
29//! * Each chunk `i` scans until the first entity whose `start >= range_end_i`,
30//! recording that offset as its `handoff` (the first real entity the *next*
31//! chunk owns), and keeps every earlier record.
32//! * A serial O(N) stitch replays the chunks in order. Chunk 0 is authoritative.
33//! For chunk `i>0` the previous chunk's validated handoff is a **real** entity
34//! start; we binary-search chunk `i`'s records for it. Records before it are
35//! speculative false-starts and are dropped; from it onward the scan is
36//! provably aligned (a record can only begin exactly at that offset if the
37//! `#`-hunt landed on the real `#`, and `find_entity_end` re-parses the record
38//! from its `#`, so the span is computed identically).
39//! * If the handoff is **not** present (the speculative scan overshot it, or a
40//! single record spans the whole chunk), we fall back to a serial rescan of
41//! that one range from the known-real handoff — identical output to the serial
42//! builder for those bytes. This never triggers on real files; it is the
43//! correctness net that keeps the merge byte-identical on adversarial input.
44//!
45//! Concatenating the validated slices in chunk order reproduces the serial
46//! file-order entity stream with no gap and no overlap, so inserting them in that
47//! order preserves last-wins exactly.
48//!
49//! ## Targets
50//!
51//! Native only. On wasm32 rayon runs inline (no worker threads are wired), so a
52//! parallel driver buys nothing and only adds merge overhead — the wasm build
53//! delegates straight to the serial scanner and is unchanged.
54
55use ifc_lite_core::{EntityIndex, EntityScanner};
56
57/// One shard's speculative scan over `[range_start, range_end)`.
58///
59/// This is the exact per-chunk primitive [`build_entity_index_parallel`] fans
60/// across cores, exposed for the wasm **sharded pre-pass**: each browser
61/// geometry worker calls it on a byte range and the main thread stitches the
62/// columns (binary-searching each shard for the previous shard's handoff — see
63/// the [`native::stitch`] doc). Compiled on all targets (the `native` merge is
64/// wasm-gated, but the shard primitive itself is target-independent).
65///
66/// Chunk 0 (`range_start == 0`) uses the header-aware [`EntityScanner::new`];
67/// every other shard starts *speculatively* at `range_start` via
68/// [`EntityScanner::new_at`] (which may land mid-record — the handoff stitch
69/// makes that exact, not heuristic). Returns every record with
70/// `start < range_end` (strictly increasing in `start`) plus the `handoff`: the
71/// `start` of the first record at/after `range_end` (the next shard's first real
72/// entity), or `None` at EOF.
73/// One shard's records: `(id, start, end)` per entity, strictly increasing in `start`.
74pub type ShardRecords = Vec<(u32, usize, usize)>;
75
76pub fn scan_shard(
77 content: &[u8],
78 range_start: usize,
79 range_end: usize,
80) -> (ShardRecords, Option<usize>) {
81 // Deliberately NOT delegating to `scan_shard_classified`: index-only
82 // callers (native exporters / georeferencing via
83 // `build_entity_index_parallel`) would pay a per-entity keyword
84 // classification — string matches + the `has_geometry_by_name` cache —
85 // across every record for a column they never read.
86 let mut scanner = if range_start == 0 {
87 EntityScanner::new(content)
88 } else {
89 EntityScanner::new_at(content, range_start)
90 };
91 let mut records = Vec::new();
92 let mut handoff = None;
93 while let Some((id, _type_name, start, entity_end)) = scanner.next_entity() {
94 if start >= range_end {
95 handoff = Some(start);
96 break;
97 }
98 records.push((id, start, entity_end));
99 }
100 (records, handoff)
101}
102
103
104/// Build the entity index (expressId -> byte span) across all available cores.
105///
106/// Byte-identical to [`ifc_lite_core::build_entity_index`] over the same
107/// `content`; a drop-in replacement wherever the index is built as a standalone
108/// scan on native. On wasm32 it *is* the serial builder.
109///
110/// Safe to nest under an outer rayon task (it is a pure map-reduce with no locks
111/// or channels); rayon work-steals rather than deadlocking. In practice every
112/// caller invokes it at the top level, before the per-element geometry
113/// `par_iter`, so no nesting occurs.
114pub fn build_entity_index_parallel<T>(content: &T) -> EntityIndex
115where
116 T: AsRef<[u8]> + ?Sized,
117{
118 let content = content.as_ref();
119 #[cfg(target_arch = "wasm32")]
120 {
121 ifc_lite_core::build_entity_index(content)
122 }
123 #[cfg(not(target_arch = "wasm32"))]
124 {
125 native::build(content)
126 }
127}
128
129#[cfg(not(target_arch = "wasm32"))]
130mod native {
131 use ifc_lite_core::{build_entity_index, EntityIndex, EntityScanner};
132 use rayon::prelude::*;
133 use rustc_hash::FxHashMap;
134
135 /// Below this DATA-section size the fork/join + serial-merge overhead
136 /// outweighs the scan win, so we run the serial scanner unchanged.
137 const PARALLEL_MIN_BYTES: usize = 8 * 1024 * 1024;
138
139 /// Target minimum bytes per chunk. Chunks are byte ranges, and scan cost is
140 /// ~proportional to bytes, so equal byte splits balance the work; this floor
141 /// keeps the chunk count sane on merely-large (not huge) files.
142 const MIN_CHUNK_BYTES: usize = 2 * 1024 * 1024;
143
144 pub(super) fn build(content: &[u8]) -> EntityIndex {
145 let n = chunk_count(content.len());
146 if n <= 1 {
147 return build_entity_index(content);
148 }
149 with_chunks(content, n)
150 }
151
152 fn chunk_count(len: usize) -> usize {
153 if len < PARALLEL_MIN_BYTES {
154 return 1;
155 }
156 let threads = rayon::current_num_threads().max(1);
157 let by_size = (len / MIN_CHUNK_BYTES).max(1);
158 threads.min(by_size)
159 }
160
161 /// One chunk's speculative scan: every record with `start < range_end`, plus
162 /// the `start` of the first record at/after `range_end` (the next chunk's
163 /// first real entity). `records` is strictly increasing in `start`.
164 struct ChunkScan {
165 records: Vec<(u32, usize, usize)>,
166 handoff: Option<usize>,
167 }
168
169 #[inline]
170 fn range_end(i: usize, n_chunks: usize, len: usize) -> usize {
171 if i + 1 == n_chunks {
172 len
173 } else {
174 (i + 1) * len / n_chunks
175 }
176 }
177
178 fn scan_chunk(content: &[u8], i: usize, n_chunks: usize) -> ChunkScan {
179 let start = i * content.len() / n_chunks;
180 let end = range_end(i, n_chunks, content.len());
181 // Chunk 0 uses `new` for the exact header-skip / quoted-`DATA;`
182 // semantics (`scan_shard` selects it on `range_start == 0`); every other
183 // chunk starts speculatively at its byte offset. Same shard primitive the
184 // wasm sharded pre-pass calls per worker, so the merge cannot drift.
185 let (records, handoff) = super::scan_shard(content, start, end);
186 ChunkScan { records, handoff }
187 }
188
189 /// Scan with an explicit chunk count. Public within the crate so the
190 /// byte-identity tests can force many boundary positions (including inside a
191 /// quoted string) on a small buffer.
192 pub(super) fn with_chunks(content: &[u8], n_chunks: usize) -> EntityIndex {
193 let len = content.len();
194 let n_chunks = n_chunks.max(1).min(len.max(1));
195 if n_chunks == 1 {
196 return build_entity_index(content);
197 }
198 let chunks: Vec<ChunkScan> = (0..n_chunks)
199 .into_par_iter()
200 .map(|i| scan_chunk(content, i, n_chunks))
201 .collect();
202 stitch(content, &chunks, n_chunks)
203 }
204
205 fn stitch(content: &[u8], chunks: &[ChunkScan], n_chunks: usize) -> EntityIndex {
206 let len = content.len();
207 // Same capacity heuristic as the serial builder.
208 let mut index: EntityIndex =
209 FxHashMap::with_capacity_and_hasher(len / 50, Default::default());
210
211 // Chunk 0 is authoritative: it started at the real header-skip boundary.
212 for &(id, start, end) in &chunks[0].records {
213 index.insert(id, (start, end));
214 }
215 let mut expected_start = chunks[0].handoff;
216
217 for (i, chunk) in chunks.iter().enumerate().skip(1) {
218 // `expected_start` is the real entity start where chunk `i` begins,
219 // validated by chunk `i-1`. `None` => no more real entities.
220 let target = match expected_start {
221 Some(t) => t,
222 None => break,
223 };
224 let end = range_end(i, n_chunks, len);
225 let recs = &chunk.records;
226 // `records` is strictly increasing in `start`, so a binary search
227 // locates the real boundary (or proves the chunk never re-synced).
228 match recs.binary_search_by(|&(_, start, _)| start.cmp(&target)) {
229 Ok(p) => {
230 for &(id, start, e) in &recs[p..] {
231 index.insert(id, (start, e));
232 }
233 expected_start = chunk.handoff;
234 }
235 Err(_) => {
236 // Rare: the speculative scan overshot the real boundary, or a
237 // single record spans the whole chunk. Serially rescan this
238 // range from the known-real `target` — byte-identical to the
239 // serial builder for these bytes — and recompute the handoff.
240 expected_start = rescan_range(content, target, end, &mut index);
241 }
242 }
243 }
244 index
245 }
246
247 /// Serial rescan from a known-real entity start `target` up to `end`,
248 /// inserting each entity; returns the first entity start at/after `end` (the
249 /// handoff for the next chunk), or `None` at EOF.
250 fn rescan_range(
251 content: &[u8],
252 target: usize,
253 end: usize,
254 index: &mut EntityIndex,
255 ) -> Option<usize> {
256 let mut scanner = EntityScanner::new_at(content, target);
257 while let Some((id, _type_name, start, entity_end)) = scanner.next_entity() {
258 if start >= end {
259 return Some(start);
260 }
261 index.insert(id, (start, entity_end));
262 }
263 None
264 }
265
266 #[cfg(test)]
267 mod tests {
268 use super::with_chunks;
269 use ifc_lite_core::build_entity_index;
270
271 /// Assert `with_chunks(content, n)` equals the serial index for a range of
272 /// chunk counts — many `n` means many boundary positions, so a boundary
273 /// lands inside strings/comments/records across the sweep.
274 fn assert_parallel_matches_serial(content: &[u8], label: &str) {
275 let serial = build_entity_index(content);
276 for n in [1usize, 2, 3, 4, 5, 7, 8, 11, 16, 32, 64] {
277 let par = with_chunks(content, n);
278 assert_eq!(
279 par, serial,
280 "parallel index (n_chunks={n}) != serial for {label}"
281 );
282 }
283 }
284
285 #[test]
286 fn empty_and_tiny_and_malformed() {
287 assert_parallel_matches_serial(b"", "empty");
288 assert_parallel_matches_serial(b"\n", "single-newline");
289 assert_parallel_matches_serial(
290 b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\nENDSEC;\n",
291 "header-only",
292 );
293 assert_parallel_matches_serial(
294 b"#1=IFCWALL('g',$,$,$,$,$,$,$);\n",
295 "no-header",
296 );
297 // Truncated / malformed: unterminated record, stray '#', bad digits.
298 assert_parallel_matches_serial(
299 b"DATA;\n#1=IFCWALL('g',$,$\n#2=IFCDOOR( #notanid #=x ; ;",
300 "malformed",
301 );
302 }
303
304 #[test]
305 fn simple_data_section() {
306 let mut content = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
307 for id in 1..=200u32 {
308 content.push_str(&format!(
309 "#{id}=IFCCARTESIANPOINT(({}.,{}.,{}.));\n",
310 id, id, id
311 ));
312 }
313 content.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
314 assert_parallel_matches_serial(content.as_bytes(), "simple-200");
315 }
316
317 /// Duplicate ids must resolve last-wins in file order, exactly as the
318 /// serial `insert` loop does.
319 #[test]
320 fn duplicate_ids_last_wins() {
321 let mut content = String::from("DATA;\n");
322 for _ in 0..3 {
323 for id in 1..=50u32 {
324 content.push_str(&format!("#{id}=IFCWALL('g{id}',$,$,$,$,$,$,$);\n"));
325 }
326 }
327 assert_parallel_matches_serial(content.as_bytes(), "duplicate-ids");
328 }
329
330 /// Adversarial: a record whose quoted string contains fake `;` terminators
331 /// and fake `#N=IFCWALL(...)` records. A chunk boundary inside the string
332 /// makes the speculative scanner emit garbage until it re-syncs; the stitch
333 /// (incl. the fallback) must still reproduce the serial index exactly.
334 #[test]
335 fn chunk_boundary_inside_quoted_string() {
336 let mut fake = String::new();
337 for k in 0..400 {
338 fake.push_str(&format!(";\\n#{}=IFCWALL(fake ; still in string ", 90000 + k));
339 }
340 let mut content = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
341 content.push_str("#1=IFCPROJECT('guid',$,$,$,$,$,$,$,$);\n");
342 content.push_str(&format!("#2=IFCWALL('{fake}',$,$,$,$,$,$,$);\n"));
343 for id in 3..=120u32 {
344 content.push_str(&format!("#{id}=IFCDOOR('g{id}',$,$,$,$,$,$,$);\n"));
345 }
346 content.push_str("ENDSEC;\n");
347 assert_parallel_matches_serial(content.as_bytes(), "in-string-boundary");
348 }
349
350 /// One record larger than a chunk (forces the "record spans chunk" /
351 /// fallback path where the handoff sits beyond a chunk's whole range).
352 #[test]
353 fn record_larger_than_chunk() {
354 let big_name = "X".repeat(20_000);
355 let mut content = String::from("DATA;\n");
356 content.push_str("#1=IFCPROJECT('g',$,$,$,$,$,$,$,$);\n");
357 content.push_str(&format!("#2=IFCWALL('{big_name}',$,$,$,$,$,$,$);\n"));
358 for id in 3..=40u32 {
359 content.push_str(&format!("#{id}=IFCDOOR('g{id}',$,$,$,$,$,$,$);\n"));
360 }
361 assert_parallel_matches_serial(content.as_bytes(), "record-larger-than-chunk");
362 }
363
364 /// Fixture leg: byte-identical over real models when present. Sweeps chunk
365 /// counts AND checks the public `build_entity_index_parallel` (thread-count
366 /// driven) path. Skips (never fails) when fixtures are absent.
367 #[test]
368 fn fixtures_byte_identical() {
369 for rel in [
370 "ara3d/schependomlaan.ifc",
371 "ara3d/AC-20-Smiley-West-10-Bldg.ifc",
372 "various/01_BIMcollab_Example_ARC.ifc",
373 ] {
374 let path = format!("{}/../../tests/models/{}", env!("CARGO_MANIFEST_DIR"), rel);
375 let Ok(content) = std::fs::read(&path) else {
376 eprintln!("skipping {rel}: fixture absent — run `pnpm fixtures`");
377 continue;
378 };
379 assert_parallel_matches_serial(&content, rel);
380 assert_eq!(
381 super::super::build_entity_index_parallel(&content),
382 build_entity_index(&content),
383 "public build_entity_index_parallel != serial for {rel}"
384 );
385 }
386 }
387 }
388}