Skip to main content

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}