Skip to main content

mlt_core/encoder/
optimizer.rs

1use bitvec::vec::BitVec;
2
3use crate::decoder::{Morton, PropKind, TileLayer};
4use crate::encoder::model::{CurveParams, StagedLayer};
5use crate::encoder::property::encode::write_properties;
6use crate::encoder::{
7    Codecs, Encoder, EncoderConfig, SortStrategy, StagedId, spatial_sort_likely_to_help,
8};
9use crate::{MltError, MltResult, PropValue};
10
11impl StagedLayer {
12    /// Encode and serialize the layer directly into `enc`, without creating any
13    /// intermediate representation.
14    ///
15    /// This is the hot path inside `TileLayer::encode`: each sort-strategy
16    /// trial calls this method on its own fresh `Encoder`, and only the
17    /// `Encoder` with the smallest `total_len()` is kept.
18    #[hotpath::measure]
19    pub fn encode_into(self, mut enc: Encoder, codecs: &mut Codecs) -> MltResult<Encoder> {
20        if self.name.is_empty() {
21            return Err(MltError::MissingLayerName);
22        }
23        let column_count = usize::from(!matches!(self.id, StagedId::None))
24            + 1 // geometry
25            + self.properties.len();
26
27        let StagedLayer {
28            name,
29            extent,
30            id,
31            geometry,
32            properties,
33        } = self;
34
35        id.write_to(&mut enc, codecs)?;
36        geometry.write_to(&mut enc, codecs)?;
37        write_properties(&properties, &mut enc, codecs)?;
38        enc.write_header(&name, extent.get(), column_count)?;
39
40        Ok(enc)
41    }
42}
43
44/// Seed the encoder's curve-derived caches so the Hilbert/Morton dictionary
45/// builders skip their min/max scan. `Morton::new` returns `Err` when bits > 16;
46/// `dict_may_be_beneficial` reads `morton_cache.is_none()` and falls back to
47/// a Vec2-only path in that case.
48fn seed_curve_caches(enc: &mut Encoder, curve_params: CurveParams) {
49    enc.hilbert_cache = Some(curve_params);
50    enc.morton_cache = Morton::new(curve_params.bits, curve_params.shift).ok();
51}
52
53/// Feature-count threshold above which the spatial trial is subject to the
54/// bounding-box pruning heuristic.
55const SORT_TRIAL_THRESHOLD: usize = 512;
56
57impl TileLayer {
58    /// Encode a [`TileLayer`] to bytes, automatically optimizing all encoding choices.
59    ///
60    /// This is the primary encoding entry point. It:
61    /// 1. Determines which sort strategies to try based on `cfg`
62    /// 2. Tries each sort strategy, encoding and measuring the output size
63    /// 3. Returns the smallest encoding as a complete layer record (including tag and length prefix)
64    ///
65    /// All encoding choices — sort order, per-stream integer encodings, string compression,
66    /// vertex buffer layout — are selected automatically to minimize output size.
67    #[hotpath::measure]
68    pub fn encode(self, cfg: EncoderConfig) -> MltResult<Vec<u8>> {
69        if self.name().is_empty() {
70            return Err(MltError::MissingLayerName);
71        }
72        if self.features().is_empty() {
73            return Ok(Vec::new());
74        }
75
76        let mut sort_by = vec![SortStrategy::Unsorted];
77        let try_spatial_sort =
78            cfg.attempt_spatial_morton_sort() || cfg.attempt_spatial_hilbert_sort();
79        if try_spatial_sort
80            && (self.feature_count() < SORT_TRIAL_THRESHOLD || spatial_sort_likely_to_help(&self))
81        {
82            if cfg.attempt_spatial_morton_sort() {
83                sort_by.push(SortStrategy::SpatialMorton);
84            }
85            if cfg.attempt_spatial_hilbert_sort() {
86                sort_by.push(SortStrategy::SpatialHilbert);
87            }
88        }
89        if cfg.attempt_id_sort() {
90            sort_by.push(SortStrategy::Id);
91        }
92
93        let stats = self.analyze(cfg.allow_shared_dict())?;
94        // Bounds are order-invariant, so this scan is shared across every
95        // sort trial and the encoder's Hilbert/Morton dictionary builders.
96        let curve_params = self.curve_params();
97
98        // `Encoder::preserve_results` clears caches only on the moved-out
99        // archive, so a single seeding here serves every trial that reuses
100        // `enc`.
101        let mut enc = Encoder::new(cfg);
102        seed_curve_caches(&mut enc, curve_params);
103
104        let (last, init) = sort_by.split_last().expect("at least one strategy");
105        if init.is_empty() {
106            let mut codecs = Codecs::default();
107            StagedLayer::from_tile(self, *last, &stats, cfg.tessellate(), curve_params)
108                .encode_into(enc, &mut codecs)?
109        } else {
110            let mut codecs = Codecs::default();
111            enc = {
112                let first = init[0];
113                StagedLayer::from_tile(self.clone(), first, &stats, cfg.tessellate(), curve_params)
114                    .encode_into(enc, &mut codecs)?
115            };
116            let mut best = enc.preserve_results();
117            // Clone for all-but-last strategies
118            for &sort in &init[1..] {
119                let layer = StagedLayer::from_tile(
120                    self.clone(),
121                    sort,
122                    &stats,
123                    cfg.tessellate(),
124                    curve_params,
125                );
126                enc = layer.encode_into(enc, &mut codecs)?;
127                if enc.total_len() < best.total_len() {
128                    best = enc.preserve_results();
129                } else {
130                    // The losing trial's bytes must be dropped so the next trial
131                    // encodes into a clean buffer. `preserve_results` only empties
132                    // `enc` when a trial wins; without this, the next `encode_into`
133                    // would append to the loser's bytes and overcount its
134                    // `total_len`, so later trials could never win.
135                    enc.clear_results();
136                }
137            }
138            // Last strategy: consume self, no clone
139            let layer = StagedLayer::from_tile(self, *last, &stats, cfg.tessellate(), curve_params);
140            enc = layer.encode_into(enc, &mut codecs)?;
141            if enc.total_len() < best.total_len() {
142                best = enc.preserve_results();
143            }
144            best
145        }
146        .into_layer_bytes()
147    }
148}
149
150/// Row-order-independent presence classification for IDs and properties.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum Presence {
153    /// No feature has a value for this logical column.
154    AllNull,
155    /// Every feature has a value for this logical column.
156    AllPresent,
157    /// Some, but not all, features have a value for this logical column.
158    Mixed,
159    /// Mixed presence with the same per-feature mask as an earlier property column.
160    SameAsProp(usize),
161}
162impl Presence {
163    /// Create presence value
164    #[must_use]
165    pub fn from_bits(bits: &BitVec<u8>, existing: &[(BitVec<u8>, usize)]) -> Self {
166        if bits.not_any() {
167            Self::AllNull
168        } else if bits.all() {
169            Self::AllPresent
170        } else if let Some((_, idx)) = existing.iter().find(|(v, _)| v == bits) {
171            Self::SameAsProp(*idx)
172        } else {
173            Self::Mixed
174        }
175    }
176}
177
178/// How a property participates in a shared dictionary group.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum SharedDictRole {
181    /// The property is encoded as a standalone column.
182    None,
183    /// The property is the first column in this group and emits the shared dictionary with this prefix.
184    Owner(String),
185    /// The property is emitted by the group owner at this property index.
186    Member(usize),
187}
188
189/// Row-order-independent facts for a single property column.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct PropertyStats {
192    pub presence: Presence,
193    pub stats: PropertyTypedStats,
194}
195
196/// Row-order-independent layer facts computed once before sort trials.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct LayerStats {
199    pub id: Option<PropertyStats>,
200    pub properties: Vec<PropertyStats>,
201}
202
203/// Row-order-independent value statistics for a property column.
204#[derive(Debug, Clone, Default, PartialEq, Eq)]
205pub enum PropertyTypedStats {
206    /// No present values.
207    #[default]
208    None,
209    Bool,
210    Signed {
211        min: i64,
212        max: i64,
213    },
214    Unsigned {
215        min: u64,
216        max: u64,
217    },
218    F32,
219    F64,
220    String {
221        shared_dict: SharedDictRole,
222    },
223}
224
225impl PropertyTypedStats {
226    #[must_use]
227    pub fn values_fit_u32(&self) -> bool {
228        match self {
229            Self::None | Self::Bool | Self::F32 | Self::F64 | Self::String { .. } => false,
230            Self::Signed { min, max } => *min >= 0 && u32::try_from(*max).is_ok(),
231            Self::Unsigned { max, .. } => u32::try_from(*max).is_ok(),
232        }
233    }
234
235    /// Returns `true` if every value fits in an `i32`.
236    ///
237    /// Unlike [`Self::values_fit_u32`] this admits negative values.
238    #[must_use]
239    pub fn values_fit_i32(&self) -> bool {
240        match self {
241            Self::None | Self::Bool | Self::F32 | Self::F64 | Self::String { .. } => false,
242            Self::Signed { min, max } => i32::try_from(*min).is_ok() && i32::try_from(*max).is_ok(),
243            Self::Unsigned { max, .. } => i32::try_from(*max).is_ok(),
244        }
245    }
246
247    #[must_use]
248    pub fn shared_dict(&self) -> SharedDictRole {
249        match self {
250            Self::String { shared_dict, .. } => shared_dict.clone(),
251            _ => SharedDictRole::None,
252        }
253    }
254
255    pub(crate) fn set_shared_dict(&mut self, role: SharedDictRole) {
256        match self {
257            Self::String { shared_dict, .. } => *shared_dict = role,
258            _ => debug_assert_eq!(role, SharedDictRole::None),
259        }
260    }
261
262    pub(crate) fn push(
263        &mut self,
264        prop: &PropValue,
265        column_idx: usize,
266        property_name: &str,
267    ) -> MltResult<bool> {
268        match prop {
269            PropValue::Bool(Some(_)) => {
270                self.merge_same_kind(Self::Bool, column_idx, property_name)?;
271            }
272            PropValue::I8(Some(v)) => {
273                self.merge_signed(i64::from(*v), column_idx, property_name)?;
274            }
275            PropValue::U8(Some(v)) => {
276                self.merge_unsigned(u64::from(*v), column_idx, property_name)?;
277            }
278            PropValue::I32(Some(v)) => {
279                self.merge_signed(i64::from(*v), column_idx, property_name)?;
280            }
281            PropValue::U32(Some(v)) => {
282                self.merge_unsigned(u64::from(*v), column_idx, property_name)?;
283            }
284            PropValue::I64(Some(v)) => self.merge_signed(*v, column_idx, property_name)?,
285            PropValue::U64(Some(v)) => self.merge_unsigned(*v, column_idx, property_name)?,
286            PropValue::F32(Some(_)) => {
287                self.merge_same_kind(Self::F32, column_idx, property_name)?;
288            }
289            PropValue::F64(Some(_)) => {
290                self.merge_same_kind(Self::F64, column_idx, property_name)?;
291            }
292            PropValue::Str(Some(_)) => self.merge_string(column_idx, property_name)?,
293            _ => return Ok(false),
294        }
295        Ok(true)
296    }
297
298    fn merge_signed(
299        &mut self,
300        value: i64,
301        column_idx: usize,
302        property_name: &str,
303    ) -> MltResult<()> {
304        match self {
305            Self::None => {
306                *self = Self::Signed {
307                    min: value,
308                    max: value,
309                };
310            }
311            Self::Signed { min, max } => {
312                *min = (*min).min(value);
313                *max = (*max).max(value);
314            }
315            _ => return mixed_prop_err(column_idx, property_name),
316        }
317        Ok(())
318    }
319
320    fn merge_unsigned(
321        &mut self,
322        value: u64,
323        column_idx: usize,
324        property_name: &str,
325    ) -> MltResult<()> {
326        match self {
327            Self::None => {
328                *self = Self::Unsigned {
329                    min: value,
330                    max: value,
331                };
332            }
333            Self::Unsigned { min, max } => {
334                *min = (*min).min(value);
335                *max = (*max).max(value);
336            }
337            _ => return mixed_prop_err(column_idx, property_name),
338        }
339        Ok(())
340    }
341
342    fn merge_string(&mut self, column_idx: usize, property_name: &str) -> MltResult<()> {
343        match self {
344            Self::None => {
345                *self = Self::String {
346                    shared_dict: SharedDictRole::None,
347                };
348            }
349            Self::String { .. } => {}
350            _ => return mixed_prop_err(column_idx, property_name),
351        }
352        Ok(())
353    }
354
355    fn merge_same_kind(
356        &mut self,
357        kind: Self,
358        column_idx: usize,
359        property_name: &str,
360    ) -> MltResult<()> {
361        match self {
362            Self::None => *self = kind,
363            Self::Bool if matches!(kind, Self::Bool) => {}
364            Self::F32 if matches!(kind, Self::F32) => {}
365            Self::F64 if matches!(kind, Self::F64) => {}
366            _ => return mixed_prop_err(column_idx, property_name),
367        }
368        Ok(())
369    }
370}
371
372impl TileLayer {
373    /// Analyze a [`TileLayer`] and return reusable ID/property facts for the optimizer.
374    #[hotpath::measure]
375    pub(crate) fn analyze(&self, allow_shared_dict: bool) -> MltResult<LayerStats> {
376        let mut property_bits = Vec::with_capacity(self.property_names().len());
377        let mut properties = self.analyze_properties(&mut property_bits)?;
378        let id = self.analyze_ids(&property_bits);
379        if allow_shared_dict {
380            self.group_string_properties(&mut properties);
381        }
382        Ok(LayerStats { id, properties })
383    }
384
385    fn analyze_ids(&self, property_bits: &[(BitVec<u8>, usize)]) -> Option<PropertyStats> {
386        let mut min = u64::MAX;
387        let mut max = 0u64;
388        let mut bits = BitVec::<u8>::with_capacity(self.feature_count());
389        for feature in self.features() {
390            if let Some(id) = feature.id() {
391                min = min.min(id);
392                max = max.max(id);
393                bits.push(true);
394            } else {
395                bits.push(false);
396            }
397        }
398        let presence = Presence::from_bits(&bits, property_bits);
399        if presence == Presence::AllNull {
400            None
401        } else {
402            Some(PropertyStats {
403                presence,
404                stats: PropertyTypedStats::Unsigned { min, max },
405            })
406        }
407    }
408
409    fn analyze_properties(
410        &self,
411        property_bits: &mut Vec<(BitVec<u8>, usize)>,
412    ) -> MltResult<Vec<PropertyStats>> {
413        self.property_names()
414            .iter()
415            .enumerate()
416            .map(|(col_idx, name)| -> MltResult<PropertyStats> {
417                let mut kind = None;
418                let mut stats = PropertyTypedStats::default();
419                let mut bits = BitVec::<u8>::with_capacity(self.feature_count());
420                for feature in self.features() {
421                    let prop = feature.properties().get(col_idx);
422                    if let Some(prop_kind) = prop.map(PropKind::from) {
423                        match kind {
424                            Some(kind) if kind != prop_kind => {
425                                return mixed_prop_err(col_idx, name.as_str());
426                            }
427                            None => kind = Some(prop_kind),
428                            _ => {}
429                        }
430                    }
431                    if let Some(prop) = prop
432                        && stats.push(prop, col_idx, name)?
433                    {
434                        bits.push(true);
435                    } else {
436                        bits.push(false);
437                    }
438                }
439
440                let presence = Presence::from_bits(&bits, property_bits);
441                if presence == Presence::Mixed {
442                    property_bits.push((bits, col_idx));
443                }
444                Ok(PropertyStats { presence, stats })
445            })
446            .collect()
447    }
448}
449
450#[inline]
451fn mixed_prop_err<T>(column_idx: usize, property_name: &str) -> MltResult<T> {
452    Err(MltError::MixedPropertyTypes(
453        column_idx,
454        property_name.to_owned(),
455    ))
456}