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 ///
162 /// Reports the scanner's #3395 refusals for the same reason
163 /// [`crate::build_entity_index`] does: this is a whole-file index build, so
164 /// a refused record is a record the model will not contain.
165 pub fn from_scan<T>(content: &T) -> Self
166 where
167 T: AsRef<[u8]> + ?Sized,
168 {
169 let content = content.as_ref();
170 let estimated = content.len() / 50;
171 let mut ids = Vec::with_capacity(estimated);
172 let mut starts = Vec::with_capacity(estimated);
173 let mut lengths = Vec::with_capacity(estimated);
174 let mut scanner = EntityScanner::new(content);
175 while let Some((id, _type_name, start, end)) = scanner.next_entity() {
176 debug_assert!(end <= u32::MAX as usize, "entity offset exceeds the u32 column ceiling");
177 ids.push(id);
178 starts.push(start as u32);
179 lengths.push((end - start) as u32);
180 }
181 crate::parser::report_scan_diagnostics(
182 scanner.skipped_oversized_ids(),
183 scanner.malformed_record_start().is_some(),
184 );
185 Self::from_unsorted(ids, starts, lengths)
186 }
187
188 /// Sort the (id, start, length) triples by id and collapse duplicate ids
189 /// keeping the one that appeared **last** in the input order (matching
190 /// `FxHashMap::insert`). The input `Vec`s are in original (file / delivery)
191 /// order.
192 fn from_unsorted(ids: Vec<u32>, starts: Vec<u32>, lengths: Vec<u32>) -> Self {
193 let n = ids.len();
194 // Argsort a permutation, ordering by (id, original_index). Ties break by
195 // original index ascending, so within an equal-id run the last element
196 // has the greatest original index == last-in-input-order.
197 let mut perm: Vec<u32> = (0..n as u32).collect();
198 perm.sort_unstable_by(|&a, &b| {
199 let ka = ids[a as usize];
200 let kb = ids[b as usize];
201 ka.cmp(&kb).then_with(|| a.cmp(&b))
202 });
203
204 let mut out_ids: Vec<u32> = Vec::with_capacity(n);
205 let mut out_starts: Vec<u32> = Vec::with_capacity(n);
206 let mut out_lengths: Vec<u32> = Vec::with_capacity(n);
207 for &p in &perm {
208 let p = p as usize;
209 let id = ids[p];
210 if out_ids.last() == Some(&id) {
211 // Duplicate id: overwrite the tail so the LAST occurrence wins.
212 let li = out_ids.len() - 1;
213 out_starts[li] = starts[p];
214 out_lengths[li] = lengths[p];
215 } else {
216 out_ids.push(id);
217 out_starts.push(starts[p]);
218 out_lengths.push(lengths[p]);
219 }
220 }
221 out_ids.shrink_to_fit();
222 out_starts.shrink_to_fit();
223 out_lengths.shrink_to_fit();
224 Self {
225 ids: out_ids,
226 starts: out_starts,
227 lengths: out_lengths,
228 }
229 }
230
231 /// Binary-search the byte span of `id`. Returns `(start, end)` where
232 /// `end = start + length`, exactly matching [`EntityIndex`](crate::EntityIndex)'s
233 /// tuple, or `None` if the id is absent.
234 #[inline]
235 pub fn lookup(&self, id: u32) -> Option<(usize, usize)> {
236 match self.ids.binary_search(&id) {
237 Ok(i) => {
238 let start = self.starts[i] as usize;
239 Some((start, start + self.lengths[i] as usize))
240 }
241 Err(_) => None,
242 }
243 }
244
245 /// Sorted, unique id column (for re-emitting the entity-index event).
246 #[inline]
247 pub fn ids(&self) -> &[u32] {
248 &self.ids
249 }
250
251 /// Byte-start column, parallel to [`Self::ids`].
252 #[inline]
253 pub fn starts(&self) -> &[u32] {
254 &self.starts
255 }
256
257 /// Byte-length column, parallel to [`Self::ids`].
258 #[inline]
259 pub fn lengths(&self) -> &[u32] {
260 &self.lengths
261 }
262
263 /// Number of indexed entities.
264 #[inline]
265 pub fn len(&self) -> usize {
266 self.ids.len()
267 }
268
269 /// Whether the index is empty.
270 #[inline]
271 pub fn is_empty(&self) -> bool {
272 self.ids.is_empty()
273 }
274}
275
276/// True iff `ids` is strictly ascending (which also proves uniqueness). O(n).
277#[inline]
278fn is_strictly_ascending(ids: &[u32]) -> bool {
279 ids.windows(2).all(|w| w[0] < w[1])
280}
281
282/// The index representation an [`EntityDecoder`](crate::EntityDecoder) holds:
283/// either the legacy `FxHashMap` (native / lazily-built paths) or the compact
284/// columnar index (wasm shared-index ingestion). A thin dispatch keeps the
285/// decoder hot path (`decode_by_id`) agnostic to which one is installed.
286pub(crate) enum EntityIndexStore {
287 Hash(Arc<EntityIndex>),
288 Columnar(Arc<ColumnarEntityIndex>),
289}
290
291impl EntityIndexStore {
292 /// Resolve `id` to its `(start, end)` byte span.
293 #[inline]
294 pub(crate) fn lookup(&self, id: u32) -> Option<(usize, usize)> {
295 match self {
296 EntityIndexStore::Hash(m) => m.get(&id).copied(),
297 EntityIndexStore::Columnar(c) => c.lookup(id),
298 }
299 }
300}
301
302impl<'a> crate::EntityDecoder<'a> {
303 /// Create a decoder backed by a shared columnar index (wasm shared-index
304 /// path). Mirrors [`EntityDecoder::with_arc_index`](crate::EntityDecoder::with_arc_index)
305 /// but installs the compact representation.
306 pub fn with_arc_columnar_index<T>(content: &'a T, index: Arc<ColumnarEntityIndex>) -> Self
307 where
308 T: AsRef<[u8]> + ?Sized,
309 {
310 let mut decoder = Self::new(content);
311 decoder.set_columnar_index(index);
312 decoder
313 }
314
315 /// Install a shared columnar index into an existing decoder. Like
316 /// [`EntityDecoder::set_entity_index`](crate::EntityDecoder::set_entity_index)
317 /// but for the compact representation; afterwards `build_index` no-ops.
318 pub fn set_columnar_index(&mut self, index: Arc<ColumnarEntityIndex>) {
319 self.entity_index = Some(EntityIndexStore::Columnar(index));
320 }
321}
322
323#[cfg(test)]
324#[path = "columnar_index_tests.rs"]
325mod columnar_index_tests;