This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-mosaic.git
The following commit(s) were added to refs/heads/main by this push:
new cf6a330 perf(core): reduce dictionary and nullable write overhead
(#91)
cf6a330 is described below
commit cf6a330aee7f76d2f0bd1c4694e2a24520cdf084
Author: jianguotian <[email protected]>
AuthorDate: Sun Sep 20 20:03:41 2026 +0800
perf(core): reduce dictionary and nullable write overhead (#91)
---
core/src/bucket_writer.rs | 497 ++++++++++++++++++++++++++++++++++++----------
1 file changed, 396 insertions(+), 101 deletions(-)
diff --git a/core/src/bucket_writer.rs b/core/src/bucket_writer.rs
index 487a1a3..9828c90 100644
--- a/core/src/bucket_writer.rs
+++ b/core/src/bucket_writer.rs
@@ -54,6 +54,13 @@ pub struct ChildColumnMeta {
pub num_elements: usize,
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum DictTracking {
+ Pending,
+ Active,
+ Disabled,
+}
+
pub struct BucketWriter {
num_primary: usize,
total_columns: usize,
@@ -68,6 +75,7 @@ pub struct BucketWriter {
long_dict_maps: Vec<Option<HashMap<u64, usize>>>,
byte_dict_maps: Vec<Option<HashMap<Vec<u8>, usize>>>,
+ dict_tracking: Vec<DictTracking>,
dict_total_bytes: Vec<usize>,
max_dict_total_bytes: usize,
max_dict_entries: usize,
@@ -87,18 +95,6 @@ impl BucketWriter {
let total_columns = physical_types.len();
let fixed_widths: Vec<i32> =
physical_types.iter().map(types::fixed_width).collect();
- let mut long_dict_maps = Vec::with_capacity(total_columns);
- let mut byte_dict_maps = Vec::with_capacity(total_columns);
- for fw in &fixed_widths {
- if uses_long_dict(*fw) {
- long_dict_maps.push(Some(HashMap::new()));
- byte_dict_maps.push(None);
- } else {
- long_dict_maps.push(None);
- byte_dict_maps.push(Some(HashMap::new()));
- }
- }
-
BucketWriter {
num_primary,
total_columns,
@@ -108,8 +104,9 @@ impl BucketWriter {
non_null_counts: vec![0; total_columns],
const_tracking: vec![true; total_columns],
first_value_len: vec![0; total_columns],
- long_dict_maps,
- byte_dict_maps,
+ long_dict_maps: (0..total_columns).map(|_| None).collect(),
+ byte_dict_maps: (0..total_columns).map(|_| None).collect(),
+ dict_tracking: vec![DictTracking::Pending; total_columns],
dict_total_bytes: vec![0; total_columns],
max_dict_total_bytes,
max_dict_entries,
@@ -318,8 +315,17 @@ impl BucketWriter {
}
let typed = downcast_array(array, dt)?;
+ let null_count = array.null_count();
+
+ if null_count == num_new_rows {
+ let nulls = array
+ .nulls()
+ .expect("an all-null array must have a physical null buffer");
+ append_null_bitmap(&mut self.null_bitmaps[col], start_row, nulls);
+ return Ok(0);
+ }
- if array.null_count() == 0 {
+ if null_count == 0 {
if let Some(col_size) =
self.append_no_null_batch(col, &typed, start_row,
num_new_rows)?
{
@@ -327,6 +333,15 @@ impl BucketWriter {
}
}
+ if let TypedArrayRef::TimestampMillis(array) = &typed {
+ return Ok(self.append_nullable_timestamp_millis_batch(
+ col,
+ array,
+ start_row,
+ num_new_rows,
+ ));
+ }
+
let mut col_size = 0usize;
for row in 0..num_new_rows {
let abs_row = start_row + row;
@@ -339,40 +354,7 @@ impl BucketWriter {
let written = self.value_buffers[col].len() - before;
col_size += written;
- if self.const_tracking[col] {
- if self.non_null_counts[col] == 1 {
- self.first_value_len[col] = written;
- } else if written != self.first_value_len[col]
- || !equals_first_value(&self.value_buffers[col],
before, written)
- {
- self.const_tracking[col] = false;
- }
- }
-
- if let Some(ref mut dict) = self.long_dict_maps[col] {
- let key = values::extract_fixed_key(
- &self.value_buffers[col],
- before,
- self.fixed_widths[col],
- );
- let len = dict.len();
- dict.entry(key).or_insert(len);
- if dict.len() > self.max_dict_entries {
- self.long_dict_maps[col] = None;
- }
- } else if let Some(ref mut dict) = self.byte_dict_maps[col] {
- let slice = &self.value_buffers[col][before..before +
written];
- if !dict.contains_key(slice) {
- let len = dict.len();
- dict.insert(slice.to_vec(), len);
- self.dict_total_bytes[col] += written;
- }
- if dict.len() > self.max_dict_entries
- || self.dict_total_bytes[col] >
self.max_dict_total_bytes
- {
- self.byte_dict_maps[col] = None;
- }
- }
+ self.track_encoding_value(col, before, written);
}
}
Ok(col_size)
@@ -492,52 +474,14 @@ impl BucketWriter {
let col_size = buf.len() - before_all;
let fw = self.fixed_widths[col] as usize;
- let prev_non_null = self.non_null_counts[col];
- self.non_null_counts[col] += num_rows;
-
- if self.const_tracking[col] {
- if prev_non_null == 0 {
- self.first_value_len[col] = fw;
- for i in 1..num_rows {
- if !equals_first_value(buf, before_all + i * fw, fw) {
- self.const_tracking[col] = false;
- break;
- }
- }
- } else {
- for i in 0..num_rows {
- if !equals_first_value(buf, before_all + i * fw, fw) {
- self.const_tracking[col] = false;
- break;
- }
- }
- }
- }
-
- if let Some(ref mut dict) = self.long_dict_maps[col] {
- for i in 0..num_rows {
- let key =
- values::extract_fixed_key(buf, before_all + i * fw,
self.fixed_widths[col]);
- let len = dict.len();
- dict.entry(key).or_insert(len);
- if dict.len() > self.max_dict_entries {
- self.long_dict_maps[col] = None;
- break;
- }
- }
- } else if let Some(ref mut dict) = self.byte_dict_maps[col] {
+ if !self.const_tracking[col] && self.dict_tracking[col] ==
DictTracking::Disabled {
+ self.non_null_counts[col] += num_rows;
+ } else {
for i in 0..num_rows {
- let start = before_all + i * fw;
- let slice = &buf[start..start + fw];
- if !dict.contains_key(slice) {
- let len = dict.len();
- dict.insert(slice.to_vec(), len);
- self.dict_total_bytes[col] += fw;
- }
- if dict.len() > self.max_dict_entries
- || self.dict_total_bytes[col] > self.max_dict_total_bytes
- {
- self.byte_dict_maps[col] = None;
+ self.non_null_counts[col] += 1;
+ self.track_encoding_value(col, before_all + i * fw, fw);
+ if !self.const_tracking[col] && self.dict_tracking[col] ==
DictTracking::Disabled {
+ self.non_null_counts[col] += num_rows - i - 1;
break;
}
}
@@ -549,6 +493,116 @@ impl BucketWriter {
Ok(Some(col_size))
}
+ fn append_nullable_timestamp_millis_batch(
+ &mut self,
+ col: usize,
+ array: &TimestampMillisecondArray,
+ start_row: usize,
+ num_rows: usize,
+ ) -> usize {
+ let nulls = array
+ .nulls()
+ .expect("nullable timestamp batch must have a physical null
buffer");
+ debug_assert!(nulls.null_count() > 0);
+ debug_assert!(nulls.null_count() < num_rows);
+
+ append_null_bitmap(&mut self.null_bitmaps[col], start_row, nulls);
+
+ let valid_count = num_rows - nulls.null_count();
+ self.value_buffers[col].reserve(valid_count * 8);
+ let values = array.values();
+ let mut track_encoding =
+ self.const_tracking[col] || self.dict_tracking[col] !=
DictTracking::Disabled;
+
+ for row in nulls.valid_indices() {
+ let before = self.value_buffers[col].len();
+
self.value_buffers[col].extend_from_slice(&values[row].to_be_bytes());
+ self.non_null_counts[col] += 1;
+
+ if track_encoding {
+ self.track_encoding_value(col, before, 8);
+ track_encoding =
+ self.const_tracking[col] || self.dict_tracking[col] !=
DictTracking::Disabled;
+ }
+ }
+
+ valid_count * 8
+ }
+
+ fn track_encoding_value(&mut self, col: usize, value_start: usize,
value_len: usize) {
+ if self.non_null_counts[col] == 1 {
+ self.first_value_len[col] = value_len;
+ return;
+ }
+
+ if self.const_tracking[col] {
+ if value_len == self.first_value_len[col]
+ && equals_first_value(&self.value_buffers[col], value_start,
value_len)
+ {
+ return;
+ }
+
+ self.const_tracking[col] = false;
+ self.activate_dict_tracking(col, value_start, value_len);
+ } else {
+ self.track_dict_value(col, value_start, value_len);
+ }
+ }
+
+ fn activate_dict_tracking(&mut self, col: usize, value_start: usize,
value_len: usize) {
+ debug_assert_eq!(self.dict_tracking[col], DictTracking::Pending);
+ self.dict_total_bytes[col] = 0;
+ if uses_long_dict(self.fixed_widths[col]) {
+ self.long_dict_maps[col]
+ .get_or_insert_with(HashMap::new)
+ .clear();
+ } else {
+ self.byte_dict_maps[col]
+ .get_or_insert_with(HashMap::new)
+ .clear();
+ }
+ self.dict_tracking[col] = DictTracking::Active;
+
+ self.track_dict_value(col, 0, self.first_value_len[col]);
+ if self.dict_tracking[col] == DictTracking::Active {
+ self.track_dict_value(col, value_start, value_len);
+ }
+ }
+
+ fn track_dict_value(&mut self, col: usize, value_start: usize, value_len:
usize) {
+ if self.dict_tracking[col] != DictTracking::Active {
+ return;
+ }
+
+ let disable = if let Some(ref mut dict) = self.long_dict_maps[col] {
+ let key = values::extract_fixed_key(
+ &self.value_buffers[col],
+ value_start,
+ self.fixed_widths[col],
+ );
+ let len = dict.len();
+ dict.entry(key).or_insert(len);
+ dict.len() > self.max_dict_entries
+ } else if let Some(ref mut dict) = self.byte_dict_maps[col] {
+ let value = &self.value_buffers[col][value_start..value_start +
value_len];
+ if !dict.contains_key(value) {
+ let len = dict.len();
+ dict.insert(value.to_vec(), len);
+ self.dict_total_bytes[col] += value_len;
+ }
+ dict.len() > self.max_dict_entries
+ || self.dict_total_bytes[col] > self.max_dict_total_bytes
+ } else {
+ unreachable!("active dictionary tracking must have a dictionary
map")
+ };
+
+ if disable {
+ self.dict_tracking[col] = DictTracking::Disabled;
+ self.long_dict_maps[col] = None;
+ self.byte_dict_maps[col] = None;
+ }
+ }
+
fn compute_encodings(&self) -> (Vec<u8>, Vec<bool>) {
let mut encodings = vec![0u8; self.total_columns];
let mut has_nulls = vec![false; self.total_columns];
@@ -760,24 +814,19 @@ impl BucketWriter {
pub fn reset(&mut self) {
for i in 0..self.total_columns {
- for b in &mut self.null_bitmaps[i] {
- *b = 0;
- }
+ self.null_bitmaps[i].fill(0);
self.value_buffers[i].clear();
self.non_null_counts[i] = 0;
self.const_tracking[i] = true;
self.first_value_len[i] = 0;
+ self.dict_tracking[i] = DictTracking::Pending;
self.dict_total_bytes[i] = 0;
if uses_long_dict(self.fixed_widths[i]) {
if let Some(ref mut dict) = self.long_dict_maps[i] {
dict.clear();
- } else {
- self.long_dict_maps[i] = Some(HashMap::new());
}
} else if let Some(ref mut dict) = self.byte_dict_maps[i] {
dict.clear();
- } else {
- self.byte_dict_maps[i] = Some(HashMap::new());
}
}
self.num_rows = 0;
@@ -787,6 +836,9 @@ impl BucketWriter {
}
fn get_dict_size(&self, col: usize) -> usize {
+ if self.dict_tracking[col] != DictTracking::Active {
+ return 0;
+ }
if let Some(ref dict) = self.long_dict_maps[col] {
return dict.len();
}
@@ -833,6 +885,9 @@ impl BucketWriter {
}
fn dict_encoded_size(&self, col: usize) -> usize {
+ if self.dict_tracking[col] != DictTracking::Active {
+ return usize::MAX;
+ }
let (num_entries, entry_bytes) = if let Some(ref dict) =
self.long_dict_maps[col] {
(dict.len(), dict.len() * self.fixed_widths[col] as usize)
} else if let Some(ref dict) = self.byte_dict_maps[col] {
@@ -891,6 +946,41 @@ impl BucketWriter {
}
}
+fn append_null_bitmap(bitmap: &mut [u8], start_row: usize, nulls: &NullBuffer)
{
+ debug_assert!(start_row + nulls.len() <= bitmap.len() * 8);
+
+ let chunks = nulls.inner().bit_chunks();
+ for (chunk, valid_bits) in chunks.iter().enumerate() {
+ or_null_bits(bitmap, start_row + chunk * 64, !valid_bits, 64);
+ }
+
+ let remainder_len = nulls.len() % 64;
+ if remainder_len != 0 {
+ let remainder_mask = (1u64 << remainder_len) - 1;
+ let null_bits = !chunks.remainder_bits() & remainder_mask;
+ or_null_bits(
+ bitmap,
+ start_row + nulls.len() - remainder_len,
+ null_bits,
+ remainder_len,
+ );
+ }
+}
+
+#[inline]
+fn or_null_bits(bitmap: &mut [u8], mut bit_offset: usize, mut bits: u64, mut
len: usize) {
+ while len != 0 {
+ let byte_offset = bit_offset / 8;
+ let bit_in_byte = bit_offset % 8;
+ let take = len.min(8 - bit_in_byte);
+ let mask = ((1u16 << take) - 1) as u8;
+ bitmap[byte_offset] |= ((bits as u8) & mask) << bit_in_byte;
+ bits >>= take;
+ bit_offset += take;
+ len -= take;
+ }
+}
+
enum TypedArrayRef<'a> {
Boolean(&'a BooleanArray),
Int8(&'a Int8Array),
@@ -1730,6 +1820,211 @@ mod tests {
assert_eq!(data[h] & 0x03, ENCODING_CONST);
}
+ #[test]
+ fn test_const_columns_do_not_build_dictionaries() {
+ let types = [DataType::Int32, DataType::Utf8];
+ let type_refs: Vec<&DataType> = types.iter().collect();
+ let mut writer = BucketWriter::new(&type_refs, 32768, 255);
+
+ let ints = Int32Array::from(vec![42; 100]);
+ let strings = StringArray::from(vec!["same"; 100]);
+ writer
+ .write_columns(&[&ints, &strings], &[&types[0], &types[1]])
+ .unwrap();
+
+ assert!(writer.long_dict_maps[0].is_none());
+ assert!(writer.byte_dict_maps[1].is_none());
+ assert_eq!(writer.dict_total_bytes[1], 0);
+ }
+
+ #[test]
+ fn test_dictionary_tracking_starts_on_first_non_const_value() {
+ let types = [DataType::Int32, DataType::Utf8];
+ let type_refs: Vec<&DataType> = types.iter().collect();
+ let mut writer = BucketWriter::new(&type_refs, 32768, 255);
+
+ let const_ints = Int32Array::from(vec![42; 50]);
+ let const_strings = StringArray::from(vec!["same"; 50]);
+ writer
+ .write_columns(&[&const_ints, &const_strings], &[&types[0],
&types[1]])
+ .unwrap();
+
+ let varied_ints = Int32Array::from(vec![42, 7, 42, 7]);
+ let varied_strings = StringArray::from(vec!["same", "other", "same",
"other"]);
+ writer
+ .write_columns(&[&varied_ints, &varied_strings], &[&types[0],
&types[1]])
+ .unwrap();
+
+ assert_eq!(writer.dict_tracking, vec![DictTracking::Active; 2]);
+ assert_eq!(writer.long_dict_maps[0].as_ref().unwrap().len(), 2);
+ assert_eq!(writer.byte_dict_maps[1].as_ref().unwrap().len(), 2);
+
+ let data = writer.finish();
+ assert_eq!(data[0] & 0x03, ENCODING_DICT);
+ assert_eq!((data[0] >> 2) & 0x03, ENCODING_DICT);
+ }
+
+ #[test]
+ fn test_dictionary_tracking_disables_after_entry_limit() {
+ let types = [DataType::Int32, DataType::Utf8];
+ let type_refs: Vec<&DataType> = types.iter().collect();
+ let mut writer = BucketWriter::new(&type_refs, 32768, 255);
+
+ let ints = Int32Array::from_iter_values((0..255).chain(0..255));
+ let string_values: Vec<String> = (0..255)
+ .chain(0..255)
+ .map(|i| format!("value-{i:03}"))
+ .collect();
+ let strings =
StringArray::from_iter_values(string_values.iter().map(String::as_str));
+ writer
+ .write_columns(&[&ints, &strings], &[&types[0], &types[1]])
+ .unwrap();
+
+ assert_eq!(writer.dict_tracking, vec![DictTracking::Active; 2]);
+ assert_eq!(writer.long_dict_maps[0].as_ref().unwrap().len(), 255);
+ assert_eq!(writer.byte_dict_maps[1].as_ref().unwrap().len(), 255);
+ let data = writer.finish();
+ assert_eq!(data[0] & 0x03, ENCODING_DICT);
+ assert_eq!((data[0] >> 2) & 0x03, ENCODING_DICT);
+
+ let next_int = Int32Array::from(vec![255]);
+ let next_string = StringArray::from(vec!["value-255"]);
+ writer
+ .write_columns(&[&next_int, &next_string], &[&types[0], &types[1]])
+ .unwrap();
+
+ assert_eq!(writer.dict_tracking, vec![DictTracking::Disabled; 2]);
+ assert!(writer.long_dict_maps.iter().all(Option::is_none));
+ assert!(writer.byte_dict_maps.iter().all(Option::is_none));
+
+ let later_ints = Int32Array::from(vec![0, 256]);
+ let later_strings = StringArray::from(vec!["value-000", "value-256"]);
+ writer
+ .write_columns(&[&later_ints, &later_strings], &[&types[0],
&types[1]])
+ .unwrap();
+
+ assert_eq!(writer.dict_tracking, vec![DictTracking::Disabled; 2]);
+ assert!(writer.long_dict_maps.iter().all(Option::is_none));
+ assert!(writer.byte_dict_maps.iter().all(Option::is_none));
+ let data = writer.finish();
+ assert_eq!(data[0] & 0x03, ENCODING_PLAIN);
+ assert_eq!((data[0] >> 2) & 0x03, ENCODING_PLAIN);
+ }
+
+ #[test]
+ fn test_dictionary_tracking_disables_after_byte_limit() {
+ let types = [DataType::Utf8];
+ let type_refs: Vec<&DataType> = types.iter().collect();
+ // Encoded dictionary keys include the one-byte string length prefix:
+ // "a" is 2 bytes and "bb" is 3 bytes, exactly filling this budget.
+ let mut writer = BucketWriter::new(&type_refs, 5, 255);
+
+ // Repeat enough rows so DICT is smaller than PLAIN and the encoding
assertion is stable.
+ let mut within_budget: Vec<Option<&str>> = (0..64)
+ .map(|i| Some(if i % 2 == 0 { "a" } else { "bb" }))
+ .collect();
+ within_budget.push(None);
+ let within_budget = StringArray::from(within_budget);
+ writer
+ .write_columns(&[&within_budget], &[&DataType::Utf8])
+ .unwrap();
+
+ assert_eq!(writer.dict_tracking[0], DictTracking::Active);
+ assert_eq!(writer.byte_dict_maps[0].as_ref().unwrap().len(), 2);
+ assert_eq!(writer.dict_total_bytes[0], 5);
+ let data = writer.finish();
+ assert_eq!(data[0] & 0x03, ENCODING_DICT);
+
+ let beyond_budget = StringArray::from(vec![Some("a"), Some("ccc"),
None]);
+ writer
+ .write_columns(&[&beyond_budget], &[&DataType::Utf8])
+ .unwrap();
+
+ assert_eq!(writer.dict_tracking[0], DictTracking::Disabled);
+ assert!(writer.byte_dict_maps[0].is_none());
+ let data = writer.finish();
+ assert_eq!(data[0] & 0x03, ENCODING_PLAIN);
+ }
+
+ #[test]
+ fn test_append_null_bitmap_handles_source_and_destination_offsets() {
+ let validity = BooleanBuffer::from(vec![
+ true, false, true, false, false, true, true, false, true, false,
true,
+ ]);
+ let nulls = NullBuffer::new(validity).slice(2, 7);
+ let mut bitmap = vec![0b0000_0101, 0, 0];
+
+ append_null_bitmap(&mut bitmap, 3, &nulls);
+
+ assert_eq!(bitmap, vec![0b0011_0101, 0b0000_0001, 0]);
+ }
+
+ #[test]
+ fn test_append_null_bitmap_matches_scalar_copy_across_chunk_boundaries() {
+ for source_offset in 0..8 {
+ for destination_offset in 0..16 {
+ for len in [1, 7, 8, 9, 63, 64, 65, 127, 128, 129] {
+ let validity: Vec<bool> = (0..source_offset + len)
+ .map(|i| i % 3 != 0 && i % 11 != 0)
+ .collect();
+ let nulls =
NullBuffer::new(BooleanBuffer::from(validity.clone()))
+ .slice(source_offset, len);
+ let mut actual = vec![0u8; (destination_offset +
len).div_ceil(8) + 1];
+ let mut expected = actual.clone();
+
+ for row in 0..len {
+ if !validity[source_offset + row] {
+ let bit = destination_offset + row;
+ expected[bit / 8] |= 1 << (bit % 8);
+ }
+ }
+ append_null_bitmap(&mut actual, destination_offset,
&nulls);
+
+ assert_eq!(
+ actual, expected,
+ "source_offset={source_offset},
destination_offset={destination_offset}, len={len}"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn
test_nullable_timestamp_millis_batch_preserves_slices_and_append_offsets() {
+ let data_type =
DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None);
+ let mut writer = BucketWriter::new(&[&data_type], 32768, 255);
+
+ let source = TimestampMillisecondArray::from(vec![
+ Some(99),
+ Some(10),
+ None,
+ Some(10),
+ Some(20),
+ None,
+ ]);
+ let first = source.slice(1, 4);
+ let first_size = writer.write_columns(&[&first],
&[&data_type]).unwrap();
+ assert_eq!(first_size, 28);
+
+ let second = TimestampMillisecondArray::from(vec![None, Some(20),
None]);
+ let second_size = writer.write_columns(&[&second],
&[&data_type]).unwrap();
+ assert_eq!(second_size, 11);
+
+ assert_eq!(writer.num_rows, 7);
+ assert_eq!(writer.non_null_counts[0], 4);
+ assert_eq!(writer.null_bitmaps[0][0], 0b0101_0010);
+ assert_eq!(
+ writer.value_buffers[0],
+ [10_i64, 10, 20, 20]
+ .into_iter()
+ .flat_map(i64::to_be_bytes)
+ .collect::<Vec<_>>()
+ );
+ assert!(!writer.const_tracking[0]);
+ assert_eq!(writer.dict_tracking[0], DictTracking::Active);
+ assert_eq!(writer.long_dict_maps[0].as_ref().unwrap().len(), 2);
+ }
+
#[test]
fn test_dict_string_encoding() {
let types = [DataType::Utf8];