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

etseidl 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 3320517b91 [Parquet] Implement num distinct values for parquet writer 
(#10654)
3320517b91 is described below

commit 3320517b9130fc8d07c064bc48bfa873d4fec020
Author: RIchard Baah <[email protected]>
AuthorDate: Wed Aug 19 17:31:53 2026 -0400

    [Parquet] Implement num distinct values for parquet writer (#10654)
    
    # Which issue does this PR close?
    
    - Closes #8608.
    - Closes #10650
    
    # Rationale for this change
    
    see #8608, #10650 & https://github.com/apache/datafusion/issues/24114
    
    # What changes are included in this PR?
    Adds `set_write_row_group_number_distinct_values` to WriterProperties,
    which when enabled causes the ArrowWriter to track the exact number of
    distinct non-null values per column across the full row group and write
    it into the column chunk statistics footer as `distinct_count`. Tracking
    is implemented by hashing each non-null value using XxHash64 into a
    per-column HashSet<u64> that persists across batch writes and is
    finalized in `close()`. **The flag defaults to false so there is no
    impact on existing writers.**
    
    # Are these changes tested?
    yes, see test.
    
    # Are there any user-facing changes?
    yes, users will not be able to write `distinct_count` meta data to
    parquet files.
---
 parquet/benches/arrow_writer.rs       |   5 +
 parquet/src/arrow/arrow_writer/mod.rs | 209 +++++++++++++++++++++++++++++++++-
 parquet/src/column/writer/mod.rs      |  28 ++++-
 parquet/src/file/properties.rs        |  43 +++++++
 4 files changed, 282 insertions(+), 3 deletions(-)

diff --git a/parquet/benches/arrow_writer.rs b/parquet/benches/arrow_writer.rs
index 73dc6fae79..54d15284d7 100644
--- a/parquet/benches/arrow_writer.rs
+++ b/parquet/benches/arrow_writer.rs
@@ -785,6 +785,11 @@ fn create_writer_props() -> Vec<(&'static str, 
WriterProperties)> {
         .build();
     props.push(("cdc", prop));
 
+    let prop = WriterProperties::builder()
+        .set_write_row_group_number_distinct_values(true)
+        .build();
+    props.push(("number_distinct_values", prop));
+
     props
 }
 
diff --git a/parquet/src/arrow/arrow_writer/mod.rs 
b/parquet/src/arrow/arrow_writer/mod.rs
index 956ccf6f15..947f07a627 100644
--- a/parquet/src/arrow/arrow_writer/mod.rs
+++ b/parquet/src/arrow/arrow_writer/mod.rs
@@ -44,6 +44,8 @@ use crate::column::writer::{
     ColumnCloseResult, ColumnWriter, GenericColumnWriter, get_column_writer,
 };
 use crate::data_type::{ByteArray, FixedLenByteArray};
+use std::collections::HashSet;
+type DistinctValuesSet = HashSet<u64>;
 #[cfg(feature = "encryption")]
 use crate::encryption::encrypt::FileEncryptor;
 use crate::errors::{ParquetError, Result};
@@ -1068,6 +1070,9 @@ impl ArrowColumnChunk {
 pub struct ArrowColumnWriter {
     writer: ArrowColumnWriterImpl,
     chunk: SharedColumnChunk,
+    /// Non-null value hashes accumulated across all writes for this column's 
row group.
+    /// `None` when tracking is disabled via 
[`WriterProperties::write_row_group_number_distinct_values`].
+    distinct_values_seen: Option<DistinctValuesSet>,
 }
 
 impl std::fmt::Debug for ArrowColumnWriter {
@@ -1117,6 +1122,32 @@ impl ArrowColumnWriter {
     }
 
     fn write_internal(&mut self, levels: &ArrayLevels) -> Result<()> {
+        if let Some(seen) = &mut self.distinct_values_seen {
+            let array = levels.array();
+            let non_null = levels.non_null_indices();
+            match array.as_any_dictionary_opt() {
+                Some(dict) => {
+                    // For dictionary arrays, hash the integer keys rather 
than the actual values.
+                    // Key cardinality equals value cardinality, so 
distinct-value counting stays
+                    // correct while avoiding the cost of hashing 
arbitrary-length values.
+                    let keys = dict.keys();
+                    let key_data = keys.to_data();
+                    let offset = key_data.offset();
+                    let width = arrow_key_byte_width(keys.data_type());
+                    if width > 0 {
+                        let buffer = key_data.buffers()[0].as_slice();
+                        // Only visit non-null rows to avoid counting nulls as 
a distinct value.
+                        for &row in non_null {
+                            let pos = (offset + row) * width;
+                            seen.insert(hash_bytes(&buffer[pos..pos + width]));
+                        }
+                    }
+                }
+                // For plain arrays, hash the actual values directly.
+                None => update_distinct_values_seen(array.as_ref(), non_null, 
seen),
+            }
+        }
+
         match &mut self.writer {
             ArrowColumnWriterImpl::Column(c) => {
                 let leaf = levels.array();
@@ -1138,9 +1169,24 @@ impl ArrowColumnWriter {
 
     /// Close this column returning the written [`ArrowColumnChunk`]
     pub fn close(self) -> Result<ArrowColumnChunk> {
+        let distinct_count = self
+            .distinct_values_seen
+            .as_ref()
+            .filter(|s| !s.is_empty())
+            .map(|s| s.len() as u64);
         let close = match self.writer {
-            ArrowColumnWriterImpl::ByteArray(c) => c.close()?,
-            ArrowColumnWriterImpl::Column(c) => c.close()?,
+            ArrowColumnWriterImpl::ByteArray(mut c) => {
+                if let Some(count) = distinct_count {
+                    c.set_distinct_count_override(count);
+                }
+                c.close()?
+            }
+            ArrowColumnWriterImpl::Column(mut c) => {
+                if let Some(count) = distinct_count {
+                    c.set_distinct_count_override(count);
+                }
+                c.close()?
+            }
         };
         let chunk = Arc::try_unwrap(self.chunk).ok().unwrap();
         let data = chunk.into_inner().unwrap();
@@ -1405,6 +1451,8 @@ impl ArrowColumnWriterFactory {
         leaves: &mut Iter<'_, ColumnDescPtr>,
         out: &mut Vec<ArrowColumnWriter>,
     ) -> Result<()> {
+        let write_distinct_values = 
props.write_row_group_number_distinct_values();
+
         // Instantiate writers for normal columns
         let col = |desc: &ColumnDescPtr| -> Result<ArrowColumnWriter> {
             let page_writer = self.create_page_writer(desc, out.len())?;
@@ -1413,6 +1461,7 @@ impl ArrowColumnWriterFactory {
             Ok(ArrowColumnWriter {
                 chunk,
                 writer: ArrowColumnWriterImpl::Column(writer),
+                distinct_values_seen: write_distinct_values.then(HashSet::new),
             })
         };
 
@@ -1424,6 +1473,7 @@ impl ArrowColumnWriterFactory {
             Ok(ArrowColumnWriter {
                 chunk,
                 writer: ArrowColumnWriterImpl::ByteArray(writer),
+                distinct_values_seen: write_distinct_values.then(HashSet::new),
             })
         };
 
@@ -1899,6 +1949,109 @@ fn chunk_contiguous_vec(arena: Vec<u8>, chunk_size: 
usize) -> Vec<FixedLenByteAr
     values
 }
 
+/// Hash a byte slice to a u64 for NDV tracking.
+#[inline]
+fn hash_bytes(bytes: &[u8]) -> u64 {
+    twox_hash::XxHash64::oneshot(0, bytes)
+}
+
+/// Returns the byte width of an Arrow dictionary key type, or 0 if 
unsupported.
+fn arrow_key_byte_width(dt: &ArrowDataType) -> usize {
+    match dt {
+        ArrowDataType::Int8 | ArrowDataType::UInt8 => 1,
+        ArrowDataType::Int16 | ArrowDataType::UInt16 => 2,
+        ArrowDataType::Int32 | ArrowDataType::UInt32 => 4,
+        ArrowDataType::Int64 | ArrowDataType::UInt64 => 8,
+        _ => 0,
+    }
+}
+
+/// Returns the fixed byte width for primitive Arrow types, or `None` for 
variable-length types.
+fn fixed_byte_width(dt: &ArrowDataType) -> Option<usize> {
+    use ArrowDataType::*;
+    match dt {
+        Int8 | UInt8 => Some(1),
+        Int16 | UInt16 | Float16 => Some(2),
+        Int32 | UInt32 | Float32 | Date32 | Time32(_) | Decimal32(_, _) => 
Some(4),
+        Int64
+        | UInt64
+        | Float64
+        | Date64
+        | Time64(_)
+        | Timestamp(_, _)
+        | Duration(_)
+        | Decimal64(_, _) => Some(8),
+        Interval(IntervalUnit::YearMonth) => Some(4),
+        Interval(IntervalUnit::DayTime) => Some(8),
+        Interval(IntervalUnit::MonthDayNano) => Some(16),
+        Decimal128(_, _) => Some(16),
+        Decimal256(_, _) => Some(32),
+        _ => None,
+    }
+}
+
+/// Hash the non-null values in `array` (at `non_null_indices`) into `seen`.
+///
+/// Handles primitive, boolean, fixed-size-binary, and variable-length 
(Utf8/Binary)
+/// arrays. Unsupported types are silently skipped, leaving `seen` unchanged 
for
+/// those values (NDV is best-effort).
+fn update_distinct_values_seen(
+    array: &dyn arrow_array::Array,
+    non_null_indices: &[usize],
+    seen: &mut DistinctValuesSet,
+) {
+    let data = array.to_data();
+    let offset = data.offset();
+
+    match array.data_type() {
+        ArrowDataType::Boolean => {
+            let arr = array
+                .as_any()
+                .downcast_ref::<arrow_array::BooleanArray>()
+                .unwrap();
+            for &row in non_null_indices {
+                seen.insert(arr.value(row) as u64);
+            }
+        }
+        ArrowDataType::Utf8 | ArrowDataType::Binary => {
+            let offsets = data.buffers()[0].typed_data::<i32>();
+            let values = data.buffers()[1].as_slice();
+            for &row in non_null_indices {
+                let start = offsets[offset + row] as usize;
+                let end = offsets[offset + row + 1] as usize;
+                seen.insert(hash_bytes(&values[start..end]));
+            }
+        }
+        ArrowDataType::LargeUtf8 | ArrowDataType::LargeBinary => {
+            let offsets = data.buffers()[0].typed_data::<i64>();
+            let values = data.buffers()[1].as_slice();
+            for &row in non_null_indices {
+                let start = offsets[offset + row] as usize;
+                let end = offsets[offset + row + 1] as usize;
+                seen.insert(hash_bytes(&values[start..end]));
+            }
+        }
+        ArrowDataType::FixedSizeBinary(byte_width) => {
+            let byte_width = *byte_width as usize;
+            let buffer = data.buffers()[0].as_slice();
+            for &row in non_null_indices {
+                let start = (offset + row) * byte_width;
+                seen.insert(hash_bytes(&buffer[start..start + byte_width]));
+            }
+        }
+        data_type => {
+            if let Some(width) = fixed_byte_width(data_type) {
+                let buffer = data.buffers()[0].as_slice();
+                for &row in non_null_indices {
+                    let pos = (offset + row) * width;
+                    seen.insert(hash_bytes(&buffer[pos..pos + width]));
+                }
+            }
+            // Utf8View, BinaryView, nested types: skip
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -6026,6 +6179,58 @@ mod tests {
         ree_write_read_roundtrip(sliced, flat);
     }
 
+    #[test]
+    fn test_number_distinct_values_exact_count() {
+        // 50 distinct Int32 values repeated across 100k rows, with every 7th 
row null.
+        // Nulls must not be counted as a distinct value.
+        let cardinality = 50u32;
+        let array: ArrayRef = 
Arc::new(Int32Array::from_iter((0..100_000u32).map(|i| {
+            if i % 7 == 0 {
+                None
+            } else {
+                Some((i % cardinality) as i32)
+            }
+        })));
+        let schema = Arc::new(Schema::new(vec![Field::new("x", 
DataType::Int32, true)]));
+        let batch = RecordBatch::try_new(schema, vec![array]).unwrap();
+
+        let props = WriterProperties::builder()
+            .set_write_row_group_number_distinct_values(true)
+            .build();
+        let mut buf = Vec::new();
+        let mut writer = ArrowWriter::try_new(&mut buf, batch.schema(), 
Some(props)).unwrap();
+        writer.write(&batch).unwrap();
+        let metadata = writer.close().unwrap();
+
+        let count = metadata
+            .row_group(0)
+            .column(0)
+            .statistics()
+            .and_then(|s| s.distinct_count_opt())
+            .expect("distinct_count should be set");
+        // Must equal cardinality exactly; nulls must not inflate the count.
+        assert_eq!(count, cardinality as u64);
+    }
+
+    #[test]
+    fn test_number_distinct_values_not_written_by_default() {
+        let array: ArrayRef = Arc::new(Int32Array::from_iter_values(0..100));
+        let schema = Arc::new(Schema::new(vec![Field::new("x", 
DataType::Int32, false)]));
+        let batch = RecordBatch::try_new(schema, vec![array]).unwrap();
+
+        let mut buf = Vec::new();
+        let mut writer = ArrowWriter::try_new(&mut buf, batch.schema(), 
None).unwrap();
+        writer.write(&batch).unwrap();
+        let metadata = writer.close().unwrap();
+
+        let count = metadata
+            .row_group(0)
+            .column(0)
+            .statistics()
+            .and_then(|s| s.distinct_count_opt());
+        assert!(count.is_none());
+    }
+
     #[test]
     fn ree_struct_with_ree_child() {
         // Struct with a REE string field and a REE int field — confirms
diff --git a/parquet/src/column/writer/mod.rs b/parquet/src/column/writer/mod.rs
index d49a1e4897..6bda82d646 100644
--- a/parquet/src/column/writer/mod.rs
+++ b/parquet/src/column/writer/mod.rs
@@ -114,6 +114,14 @@ impl ColumnWriter<'_> {
         downcast_writer!(self, typed, typed.add_data_page())
     }
 
+    /// Sets a pre-computed distinct count on this column writer.
+    ///
+    /// See [`GenericColumnWriter::set_distinct_count_override`] for details.
+    #[cfg(feature = "arrow")]
+    pub(crate) fn set_distinct_count_override(&mut self, count: u64) {
+        downcast_writer!(self, typed, typed.set_distinct_count_override(count))
+    }
+
     /// Close this [`ColumnWriter`], returning the metadata for the column 
chunk.
     pub fn close(self) -> Result<ColumnCloseResult> {
         downcast_writer!(self, typed, typed.close())
@@ -453,6 +461,10 @@ pub struct GenericColumnWriter<'a, E: ColumnValueEncoder> {
     // Metrics per column writer
     column_metrics: ColumnMetrics<E::T>,
 
+    /// Pre-computed distinct count to write into column chunk statistics.
+    /// When set, takes precedence over `column_metrics.column_distinct_count`.
+    distinct_count_override: Option<u64>,
+
     /// The order of encodings within the generated metadata does not impact 
its meaning,
     /// but we use a BTreeSet so that the output is deterministic
     encodings: BTreeSet<Encoding>,
@@ -529,6 +541,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, E> {
             data_pages: VecDeque::new(),
             page_metrics,
             column_metrics,
+            distinct_count_override: None,
             column_index_builder,
             offset_index_builder,
             encodings,
@@ -539,6 +552,16 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, E> 
{
         }
     }
 
+    /// Sets a pre-computed distinct count to write into column chunk 
statistics.
+    ///
+    /// When set, this value is written as `distinct_count` in the row group 
statistics
+    /// footer. It takes precedence over any `distinct_count` passed through
+    /// [`Self::write_batch_with_statistics`].
+    #[cfg(feature = "arrow")]
+    pub(crate) fn set_distinct_count_override(&mut self, count: u64) {
+        self.distinct_count_override = Some(count);
+    }
+
     #[expect(clippy::too_many_arguments)]
     pub(crate) fn write_batch_internal(
         &mut self,
@@ -1527,10 +1550,13 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
         if self.statistics_enabled != EnabledStatistics::None {
             let backwards_compatible_min_max = 
self.descr.sort_order().is_signed();
 
+            let distinct_count = self
+                .distinct_count_override
+                .or(self.column_metrics.column_distinct_count);
             let statistics = ValueStatistics::<E::T>::new(
                 self.column_metrics.min_column_value.clone(),
                 self.column_metrics.max_column_value.clone(),
-                self.column_metrics.column_distinct_count,
+                distinct_count,
                 Some(self.column_metrics.num_column_nulls),
                 false,
             )
diff --git a/parquet/src/file/properties.rs b/parquet/src/file/properties.rs
index 174c783377..c7a4f550b8 100644
--- a/parquet/src/file/properties.rs
+++ b/parquet/src/file/properties.rs
@@ -68,6 +68,8 @@ pub const DEFAULT_STATISTICS_TRUNCATE_LENGTH: Option<usize> = 
Some(64);
 pub const DEFAULT_OFFSET_INDEX_DISABLED: bool = false;
 /// Default values for [`WriterProperties::coerce_types`]
 pub const DEFAULT_COERCE_TYPES: bool = false;
+/// Default value for 
[`WriterProperties::write_row_group_number_distinct_values`]
+pub const DEFAULT_WRITE_ROW_GROUP_NUMBER_DISTINCT_VALUES: bool = false;
 /// Default value for 
[`WriterProperties::data_page_v2_compression_ratio_threshold`]
 pub const DEFAULT_DATA_PAGE_V2_COMPRESSION_RATIO_THRESHOLD: f64 = 1.0;
 /// Default value for [`WriterProperties::write_path_in_schema`]
@@ -254,6 +256,7 @@ pub struct WriterProperties {
     column_index_truncate_length: Option<usize>,
     statistics_truncate_length: Option<usize>,
     coerce_types: bool,
+    write_row_group_number_distinct_values: bool,
     content_defined_chunking: Option<CdcOptions>,
     write_path_in_schema: bool,
     #[cfg(feature = "encryption")]
@@ -433,6 +436,14 @@ impl WriterProperties {
         self.coerce_types
     }
 
+    /// Returns `true` if the writer should compute and store the distinct 
count
+    /// (`num_distinct_values`) in row group column chunk statistics.
+    ///
+    /// For more details see 
[`WriterPropertiesBuilder::set_write_row_group_number_distinct_values`]
+    pub fn write_row_group_number_distinct_values(&self) -> bool {
+        self.write_row_group_number_distinct_values
+    }
+
     /// Returns `true` if the `path_in_schema` field of the `ColumnMetaData` 
Thrift struct
     /// should be written.
     ///
@@ -595,6 +606,7 @@ pub struct WriterPropertiesBuilder {
     column_index_truncate_length: Option<usize>,
     statistics_truncate_length: Option<usize>,
     coerce_types: bool,
+    write_row_group_number_distinct_values: bool,
     content_defined_chunking: Option<CdcOptions>,
     write_path_in_schema: bool,
     #[cfg(feature = "encryption")]
@@ -620,6 +632,7 @@ impl Default for WriterPropertiesBuilder {
             column_index_truncate_length: DEFAULT_COLUMN_INDEX_TRUNCATE_LENGTH,
             statistics_truncate_length: DEFAULT_STATISTICS_TRUNCATE_LENGTH,
             coerce_types: DEFAULT_COERCE_TYPES,
+            write_row_group_number_distinct_values: 
DEFAULT_WRITE_ROW_GROUP_NUMBER_DISTINCT_VALUES,
             content_defined_chunking: None,
             write_path_in_schema: DEFAULT_WRITE_PATH_IN_SCHEMA,
             #[cfg(feature = "encryption")]
@@ -675,6 +688,7 @@ impl WriterPropertiesBuilder {
             column_index_truncate_length: self.column_index_truncate_length,
             statistics_truncate_length: self.statistics_truncate_length,
             coerce_types: self.coerce_types,
+            write_row_group_number_distinct_values: 
self.write_row_group_number_distinct_values,
             content_defined_chunking: self.content_defined_chunking,
             write_path_in_schema: self.write_path_in_schema,
             #[cfg(feature = "encryption")]
@@ -896,6 +910,34 @@ impl WriterPropertiesBuilder {
         self
     }
 
+    /// Enable or disable writing the distinct value count 
(`num_distinct_values`) into
+    /// row group column chunk statistics (defaults to `false` via
+    /// [`DEFAULT_WRITE_ROW_GROUP_NUMBER_DISTINCT_VALUES`]).
+    ///
+    /// When enabled, the [`ArrowWriter`] scans each column's values before 
encoding
+    /// and stores the number of distinct non-null values in the row group 
statistics
+    /// footer.
+    ///
+    /// # Compatibility
+    ///
+    /// This setting only takes effect when using [`ArrowWriter`]. The 
row-based
+    /// [`SerializedFileWriter`] / [`SerializedRowGroupWriter`] APIs do not 
populate
+    /// `num_distinct_values` and will ignore this flag.
+    ///
+    /// # Performance
+    ///
+    /// Computing the distinct count requires hashing every non-null value in 
the column.
+    /// For large row groups or columns with many values this adds significant 
overhead.
+    /// Benchmark your workload before enabling this globally.
+    ///
+    /// [`ArrowWriter`]: crate::arrow::ArrowWriter
+    /// [`SerializedFileWriter`]: crate::file::writer::SerializedFileWriter
+    /// [`SerializedRowGroupWriter`]: 
crate::file::writer::SerializedRowGroupWriter
+    pub fn set_write_row_group_number_distinct_values(mut self, value: bool) 
-> Self {
+        self.write_row_group_number_distinct_values = value;
+        self
+    }
+
     /// EXPERIMENTAL: Should the writer emit the `path_in_schema` element of 
the
     /// `ColumnMetaData` Thrift struct. Defaults to `true` via 
[`DEFAULT_WRITE_PATH_IN_SCHEMA`].
     ///
@@ -1342,6 +1384,7 @@ impl From<WriterProperties> for WriterPropertiesBuilder {
             column_index_truncate_length: props.column_index_truncate_length,
             statistics_truncate_length: props.statistics_truncate_length,
             coerce_types: props.coerce_types,
+            write_row_group_number_distinct_values: 
props.write_row_group_number_distinct_values,
             content_defined_chunking: props.content_defined_chunking,
             write_path_in_schema: props.write_path_in_schema,
             #[cfg(feature = "encryption")]

Reply via email to