Skip to main content

parquet/column/writer/
byte_budget_chunker.rs

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