Skip to main content

parquet/column/writer/
byte_budget_chunker.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: See the NOTICE file distributed with this work for additional information regarding copyright ownership
3// SPDX-FileContributor: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements
4
5//! See [`ByteBudgetChunker`] for byte-budget-aware mini-batch sizing.
6
7use crate::basic::Type;
8use crate::column::writer::LevelDataRef;
9use crate::column::writer::encoder::ColumnValueEncoder;
10use crate::file::properties::ResolvedColumnProperties;
11use crate::schema::types::ColumnDescriptor;
12
13/// How [`write_granular_chunk`] should cut mini-batch windows in one chunk.
14///
15/// Cutting on exact value counts is the precise option and the expensive one:
16/// it roughly doubles the number of mini-batches on a nullable column, because
17/// the level:value ratio no longer rounds a window up to cover a second value.
18/// It is used only where that precision buys something.
19///
20/// [`write_granular_chunk`]: super::GenericColumnWriter::write_granular_chunk
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub(crate) enum SubBatchStrategy {
23    /// Cut after exactly this many values, walking definition levels to find
24    /// the boundary.
25    ///
26    /// Used against the data page budget for encodings that compress a value
27    /// against its predecessor, where the round-up below costs whole values of
28    /// output: 16 MiB rather than 2 MiB for 128 values at one null in 16. See
29    /// [#10538] for the measurements.
30    ///
31    /// [#10538]: https://github.com/apache/arrow-rs/issues/10538
32    Values(usize),
33    /// Cut after this many levels: the value budget scaled by the chunk's
34    /// level:value ratio, rounded up.
35    ///
36    /// Used everywhere else, because everywhere else the round-up changes no
37    /// bytes — the *dictionary page* budget shrinks toward zero as the
38    /// dictionary fills, so a one-value budget is routine there for ordinary
39    /// values, and `PLAIN` and `DELTA_LENGTH_BYTE_ARRAY` values cost the same
40    /// wherever they land — while value-exact windows cost throughput
41    /// ([#10554]).
42    ///
43    /// The round-up must stay bounded for that price to be acceptable. The
44    /// level count alone does not bound it: the ratio is a chunk *average*,
45    /// so a window over a run of clustered values can hold far more than the
46    /// budget (28 values of 76 MiB followed by 996 nulls all land in a
47    /// 37-level window, [#11994]). Each window is therefore also capped at
48    /// `max_values` values, twice the value budget, so the page bound [#9972]
49    /// added holds whatever the null layout: two values per page rather than
50    /// one. The cap walks the window's definition levels, but adds no
51    /// mini-batches where nulls are evenly spread.
52    ///
53    /// [#9972]: https://github.com/apache/arrow-rs/pull/9972
54    /// [#10554]: https://github.com/apache/arrow-rs/pull/10554
55    /// [#11994]: https://github.com/apache/arrow-rs/issues/11994
56    Levels { levels: usize, max_values: usize },
57}
58
59/// Picks byte-budget-aware mini-batch sizes for one column.
60///
61/// The parquet column writer checks the data page byte limit only *after*
62/// each mini-batch finishes writing. Mini-batches are sized in rows
63/// (`write_batch_size`, default 1024), so for BYTE_ARRAY columns whose
64/// values are large (e.g. multi-MiB blobs) a single mini-batch can buffer
65/// GiB into one page before the limit is consulted.
66///
67/// This isolates the per-chunk decision that prevents that: given a chunk's
68/// level data and the input values, pick the largest `sub_batch_size` such
69/// that one mini-batch fits in one page byte budget. For the overwhelmingly
70/// common case (small or fixed-width values) the answer is just `chunk_size`
71/// and the decision is O(1) on the column type — only when the input might
72/// overflow does the chunker consult the encoder's byte estimate.
73pub(crate) struct ByteBudgetChunker {
74    /// Configured data page byte limit for the column.
75    page_byte_limit: usize,
76    /// Max definition level of the column; a level equal to this marks a
77    /// present (non-null) leaf value. Used to count values per chunk.
78    max_def_level: i16,
79    /// `true` when no chunk of `base_batch_size` values can ever overflow
80    /// `page_byte_limit` regardless of input. Set once at column open from
81    /// the physical type's known per-value byte size; lets the per-chunk
82    /// decision short-circuit with no work for every numeric, bool, or
83    /// narrow `FIXED_LEN_BYTE_ARRAY` column.
84    static_always_fits: bool,
85    /// Configured dictionary page byte limit for the column.
86    dict_page_byte_limit: usize,
87    /// As [`Self::static_always_fits`] but for the dictionary page: `true`
88    /// when one `base_batch_size` mini-batch of this fixed-width type cannot
89    /// overshoot `dict_page_byte_limit` by more than one mini-batch's worth.
90    static_dict_always_fits: bool,
91}
92
93impl ByteBudgetChunker {
94    #[inline]
95    pub(crate) fn new(
96        descr: &ColumnDescriptor,
97        column_props: &ResolvedColumnProperties,
98        base_batch_size: usize,
99    ) -> Self {
100        let page_byte_limit = column_props.data_page_size_limit;
101        let dict_page_byte_limit = column_props.dictionary_page_size_limit;
102        let static_bytes_per_value = match descr.physical_type() {
103            Type::BOOLEAN => Some(1),
104            Type::INT32 | Type::FLOAT => Some(std::mem::size_of::<i32>()),
105            Type::INT64 | Type::DOUBLE => Some(std::mem::size_of::<i64>()),
106            Type::INT96 => Some(12),
107            Type::FIXED_LEN_BYTE_ARRAY => Some(descr.type_length().max(0) as usize),
108            Type::BYTE_ARRAY => None,
109        };
110        let static_fits = |limit: usize| {
111            static_bytes_per_value
112                .map(|b| b.saturating_mul(base_batch_size) <= limit)
113                .unwrap_or(false)
114        };
115        Self {
116            page_byte_limit,
117            max_def_level: descr.max_def_level(),
118            static_always_fits: static_fits(page_byte_limit),
119            dict_page_byte_limit,
120            static_dict_always_fits: static_fits(dict_page_byte_limit),
121        }
122    }
123
124    /// Decide how many *values* at the start of a chunk belong in one
125    /// mini-batch, so the mini-batch cannot overflow whichever page is
126    /// currently accumulating value bytes: the data page when plain-encoding,
127    /// or the *dictionary* page while dictionary-encoding.
128    ///
129    /// `None` means the whole chunk fits in a single mini-batch — the common
130    /// case. `Some(_)` triggers granular sub-batching in
131    /// `write_batch_internal`; see [`SubBatchStrategy`] for why the data page and
132    /// dictionary page budgets get different windowing.
133    ///
134    /// While dictionary-encoding, the data page holds only small RLE indices,
135    /// but the dictionary page accumulates the distinct values themselves —
136    /// so it is the dictionary page's remaining budget that must bound the
137    /// mini-batch. The per-mini-batch dictionary spill check would otherwise
138    /// let one mini-batch of large values balloon the dictionary page.
139    ///
140    /// Returns `None` immediately (no value inspection) when the chunk is
141    /// empty, or when the column is a fixed-width type whose mini-batches
142    /// statically cannot overshoot the relevant page.
143    ///
144    /// `#[inline]`: this is a tiny per-chunk dispatcher; the actual byte
145    /// inspection lives in the out-of-line `byte_budget_sub_batch`.
146    #[inline]
147    pub(crate) fn pick_sub_batch<E: ColumnValueEncoder>(
148        &self,
149        encoder: &E,
150        values: &E::Values,
151        value_indices: Option<&[usize]>,
152        chunk_def: LevelDataRef<'_>,
153        values_offset: usize,
154        chunk_size: usize,
155    ) -> Option<SubBatchStrategy> {
156        if chunk_size == 0 {
157            return None;
158        }
159        // The second element selects the windowing; see [`SubBatchStrategy`]. Only a
160        // constant data page budget under an encoding that compresses against
161        // the previous value is worth cutting exactly.
162        let (budget, value_exact) = if encoder.has_dictionary() {
163            if self.static_dict_always_fits {
164                return None;
165            }
166            // Bound the mini-batch by the dictionary page's *remaining*
167            // budget (it accumulates across mini-batches until it spills).
168            // An encoder that cannot size its dictionary page (`?`) leaves
169            // the chunk unsplit.
170            let used = encoder.estimated_dict_page_size()?;
171            (self.dict_page_byte_limit.saturating_sub(used), false)
172        } else {
173            if self.static_always_fits {
174                return None;
175            }
176            (
177                self.page_byte_limit,
178                encoder.compresses_against_previous_value(),
179            )
180        };
181        self.byte_budget_sub_batch::<E>(
182            values,
183            value_indices,
184            chunk_def,
185            values_offset,
186            chunk_size,
187            budget,
188            value_exact,
189        )
190    }
191
192    /// Inspect value sizes to decide how many of the chunk's values fit in
193    /// `budget` bytes (the data page or dictionary page remaining budget).
194    ///
195    /// `#[inline(never)]` keeps this slow path out of the hot
196    /// `write_batch_internal` loop; numeric and bool columns never reach it.
197    #[inline(never)]
198    #[expect(clippy::too_many_arguments)]
199    fn byte_budget_sub_batch<E: ColumnValueEncoder>(
200        &self,
201        values: &E::Values,
202        value_indices: Option<&[usize]>,
203        chunk_def: LevelDataRef<'_>,
204        values_offset: usize,
205        chunk_size: usize,
206        budget: usize,
207        value_exact: bool,
208    ) -> Option<SubBatchStrategy> {
209        // How many of this chunk's levels carry an actual value. For a
210        // non-nullable, unrepeated column every level is a value, so
211        // `value_count` is O(1) (`Absent`/`Uniform` def levels); only
212        // nullable or nested columns pay the O(chunk_size) def-level scan.
213        let vals_in_chunk = chunk_def.value_count(chunk_size, self.max_def_level);
214        if vals_in_chunk == 0 {
215            return None;
216        }
217        // Ask the encoder how many of the next values fit in one page byte
218        // budget. Dispatch on whether the caller supplied gather indices;
219        // this mirrors how `write_mini_batch` picks `write_gather` vs
220        // `write`.
221        let fit = match value_indices {
222            Some(idx) => {
223                let end = (values_offset + vals_in_chunk).min(idx.len());
224                let start = values_offset.min(end);
225                E::count_values_within_byte_budget_gather(values, &idx[start..end], budget)
226            }
227            None => {
228                E::count_values_within_byte_budget(values, values_offset, vals_in_chunk, budget)
229            }
230        };
231        match fit {
232            // The encoder cannot size these values; write the chunk whole.
233            None => None,
234            // All of the chunk's values fit in the budget — no sub-batching.
235            Some(values_per_subbatch) if values_per_subbatch >= vals_in_chunk => None,
236            Some(values_per_subbatch) => {
237                // `count_values_within_byte_budget` never reports zero, but a
238                // zero-wide window would not advance `write_granular_chunk`.
239                let values_per_subbatch = values_per_subbatch.max(1);
240                Some(if value_exact {
241                    SubBatchStrategy::Values(values_per_subbatch)
242                } else {
243                    // Scale to a level count. Inexact on nullable chunks, and
244                    // deliberately so — see `SubBatchStrategy::Levels`.
245                    let levels_per_subbatch = if vals_in_chunk == chunk_size {
246                        values_per_subbatch
247                    } else {
248                        (values_per_subbatch * chunk_size)
249                            .div_ceil(vals_in_chunk)
250                            .max(1)
251                    };
252                    SubBatchStrategy::Levels {
253                        levels: chunk_size.min(levels_per_subbatch),
254                        max_values: values_per_subbatch.saturating_mul(2),
255                    }
256                })
257            }
258        }
259    }
260}