Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
73e7640
first pass
May 22, 2026
7d7581d
fix
May 22, 2026
e5217aa
second claude pass
May 22, 2026
7f2d72f
small fixes
May 26, 2026
c03c755
fix
May 26, 2026
3081b44
fix
May 26, 2026
f264ecf
clean mod
mhk197 May 27, 2026
ed247b5
fix writer
mhk197 May 27, 2026
a9ba90b
fix writer
mhk197 May 27, 2026
93e604d
fix
mhk197 May 27, 2026
7a8142b
improve projection eval
mhk197 May 27, 2026
27b46c1
projection evaluation
mhk197 May 27, 2026
7cc6fca
tests
mhk197 May 27, 2026
167c003
tests
mhk197 May 27, 2026
3914d0b
fix test
mhk197 May 27, 2026
5b881d8
skip pruning eval
mhk197 May 27, 2026
d5174df
few more tests
mhk197 May 27, 2026
dfbd616
lint fix
mhk197 May 28, 2026
b1e1616
fix test
mhk197 May 28, 2026
f5bab36
quick fix
mhk197 May 28, 2026
9902f9a
add anylist matcher
mhk197 May 28, 2026
9174563
read validity with all-true mask, not caller's mask
mhk197 May 28, 2026
15a20f8
narrow elements io for sparse mask instead of defering filtering
mhk197 May 28, 2026
8a023dc
shortcut on whole-chunk unmasked reads
mhk197 May 28, 2026
0a2f0aa
add required fallback to ListLayoutStrategy for non-list input
mhk197 Jun 17, 2026
cbb5061
fmt
mhk197 Jun 17, 2026
fcbbbcf
cleanup
mhk197 Jun 17, 2026
7303d2a
fix rebase conflicts
mhk197 Jun 17, 2026
3c03c13
use ListLayoutStrategy as default leaf under unstable_encodings
mhk197 Jun 17, 2026
734f0e2
recurse into nested lists
mhk197 Jun 17, 2026
73070a0
fix: update ListLayout::build to use LayoutBuildContext after trait c…
mhk197 Jun 22, 2026
ca927b6
comments
mhk197 Jun 22, 2026
3298277
fix
mhk197 Jun 22, 2026
cf2d07e
clean up writer
mhk197 Jun 23, 2026
e25f7b3
Improve list reader
mhk197 Jun 23, 2026
6f23b7f
Fix list docs
mhk197 Jun 23, 2026
3f06868
Add list filter evaluation
mhk197 Jun 23, 2026
4bbc400
Read list lengths from list layout offsets
mhk197 Jun 29, 2026
6af9008
Move list expression planning to expr module
mhk197 Jul 1, 2026
d4d876d
Fix list layout checks after rebase
mhk197 Jul 1, 2026
6509c27
Remove list projection path labels
mhk197 Jul 1, 2026
1181ae6
Refine list expression child classification
mhk197 Jul 1, 2026
8cff38c
remove stale comment
mhk197 Jul 6, 2026
75d999f
simplify projection
mhk197 Jul 6, 2026
fc7ed0a
make list layout the default
mhk197 Jul 7, 2026
284cf25
chunk list elements; bounded reader for sub-ranges
mhk197 Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 47 additions & 7 deletions vortex-file/src/strategy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ use vortex_layout::layouts::compressed::CompressingStrategy;
use vortex_layout::layouts::compressed::CompressorPlugin;
use vortex_layout::layouts::dict::writer::DictStrategy;
use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
use vortex_layout::layouts::list::writer::ListLayoutStrategy;
use vortex_layout::layouts::repartition::RepartitionStrategy;
use vortex_layout::layouts::repartition::RepartitionWriterOptions;
use vortex_layout::layouts::table::TableStrategy;
Expand All @@ -71,6 +72,14 @@ use vortex_zstd::ZstdBuffers;

const ONE_MEG: u64 = 1 << 20;

/// Upper bound on the number of `elements` rows in a single element-chunk of a list column.
///
/// The `elements` child of a list is repartitioned into chunks so a reader can fetch only the
/// element-chunks a row range overlaps. `block_size_target` keeps each chunk near 1 MiB for
/// fixed-width elements; this bounds the row count for narrow/variable-width elements where the
/// byte target alone would produce very large chunks.
const ELEMENTS_PER_CHUNK: usize = 1 << 20;

/// Static registry of all allowed array encodings for file writing.
///
/// This includes all canonical encodings from vortex-array plus all compressed
Expand Down Expand Up @@ -242,12 +251,8 @@ impl WriteStrategyBuilder {
Arc::new(FlatLayoutStrategy::default())
};

// 7. for each chunk create a flat layout
let chunked = ChunkedLayoutStrategy::new(Arc::clone(&flat));
// 6. buffer chunks so they end up with closer segment ids physically
let buffered = BufferedStrategy::new(chunked, 2 * ONE_MEG); // 2MB

// 5. compress each chunk.
// The data compressor is built up front: the list leaf (step 7) reuses it to compress each
// elements chunk, and the outer chain reuses it at step 5.
// Exclude IntDictScheme from the data compressor because DictStrategy (step 3) already
// dictionary-encodes columns. Allowing IntDictScheme here would redundantly
// dictionary-encode the integer codes produced by that earlier step.
Expand All @@ -260,7 +265,42 @@ impl WriteStrategyBuilder {
),
CompressorConfig::Opaque(compressor) => Arc::clone(compressor),
};
let compressing = CompressingStrategy::new(buffered, data_compressor);

// The `elements` child of a list is repartitioned into ~1 MiB chunks, each compressed
// independently, producing a `ChunkedLayout`. This lets the reader fetch only the
// element-chunks a row range overlaps (bounded read) instead of the whole elements buffer.
let chunked_elements: Arc<dyn LayoutStrategy> = Arc::new(RepartitionStrategy::new(
CompressingStrategy::new(
ChunkedLayoutStrategy::new(Arc::clone(&flat)),
Arc::clone(&data_compressor),
),
RepartitionWriterOptions {
block_size_minimum: ONE_MEG,
block_len_multiple: ELEMENTS_PER_CHUNK,
block_size_target: Some(ONE_MEG),
canonicalize: true,
},
));

// 7. for each chunk create a layout. List-typed chunks route through
// `ListLayoutStrategy` (separately-addressable elements/offsets/validity sub-layouts;
// non-list chunks fall through its built-in fallback to `flat`). Offsets, validity, and the
// fallback thread the configured `flat` (which carries `allow_encodings` / any custom flat
// override); elements route through the chunked+compressed strategy above.
let leaf: Arc<dyn LayoutStrategy> = Arc::new(
ListLayoutStrategy::default()
.with_elements(chunked_elements)
.with_offsets(Arc::clone(&flat))
.with_validity(Arc::clone(&flat))
.with_fallback(Arc::clone(&flat)),
);

let chunked = ChunkedLayoutStrategy::new(leaf);
// 6. buffer chunks so they end up with closer segment ids physically
let buffered = BufferedStrategy::new(chunked, 2 * ONE_MEG); // 2MB

// 5. compress each chunk.
let compressing = CompressingStrategy::new(buffered, Arc::clone(&data_compressor));

// 4. prior to compression, coalesce up to a minimum size
let coalescing = RepartitionStrategy::new(
Expand Down
44 changes: 44 additions & 0 deletions vortex-file/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2004,6 +2004,50 @@ async fn test_segment_ordering_dict_codes_before_values() -> VortexResult<()> {
Ok(())
}

/// A list column whose per-chunk elements exceed the ~1 MiB element-chunk target is written with a
/// chunked `elements` layout, so a reader can fetch only the element-chunks a row range overlaps.
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn list_layout_chunks_large_elements() -> VortexResult<()> {
// 20k lists of length 64 => ~1.28M i32 elements. After the 8k-row repartition each list-chunk
// still holds > 1 MiB of elements, so `elements` is written as a chunked layout (rather than a
// single flat segment, which `ChunkedLayoutStrategy` would collapse to for < 1 MiB).
let list_len = 64u32;
let n_rows = 20_000usize;
let n_elems = n_rows * list_len as usize;
let elements = PrimitiveArray::from_iter(0..n_elems as i32).into_array();
let offsets = PrimitiveArray::from_iter((0..=n_rows as u32).map(|i| i * list_len)).into_array();
let list = ListArray::try_new(elements, offsets, Validity::NonNullable)?.into_array();
let st = StructArray::from_fields(&[("l", list)])?.into_array();

let mut buf = ByteBufferMut::empty();
let summary = SESSION
.write_options()
.write(&mut buf, st.to_array_stream())
.await?;

fn any_list_has_chunked_elements(layout: &dyn Layout) -> bool {
if layout.encoding_id().as_ref() == "vortex.list"
&& layout.child(0).unwrap().encoding_id().as_ref() == "vortex.chunked"
{
return true;
}
for child in layout.children().unwrap() {
if any_list_has_chunked_elements(child.as_ref()) {
return true;
}
}
false
}

let root = summary.footer().layout();
assert!(
any_list_has_chunked_elements(root.as_ref()),
"expected a list layout with chunked elements"
);
Ok(())
}

#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn test_segment_ordering_zonemaps_after_data() -> VortexResult<()> {
Expand Down
1 change: 1 addition & 0 deletions vortex-layout/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ vortex-utils = { workspace = true, features = ["dashmap"] }

[dev-dependencies]
futures = { workspace = true, features = ["executor"] }
insta = { workspace = true }
rstest = { workspace = true }
temp-env = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros"] }
Expand Down
2 changes: 1 addition & 1 deletion vortex-layout/src/layouts/chunked/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

mod reader;
pub(crate) mod reader;
pub mod writer;

use std::sync::Arc;
Expand Down
151 changes: 151 additions & 0 deletions vortex-layout/src/layouts/list/expr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

use vortex_array::expr::Expression;
use vortex_array::expr::is_root;
use vortex_array::expr::not;
use vortex_array::expr::root;
use vortex_array::scalar_fn::fns::is_not_null::IsNotNull;
use vortex_array::scalar_fn::fns::is_null::IsNull;
use vortex_array::scalar_fn::fns::list_length::ListLength;
use vortex_error::VortexResult;

/// The deepest list child an expression needs, cheapest first, where I/O cost order is defined as
/// `Validity < OffsetsAndValidity < All`.
///
/// For example:
/// - `is_null(root())` only needs the validity child.
/// - `list_length(root())` only needs the offsets and validity children.
/// - `root()` needs elements, offsets, and validity children.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum ListChildrenNeeded {
/// Only the validity child is needed (`is_null` / `is_not_null`).
Validity,
/// The offsets and validity children are needed, but not the element values (`list_length`).
OffsetsAndValidity,
/// All children are needed.
All,
}

/// The minimal list children needed to evaluate `expr`, where `root()` is a field with list dtype.
pub(super) fn get_necessary_list_children(expr: &Expression) -> ListChildrenNeeded {
if is_null_root(expr) {
return ListChildrenNeeded::Validity;
}

if is_list_length_root(expr) {
return ListChildrenNeeded::OffsetsAndValidity;
}

if is_root(expr) {
return ListChildrenNeeded::All;
}

// Otherwise the requirement is the max over the operands. Childless expressions that never
// touch the list, such as literals, fall back to the cheapest usable child.
expr.children()
.iter()
.map(get_necessary_list_children)
.max()
.unwrap_or(ListChildrenNeeded::Validity)
}

fn is_null_root(expr: &Expression) -> bool {
(expr.is::<IsNull>() || expr.is::<IsNotNull>())
&& expr.children().len() == 1
&& is_root(expr.child(0))
}

fn is_list_length_root(expr: &Expression) -> bool {
expr.is::<ListLength>() && expr.children().len() == 1 && is_root(expr.child(0))
}

/// Rewrite a validity-class expression so it can be evaluated against the list's validity bool
/// array (`true` == valid row): `is_not_null(root())` becomes `root()` and `is_null(root())`
/// becomes `not(root())`. All other nodes are rebuilt with rewritten children.
pub(super) fn rewrite_validity_expr(expr: &Expression) -> VortexResult<Expression> {
if expr.is::<IsNotNull>() && expr.children().len() == 1 && is_root(expr.child(0)) {
return Ok(root());
}
if expr.is::<IsNull>() && expr.children().len() == 1 && is_root(expr.child(0)) {
return Ok(not(root()));
}
let children = expr
.children()
.iter()
.map(rewrite_validity_expr)
.collect::<VortexResult<Vec<_>>>()?;
expr.clone().with_children(children)
}

/// Rewrite an offsets-class expression so it can be evaluated against an array of list lengths.
/// `list_length(root())` becomes `root()`. Other references to `root()` are left intact: for
/// offsets-class expressions they can only be validity checks, and the lengths array carries the
/// same validity as the original list.
pub(super) fn rewrite_offsets_expr(expr: &Expression) -> VortexResult<Expression> {
if is_list_length_root(expr) {
return Ok(root());
}

let children = expr
.children()
.iter()
.map(rewrite_offsets_expr)
.collect::<VortexResult<Vec<_>>>()?;
expr.clone().with_children(children)
}

#[cfg(test)]
mod tests {
use rstest::rstest;
use vortex_array::dtype::DType;
use vortex_array::dtype::Nullability;
use vortex_array::dtype::PType;
use vortex_array::expr::cast;
use vortex_array::expr::eq;
use vortex_array::expr::gt;
use vortex_array::expr::is_not_null;
use vortex_array::expr::is_null;
use vortex_array::expr::list_length;
use vortex_array::expr::lit;
use vortex_array::expr::not;
use vortex_array::expr::root;

use super::*;

/// `get_necessary_list_children` keys off the deepest list child an expression touches; `All`
/// is the always-correct default for anything not specifically recognized.
#[rstest]
// `is_null` / `is_not_null` of the list itself need only validity.
#[case::is_null(is_null(root()), ListChildrenNeeded::Validity)]
#[case::is_not_null(is_not_null(root()), ListChildrenNeeded::Validity)]
// Compound over validity-only operands stays validity.
#[case::not_is_null(not(is_null(root())), ListChildrenNeeded::Validity)]
// A list-independent (constant) expression falls to the cheapest usable child.
#[case::constant(lit(5), ListChildrenNeeded::Validity)]
// `list_length(root())` needs offsets and validity, but not elements.
#[case::list_length(list_length(root()), ListChildrenNeeded::OffsetsAndValidity)]
// Compound over offsets-only operands stays offsets.
#[case::list_length_filter(
gt(list_length(root()), lit(1u64)),
ListChildrenNeeded::OffsetsAndValidity
)]
#[case::cast_list_length(
cast(
list_length(root()),
DType::Primitive(PType::I64, Nullability::Nullable),
),
ListChildrenNeeded::OffsetsAndValidity
)]
// A bare list reference needs the elements.
#[case::bare_root(root(), ListChildrenNeeded::All)]
// Any other fn over the list needs the elements.
#[case::not_root(not(root()), ListChildrenNeeded::All)]
// `is_null` only short-circuits to validity when its argument is the list itself.
#[case::is_null_of_derived(is_null(not(root())), ListChildrenNeeded::All)]
// Max over operands: validity + elements => elements.
#[case::validity_and_elements(eq(is_null(root()), root()), ListChildrenNeeded::All)]
fn classify_expr_class(#[case] expr: Expression, #[case] expected: ListChildrenNeeded) {
assert_eq!(get_necessary_list_children(&expr), expected);
}
}
Loading
Loading