Skip to content

parquet: dictionary page decode copies and validates the whole dictionary even when few values are referenced #11303

Open

Description

@zhuqi-lucas

Describe the bug

Decoding a byte-array dictionary page copies every value into a fresh buffer and UTF-8 validates all of them, even when the read only resolves a handful of keys. On a selective read the cost is proportional to the dictionary, not to the rows produced: a 100k-entry dictionary costs the same whether the query returns 2 rows or 200k.

set_dict materializes the whole dictionary:

// parquet/src/arrow/array_reader/byte_array.rs
fn set_dict(&mut self, buf: Bytes, num_values: u32, encoding: Encoding, _is_sorted: bool) -> Result<()> {
    ...
    let mut buffer = OffsetBuffer::with_capacity(0);
    let mut decoder = ByteArrayDecoderPlain::new(buf, num_values as usize, Some(num_values as usize), self.validate_utf8);
    decoder.read(&mut buffer, usize::MAX)?;   // every value copied, all of them validated
    self.dict = Some(buffer);
    Ok(())
}

OffsetBuffer owns its bytes, so this is a real allocation plus a full copy of the page:

pub struct OffsetBuffer<I: OffsetSizeTrait> {
    pub offsets: Vec<I>,
    pub values: Vec<u8>,
}

But the only consumer takes slices and copies just the referenced entries:

// ByteArrayDecoderDictionary::read
output.extend_from_dictionary(keys, dict.offsets.as_slice(), dict.values.as_slice())
pub fn extend_from_dictionary<K, V>(&mut self, keys: &[K], dict_offsets: &[V], dict_values: &[u8]) -> Result<()> {
    for key in keys {
        ...
        // Dictionary values are verified when decoding dictionary page
        self.values.extend_from_slice(&dict_values[start_offset..end_offset]);
        ...
    }
}

So the dictionary is used purely as a lookup table of (offsets, bytes). The owned copy built in set_dict is never needed as such.

To Reproduce

Read a small RowSelection (a few rows) from a row group whose column chunk has a large dictionary. The dictionary copy and validation dominate, and they do not shrink as the selection shrinks.

Expected behavior

Building the dictionary should cost a scan of the length prefixes, not a copy of the page.

Concretely: keep the decompressed page as the already-refcounted Bytes and build only the offsets, then let extend_from_dictionary copy the referenced entries out of that borrowed slice as it already does. That removes the allocation and the full copy entirely.

UTF-8 validation would move with it. Today set_dict validates the whole dictionary and extend_from_dictionary skips validation on that basis (see its comment). Validating the copied-out entries instead makes the cost proportional to rows produced. Note this is a small behavior change: a dictionary entry containing invalid UTF-8 that is never referenced would no longer fail the read. That seems like an improvement, but it is a change, so worth deciding deliberately.

What this does not address is decompression: a compressed dictionary page still has to be decompressed as a unit before the offsets can be walked.

Additional context

This is the case that the lazy dictionary work does not reach. Skipping the dictionary when a column chunk is skipped entirely (#11154, #11168) helps only when no rows are needed from the chunk; as soon as one row is needed, the full dictionary cost returns. Selective point lookups and small LIMIT queries over high-cardinality string columns hit this constantly.

Happy to work on this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions