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
