This is an automated email from the ASF dual-hosted git repository.
alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 665462d5b6 optimize(parquet): Nested list batching child.write calls
(#10085)
665462d5b6 is described below
commit 665462d5b6810aeda3a9945eb8515141a3920dd3
Author: mwish <[email protected]>
AuthorDate: Tue Jul 21 04:18:02 2026 +0800
optimize(parquet): Nested list batching child.write calls (#10085)
# Which issue does this PR close?
- Closes #10079 .
# Rationale for this change
Optimize nested list call recursive write counts.
# What changes are included in this PR?
Separate list write function to direct-by-offsets and by backward scan
# Are these changes tested?
Covered by existing
# Are there any user-facing changes?
No
---------
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
Co-authored-by: Andrew Lamb <[email protected]>
---
parquet/src/arrow/arrow_writer/levels.rs | 242 ++++++++++++++-----------------
1 file changed, 110 insertions(+), 132 deletions(-)
diff --git a/parquet/src/arrow/arrow_writer/levels.rs
b/parquet/src/arrow/arrow_writer/levels.rs
index 8918cfb148..7dfb875efd 100644
--- a/parquet/src/arrow/arrow_writer/levels.rs
+++ b/parquet/src/arrow/arrow_writer/levels.rs
@@ -384,131 +384,134 @@ impl LevelInfoBuilder {
return;
}
- // Fast path for "last-level list": when the child has no nested
rep_levels,
- // each child element produces exactly one rep_level entry. We can
batch
- // contiguous non-empty list slots into a single child.write() call,
then
- // fix up the rep_levels at list-slot boundaries using offsets
directly.
- //
- // Kept as a separate function so the compiler can optimize
write_list's
+ // Dispatch to separate functions so the compiler can optimize each
// hot loop independently (function body size affects codegen quality).
if is_last_level {
- Self::write_list_last_level(child, ctx, offsets, nulls, range);
- return;
+ Self::write_list_direct(child, ctx, offsets, nulls, range);
+ } else {
+ Self::write_list_scan(child, ctx, offsets, nulls, range);
}
+ }
- let offsets = &offsets[range.start..range.end + 1];
-
- let write_non_null_slice =
- |child: &mut LevelInfoBuilder, start_idx: usize, end_idx: usize| {
- child.write(start_idx..end_idx);
- child.visit_leaves(|leaf| {
- let rep_levels =
leaf.rep_levels.materialize_mut().unwrap();
- let mut rev = rep_levels.iter_mut().rev();
- let mut remaining = end_idx - start_idx;
+ /// Batch write for lists whose child has no nested repetition.
+ ///
+ /// "direct" means writing the child rep levels using offsets without
scanning.
+ fn write_list_direct<O: OffsetSizeTrait>(
+ child: &mut LevelInfoBuilder,
+ ctx: &LevelContext,
+ offsets: &[O],
+ nulls: Option<&NullBuffer>,
+ range: Range<usize>,
+ ) {
+ let list_start_rep = ctx.rep_level - 1;
- loop {
- let next = rev.next().unwrap();
- if *next > ctx.rep_level {
- // Nested element - ignore
- continue;
- }
+ let emit_non_empty_run = |child: &mut LevelInfoBuilder, run_offsets:
&[O]| {
+ debug_assert!(run_offsets.len() >= 2);
+ let values_start = run_offsets[0].as_usize();
+ let values_end = run_offsets[run_offsets.len() - 1].as_usize();
+ debug_assert!(values_end > values_start);
- remaining -= 1;
- if remaining == 0 {
- *next = ctx.rep_level - 1;
- break;
- }
- }
- })
- };
+ child.write(values_start..values_end);
- // In a list column, each row falls into one of three categories:
- // - "null": the list slot is absent (!is_valid), encoded at def_level
- 2
- // - "empty": the list slot is present but has zero elements
- // (offsets[i] == offsets[i+1]), encoded at def_level - 1
- // - non-empty: the list slot has child values, which are recursed into
- //
- // Consecutive runs of null or empty rows are batched and written
together.
- let write_null_run = |child: &mut LevelInfoBuilder, count: usize| {
- if count > 0 {
- child.visit_leaves(|leaf| {
- leaf.append_rep_level_run(ctx.rep_level - 1, count);
- leaf.append_def_level_run(ctx.def_level - 2, count);
- });
- }
+ // The first element of each list slot needs rep_level =
+ // list_start_rep to mark a new list boundary. Because there's a
1:1
+ // mapping between child elements and rep_level entries, the
position
+ // of each slot's first element is directly computable from
offsets.
+ child.visit_leaves(|leaf| {
+ debug_assert!(leaf.max_rep_level == ctx.rep_level);
+ let rep_levels = leaf.rep_levels.materialize_mut().unwrap();
+ let batch_len = values_end - values_start;
+ let batch_base = rep_levels.len() - batch_len;
+ for slot_offset in run_offsets.iter().take(run_offsets.len() -
1) {
+ let pos = batch_base + (slot_offset.as_usize() -
values_start);
+ rep_levels[pos] = list_start_rep;
+ }
+ });
};
- let write_empty_run = |child: &mut LevelInfoBuilder, count: usize| {
- if count > 0 {
- child.visit_leaves(|leaf| {
- leaf.append_rep_level_run(ctx.rep_level - 1, count);
- leaf.append_def_level_run(ctx.def_level - 1, count);
- });
- }
- };
+ Self::write_list_impl(child, ctx, offsets, nulls, range,
emit_non_empty_run);
+ }
- match nulls {
- Some(nulls) => {
- let null_offset = range.start;
- let mut pending_nulls: usize = 0;
- let mut pending_empties: usize = 0;
+ /// Batch write for lists whose child has nested repetition.
+ ///
+ /// After batch-writing child elements, scans backward through rep_levels
+ /// counting child-element starts to find and stamp slot boundaries.
+ ///
+ /// Scan backward because we don't know start offset before writing.
+ fn write_list_scan<O: OffsetSizeTrait>(
+ child: &mut LevelInfoBuilder,
+ ctx: &LevelContext,
+ offsets: &[O],
+ nulls: Option<&NullBuffer>,
+ range: Range<usize>,
+ ) {
+ let list_start_rep = ctx.rep_level - 1;
- // TODO: Faster bitmask iteration (#1757)
- for (idx, w) in offsets.windows(2).enumerate() {
- let is_valid = nulls.is_valid(idx + null_offset);
- let start_idx = w[0].as_usize();
- let end_idx = w[1].as_usize();
+ let emit_non_empty_run = |child: &mut LevelInfoBuilder, run_offsets:
&[O]| {
+ debug_assert!(run_offsets.len() >= 2);
+ let values_start = run_offsets[0].as_usize();
+ let values_end = run_offsets[run_offsets.len() - 1].as_usize();
+ debug_assert!(values_end > values_start);
- if !is_valid {
- write_empty_run(child, pending_empties);
- pending_empties = 0;
- pending_nulls += 1;
- } else if start_idx == end_idx {
- write_null_run(child, pending_nulls);
- pending_nulls = 0;
- pending_empties += 1;
- } else {
- write_null_run(child, pending_nulls);
- pending_nulls = 0;
- write_empty_run(child, pending_empties);
- pending_empties = 0;
- write_non_null_slice(child, start_idx, end_idx);
+ child.write(values_start..values_end);
+
+ child.visit_leaves(|leaf| {
+ let rep_levels = leaf.rep_levels.materialize_mut().unwrap();
+
+ if leaf.max_rep_level == ctx.rep_level {
+ // This algorithm is the same as write_list_direct.
+ // Use a separate function because the branch code size
would affect codegen
+ // quality of the hot loop of write_list_direct.
+ let batch_len = values_end - values_start;
+ let batch_base = rep_levels.len() - batch_len;
+ for slot_offset in
run_offsets.iter().take(run_offsets.len() - 1) {
+ let pos = batch_base + (slot_offset.as_usize() -
values_start);
+ rep_levels[pos] = list_start_rep;
}
- }
- write_null_run(child, pending_nulls);
- write_empty_run(child, pending_empties);
- }
- None => {
- let mut pending_empties: usize = 0;
- for w in offsets.windows(2) {
- let start_idx = w[0].as_usize();
- let end_idx = w[1].as_usize();
- if start_idx == end_idx {
- pending_empties += 1;
- } else {
- write_empty_run(child, pending_empties);
- pending_empties = 0;
- write_non_null_slice(child, start_idx, end_idx);
+ } else {
+ // Backward scan: count child-element starts (rep <=
ctx.rep_level)
+ // and stamp list_start_rep at slot boundaries.
+ let mut slot_bounds = run_offsets[..run_offsets.len() -
1].iter().rev();
+ let mut next_stamp_at = values_end -
slot_bounds.next().unwrap().as_usize();
+ let mut seen = 0usize;
+
+ for rep in rep_levels.iter_mut().rev() {
+ // Asserting rep >= ctx.rep_level to ensure low level
depth haven't
+ // being written.
+ debug_assert!(*rep >= ctx.rep_level);
+ // Count element starts by skipping nested reps (rep >
ctx.rep_level).
+ //
+ // `==` would also work here: the child is written
before the
+ // parent, so no entry within the batch has rep <
ctx.rep_level.
+ // Benchmarks show no difference, so keep the more
defensive `<=`.
+ if *rep <= ctx.rep_level {
+ seen += 1;
+ if seen == next_stamp_at {
+ *rep = list_start_rep;
+ match slot_bounds.next() {
+ Some(offset) => next_stamp_at = values_end
- offset.as_usize(),
+ None => break,
+ }
+ }
+ }
}
}
- write_empty_run(child, pending_empties);
- }
- }
+ });
+ };
+
+ Self::write_list_impl(child, ctx, offsets, nulls, range,
emit_non_empty_run);
}
- /// Optimized write path for lists whose child has no nested repetition
levels.
- ///
- /// When the child is a leaf (or a struct of leaves), each child element
maps to
- /// exactly one rep_level entry. This lets us batch contiguous non-empty
list
- /// slots into a single `child.write()` call, then stamp the list-start
markers
- /// at positions computed directly from offsets — avoiding per-slot
`write` +
- /// reverse-scan overhead.
- fn write_list_last_level<O: OffsetSizeTrait>(
+ /// Shared run-classification loop for write_list_direct and
write_list_scan.
+ /// Monomorphized per `emit_non_empty_run` closure type, giving the
compiler
+ /// separate optimization contexts for each backfill strategy.
+ fn write_list_impl<O: OffsetSizeTrait>(
child: &mut LevelInfoBuilder,
ctx: &LevelContext,
offsets: &[O],
nulls: Option<&NullBuffer>,
range: Range<usize>,
+ mut emit_non_empty_run: impl FnMut(&mut LevelInfoBuilder, &[O]),
) {
let null_offset = range.start;
let offsets = &offsets[range.start..range.end + 1];
@@ -528,33 +531,6 @@ impl LevelInfoBuilder {
});
};
- let emit_non_empty_run = |child: &mut LevelInfoBuilder, run_offsets:
&[O]| {
- debug_assert!(run_offsets.len() >= 2);
- let values_start = run_offsets[0].as_usize();
- let values_end = run_offsets[run_offsets.len() - 1].as_usize();
- debug_assert!(values_end > values_start);
-
- // Write all leaf values in one batch. Since the child has no
nested
- // rep, this emits (values_end - values_start) rep_levels all equal
- // to ctx.rep_level (= "continuation within list").
- child.write(values_start..values_end);
-
- // The first element of each list slot needs rep_level =
- // list_start_rep to mark a new list boundary. Because there's a
1:1
- // mapping between child elements and rep_level entries, the
position
- // of each slot's first element is directly computable from
offsets.
- child.visit_leaves(|leaf| {
- let rep_levels = leaf.rep_levels.materialize_mut().unwrap();
- let batch_len = values_end - values_start;
- let batch_base = rep_levels.len() - batch_len;
-
- for slot_offset in run_offsets.iter().take(run_offsets.len() -
1) {
- let list_start_pos = batch_base + (slot_offset.as_usize()
- values_start);
- rep_levels[list_start_pos] = list_start_rep;
- }
- });
- };
-
// Classify each slot, detect run boundaries, flush on transition.
#[derive(Clone, Copy, PartialEq)]
enum SlotKind {
@@ -591,7 +567,9 @@ impl LevelInfoBuilder {
}
match nulls {
- Some(nulls) => {
+ // A null buffer without any null can skip the per-slot validity
+ // checks and use the null-free classification loop below.
+ Some(nulls) if nulls.null_count() > 0 => {
let mut run_kind = classify!(0, nulls);
let mut run_start: usize = 0;
for i in 1..num_slots {
@@ -604,7 +582,7 @@ impl LevelInfoBuilder {
}
flush_run!(run_kind, run_start, num_slots);
}
- None => {
+ _ => {
let mut run_kind = if offsets[0] == offsets[1] {
SlotKind::Empty
} else {