Polars Chunks Its Columns So Parallelism Has Something to Eat
A ChunkedArray is a Vec of Arrow arrays plus length and null bookkeeping. That layout is what gives Polars cheap appends, per-chunk kernels, and parallel work units.
TL;DR: A
ChunkedArrayis not one big array. It is aVec<ArrayRef>plus a cachedlengthandnull_count, and every kernel inpolars-corewalks that vec one chunk at a time. Chunk boundaries are therefore simultaneously the unit of work, the unit of parallelism, and the granularity of validity bookkeeping. The price is an extra indirection on every random access, which is why the crate also shipsrechunk()to fold many chunks back into one contiguous Arrow array.
I went looking for where Polars decides to split a column into pieces, expecting to find some threshold like “64 MiB per chunk” or “one chunk per core”. There is no such constant. The architecture is much simpler and much more interesting than a threshold: the column is a list of chunks, permanently, and everything else in the engine is written to cope with that fact.
A column is a Vec of Arrow arrays with bookkeeping on the side
Here is the entire data structure, minus the doc comment:
pub struct ChunkedArray<T: PolarsDataType> {
pub(crate) field: Arc<Field>,
pub(crate) chunks: Vec<ArrayRef>,
pub(crate) flags: StatisticsFlagsIM,
length: usize,
null_count: usize,
_pd: std::marker::PhantomData<T>,
}crates/polars-core/src/chunked_array/mod.rs:140
The T::Array inside each ArrayRef is the concrete Arrow array type (PrimitiveArray<i64>,
Utf8ViewArray, …); T is only a PhantomData marker that selects the right array type and the
right PolarsDataType. length and null_count are cached: they are the sum over the chunks,
maintained eagerly so that len() is a field read instead of a walk over the chunk vec.
The crate’s own doc comment explains the motivation better than I can:
/// # Memory layout
///
/// [`ChunkedArray`]s use [Apache Arrow](https://github.com/apache/arrow) as backend for the memory layout.
/// Arrows memory is immutable which makes it possible to make multiple zero copy (sub)-views from a single array.
///
/// To be able to append data, Polars uses chunks to append new memory locations, hence the [`ChunkedArray<T>`] data structure.
/// Appends are cheap, because it will not lead to a full reallocation of the whole array (as could be the case with a Rust Vec).
///
/// However, multiple chunks in a [`ChunkedArray`] will slow down many operations that need random access because we have an extra indirection
/// and indexes need to be mapped to the proper chunk. Arithmetic may also be slowed down by this.
/// When multiplying two [`ChunkedArray`]s with different chunk sizes they cannot utilize [SIMD](https://en.wikipedia.org/wiki/SIMD) for instance.
///
/// If you want to have predictable performance
/// (no unexpected re-allocation of memory), it is advised to call the [`ChunkedArray::rechunk`] after
/// multiple append operations.
crates/polars-core/src/chunked_array/mod.rs:114
Two things hide in that paragraph. First: Arrow arrays are immutable, so “append” cannot mean “extend a buffer”. It means “add another buffer to the list”. Second: the doc admits the cost twice — extra indirection for random access, and loss of SIMD when two columns have mismatched chunk boundaries. That is the whole design in miniature: cheap appends, expensive random access.
Because length/null_count are cached but derivable, there are two ways to build a
ChunkedArray, and the unsafe one asks you to promise they are right:
/// Create a new [`ChunkedArray`] and compute its `length` and `null_count`.
///
/// If you want to explicitly the `length` and `null_count`, look at
/// [`ChunkedArray::new_with_dims`]
fn new_with_compute_len(field: Arc<Field>, chunks: Vec<ArrayRef>) -> Self {
unsafe {
let mut chunked_arr = Self::new_with_dims(field, chunks, 0, 0);
chunked_arr.compute_len();
chunked_arr
}
}
/// Create a new [`ChunkedArray`] and explicitly set its `length` and `null_count`.
/// # Safety
/// The length and null_count must be correct.
pub unsafe fn new_with_dims(
field: Arc<Field>,
chunks: Vec<ArrayRef>,
length: usize,
null_count: usize,
) -> Self {
Self {
field,
chunks,
flags: StatisticsFlagsIM::empty(),
_pd: Default::default(),
length,
null_count,
}
}crates/polars-core/src/chunked_array/mod.rs:188
And the computation that fills them in, with a fast path for the single-chunk case that is by far the most common:
/// Compute the length
pub(crate) fn compute_len(&mut self) {
fn inner(chunks: &[ArrayRef]) -> usize {
match chunks.len() {
// fast path
1 => chunks[0].len(),
_ => chunks.iter().fold(0, |acc, arr| acc + arr.len()),
}
}
let len = inner(&self.chunks);
// Length limit is `IdxSize::MAX - 1`. We use `IdxSize::MAX` to indicate `NULL` in indexing.
if len >= (IdxSize::MAX as usize) && CHECK_LENGTH.get() {
panic!("{}", LENGTH_LIMIT_MSG);
}
self.length = len;
self.null_count = self
.chunks
.iter()
.map(|arr| arr.null_count())
.sum::<usize>();
}crates/polars-core/src/chunked_array/ops/chunkops.rs:152
Iterating: a flat_map over chunk boundaries
Element-wise iteration does not walk a single array. It flattens the chunk vec:
#[inline]
pub fn iter(&self) -> impl PolarsIterator<Item = Option<T::Physical<'_>>> {
// SAFETY: we set the correct length of the iterator.
unsafe {
self.downcast_iter()
.flat_map(|arr| arr.iter())
.trust_my_length(self.len())
}
}crates/polars-core/src/chunked_array/iterator/mod.rs:12
trust_my_length is the interesting part: because self.length is cached, the iterator can claim
an exact size without counting anything, which lets downstream code use TrustedLen paths (no
bounds-checked size_hint, no zero-length guard). PolarsIterator is defined as
ExactSizeIterator + DoubleEndedIterator + Send + Sync + TrustedLen, so every iterator you get
out of a ChunkedArray inherits that contract.
The chunk-level view is downcast_iter, which is where the dyn Array -> concrete array cast
happens:
#[inline]
pub fn downcast_iter(&self) -> impl DoubleEndedIterator<Item = &T::Array> {
self.chunks.iter().map(|arr| {
// SAFETY: T::Array guarantees this is correct.
let arr = &**arr;
unsafe { &*(arr as *const dyn Array as *const T::Array) }
})
}crates/polars-core/src/chunked_array/ops/downcast.rs:63
There is also a named Chunks type that does the same cast but hands you random access by chunk
index, which is what you want when you want the chunk boundaries, not the values:
pub struct Chunks<'a, T> {
chunks: &'a [ArrayRef],
phantom: PhantomData<T>,
}
impl<'a, T> Chunks<'a, T> {
fn new(chunks: &'a [ArrayRef]) -> Self {
Chunks {
chunks,
phantom: PhantomData,
}
}
#[inline]
pub fn get(&self, index: usize) -> Option<&'a T> {
self.chunks.get(index).map(|arr| {
let arr = &**arr;
unsafe { &*(arr as *const dyn Array as *const T) }
})
}crates/polars-core/src/chunked_array/ops/downcast.rs:23
Random access across chunk boundaries is where the extra indirection from the doc comment becomes
billable. To read element i, Polars has to know which chunk it lives in:
/// Get the index of the chunk and the index of the value in that chunk.
#[inline]
pub(crate) fn index_to_chunked_index(&self, index: usize) -> (usize, usize) {
// Fast path.
if self.chunks.len() == 1 {
// SAFETY: chunks.len() == 1 guarantees this is correct.
let len = unsafe { self.chunks.get_unchecked(0).len() };
return if index < len {
(0, index)
} else {
(1, index - len)
};
}
let chunk_lens = self.chunk_lengths();
let len = self.len();
if index <= len / 2 {
// Access from lhs.
index_to_chunked_index(chunk_lens, index)
} else {
// Access from rhs.
let index_from_back = len - index;
index_to_chunked_index_rev(chunk_lens.rev(), index_from_back, self.chunks.len())
}
}crates/polars-core/src/chunked_array/ops/downcast.rs:137
The trick here is that it searches from whichever end is closer. Scanning chunk lengths from the
left is O(n_chunks); scanning from the right is also O(n_chunks) but the constant is the same.
Either way it is linear in the number of chunks, which is why “one chunk” is the fast path and why
the number of chunks is a real performance knob rather than a cosmetic one.
Why chunks exist: append is O(1), rechunk is O(n)
Appending is the reason the design exists. Because Arrow memory is immutable, “append” is really “push another chunk onto the vec”:
pub(crate) fn new_chunks(chunks: &mut Vec<ArrayRef>, other: &[ArrayRef], len: usize) {
// Replace an empty array.
if chunks.len() == 1 && len == 0 {
other.clone_into(chunks);
} else {
for chunk in other {
if !chunk.is_empty() {
chunks.push(chunk.clone());
}
}
}
}crates/polars-core/src/chunked_array/ops/append.rs:6
and the bookkeeping that goes with it:
/// Append in place. This is done by adding the chunks of `other` to this [`ChunkedArray`].
///
/// See also [`extend`](Self::extend) for appends to the underlying memory
pub fn append(&mut self, other: &Self) -> PolarsResult<()> {
update_sorted_flag_before_append::<T>(self, other);
let len = self.len();
self.length = self
.length
.checked_add(other.length)
.ok_or_else(|| polars_err!(ComputeError: LENGTH_LIMIT_MSG))?;
self.null_count += other.null_count;
new_chunks(&mut self.chunks, &other.chunks, len);
Ok(())
}crates/polars-core/src/chunked_array/ops/append.rs:164
No reallocation of the data, no copy: just a Vec::push of an Arc’d array and two integer
additions. If you append a thousand frames one at a time, you get a thousand chunks, and the
per-element cost of the result has quietly degraded to “scan the chunk length list”.
The escape hatch is rechunk, which collapses everything into one contiguous Arrow array:
/// Rechunks this ChunkedArray, returning a new Cow::Owned ChunkedArray if it was
/// rechunked or simply a Cow::Borrowed of itself if it was already a single chunk.
pub fn rechunk(&self) -> Cow<'_, Self> {
match self.dtype() {
#[cfg(feature = "object")]
DataType::Object(_) => {
panic!("implementation error")
},
_ => {
if self.chunks.len() == 1 {
Cow::Borrowed(self)
} else {
let chunks = vec![concatenate_unchecked(&self.chunks).unwrap()];
let mut ca = unsafe { self.copy_with_chunks(chunks) };
use StatisticsFlags as F;
ca.retain_flags_from(self, F::IS_SORTED_ANY | F::CAN_FAST_EXPLODE_LIST);
Cow::Owned(ca)
}
},
}
}
/// Rechunks this ChunkedArray in-place.
pub fn rechunk_mut(&mut self) {
if self.chunks.len() > 1 {
let rechunked = concatenate_unchecked(&self.chunks).unwrap();
if self.chunks.capacity() <= 8 {
// Reuse chunk allocation if not excessive.
self.chunks.clear();
self.chunks.push(rechunked);
} else {
self.chunks = vec![rechunked];
}
}
}crates/polars-core/src/chunked_array/ops/chunkops.rs:179
Note that rechunk returns Cow::Borrowed(self) when there is only one chunk: rechunking a
single-chunk column is free, and the flags (sorted-ness, fast-explode) are carried over because
the data did not change.
The actual concatenation happens in polars-arrow, and for a primitive column it is a memcpy plus
a validity rebuild:
fn concatenate_primitive<T: NativeType, A: AsRef<dyn Array>>(arrays: &[A]) -> PrimitiveArray<T> {
let dtype = arrays[0].as_ref().dtype().clone();
let (total_len, null_count) = len_null_count(arrays);
let validity = concatenate_validities_with_len_null_count(arrays, total_len, null_count);
let mut out = Vec::with_capacity(total_len);
for arr in arrays {
let arr: &PrimitiveArray<T> = arr.as_ref().as_any().downcast_ref().unwrap();
out.extend_from_slice(arr.values());
}
unsafe { PrimitiveArray::new_unchecked(dtype, Buffer::from(out), validity) }
}crates/polars-arrow/src/compute/concatenate.rs:133
So rechunk is a real allocation and a real copy of the entire column. That is the trade: you pay
O(n) once to get back O(1) random access, SIMD-friendly contiguous buffers, and a single chunk for
the next kernel to chew on.
Validity bookkeeping is per chunk, and merging it is explicit
Nulls in Arrow are a validity bitmap next to the values. In a chunked column, each chunk carries
its own bitmap, and the column-level null_count is just their sum. Whenever you need one
bitmap for the whole column, Polars builds it explicitly:
pub fn rechunk_validity(&self) -> Option<Bitmap> {
if self.chunks.len() == 1 {
return self.chunks[0].validity().cloned();
}
if !self.has_nulls() || self.is_empty() {
return None;
}
let mut bm = BitmapBuilder::with_capacity(self.len());
for arr in self.downcast_iter() {
if let Some(v) = arr.validity() {
bm.extend_from_bitmap(v);
} else {
bm.extend_constant(arr.len(), true);
}
}
bm.into_opt_validity()
}crates/polars-core/src/chunked_array/ops/chunkops.rs:209
And the reverse direction — setting one column-wide validity bitmap across chunks — requires slicing it to each chunk’s width, which is exactly the kind of offset bookkeeping that a chunked layout makes necessary:
pub fn set_validity(&mut self, validity: Option<Bitmap>) {
assert!(
!self.dtype().is_struct(),
"set_outer_validity should be used for struct types"
);
if let Some(v) = &validity {
assert_eq!(self.len(), v.len());
}
let mut i = 0;
for chunk in unsafe { self.chunks_mut() } {
*chunk =
chunk.with_validity(validity.as_ref().map(|v| v.clone().sliced(i, chunk.len())));
i += chunk.len();
}
self.null_count = validity.map(|v| v.unset_bits()).unwrap_or(0);
self.set_fast_explode_list(false);
}crates/polars-core/src/chunked_array/mod.rs:631
Chunks are the unit of parallel work
Here is where the design pays off. Anything that can be expressed as “do this independently per chunk” can be handed to Rayon, and the chunk vec gives you the split for free.
Parallel construction is the clearest example. The module doc in from_iterator_par.rs spells
out the two strategies and, crucially, their interaction with chunk count:
//! Parallel iterator collection into [`ChunkedArray<T>`]
//!
//! Two strategies:
//!
//! - `collect_into_linked_list*` — for iterators of unknown length. Folds into
//! one accumulator per rayon task and concatenates, so the resulting chunk
//! count equals the task count and varies with thread count and stealing.
//! `optional_rechunk` is the only backstop.
//!
//! - `collect_*_par` — for callers with O(1) random access and a known output
//! length. Writes into a single preallocated buffer, so the result is always
//! one chunk regardless of how rayon split the work. Preferable.
crates/polars-core/src/chunked_array/from_iterator_par.rs:1
“the resulting chunk count equals the task count and varies with thread count and stealing” is a
lovely hazard: the shape of your data depends on how many threads happened to run. That is why
there is an optional_rechunk backstop, and why the “known length” variant exists:
impl<T> FromParallelIterator<Option<T::Native>> for ChunkedArray<T>
where
T: PolarsNumericType,
{
fn from_par_iter<I: IntoParallelIterator<Item = Option<T::Native>>>(iter: I) -> Self {
let chunks = collect_into_linked_list(iter, MutablePrimitiveArray::new);
Self::from_chunk_iter(PlSmallStr::EMPTY, chunks).optional_rechunk()
}
}crates/polars-core/src/chunked_array/from_iterator_par.rs:97
with the backstop being a heuristic on chunk density rather than a fixed size:
fn should_rechunk(&self) -> bool {
self.chunks.len() > 1 && self.chunks.len() > self.len() / 3
}
fn optional_rechunk(mut self) -> Self {
// Rechunk if we have many small chunks.
if self.should_rechunk() {
self.rechunk_mut()
}
self
}crates/polars-core/src/chunked_array/mod.rs:153
More chunks than a third of the rows means the per-chunk overhead has overtaken the per-row work. That is the whole heuristic, and it is a ratio, not a byte count.
Parallel hashing shows the pattern in its final form. Polars slices the column into thread-count-sized ranges and hashes each range in parallel:
#[allow(clippy::needless_lifetimes)]
pub fn to_bytes_hashes<'a>(
&'a self,
mut multithreaded: bool,
hb: PlRandomState,
) -> Vec<Vec<BytesHash<'a>>> {
multithreaded &= RAYON.current_num_threads() > 1;
let null_h = hb.hash_one(0xde259df92c607d49_u64);
if multithreaded {
let n_partitions = _set_partition_size();
let split = _split_offsets(self.len(), n_partitions);
RAYON.install(|| {
split
.into_par_iter()
.map(|(offset, len)| {
let ca = self.slice(offset as i64, len);
let byte_hashes = fill_bytes_hashes(&ca, null_h, hb.clone());
// SAFETY:
// the underlying data is tied to self
unsafe {
std::mem::transmute::<Vec<BytesHash<'_>>, Vec<BytesHash<'a>>>(
byte_hashes,
)
}
})
.collect::<Vec<_>>()
})
} else {
vec![fill_bytes_hashes(self, null_h, hb)]
}
}crates/polars-core/src/chunked_array/binary.rs:67
Note that the parallel unit here is a slice, not an existing chunk: Polars slices the column into equal ranges, which may cut across chunk boundaries (slicing is zero-copy, it just re-slices the Arrow buffers). So chunk boundaries are a hint about where work can be cut, and the engine is willing to cut elsewhere when it wants a specific number of tasks.
At the DataFrame level the same idea shows up when you want to collapse chunks:
/// Aggregate all the chunks in the DataFrame to a single chunk in parallel.
/// This may lead to more peak memory consumption.
pub fn rechunk_mut_par(&mut self) -> &mut Self {
if self.columns().iter().any(|c| c.n_chunks() > 1) {
RAYON.install(|| {
unsafe { self.columns_mut_retain_schema() }
.par_iter_mut()
.for_each(|c| *c = c.rechunk());
})
}
self
}crates/polars-core/src/frame/mod.rs:301
Here the parallelism is across columns, not within a column — which is exactly why a chunked layout matters. Each column’s chunks can be concatenated independently, so N columns give N independent tasks.
Reading: a parquet row group is a chunk
Chunking is not only something Polars does to its own data; it arrives from the storage layer.
The parquet reader materializes one DataFrame per row group:
for md in &file_metadata.row_groups[row_group_start..row_group_end] {
let rg_slice =
split_slice_at_file(&mut n_rows_processed, md.num_rows(), slice.0, slice_end);
let current_row_count = md.num_rows() as IdxSize;
let sorting_map = create_sorting_map(md);
let f = |column_i: &usize| {
let (name, field) = schema.get_at_index(*column_i).unwrap();
let Some(iter) = md.columns_under_root_iter(name) else {
return Ok(Column::full_null(
name.clone(),
rg_slice.1,
&DataType::from_arrow_field(field),
));
};
let part = iter.collect::<Vec<_>>();
let (mut series, _) = column_idx_to_series(
*column_i,
part.as_slice(),
Some(Filter::new_ranged(rg_slice.0, rg_slice.0 + rg_slice.1)),
schema,
store,
)?;
try_set_sorted_flag(&mut series, *column_i, &sorting_map);
Ok(series.into_column())
};crates/polars-io/src/parquet/read/read_impl.rs:211
Stacking those per-row-group frames back together is exactly a chunk append:
pub fn vstack_mut(&mut self, other: &DataFrame) -> PolarsResult<&mut Self> {
if self.width() != other.width() {
polars_ensure!(
self.shape() == (0, 0),
ShapeMismatch:
"unable to append to a DataFrame of shape {:?} with a DataFrame of width {}",
self.shape(), other.width(),
);
self.clone_from(other);
return Ok(self);
}
let new_height = usize::checked_add(self.height(), other.height()).unwrap();
unsafe { self.columns_mut_retain_schema() }
.iter_mut()
.zip(other.columns())
.try_for_each::<_, PolarsResult<_>>(|(left, right)| {
ensure_can_extend(&*left, right)?;
left.append(right)
.with_context(|| format!("failed to vstack column '{}'", right.name()))?;
Ok(())
})?;
unsafe { self.set_height(new_height) };crates/polars-core/src/frame/mod.rs:637
So a parquet file with 40 row groups read into one frame gives you columns with 40 chunks. That is
fine for sequential kernels, but it is hostile to random access and to SIMD, so Polars keeps an
align_chunks_par escape hatch that rechunks when the chunk layout of different columns
disagrees. The first check it makes (excerpt) is whether all columns even agree on the number
of chunks:
/// Returns true if the chunks of the columns do not align and re-chunking should be done
pub fn should_rechunk(&self) -> bool {
// Scalar columns have no chunks of their own and align with any layout, even when they
// are materialized.
//
// Fast check. It is also needed for correctness, as code below doesn't check if the number
// of chunks is equal.
if !self
.columns()
.iter()
.filter_map(Column::as_series)
.map(|s| s.n_chunks())
.all_equal()
{
return true;
}crates/polars-core/src/frame/mod.rs:325
and the writer goes the other way, deliberately creating chunks sized to the target row group:
/// Split DataFrame into chunks in preparation for writing. The chunks have a
/// maximum number of rows per chunk to ensure reasonable memory efficiency when
/// reading the resulting file, and a minimum size per chunk to ensure
/// reasonable performance when writing.
pub fn chunk_df_for_writing(
df: &mut DataFrame,
row_group_size: usize,
) -> PolarsResult<std::borrow::Cow<'_, DataFrame>> {
// ensures all chunks are aligned.
df.align_chunks_par();
// Accumulate many small chunks to the row group size.
// See: #16403
if !df.columns().is_empty()
&& df
.first_col_chunk_lengths()
.take(5)
.all(|len| len < row_group_size)
{crates/polars-core/src/frame/chunks.rs:105
Round-tripping through parquet therefore tends to increase your chunk count, and Polars knows it, which is why the reading side has a matching “accumulate small chunks” step.
The cost of the layout: alignment
If chunking is a list, then two columns with different lists cannot be zipped. Every binary kernel has to reconcile them first, and the reconciliation strategy is a real cost:
pub fn align_chunks_binary<'a, T, B>(
left: &'a ChunkedArray<T>,
right: &'a ChunkedArray<B>,
) -> (Cow<'a, ChunkedArray<T>>, Cow<'a, ChunkedArray<B>>)
where
B: PolarsDataType,
T: PolarsDataType,
{
let assert = || {
assert_eq!(
left.len(),
right.len(),
"expected arrays of the same length"
)
};
match (left.chunks.len(), right.chunks.len()) {
// All chunks are equal length
(1, 1) => (Cow::Borrowed(left), Cow::Borrowed(right)),
// All chunks are equal length
(a, b)
if a == b
&& left
.chunk_lengths()
.zip(right.chunk_lengths())
.all(|(l, r)| l == r) =>
{
(Cow::Borrowed(left), Cow::Borrowed(right))
},
(_, 1) => {
assert();
(
Cow::Borrowed(left),
Cow::Owned(right.match_chunks(left.chunk_lengths())),
)
},
(1, _) => {
assert();
(
Cow::Owned(left.match_chunks(right.chunk_lengths())),
Cow::Borrowed(right),
)
},
(_, _) => {
assert();
// could optimize to choose to rechunk a primitive and not a string or list type
let left = left.rechunk();
(
Cow::Owned(left.match_chunks(right.chunk_lengths())),
Cow::Borrowed(right),
)
},
}
}crates/polars-core/src/utils/mod.rs:925
Read the last arm carefully: when the chunk layouts disagree irreconcilably, Polars rechunks one
side and then re-slices it to match the other side’s boundaries. match_chunks is a zero-copy
re-slicing, so the cost is one full copy of the rechunked column. Note the comment admitting that
rechunking a primitive column would be cheaper than rechunking a string column — the copy is
dtype-dependent, and Polars picks the left operand by convention, not optimally.
This alignment is where a chunked layout leaks into the semantics of your query. Filtering a column with a boolean mask is a binary kernel, so the mask’s chunk boundaries decide the output’s chunk boundaries:
impl<T> ChunkFilter<T> for ChunkedArray<T>
where
T: PolarsDataType<IsObject = FalseT>,
{
fn filter(&self, filter: &BooleanChunked) -> PolarsResult<ChunkedArray<T>> {
// Broadcast.
if filter.len() == 1 {
return match filter.get(0) {
Some(true) => Ok(self.clone()),
_ => Ok(self.clear()),
};
}
check_filter_len!(self, filter);
Ok(unsafe {
arity::binary_unchecked_same_type(
self,
filter,
|left, mask| filter_fn(left, mask),
true,
true,
)
})
}
}crates/polars-core/src/chunked_array/ops/filter.rs:21
and the kernel it delegates to is a zip over chunk pairs:
let (lhs, rhs) = align_chunks_binary(lhs, rhs);
let chunks = lhs
.downcast_iter()
.zip(rhs.downcast_iter())
.map(|(lhs_arr, rhs_arr)| op(lhs_arr, rhs_arr))
.collect();
let mut ca = lhs.copy_with_chunks(chunks);
let mut retain_flags = StatisticsFlags::empty();
use StatisticsFlags as F;
retain_flags.set(F::IS_SORTED_ANY, keep_sorted);
retain_flags.set(F::CAN_FAST_EXPLODE_LIST, keep_fast_explode);
ca.retain_flags_from(lhs.as_ref(), retain_flags);
cacrates/polars-core/src/chunked_array/ops/arity.rs:591
This is the closest thing to per-chunk predicate pushdown that the eager engine has: the mask is applied one chunk pair at a time, and each chunk’s mask gets a cheap all-true/all-false test before the real work:
// Fast-path: completely empty or completely full mask.
let false_count = mask.unset_bits();
if false_count == mask.len() {
return new_empty_array(array.dtype().clone());
}
if false_count == 0 {
return array.to_boxed();
}crates/polars-compute/src/filter/mod.rs:48
A chunk whose mask is entirely false produces an empty chunk; a chunk whose mask is entirely true produces a zero-copy clone. So a predicate that is “false everywhere except one chunk” costs almost nothing in the other chunks. But note what does not happen: the chunk is not dropped, and the predicate is not evaluated against per-chunk statistics. Predicate pushdown proper lives in the lazy engine and the storage layer (row-group min/max), not here. What chunking buys at this level is the granularity of the shortcut.
The bottom line
The chunked layout is a bet that the common case is “append a lot, scan sequentially, occasionally need random access”, and the payoff is that appends are free, kernels are trivially chunk-parallel, and the storage format’s natural boundaries (row groups) become the in-memory boundaries for free.
The costs are concrete and worth remembering:
- Random access is slower. Every
get(i)may have to locate the chunk first. If your workload is point lookups, rechunk to one chunk first. - Mismatched chunk boundaries cost a copy. Binary kernels align chunks by rechunking one side, which is a full copy of that column. Keeping columns’ chunk layouts in sync is worth doing once, up front.
- Too many chunks is worse than one big chunk. The rule of thumb in the codebase is
chunks.len() > len / 3, i.e. chunks should average at least three rows each before you stop merging them. - Chunk count is not stable across parallel paths. A parallel collect can produce a different
number of chunks depending on thread scheduling, so do not build logic on
n_chunks().
Concretely, in Rust, append-then-rechunk is a two-liner — this is a test in the crate itself:
fn test_categorical_map_after_rechunk() {
let s = Series::new(PlSmallStr::EMPTY, &["foo", "bar", "spam"]);
let mut a = s
.cast(&DataType::from_categories(Categories::global()))
.unwrap();
a.append(&a.slice(0, 2)).unwrap();
let a = a.rechunk();
assert!(a.cat32().unwrap().get_mapping().num_cats_upper_bound() > 0);
}crates/polars-core/src/chunked_array/ops/chunkops.rs:382
Series::rechunk is the same Cow-returning helper as the ChunkedArray one, and for a
single-chunk column it is free. Chunking is the default; rechunking is the deliberate act of
giving up cheap appends to buy back contiguity.