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}