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. WASM ingestion (`setEntityIndex` and `cached_entity_index`) already
27//! lives in that linear address space and delivers `&[u32]` columns. The native
28//! processor also uses this representation for large sources with sparse ids,
29//! after checking their byte length fits `u32`; dense ids may use
30//! [`crate::DenseEntityIndex`]. Wider native sources keep the `usize`-carrying
31//! [`EntityIndex`](crate::EntityIndex) hashmap.
32//!
33//! # Duplicate express ids
34//!
35//! [`crate::build_entity_index`] inserts scanned spans into an `FxHashMap` in
36//! file order, so a repeated express id resolves to its **last** occurrence in
37//! the file (`HashMap::insert` overwrites). Express ids are unique per the STEP
38//! spec and duplicates essentially never occur, but this type replicates the
39//! last-in-file-order-wins behaviour deliberately (see `from_unsorted` and the
40//! `duplicate_id_last_wins` test) so a malformed file cannot diverge between the
41//! hashmap and columnar paths.
42
43use crate::decoder::EntityIndex;
44use crate::parser::EntityScanner;
45use std::sync::Arc;
46
47fn check_lengths(ids: usize, starts: usize, lengths: usize) -> Result<(), ColumnLengthMismatch> {
48    if starts == ids && lengths == ids {
49        Ok(())
50    } else {
51        Err(ColumnLengthMismatch { ids, starts, lengths })
52    }
53}
54
55/// Compact, sorted, binary-searched entity index. Columns are kept sorted by
56/// `ids` (strictly ascending, unique) so [`Self::lookup`] can `binary_search`.
57///
58/// Invariants (upheld by every constructor):
59/// - `ids.len() == starts.len() == lengths.len()`
60/// - `ids` is strictly ascending (hence unique)
61/// - `starts[i]` / `lengths[i]` are the byte offset / byte length of `ids[i]`,
62///   so `lookup` returns `(start, start + length)` to match the `(start, end)`
63///   tuple layout of [`EntityIndex`](crate::EntityIndex).
64pub struct ColumnarEntityIndex {
65    ids: Vec<u32>,
66    starts: Vec<u32>,
67    lengths: Vec<u32>,
68    // One source-scoped inverse lookup shared by native jobs and WASM batches.
69    pub(crate) styled_item_index: std::sync::OnceLock<crate::decoder::StyledItemIndexResult>,
70    pub(crate) grid_axis_index: Arc<crate::decoder::GridAxisIndex>,
71}
72
73/// The three columns handed to [`ColumnarEntityIndex::from_columns`] or
74/// [`ColumnarEntityIndex::from_owned_columns`] were not all the same length.
75/// A caller-facing error, not an empty index: an empty index is also what an
76/// empty file produces, so folding the two together hid a malformed payload.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct ColumnLengthMismatch {
79    pub ids: usize,
80    pub starts: usize,
81    pub lengths: usize,
82}
83
84impl std::fmt::Display for ColumnLengthMismatch {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(
87            f,
88            "entity index columns disagree in length: ids {}, starts {}, lengths {}",
89            self.ids, self.starts, self.lengths
90        )
91    }
92}
93
94impl std::error::Error for ColumnLengthMismatch {}
95
96impl ColumnarEntityIndex {
97    /// Build from the three delivered columns (`setEntityIndex` ingestion).
98    ///
99    /// Verifies the id column's ordering **once**, O(n): the pre-pass emits in
100    /// whatever order it iterates (its own `FxHashMap` iteration order is
101    /// arbitrary), so this cannot assume ascending. If the ids are already
102    /// strictly ascending the columns are used as-is (no sort); otherwise a
103    /// single stable argsort permutation is applied and duplicate ids are
104    /// collapsed last-in-input-order-wins.
105    ///
106    /// Columns of unequal length are a [`ColumnLengthMismatch`], checked before
107    /// any is read, so a malformed payload never panics a worker. Empty
108    /// columns are a valid, empty index.
109    pub fn from_columns(ids: &[u32], starts: &[u32], lengths: &[u32]) -> Result<Self, ColumnLengthMismatch> {
110        check_lengths(ids.len(), starts.len(), lengths.len())?;
111        Self::from_owned_columns(ids.to_vec(), starts.to_vec(), lengths.to_vec())
112    }
113
114    /// Consume binding-owned columns without another full allocation (#3989).
115    /// Validation and last-in-input-order duplicate precedence match `from_columns`.
116    pub fn from_owned_columns(ids: Vec<u32>, starts: Vec<u32>, lengths: Vec<u32>) -> Result<Self, ColumnLengthMismatch> {
117        check_lengths(ids.len(), starts.len(), lengths.len())?;
118        if is_strictly_ascending(&ids) {
119            return Ok(Self {
120                ids,
121                starts,
122                lengths,
123                styled_item_index: std::sync::OnceLock::new(),
124                grid_axis_index: Arc::default(),
125            });
126        }
127        Ok(Self::from_unsorted(ids, starts, lengths))
128    }
129
130    /// Build from an already-scanned [`EntityIndex`](crate::EntityIndex)
131    /// hashmap, CONSUMING it. On the wasm prepass path the map is ~436 MB at
132    /// 19.1 M entities and the conversion runs while the whole source file is
133    /// resident in the same <4 GiB heap; borrowing (`from_hashmap`) keeps the
134    /// map alive across the copy AND the sort, spiking ~970 MB of transients.
135    ///
136    /// Consuming drains into one interleaved `Vec<(id, start, len)>` (~229 MB)
137    /// then sorts in place (`sort_unstable`; map keys are unique, so no
138    /// stability/permutation buffer). Peak during the drain is map + rows
139    /// capacity (~665 MB) until the map drops at end-of-loop; after that the
140    /// sort is in-place and the final column split briefly overlaps rows +
141    /// outputs (~458 MB) before rows drop. Still far below the borrowing path,
142    /// and the steady-state index is ~229 MB.
143    pub fn from_hashmap_consuming(map: EntityIndex) -> Self {
144        let n = map.len();
145        // Reserve while the map is still alive: peak ≈ map + rows (~665 MB at
146        // 19.1 M). Draining without reserve would thrash reallocs for the same
147        // asymptotic peak once the vec fills.
148        let mut rows: Vec<(u32, u32, u32)> = Vec::with_capacity(n);
149        for (id, (start, end)) in map {
150            // Offsets must fit u32, as required by the module contract;
151            // see `from_hashmap`.
152            debug_assert!(end <= u32::MAX as usize, "entity offset exceeds the u32 column ceiling");
153            rows.push((id, start as u32, (end - start) as u32));
154        }
155        // `map` dropped with the loop; sort is in-place on `rows` alone.
156        rows.sort_unstable_by_key(|r| r.0);
157        let mut ids = Vec::with_capacity(rows.len());
158        let mut starts = Vec::with_capacity(rows.len());
159        let mut lengths = Vec::with_capacity(rows.len());
160        for (id, start, len) in rows {
161            ids.push(id);
162            starts.push(start);
163            lengths.push(len);
164        }
165        Self {
166            ids,
167            starts,
168            lengths,
169            styled_item_index: std::sync::OnceLock::new(),
170            grid_axis_index: Arc::default(),
171        }
172    }
173
174    /// Build from an already-scanned [`EntityIndex`](crate::EntityIndex)
175    /// hashmap. The map is unique by construction (last-in-file-order-wins was
176    /// applied by `HashMap::insert`), so this only sorts the entries. Prefer
177    /// [`Self::from_hashmap_consuming`] when the map is no longer needed - it
178    /// avoids holding map + copies concurrently (P1 review finding on #1689).
179    pub fn from_hashmap(map: &EntityIndex) -> Self {
180        let n = map.len();
181        let mut ids = Vec::with_capacity(n);
182        let mut starts = Vec::with_capacity(n);
183        let mut lengths = Vec::with_capacity(n);
184        for (&id, &(start, end)) in map.iter() {
185            // u32 offsets are sound only under the wasm32 <4GiB address space
186            // (module docs). Catch a future native caller in debug builds
187            // before a silent truncation decodes the wrong bytes.
188            debug_assert!(end <= u32::MAX as usize, "entity offset exceeds the u32 column ceiling");
189            ids.push(id);
190            starts.push(start as u32);
191            lengths.push((end - start) as u32);
192        }
193        // Entries are unique; `from_unsorted`'s dedup is a no-op but keeps one
194        // sort/build code path.
195        Self::from_unsorted(ids, starts, lengths)
196    }
197
198    /// Scan `content` and build the columns directly, replicating
199    /// [`crate::build_entity_index`]'s HEADER-skipping / quoted-string scan and
200    /// its last-in-file-order-wins duplicate handling — without ever
201    /// materializing the intermediate `FxHashMap`. Used by the wasm lazy
202    /// fallback when `setEntityIndex` was never called.
203    ///
204    /// Reports the scanner's #3395 refusals for the same reason
205    /// [`crate::build_entity_index`] does: this is a whole-file index build, so
206    /// a refused record is a record the model will not contain.
207    pub fn from_scan<T>(content: &T) -> Self
208    where
209        T: AsRef<[u8]> + ?Sized,
210    {
211        let content = content.as_ref();
212        let estimated = content.len() / 50;
213        let mut ids = Vec::with_capacity(estimated);
214        let mut starts = Vec::with_capacity(estimated);
215        let mut lengths = Vec::with_capacity(estimated);
216        let mut scanner = EntityScanner::new(content);
217        while let Some((id, _type_name, start, end)) = scanner.next_entity() {
218            debug_assert!(end <= u32::MAX as usize, "entity offset exceeds the u32 column ceiling");
219            ids.push(id);
220            starts.push(start as u32);
221            lengths.push((end - start) as u32);
222        }
223        crate::parser::report_scan_diagnostics(
224            scanner.skipped_oversized_ids(),
225            scanner.malformed_record_start().is_some(),
226        );
227        Self::from_unsorted(ids, starts, lengths)
228    }
229
230    /// Sort the (id, start, length) triples by id and collapse duplicate ids
231    /// keeping the one that appeared **last** in the input order (matching
232    /// `FxHashMap::insert`). The input `Vec`s are in original (file / delivery)
233    /// order.
234    fn from_unsorted(ids: Vec<u32>, starts: Vec<u32>, lengths: Vec<u32>) -> Self {
235        let n = ids.len();
236        // Argsort a permutation, ordering by (id, original_index). Ties break by
237        // original index ascending, so within an equal-id run the last element
238        // has the greatest original index == last-in-input-order.
239        let mut perm: Vec<u32> = (0..n as u32).collect();
240        perm.sort_unstable_by(|&a, &b| {
241            let ka = ids[a as usize];
242            let kb = ids[b as usize];
243            ka.cmp(&kb).then_with(|| a.cmp(&b))
244        });
245
246        let mut out_ids: Vec<u32> = Vec::with_capacity(n);
247        let mut out_starts: Vec<u32> = Vec::with_capacity(n);
248        let mut out_lengths: Vec<u32> = Vec::with_capacity(n);
249        for &p in &perm {
250            let p = p as usize;
251            let id = ids[p];
252            if out_ids.last() == Some(&id) {
253                // Duplicate id: overwrite the tail so the LAST occurrence wins.
254                let li = out_ids.len() - 1;
255                out_starts[li] = starts[p];
256                out_lengths[li] = lengths[p];
257            } else {
258                out_ids.push(id);
259                out_starts.push(starts[p]);
260                out_lengths.push(lengths[p]);
261            }
262        }
263        out_ids.shrink_to_fit();
264        out_starts.shrink_to_fit();
265        out_lengths.shrink_to_fit();
266        Self {
267            ids: out_ids,
268            starts: out_starts,
269            lengths: out_lengths,
270            styled_item_index: std::sync::OnceLock::new(),
271            grid_axis_index: Arc::default(),
272        }
273    }
274
275    /// Binary-search the byte span of `id`. Returns `(start, end)` where
276    /// `end = start + length`, exactly matching [`EntityIndex`](crate::EntityIndex)'s
277    /// tuple, or `None` if the id is absent.
278    #[inline]
279    pub fn lookup(&self, id: u32) -> Option<(usize, usize)> {
280        match self.ids.binary_search(&id) {
281            Ok(i) => {
282                let start = self.starts[i] as usize;
283                // A wasm32 overflow must not panic a build with overflow checks;
284                // the decoder refuses the span past the content either way (#4697).
285                Some((start, start.saturating_add(self.lengths[i] as usize)))
286            }
287            Err(_) => None,
288        }
289    }
290
291    /// Sorted, unique id column (for re-emitting the entity-index event).
292    #[inline]
293    pub fn ids(&self) -> &[u32] {
294        &self.ids
295    }
296
297    /// Byte-start column, parallel to [`Self::ids`].
298    #[inline]
299    pub fn starts(&self) -> &[u32] {
300        &self.starts
301    }
302
303    /// Byte-length column, parallel to [`Self::ids`].
304    #[inline]
305    pub fn lengths(&self) -> &[u32] {
306        &self.lengths
307    }
308
309    /// Number of indexed entities.
310    #[inline]
311    pub fn len(&self) -> usize {
312        self.ids.len()
313    }
314
315    /// Whether the index is empty.
316    #[inline]
317    pub fn is_empty(&self) -> bool {
318        self.ids.is_empty()
319    }
320}
321
322/// True iff `ids` is strictly ascending (which also proves uniqueness). O(n).
323#[inline]
324fn is_strictly_ascending(ids: &[u32]) -> bool {
325    ids.windows(2).all(|w| w[0] < w[1])
326}
327
328/// The index representation an [`EntityDecoder`](crate::EntityDecoder) holds:
329/// either the legacy `FxHashMap` (native / lazily-built paths) or the compact
330/// columnar index (wasm shared-index ingestion). A thin dispatch keeps the
331/// decoder hot path (`decode_by_id`) agnostic to which one is installed.
332pub(crate) enum EntityIndexStore {
333    Hash(Arc<EntityIndex>),
334    Columnar(Arc<ColumnarEntityIndex>),
335    Dense(Arc<crate::DenseEntityIndex>),
336}
337
338impl EntityIndexStore {
339    /// Resolve `id` to its `(start, end)` byte span.
340    #[inline]
341    pub(crate) fn lookup(&self, id: u32) -> Option<(usize, usize)> {
342        match self {
343            EntityIndexStore::Hash(m) => m.get(&id).copied(),
344            EntityIndexStore::Columnar(c) => c.lookup(id),
345            EntityIndexStore::Dense(d) => d.lookup(id),
346        }
347    }
348}
349
350impl<'a> crate::EntityDecoder<'a> {
351    /// Create a decoder backed by a shared columnar index (wasm shared-index
352    /// path). Mirrors [`EntityDecoder::with_arc_index`](crate::EntityDecoder::with_arc_index)
353    /// but installs the compact representation.
354    pub fn with_arc_columnar_index<T>(content: &'a T, index: Arc<ColumnarEntityIndex>) -> Self
355    where
356        T: AsRef<[u8]> + ?Sized,
357    {
358        let mut decoder = Self::new(content);
359        decoder.set_columnar_index(index);
360        decoder
361    }
362
363    /// Install a shared columnar index into an existing decoder. Like
364    /// [`EntityDecoder::set_entity_index`](crate::EntityDecoder::set_entity_index)
365    /// but for the compact representation; afterwards `build_index` no-ops.
366    pub fn set_columnar_index(&mut self, index: Arc<ColumnarEntityIndex>) {
367        self.set_grid_axis_index(index.grid_axis_index.clone());
368        self.entity_index = Some(EntityIndexStore::Columnar(index));
369    }
370}
371
372#[cfg(test)]
373#[path = "columnar_index_tests.rs"]
374mod columnar_index_tests;