This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-11376-b77170d3c1c958b2bf3d3ccfa3287cbdc9dd7936
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git

commit c7f60c612bd16ca5485cc9acddfd216c10ea9765
Author: Neil Conway <[email protected]>
AuthorDate: Wed Oct 7 14:14:45 2026 +0000

    perf(parquet): Skip per-level validity pass for list children with no nulls 
(#11376)
    
    # Which issue does this PR close?
    
    - N/A
    
    # Rationale for this change
    
    Since #9848, reading the child column of a list builds a compact
    validity bitmap with a pass over every definition level. When every
    definition level in a chunk has the maximum value (i.e., there are no
    null lists, empty lists, or null elements in any list), we can skip
    doing the pass over the definition levels and append an all-valid run
    instead.
    
    Adding this pass caused a regression in list decoding performance
    between 58.3.0 and 59.1.0: for example, 232 ms -> 289 ms to read 5M rows
    with 62.5M values on an Apple M4 Max.
    
    Benchmark for this case was added in #11375. Results on M4 Max:
    
    - StringList/no NULLs: +0.7%, −0.6%, +0.9%
    - StringList/half NULLs: +2.8%, +3.8%, +1.0%
    - StringList/90pct NULLs: +2.7%, +0.5%, +0.9%
    - StringList/99pct NULLs: +1.0%, +0.2%, +0.7%
    - Int32List/no NULLs: +0.8%, −0.3%, −0.2%
    - Int32List/half NULLs: +1.3%, −0.3%, +0.5%
    - Int32List/90pct NULLs: +0.6%, −0.9%, −0.3%
    - Int32List/99pct NULLs: +1.1%, −0.6%, +1.0%
    - Int32List/no NULLs or empty lists: −18.4%, −19.9%, −18.5%
    - Fixed32List/no NULLs: +1.2%, −0.8%, +1.1%
    - Fixed32List/half NULLs: +1.6%, +0.8%, +2.1%
    - Fixed32List/90pct NULLs: +0.4%, −0.5%, −0.5%
    - Fixed32List/99pct NULLs: +0.2%, +0.4%, +0.9%
    
    # What changes are included in this PR?
    
    See above.
    
    # Are these changes tested?
    
    Covered by existing tests.
    
    # Are there any significant user-facing changes?
    
    No.
    
    # AI usage
    
    Developed and revised with Claude Code (Opus 5.5).
---
 parquet/src/arrow/record_reader/mod.rs | 139 ++++++++++++++++++++++++++++-----
 1 file changed, 120 insertions(+), 19 deletions(-)

diff --git a/parquet/src/arrow/record_reader/mod.rs 
b/parquet/src/arrow/record_reader/mod.rs
index 45df415ef1..1b003bb352 100644
--- a/parquet/src/arrow/record_reader/mod.rs
+++ b/parquet/src/arrow/record_reader/mod.rs
@@ -308,27 +308,34 @@ where
                 values,
                 |values, values_to_read, levels_to_read, def_levels| {
                     let output_slots = if let Some(threshold) = 
padding_threshold {
-                        let def_levels = def_levels.ok_or_else(|| {
-                            general_err!(
-                                "Definition levels should exist when data is 
less than levels!"
-                            )
-                        })?;
-                        let all_levels = def_levels.levels().ok_or_else(|| {
-                            general_err!(
-                                "Raw definition levels must be available for 
selective padding"
-                            )
-                        })?;
-                        let batch_levels = &all_levels[all_levels.len() - 
levels_to_read..];
                         let bitmap =
                             compact_bitmap.get_or_insert_with(|| 
BooleanBufferBuilder::new(0));
-
-                        definition_levels::build_filtered_validity_bitmap(
-                            batch_levels,
-                            None,
-                            Some(threshold),
-                            max_def,
-                            bitmap,
-                        )
+                        if values_to_read == levels_to_read {
+                            // Every level is at `max_def`, so each one is a 
non-null
+                            // child value and no level needs to be filtered 
out
+                            bitmap.append_n(levels_to_read, true);
+                            levels_to_read
+                        } else {
+                            let def_levels = def_levels.ok_or_else(|| {
+                                general_err!(
+                                    "Definition levels should exist when data 
is less than levels!"
+                                )
+                            })?;
+                            let all_levels = def_levels.levels().ok_or_else(|| 
{
+                                general_err!(
+                                    "Raw definition levels must be available 
for selective padding"
+                                )
+                            })?;
+                            let batch_levels = &all_levels[all_levels.len() - 
levels_to_read..];
+
+                            definition_levels::build_filtered_validity_bitmap(
+                                batch_levels,
+                                None,
+                                Some(threshold),
+                                max_def,
+                                bitmap,
+                            )
+                        }
                     } else {
                         levels_to_read
                     };
@@ -817,6 +824,100 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_selective_padding_alternating_null_pages() {
+        let message_type = "
+        message test_schema {
+          OPTIONAL GROUP my_list (LIST) {
+            REPEATED GROUP list {
+              OPTIONAL INT32 element;
+            }
+          }
+        }
+        ";
+
+        let desc = parse_message_type(message_type)
+            .map(|t| SchemaDescriptor::new(Arc::new(t)))
+            .map(|s| s.column(0))
+            .unwrap();
+
+        // Pages with no nulls alternate with pages that have null lists, empty
+        // lists and null elements. Every page boundary falls in the middle of 
a
+        // byte of the child validity bitmap, and the last page crosses into 
its
+        // third byte.
+        let pages: &[(&[i16], &[i16], &[i32])] = &[
+            // [1, 2, 3], [4, 5]
+            (&[3, 3, 3, 3, 3], &[0, 1, 1, 0, 1], &[1, 2, 3, 4, 5]),
+            // null, [6, null], [], [null, 7, 8]
+            (&[0, 3, 2, 1, 2, 3, 3], &[0, 0, 1, 0, 0, 1, 1], &[6, 7, 8]),
+            // [9], [10, 11]
+            (&[3, 3, 3], &[0, 0, 1], &[9, 10, 11]),
+            // [12, null], null
+            (&[3, 2, 0], &[0, 1, 0], &[12]),
+            // [13, 14, 15, 16]
+            (&[3, 3, 3, 3], &[0, 1, 1, 1], &[13, 14, 15, 16]),
+        ];
+        let expected_valid = [
+            true, true, true, true, true, true, false, false, true, true, 
true, true, true, true,
+            false, true, true, true, true,
+        ];
+        let expected_values = [
+            1, 2, 3, 4, 5, 6, 0, 0, 7, 8, 9, 10, 11, 12, 0, 13, 14, 15, 16,
+        ];
+        let num_records = 11;
+        let num_levels = 22;
+
+        let page_vec = pages
+            .iter()
+            .map(|(def_levels, rep_levels, values)| {
+                let mut pb = DataPageBuilderImpl::new(desc.clone(), 
def_levels.len() as u32, true);
+                pb.add_rep_levels(1, rep_levels);
+                pb.add_def_levels(3, def_levels);
+                pb.add_values::<Int32Type>(Encoding::PLAIN, values);
+                pb.consume()
+            })
+            .collect::<Vec<_>>();
+
+        // Read all records in one batch, then one record per batch
+        for batch_size in [num_records, 1] {
+            let mut record_reader =
+                RecordReader::<Int32Type>::new(desc.clone(), 
DEFAULT_BATCH_SIZE);
+            record_reader.set_padding_threshold(2);
+            record_reader
+                
.set_page_reader(Box::new(InMemoryPageReader::new(page_vec.clone())))
+                .unwrap();
+
+            for _ in 0..num_records / batch_size {
+                assert_eq!(record_reader.read_records(batch_size).unwrap(), 
batch_size);
+            }
+            assert_eq!(record_reader.num_records(), num_records);
+            assert_eq!(record_reader.num_values(), num_levels);
+            assert_eq!(record_reader.values_written(), expected_values.len());
+
+            assert_eq!(
+                record_reader.consume_compact_bitmap(),
+                Some(Buffer::from_iter(expected_valid)),
+                "batch_size {batch_size}"
+            );
+
+            // Only compare the valid values
+            let actual = record_reader.consume_record_data();
+            assert_eq!(
+                actual.len(),
+                expected_values.len(),
+                "batch_size {batch_size}"
+            );
+            for (i, valid) in expected_valid.iter().enumerate() {
+                if *valid {
+                    assert_eq!(
+                        actual[i], expected_values[i],
+                        "batch_size {batch_size}, index {i}"
+                    );
+                }
+            }
+        }
+    }
+
     #[test]
     fn test_read_more_than_one_batch() {
         // Construct column schema

Reply via email to