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

github-merge-queue[bot] 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 cacb42eaf3 perf(parquet): optimize dictionary computation for byte 
arrays (#11524)
cacb42eaf3 is described below

commit cacb42eaf39646c3ca52459c954bba348bc343b1
Author: Raz Luvaton <[email protected]>
AuthorDate: Thu Oct 29 06:06:00 2026 +0000

    perf(parquet): optimize dictionary computation for byte arrays (#11524)
    
    # Which issue does this PR close?
    
    None
    
    # Rationale for this change
    
    I want dictionary encoding to be faster for strings.
    
    The old implementation have a few things that are slow:
    1. whenever a resize is needed we rehash the entire inner map - so this
    version saves the hash in the map to avoid that
    2. for small strings we need to do random access to memory that is not
    in the cache - this version fixes that by inlining the small strings in
    the hash map
    
    Later we can add to bulk hash the values
    
    These changes are heavily inspired by DataFusion `ArrowBytesMap` and
    `GroupIndexView`
    
    # What changes are included in this PR?
    
    Add custom implementation for `Interner` that is for byte array.
    in that implementation, the hash map contain the value hashes to avoid
    rehashing and contain inline values for short strings to avoid random
    memory access
    
    # Are these changes tested?
    Existing tests
    
    # Are there any significant user-facing changes?
    I'm not sure if there are breaking changes or not since I removed some
    pub functions but not sure if they were reachable in the first place.
---
 parquet/src/arrow/arrow_writer/byte_array.rs | 314 ++++++++++++++++++++++++---
 parquet/src/util/interner.rs                 |   7 +-
 parquet/tests/arrow_writer/mod.rs            |  47 +++-
 3 files changed, 325 insertions(+), 43 deletions(-)

diff --git a/parquet/src/arrow/arrow_writer/byte_array.rs 
b/parquet/src/arrow/arrow_writer/byte_array.rs
index 70687874b8..7f3120b71e 100644
--- a/parquet/src/arrow/arrow_writer/byte_array.rs
+++ b/parquet/src/arrow/arrow_writer/byte_array.rs
@@ -32,7 +32,6 @@ use crate::geospatial::accumulator::{GeoStatsAccumulator, 
try_new_geo_stats_accu
 use crate::geospatial::statistics::GeospatialStatistics;
 use crate::schema::types::ColumnDescPtr;
 use crate::util::bit_util::num_required_bits;
-use crate::util::interner::{Interner, Storage};
 use crate::util::prefix::common_prefix_length;
 use arrow_array::types::ByteArrayType;
 use arrow_array::{
@@ -302,53 +301,287 @@ impl FallbackEncoder {
     }
 }
 
-/// [`Storage`] for the [`Interner`] used by [`DictEncoder`]
+/// Maximum size of a value that can be inlined in the hash table
+const SHORT_VALUE_LEN: usize = size_of::<u64>() - 1;
+
+/// Entry in the hash table
+#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
+struct Entry {
+    /// hash of the value (stored to avoid recomputing it in hash table check)
+    hash: u64,
+    /// if len =< [`SHORT_VALUE_LEN`]: the data inlined
+    /// if len > [`SHORT_VALUE_LEN`], the offset of where the data starts
+    offset_or_inline: OffsetOrInlineView,
+    index_in_dict: u64,
+}
+
+/// Either offset to the dictionary page bytes or inline bytes when the bytes 
are short enough
+/// to avoid doing the indirection
+///
+/// For inlined view:
+///   +------------+---------------+------------------------------+
+///   | 1 bit set  | len {7 bits}  | value {56 bits}              |
+///   +------------+---------------+------------------------------+
+///
+/// For non-inlined view:
+///   +--------------+--------------------------------------------+
+///   | 1 bit unset  | offset in dict {63 bits}                   |
+///   +--------------+--------------------------------------------+
+///
+/// the inline view contain it's length to avoid having another field for 
length in the entry
+/// since if the hashes are the same, most probably the length will be the 
same, so we can do the indirection check
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct OffsetOrInlineView(u64);
+
+impl OffsetOrInlineView {
+    const INLINE_FLAG: u64 = 1 << 63;
+    const LEN_SHIFT: u32 = 56;
+
+    #[inline]
+    fn is_non_inlined(&self) -> bool {
+        (self.0 & Self::INLINE_FLAG) == 0
+    }
+
+    #[inline]
+    fn is_inlined(&self) -> bool {
+        !self.is_non_inlined()
+    }
+
+    #[inline]
+    fn new_inlined(value: &[u8]) -> Self {
+        debug_assert!(value.len() <= 7);
+
+        // Value bytes occupy the low 56 bits, byte 0 in the least significant 
position
+        let mut buf = [0u8; 8];
+        buf[..value.len()].copy_from_slice(value);
+        let packed = u64::from_le_bytes(buf);
+
+        Self(Self::INLINE_FLAG | ((value.len() as u64) << Self::LEN_SHIFT) | 
packed)
+    }
+
+    #[inline]
+    fn new_non_inlined(offset: u64) -> Self {
+        // `offset` is a position in the dictionary page `Vec`, whose length 
is at
+        // most `isize::MAX`, so it always fits in 63 bits and can't collide 
with
+        // `INLINE_FLAG`
+        // also, having the inline flag set for non-inlined values would mean 
the dictionary page is at least 9 exabytes.
+        debug_assert_eq!(
+            offset & Self::INLINE_FLAG,
+            0,
+            "dictionary page offset must fit in 63 bits"
+        );
+        let non_inlined_value = offset;
+        Self(non_inlined_value)
+    }
+
+    fn maybe_non_inlined_value(&self) -> Option<u64> {
+        if self.is_non_inlined() {
+            // Not inlined is when the first bit is unset, so the value
+            Some(self.0)
+        } else {
+            None
+        }
+    }
+}
+
 #[derive(Debug, Default)]
-struct ByteArrayStorage {
-    /// Encoded dictionary data
+struct ByteArrayInterner {
+    state: ahash::RandomState,
+    /// Underlying hash set for each distinct value
+    map: hashbrown::hash_table::HashTable<Entry>,
+    /// Encoded dictionary page: each value prefixed by its length as a `u32`
     page: Vec<u8>,
-
-    values: Vec<std::ops::Range<usize>>,
 }
 
-impl Storage for ByteArrayStorage {
-    type Key = u64;
-    type Value = [u8];
+impl ByteArrayInterner {
+    fn intern(&mut self, value: &[u8]) -> Result<u64> {
+        let hash = self.state.hash_one(value);
+
+        // value is "small" - can be inlined
+        let (found_entry, inline) = if let Some(inline) = 
maybe_inline_value(value) {
+            // is value is already present in the set?
+            let entry = self.map.find(hash, |header| {
+                // value is stored inline so no need to consult buffer
+                // (this is the "small string optimization")
+                //
+                // no need to compare the hashes since the hash is a function 
of the value which is compared here
+                let is_same = inline == header.offset_or_inline;
+
+                if is_same {
+                    debug_assert_eq!(
+                        header.hash, hash,
+                        "hashes must match if the values are the same"
+                    );
+                }
+
+                is_same
+            });
+
+            (entry, Some(inline))
+        } else {
+            // value is not "small"
+            // Check if the value is already present in the set
+            let entry = self.map.find(hash, |header| {
+                // compare hash
+                if header.hash != hash {
+                    return false;
+                }
+
+                // If the value is not inlined compare it
+                let Some(offset) = 
header.offset_or_inline.maybe_non_inlined_value() else {
+                    // that value is inline, so it must be different
+                    return false;
+                };
+
+                self.is_at(offset, value)
+            });
+
+            (entry, None)
+        };
+
+        if let Some(entry) = found_entry {
+            return Ok(entry.index_in_dict);
+        }
 
-    fn get(&self, idx: Self::Key) -> &Self::Value {
-        &self.page[self.values[idx as usize].clone()]
+        // if no existing entry, make a new one
+        self.insert(value, hash, inline)
     }
 
-    fn push(&mut self, value: &Self::Value) -> Self::Key {
-        let key = self.values.len();
+    /// Returns whether `value` is in the dictionary page at `offset`
+    #[inline(always)]
+    fn is_at(&self, offset: u64, value: &[u8]) -> bool {
+        let offset = offset as usize;
+        // SAFETY: every value in the page is preceded by its length
+        let stored_len = unsafe { self.page.get_unchecked(offset - 4..offset) 
};
+        // Rarely false, as the hashes matched, so the branch predicts well and
+        // the comparison's reads issue before the length is known
+        if u32::from_le_bytes(stored_len.try_into().unwrap()) as usize != 
value.len() {
+            return false;
+        }
+        // SAFETY: the value at `offset` has the length just checked
+        let existing = unsafe { self.page.get_unchecked(offset..offset + 
value.len()) };
+        existing == value
+    }
 
-        self.page.reserve(4 + value.len());
-        self.page.extend_from_slice((value.len() as u32).as_bytes());
+    fn num_unique_values(&self) -> usize {
+        self.map.len()
+    }
 
-        let start = self.page.len();
+    /// Appends `value` to the dictionary, in the empty slot at `pos`
+    fn insert(
+        &mut self,
+        value: &[u8],
+        hash: u64,
+        inline: Option<OffsetOrInlineView>,
+    ) -> Result<u64> {
+        // If inline provided it should inlined
+        debug_assert!(inline.is_none_or(|i| i.is_inlined()));
+
+        // The dictionary page is PLAIN encoded, where each value is prefixed 
by
+        // its length as a 4-byte little endian integer so we need to reject 
that since we use unsafe access later
+        //
+        // From the parquet-format spec:
+        // > Encoding PLAIN:
+        // > [...] For the byte array type, it encodes the length as a 4-byte 
little
+        // > endian integer, followed by the bytes.
+        // >
+        // > Dictionary Encoding:
+        // > [...] Dictionary page format: the entries in the dictionary using 
the plain encoding.
+        // >
+        //
+        // Reference:
+        // - Plain encoding: 
https://github.com/apache/parquet-format/blob/53b8bd8576827080d4382fa457003f45b41cda6e/Encodings.md?plain=1#L72-L73
+        // - Plain dictionary encoding: 
https://github.com/apache/parquet-format/blob/53b8bd8576827080d4382fa457003f45b41cda6e/Encodings.md?plain=1#L83
+        if value.len() > u32::MAX as usize {
+            return Err(ParquetError::General(format!(
+                "Byte array value is too large to be written in dictionary: {} 
bytes",
+                value.len()
+            )));
+        }
+
+        let key = self.num_unique_values() as u64;
+        let len = value.len();
+
+        self.page.reserve(4 + value.len());
+        self.page.extend_from_slice(&(len as u32).to_le_bytes());
+        let offset = self.page.len() as u64;
         self.page.extend_from_slice(value);
-        self.values.push(start..self.page.len());
 
-        key as u64
+        // Make the new entry
+        let new_header = Entry {
+            hash,
+            offset_or_inline: inline.unwrap_or_else(|| 
OffsetOrInlineView::new_non_inlined(offset)),
+            index_in_dict: key,
+        };
+        self.map
+            .insert_unique(hash, new_header, |header| header.hash);
+
+        Ok(key)
     }
 
     fn estimated_memory_size(&self) -> usize {
-        self.page.capacity() * std::mem::size_of::<u8>()
-            + self.values.capacity() * 
std::mem::size_of::<std::ops::Range<usize>>()
+        self.page.capacity() * std::mem::size_of::<u8>() + 
self.map.allocation_size()
+    }
+
+    fn iter(&self) -> impl Iterator<Item = &[u8]> {
+        ByteArrayInternerIterator {
+            values: &self.page,
+            current_index: 0,
+        }
+    }
+}
+
+/// Iterates the values of a [`ByteArrayInterner`]'s dictionary page
+///
+/// `values` must be a page built by [`ByteArrayInterner::insert`], which is a
+/// sequence of values each preceded by its exact length as a `u32`, and
+/// `current_index` must start at 0, so it is always at the start of a value
+struct ByteArrayInternerIterator<'a> {
+    values: &'a [u8],
+    current_index: usize,
+}
+
+impl<'a> Iterator for ByteArrayInternerIterator<'a> {
+    type Item = &'a [u8];
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.current_index >= self.values.len() {
+            return None;
+        }
+
+        // SAFETY: `current_index` is at the start of a value and is not past
+        // the end of the page, so the value's 4-byte length prefix is in 
bounds
+        let len_bytes = unsafe {
+            self.values
+                .get_unchecked(self.current_index..self.current_index + 4)
+        };
+        let len = u32::from_le_bytes(len_bytes.try_into().unwrap()) as usize;
+
+        let start = self.current_index + 4;
+        let end = start + len;
+
+        // SAFETY: `insert` writes exactly `len` bytes after the prefix, and
+        // rejects values whose length does not fit in a `u32`, so the prefix 
is
+        // never truncated and `start..end` is within the page
+        let value = unsafe { self.values.get_unchecked(start..end) };
+
+        self.current_index = end;
+
+        Some(value)
     }
 }
 
 /// A dictionary encoder for byte array data
 #[derive(Debug, Default)]
 struct DictEncoder {
-    interner: Interner<ByteArrayStorage>,
+    interner: ByteArrayInterner,
     indices: Vec<u64>,
     variable_length_bytes: i64,
 }
 
 impl DictEncoder {
     /// Encode `values` to the in-progress page
-    fn encode<T>(&mut self, values: T, indices: impl ExactSizeIterator<Item = 
usize>)
+    fn encode<T>(&mut self, values: T, indices: impl ExactSizeIterator<Item = 
usize>) -> Result<()>
     where
         T: ArrayAccessor + Copy,
         T::Item: AsRef<[u8]>,
@@ -357,14 +590,16 @@ impl DictEncoder {
 
         for idx in indices {
             let value = values.value(idx);
-            let interned = self.interner.intern(value.as_ref());
+            let interned = self.interner.intern(value.as_ref())?;
             self.indices.push(interned);
             self.variable_length_bytes += value.as_ref().len() as i64;
         }
+
+        Ok(())
     }
 
     fn bit_width(&self) -> u8 {
-        let length = self.interner.storage().values.len();
+        let length = self.interner.num_unique_values();
         num_required_bits(length.saturating_sub(1) as u64)
     }
 
@@ -378,15 +613,16 @@ impl DictEncoder {
     }
 
     fn estimated_dict_page_size(&self) -> usize {
-        self.interner.storage().page.len()
+        self.interner.page.len()
     }
 
     fn flush_dict_page(self) -> DictionaryPage {
-        let storage = self.interner.into_inner();
+        let unique_values = self.interner.num_unique_values();
+        let bytes = self.interner.page;
 
         DictionaryPage {
-            buf: storage.page.into(),
-            num_values: storage.values.len(),
+            buf: bytes.into(),
+            num_values: unique_values,
             is_sorted: false,
         }
     }
@@ -422,6 +658,14 @@ impl DictEncoder {
     }
 }
 
+fn maybe_inline_value(value: &[u8]) -> Option<OffsetOrInlineView> {
+    if value.len() <= SHORT_VALUE_LEN {
+        Some(OffsetOrInlineView::new_inlined(value))
+    } else {
+        None
+    }
+}
+
 pub struct ByteArrayEncoder {
     fallback: FallbackEncoder,
     dict_encoder: Option<DictEncoder>,
@@ -487,8 +731,7 @@ impl ColumnValueEncoder for ByteArrayEncoder {
             encode,
             indices.iter().copied(),
             self
-        );
-        Ok(())
+        )
     }
 
     fn count_values_within_byte_budget_gather(
@@ -633,9 +876,8 @@ impl ColumnValueEncoder for ByteArrayEncoder {
                 }
 
                 if let Some(bloom_filter) = &mut self.bloom_filter {
-                    let storage = encoder.interner.storage();
-                    for range in &storage.values {
-                        bloom_filter.insert(&storage.page[range.clone()]);
+                    for value in encoder.interner.iter() {
+                        bloom_filter.insert(value);
                     }
                 }
 
@@ -663,7 +905,7 @@ impl ColumnValueEncoder for ByteArrayEncoder {
 /// Encodes the provided `values` and `indices` to `encoder`
 ///
 /// This is a free function so it can be used with `downcast_op!`
-fn encode<T, I>(values: T, indices: I, encoder: &mut ByteArrayEncoder)
+fn encode<T, I>(values: T, indices: I, encoder: &mut ByteArrayEncoder) -> 
Result<()>
 where
     T: ArrayAccessor + Copy,
     T::Item: Copy + Ord + AsRef<[u8]>,
@@ -709,7 +951,9 @@ where
                     bloom_filter.insert(values.value(idx).as_ref());
                 }
             }
-            encoder.fallback.encode(values, indices)
+            encoder.fallback.encode(values, indices);
+
+            Ok(())
         }
     }
 }
diff --git a/parquet/src/util/interner.rs b/parquet/src/util/interner.rs
index f4e5e170c9..96b8b5225c 100644
--- a/parquet/src/util/interner.rs
+++ b/parquet/src/util/interner.rs
@@ -17,6 +17,7 @@
 
 use crate::data_type::AsBytes;
 use hashbrown::HashTable;
+use std::fmt::Debug;
 
 const DEFAULT_DEDUP_CAPACITY: usize = 4096;
 
@@ -89,10 +90,4 @@ impl<S: Storage> Interner<S> {
     pub fn storage(&self) -> &S {
         &self.storage
     }
-
-    /// Unwraps the inner storage
-    #[cfg(feature = "arrow")]
-    pub fn into_inner(self) -> S {
-        self.storage
-    }
 }
diff --git a/parquet/tests/arrow_writer/mod.rs 
b/parquet/tests/arrow_writer/mod.rs
index caf89d3641..791205dfe5 100644
--- a/parquet/tests/arrow_writer/mod.rs
+++ b/parquet/tests/arrow_writer/mod.rs
@@ -29,7 +29,10 @@ use std::fs::File;
 use std::io::{Read as _, Seek, SeekFrom, Write as _};
 use std::sync::Arc;
 
-use arrow::array::{ArrayRef, BinaryArray, Float64Array, Int32Array, 
RecordBatch, StringArray};
+use arrow::array::{
+    ArrayRef, BinaryArray, Float64Array, Int32Array, LargeBinaryArray, 
RecordBatch, StringArray,
+};
+use arrow::buffer::{Buffer, OffsetBuffer};
 use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
 use bytes::Bytes;
 use parquet::arrow::arrow_writer::{
@@ -39,7 +42,7 @@ use parquet::arrow::arrow_writer::{
 use parquet::arrow::{ArrowSchemaConverter, ArrowWriter};
 use parquet::basic::Encoding;
 use parquet::errors::Result;
-use parquet::file::properties::{WriterProperties, WriterVersion};
+use parquet::file::properties::{EnabledStatistics, WriterProperties, 
WriterVersion};
 use parquet::file::writer::SerializedFileWriter;
 
 #[test]
@@ -141,6 +144,14 @@ unsafe impl GlobalAlloc for TrackingAllocator {
         }
         new_ptr
     }
+
+    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
+        let ptr = unsafe { self.inner.alloc_zeroed(layout) };
+        if !ptr.is_null() {
+            add_live_bytes(layout.size());
+        }
+        ptr
+    }
 }
 
 /// Run `f` and return the peak *additional* live heap (bytes) observed on this
@@ -593,3 +604,35 @@ fn unused_delta_fallback_does_not_preallocate() {
 
     assert!(peak < COLUMNS * 64 * 1024, "peak: {peak}");
 }
+
+#[test]
+// The value is 2^32 bytes, which does not fit in a 32-bit address space
+#[cfg(target_pointer_width = "64")]
+fn test_dictionary_rejects_value_longer_than_u32() {
+    // The dictionary page prefixes each value with its length as a u32, so a
+    // 2^32 byte value would have its length truncated to 0
+    let len = 1usize << 32;
+    let mut data = vec![0u8; len];
+    // Read as the next value's length if the truncated prefix were trusted
+    data[..4].copy_from_slice(&[0xFF; 4]);
+    let array = LargeBinaryArray::new(
+        OffsetBuffer::new(vec![0i64, len as i64].into()),
+        Buffer::from_vec(data),
+        None,
+    );
+    let batch = RecordBatch::try_from_iter([("col", Arc::new(array) as 
ArrayRef)]).unwrap();
+
+    let props = WriterProperties::builder()
+        // statistics are disabled to avoid copying the value for min/max and 
using a lot of extra memory in this test
+        .set_statistics_enabled(EnabledStatistics::None)
+        // The bloom filter is built from the dictionary page,
+        .set_bloom_filter_enabled(true)
+        .build();
+    let mut writer = ArrowWriter::try_new(std::io::sink(), batch.schema(), 
Some(props)).unwrap();
+
+    let err = writer.write(&batch).unwrap_err().to_string();
+    assert_eq!(
+        err,
+        "Parquet error: Byte array value is too large to be written in 
dictionary: 4294967296 bytes"
+    );
+}

Reply via email to