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}