Skip to main content

ifc_lite_core/
columnar_index.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//! Columnar entity index — a compact, binary-searched alternative to the
6//! [`EntityIndex`](crate::EntityIndex) `FxHashMap<u32, (usize, usize)>`.
7//!
8//! # Why
9//!
10//! The streaming pre-pass hands every wasm worker (N geometry workers plus the
11//! prepass and parser workers) the same pre-scanned entity index as three
12//! parallel `u32` columns
13//! via `setEntityIndex`. Each worker used to materialize a private
14//! `FxHashMap<u32, (usize, usize)>` from those columns. hashbrown rounds the
15//! bucket count up to the next power of two, so for a 19.1 M-entity model it
16//! allocates `2^25` buckets × ~13 B ≈ **436 MB per worker**, rebuilt in every
17//! realm. Three sorted `Vec<u32>` columns for the same model are
18//! `3 × 19.1 M × 4 B ≈ 229 MB` — no power-of-two rounding, no per-bucket control
19//! byte, no `(usize, usize)` widening. The lookup becomes a `binary_search`
20//! (≈24 probes at 19 M rows) instead of an O(1) hash probe; see the PR for the
21//! measured wall-time delta on the full geometry pipeline.
22//!
23//! # `u32` offsets
24//!
25//! `starts`/`lengths` are `u32`, which is only sound while the source file is
26//! < 4 GiB. This type is used **exclusively on the wasm ingestion path**
27//! (`setEntityIndex` and the wasm `cached_entity_index`), where the whole file
28//! already lives in the < 4 GiB wasm32 linear address space and the delivered
29//! columns are themselves `&[u32]`. Native / server paths that can exceed 4 GiB
30//! keep the `usize`-carrying [`EntityIndex`](crate::EntityIndex) hashmap.
31//!
32//! # Duplicate express ids
33//!
34//! [`crate::build_entity_index`] inserts scanned spans into an `FxHashMap` in
35//! file order, so a repeated express id resolves to its **last** occurrence in
36//! the file (`HashMap::insert` overwrites). Express ids are unique per the STEP
37//! spec and duplicates essentially never occur, but this type replicates the
38//! last-in-file-order-wins behaviour deliberately (see `from_unsorted` and the
39//! `duplicate_id_last_wins` test) so a malformed file cannot diverge between the
40//! hashmap and columnar paths.
41
42use crate::decoder::EntityIndex;
43use crate::parser::EntityScanner;
44use std::sync::Arc;
45
46/// Compact, sorted, binary-searched entity index. Columns are kept sorted by
47/// `ids` (strictly ascending, unique) so [`Self::lookup`] can `binary_search`.
48///
49/// Invariants (upheld by every constructor):
50/// - `ids.len() == starts.len() == lengths.len()`
51/// - `ids` is strictly ascending (hence unique)
52/// - `starts[i]` / `lengths[i]` are the byte offset / byte length of `ids[i]`,
53///   so `lookup` returns `(start, start + length)` to match the `(start, end)`
54///   tuple layout of [`EntityIndex`](crate::EntityIndex).
55pub struct ColumnarEntityIndex {
56    ids: Vec<u32>,
57    starts: Vec<u32>,
58    lengths: Vec<u32>,
59}
60
61impl ColumnarEntityIndex {
62    /// Build from the three delivered columns (`setEntityIndex` ingestion).
63    ///
64    /// Verifies the id column's ordering **once**, O(n): the pre-pass emits in
65    /// whatever order it iterates (its own `FxHashMap` iteration order is
66    /// arbitrary), so this cannot assume ascending. If the ids are already
67    /// strictly ascending the columns are used as-is (no sort); otherwise a
68    /// single stable argsort permutation is applied and duplicate ids are
69    /// collapsed last-in-input-order-wins.
70    ///
71    /// Mismatched column lengths yield an empty index (the wasm caller guards
72    /// this too), so a malformed payload never panics a worker.
73    pub fn from_columns(ids: &[u32], starts: &[u32], lengths: &[u32]) -> Self {
74        let n = ids.len();
75        if n == 0 || starts.len() != n || lengths.len() != n {
76            return Self {
77                ids: Vec::new(),
78                starts: Vec::new(),
79                lengths: Vec::new(),
80            };
81        }
82        if is_strictly_ascending(ids) {
83            // Already sorted AND unique — the common case once the producer
84            // emits sorted columns. No permutation, no dedup: just adopt them.
85            return Self {
86                ids: ids.to_vec(),
87                starts: starts.to_vec(),
88                lengths: lengths.to_vec(),
89            };
90        }
91        Self::from_unsorted(ids.to_vec(), starts.to_vec(), lengths.to_vec())
92    }
93
94    /// Build from an already-scanned [`EntityIndex`](crate::EntityIndex)
95    /// hashmap, CONSUMING it. On the wasm prepass path the map is ~436 MB at
96    /// 19.1 M entities and the conversion runs while the whole source file is
97    /// resident in the same <4 GiB heap; borrowing (`from_hashmap`) keeps the
98    /// map alive across the copy AND the sort, spiking ~970 MB of transients.
99    ///
100    /// Consuming drains into one interleaved `Vec<(id, start, len)>` (~229 MB)
101    /// then sorts in place (`sort_unstable`; map keys are unique, so no
102    /// stability/permutation buffer). Peak during the drain is map + rows
103    /// capacity (~665 MB) until the map drops at end-of-loop; after that the
104    /// sort is in-place and the final column split briefly overlaps rows +
105    /// outputs (~458 MB) before rows drop. Still far below the borrowing path,
106    /// and the steady-state index is ~229 MB.
107    pub fn from_hashmap_consuming(map: EntityIndex) -> Self {
108        let n = map.len();
109        // Reserve while the map is still alive: peak ≈ map + rows (~665 MB at
110        // 19.1 M). Draining without reserve would thrash reallocs for the same
111        // asymptotic peak once the vec fills.
112        let mut rows: Vec<(u32, u32, u32)> = Vec::with_capacity(n);
113        for (id, (start, end)) in map {
114            // u32 offsets are sound only under the wasm32 <4GiB address space
115            // (module docs); see `from_hashmap`.
116            debug_assert!(end <= u32::MAX as usize, "entity offset exceeds the u32 column ceiling");
117            rows.push((id, start as u32, (end - start) as u32));
118        }
119        // `map` dropped with the loop; sort is in-place on `rows` alone.
120        rows.sort_unstable_by_key(|r| r.0);
121        let mut ids = Vec::with_capacity(rows.len());
122        let mut starts = Vec::with_capacity(rows.len());
123        let mut lengths = Vec::with_capacity(rows.len());
124        for (id, start, len) in rows {
125            ids.push(id);
126            starts.push(start);
127            lengths.push(len);
128        }
129        Self { ids, starts, lengths }
130    }
131
132    /// Build from an already-scanned [`EntityIndex`](crate::EntityIndex)
133    /// hashmap. The map is unique by construction (last-in-file-order-wins was
134    /// applied by `HashMap::insert`), so this only sorts the entries. Prefer
135    /// [`Self::from_hashmap_consuming`] when the map is no longer needed - it
136    /// avoids holding map + copies concurrently (P1 review finding on #1689).
137    pub fn from_hashmap(map: &EntityIndex) -> Self {
138        let n = map.len();
139        let mut ids = Vec::with_capacity(n);
140        let mut starts = Vec::with_capacity(n);
141        let mut lengths = Vec::with_capacity(n);
142        for (&id, &(start, end)) in map.iter() {
143            // u32 offsets are sound only under the wasm32 <4GiB address space
144            // (module docs). Catch a future native caller in debug builds
145            // before a silent truncation decodes the wrong bytes.
146            debug_assert!(end <= u32::MAX as usize, "entity offset exceeds the u32 column ceiling");
147            ids.push(id);
148            starts.push(start as u32);
149            lengths.push((end - start) as u32);
150        }
151        // Entries are unique; `from_unsorted`'s dedup is a no-op but keeps one
152        // sort/build code path.
153        Self::from_unsorted(ids, starts, lengths)
154    }
155
156    /// Scan `content` and build the columns directly, replicating
157    /// [`crate::build_entity_index`]'s HEADER-skipping / quoted-string scan and
158    /// its last-in-file-order-wins duplicate handling — without ever
159    /// materializing the intermediate `FxHashMap`. Used by the wasm lazy
160    /// fallback when `setEntityIndex` was never called.
161    pub fn from_scan<T>(content: &T) -> Self
162    where
163        T: AsRef<[u8]> + ?Sized,
164    {
165        let content = content.as_ref();
166        let estimated = content.len() / 50;
167        let mut ids = Vec::with_capacity(estimated);
168        let mut starts = Vec::with_capacity(estimated);
169        let mut lengths = Vec::with_capacity(estimated);
170        let mut scanner = EntityScanner::new(content);
171        while let Some((id, _type_name, start, end)) = scanner.next_entity() {
172            debug_assert!(end <= u32::MAX as usize, "entity offset exceeds the u32 column ceiling");
173            ids.push(id);
174            starts.push(start as u32);
175            lengths.push((end - start) as u32);
176        }
177        Self::from_unsorted(ids, starts, lengths)
178    }
179
180    /// Sort the (id, start, length) triples by id and collapse duplicate ids
181    /// keeping the one that appeared **last** in the input order (matching
182    /// `FxHashMap::insert`). The input `Vec`s are in original (file / delivery)
183    /// order.
184    fn from_unsorted(ids: Vec<u32>, starts: Vec<u32>, lengths: Vec<u32>) -> Self {
185        let n = ids.len();
186        // Argsort a permutation, ordering by (id, original_index). Ties break by
187        // original index ascending, so within an equal-id run the last element
188        // has the greatest original index == last-in-input-order.
189        let mut perm: Vec<u32> = (0..n as u32).collect();
190        perm.sort_unstable_by(|&a, &b| {
191            let ka = ids[a as usize];
192            let kb = ids[b as usize];
193            ka.cmp(&kb).then_with(|| a.cmp(&b))
194        });
195
196        let mut out_ids: Vec<u32> = Vec::with_capacity(n);
197        let mut out_starts: Vec<u32> = Vec::with_capacity(n);
198        let mut out_lengths: Vec<u32> = Vec::with_capacity(n);
199        for &p in &perm {
200            let p = p as usize;
201            let id = ids[p];
202            if out_ids.last() == Some(&id) {
203                // Duplicate id: overwrite the tail so the LAST occurrence wins.
204                let li = out_ids.len() - 1;
205                out_starts[li] = starts[p];
206                out_lengths[li] = lengths[p];
207            } else {
208                out_ids.push(id);
209                out_starts.push(starts[p]);
210                out_lengths.push(lengths[p]);
211            }
212        }
213        out_ids.shrink_to_fit();
214        out_starts.shrink_to_fit();
215        out_lengths.shrink_to_fit();
216        Self {
217            ids: out_ids,
218            starts: out_starts,
219            lengths: out_lengths,
220        }
221    }
222
223    /// Binary-search the byte span of `id`. Returns `(start, end)` where
224    /// `end = start + length`, exactly matching [`EntityIndex`](crate::EntityIndex)'s
225    /// tuple, or `None` if the id is absent.
226    #[inline]
227    pub fn lookup(&self, id: u32) -> Option<(usize, usize)> {
228        match self.ids.binary_search(&id) {
229            Ok(i) => {
230                let start = self.starts[i] as usize;
231                Some((start, start + self.lengths[i] as usize))
232            }
233            Err(_) => None,
234        }
235    }
236
237    /// Sorted, unique id column (for re-emitting the entity-index event).
238    #[inline]
239    pub fn ids(&self) -> &[u32] {
240        &self.ids
241    }
242
243    /// Byte-start column, parallel to [`Self::ids`].
244    #[inline]
245    pub fn starts(&self) -> &[u32] {
246        &self.starts
247    }
248
249    /// Byte-length column, parallel to [`Self::ids`].
250    #[inline]
251    pub fn lengths(&self) -> &[u32] {
252        &self.lengths
253    }
254
255    /// Number of indexed entities.
256    #[inline]
257    pub fn len(&self) -> usize {
258        self.ids.len()
259    }
260
261    /// Whether the index is empty.
262    #[inline]
263    pub fn is_empty(&self) -> bool {
264        self.ids.is_empty()
265    }
266}
267
268/// True iff `ids` is strictly ascending (which also proves uniqueness). O(n).
269#[inline]
270fn is_strictly_ascending(ids: &[u32]) -> bool {
271    ids.windows(2).all(|w| w[0] < w[1])
272}
273
274/// The index representation an [`EntityDecoder`](crate::EntityDecoder) holds:
275/// either the legacy `FxHashMap` (native / lazily-built paths) or the compact
276/// columnar index (wasm shared-index ingestion). A thin dispatch keeps the
277/// decoder hot path (`decode_by_id`) agnostic to which one is installed.
278pub(crate) enum EntityIndexStore {
279    Hash(Arc<EntityIndex>),
280    Columnar(Arc<ColumnarEntityIndex>),
281}
282
283impl EntityIndexStore {
284    /// Resolve `id` to its `(start, end)` byte span.
285    #[inline]
286    pub(crate) fn lookup(&self, id: u32) -> Option<(usize, usize)> {
287        match self {
288            EntityIndexStore::Hash(m) => m.get(&id).copied(),
289            EntityIndexStore::Columnar(c) => c.lookup(id),
290        }
291    }
292}
293
294impl<'a> crate::EntityDecoder<'a> {
295    /// Create a decoder backed by a shared columnar index (wasm shared-index
296    /// path). Mirrors [`EntityDecoder::with_arc_index`](crate::EntityDecoder::with_arc_index)
297    /// but installs the compact representation.
298    pub fn with_arc_columnar_index<T>(content: &'a T, index: Arc<ColumnarEntityIndex>) -> Self
299    where
300        T: AsRef<[u8]> + ?Sized,
301    {
302        let mut decoder = Self::new(content);
303        decoder.set_columnar_index(index);
304        decoder
305    }
306
307    /// Install a shared columnar index into an existing decoder. Like
308    /// [`EntityDecoder::set_entity_index`](crate::EntityDecoder::set_entity_index)
309    /// but for the compact representation; afterwards `build_index` no-ops.
310    pub fn set_columnar_index(&mut self, index: Arc<ColumnarEntityIndex>) {
311        self.entity_index = Some(EntityIndexStore::Columnar(index));
312    }
313}
314
315#[cfg(test)]
316mod columnar_index_tests {
317    use super::*;
318
319    #[test]
320    fn sorted_unique_uses_fast_path_and_looks_up() {
321        let ids = [1u32, 5, 9, 100];
322        let starts = [10u32, 20, 30, 40];
323        let lengths = [3u32, 4, 5, 6];
324        let idx = ColumnarEntityIndex::from_columns(&ids, &starts, &lengths);
325        assert_eq!(idx.len(), 4);
326        assert_eq!(idx.lookup(1), Some((10, 13)));
327        assert_eq!(idx.lookup(5), Some((20, 24)));
328        assert_eq!(idx.lookup(100), Some((40, 46)));
329        assert_eq!(idx.lookup(2), None);
330        assert_eq!(idx.lookup(101), None);
331    }
332
333    #[test]
334    fn unsorted_input_is_sorted_then_searched() {
335        let ids = [100u32, 1, 9, 5];
336        let starts = [40u32, 10, 30, 20];
337        let lengths = [6u32, 3, 5, 4];
338        let idx = ColumnarEntityIndex::from_columns(&ids, &starts, &lengths);
339        assert_eq!(idx.ids(), &[1, 5, 9, 100]);
340        assert_eq!(idx.lookup(1), Some((10, 13)));
341        assert_eq!(idx.lookup(5), Some((20, 24)));
342        assert_eq!(idx.lookup(9), Some((30, 35)));
343        assert_eq!(idx.lookup(100), Some((40, 46)));
344    }
345
346    #[test]
347    fn duplicate_id_last_wins() {
348        // Same express id 7 appears twice; the LAST occurrence in input order
349        // must win, matching FxHashMap::insert / build_entity_index.
350        let ids = [7u32, 3, 7];
351        let starts = [10u32, 20, 30];
352        let lengths = [1u32, 2, 3];
353        let idx = ColumnarEntityIndex::from_columns(&ids, &starts, &lengths);
354        assert_eq!(idx.len(), 2);
355        // id 7 -> the (start=30, len=3) entry, NOT (10, 1)
356        assert_eq!(idx.lookup(7), Some((30, 33)));
357        assert_eq!(idx.lookup(3), Some((20, 22)));
358    }
359
360    #[test]
361    fn empty_and_mismatched_columns_are_empty() {
362        assert!(ColumnarEntityIndex::from_columns(&[], &[], &[]).is_empty());
363        assert!(ColumnarEntityIndex::from_columns(&[1, 2], &[0], &[0, 0]).is_empty());
364    }
365
366    #[test]
367    fn from_hashmap_matches_lookup() {
368        let content = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n\
369            #1=IFCCARTESIANPOINT((0.,0.,0.));\n\
370            #7=IFCCARTESIANPOINT((1.,2.,3.));\n\
371            ENDSEC;\nEND-ISO-10303-21;\n";
372        let map = crate::build_entity_index(content);
373        let col = ColumnarEntityIndex::from_hashmap(&map);
374        assert_eq!(col.len(), map.len());
375        for (&id, &(s, e)) in map.iter() {
376            assert_eq!(col.lookup(id), Some((s, e)));
377        }
378        // A scan-built index must agree byte-for-byte with the hashmap one.
379        let scanned = ColumnarEntityIndex::from_scan(content);
380        assert_eq!(scanned.ids(), col.ids());
381        for &id in col.ids() {
382            assert_eq!(scanned.lookup(id), col.lookup(id));
383        }
384    }
385
386    #[test]
387    fn consuming_and_borrowing_hashmap_builds_agree() {
388        let mut map: crate::EntityIndex = Default::default();
389        for (id, start, end) in [(7u32, 100usize, 150usize), (3, 0, 40), (9, 200, 260), (1, 41, 99)] {
390            map.insert(id, (start, end));
391        }
392        let borrowed = ColumnarEntityIndex::from_hashmap(&map);
393        let consumed = ColumnarEntityIndex::from_hashmap_consuming(map);
394        for id in [0u32, 1, 3, 7, 9, 10, u32::MAX] {
395            assert_eq!(borrowed.lookup(id), consumed.lookup(id), "id {id}");
396        }
397        assert_eq!(consumed.len(), 4);
398    }
399}