Polars Hash-Shuffles Rows Between Streaming Stages

How the Polars streaming engine routes rows into hash partitions so a group-by or join never has to sort, and how the partitions are built and exchanged.

TL;DR: A streaming group-by has to get every row with the same key into the same place, and a streaming join has to get both sides of a matching key into the same place. Polars does this with a hash shuffle, not a sort: each row’s key is hashed, the hash is mapped to one of N partitions, and each partition is then aggregated or joined on its own. Because a partition only ever contains one key’s worth of rows, the per-partition work is a plain hash table, with no comparison sort anywhere in the pipeline.

The classic way to co-locate equal keys is to sort. Sort the input by the key, then every run of equal keys is contiguous and a group-by or a merge join is a linear scan. That works, and it is what a lot of engines do. It is also a strange fit for a streaming engine, where the whole point is to never materialise the whole input: a sort has to hold the entire dataset (or spill all of it) before it can produce its first output row.

Polars’ answer is the shuffle. Same idea as a Spark repartition or a DataFusion RepartitionExec, but the implementation has some details worth reading.

The partition function

Everything rests on one function (crates/polars-utils/src/hashing.rs:109):

rust
    /// Converts a hash to a partition. It is guaranteed that the output is
    /// in the range [0, n_partitions), and that independent HashPartitioners
    /// that we initialized with the same num_partitions and seed return the same
    /// partition.
    #[inline(always)]
    pub fn hash_to_partition(&self, hash: u64) -> usize {
        // Assuming r is a 64-bit random number, we note that
        // r / 2^64 is almost a uniform random number in [0, 1), and thus
        // floor(r * n_partitions / 2^64) is almost a uniform random integer in
        // [0, n_partitions). Despite being written with u128 multiplication this
        // compiles to a single mul / mulhi instruction on x86-x64/aarch64.
        let shuffled = hash.wrapping_mul(self.seed);
        ((shuffled as u128 * self.num_partitions as u128) >> 64) as usize
    }

Two details are doing real work here.

The first is the wrapping_mul by the seed. The incoming hash is a value hash — hash(42) == hash(42) for any key column — but value hashes have terrible distribution in their low bits. Numeric key columns in particular hash to something close to the value itself, so hash % n_partitions would send every value in a contiguous run to the same partition. Multiplying by a random odd number is a universal hash function for the high bits — the classic “fibonacci hashing” trick — and it is why the high bits are used for the partition index rather than the low ones.

The second detail is that the partition index is computed as floor(h * n / 2^64) rather than h % n. The comment explains why: the u128 multiply-and-shift compiles to a single mul/mulhi pair, so there is no division instruction on the hot path.

The seed is randomised per partitioner (crates/polars-utils/src/hashing.rs:82):

rust
    #[inline]
    pub fn new(num_partitions: usize, mut seed: u64) -> Self {
        assert!(num_partitions > 0);
        // Make sure seeds bits are properly randomized, and is odd.
        const ARBITRARY1: u64 = 0x85921e81c41226a0;
        const ARBITRARY2: u64 = 0x3bc1d0faba166294;
        const ARBITRARY3: u64 = 0xfbde893e21a73756;
        seed = folded_multiply(seed ^ ARBITRARY1, ARBITRARY2);
        seed = folded_multiply(seed, ARBITRARY3);
        seed |= 1;
        Self {
            seed,
            num_partitions,
        }
    }

The | 1 is not cosmetic: the multiply-shift trick needs an odd multiplier to be a permutation, otherwise two different hashes could map to the same partition, and rows of different keys would collide into the same bucket. A collision is not a correctness bug (the hash table still compares the actual keys), but it destroys the point of having many partitions.

The other requirement is that both sides of a join must agree. Hence the doc comment: “independent HashPartitioners that we initialized with the same num_partitions and seed return the same partition”. In practice the engine always uses seed 0 (crates/polars-stream/src/nodes/joins/equi_join.rs:607):

rust
        let partitioner = HashPartitioner::new(state.num_pipelines, 0);

so the number of partitions is the number of pipelines, and both sides of the join trivially agree. The group-by picks its own partition count but still uses seed 0 (crates/polars-stream/src/nodes/group_by/mod.rs:235).

Hashes are computed once, and kept with the data

The partition index of a row is derived from a 64-bit hash of its key. That hash is not recomputed every time the row is moved; it is computed once and carried alongside the row. The carrier is HashKeys (crates/polars-expr/src/hash_keys.rs:116):

rust
/// Represents a DataFrame plus a hash per row, intended for keys in grouping
/// or joining. The hashes may or may not actually be physically pre-computed,
/// this depends per type.
#[derive(Clone, Debug)]
pub enum HashKeys {
    RowEncoded(RowEncodedKeys),
    KeyRows(KeyRowKeys),
    Binview(BinviewKeys),
    Single(SingleKeys),
}

There are four representations because hashing a u64 key column is completely different work from hashing a String key column. Single is a primitive numeric/temporal/categorical column, where the hash is essentially the value (so it is not physically stored). Binview is strings, which get a real hash. RowEncoded is the general case: the key columns are row-encoded into a byte buffer and then hashed.

Mapping hashes to partitions is (crates/polars-expr/src/hash_keys.rs:304):

rust
    /// After this call partitions will be extended with the partition for each
    /// hash. Nulls are assigned IdxSize::MAX or a specific partition depending
    /// on whether partition_nulls is true.
    pub fn gen_partitions(
        &self,
        partitioner: &HashPartitioner,
        partitions: &mut Vec<IdxSize>,
        partition_nulls: bool,
    ) {
        unsafe {
            let null_p = if partition_nulls | self.null_is_valid() {
                partitioner.null_partition() as IdxSize
            } else {
                IdxSize::MAX
            };
            partitions.reserve(self.len());
            self.for_each_hash(|_idx, opt_h| {
                partitions.push_unchecked(
                    opt_h
                        .map(|h| partitioner.hash_to_partition(h) as IdxSize)
                        .unwrap_or(null_p),
                );
            });
        }
    }

Nulls get their own treatment. HashPartitioner::null_partition() returns 0, and nulls are either routed there (when the join needs to match nulls against nulls) or marked with IdxSize::MAX and dropped from the partitioning entirely.

There is a second, faster variant that produces indices per partition rather than a partition per row (crates/polars-expr/src/hash_keys.rs:404):

rust
    fn gen_idxs_per_partition_impl<const BUILD_SKETCHES: bool, const BUILD_F2: bool>(
        &self,
        partitioner: &HashPartitioner,
        partition_idxs: &mut [Vec<IdxSize>],
        sketches: &mut [CardinalitySketch],
        mut f2_sketch: Option<&mut F2Sketch>,
        partition_nulls: bool,
    ) {

This is the one the join uses, because it does the partition assignment and the cardinality estimation in a single pass over the hashes. The sketch is a side product: it estimates how many distinct keys are in each partition, so the hash table can be allocated at the right size instead of growing.## Splitting a frame into per-partition row lists

The core of the shuffle is split (crates/polars-stream/src/hash_shuffle/split.rs:43):

rust
/// Groups the rows of `keys` (only those set in `mask`, if given) by partition: one pass for the
/// partitions and counts, a prefix sum, one pass placing the indices. Rows without a hash go to
/// partition 0. `None` with one partition and no mask: all rows, nothing computed.
pub fn split(
    keys: &HashKeys,
    mask: Option<&Bitmap>,
    partitioner: &HashPartitioner,
) -> Option<SplitRows> {
    let num_partitions = partitioner.num_partitions();
    if let Some(mask) = mask {
        assert_eq!(mask.len(), keys.len());
    }

    if num_partitions == 1 {
        let mask = mask?;
        let idxs: Vec<IdxSize> = mask.true_idx_iter().map(|i| i as IdxSize).collect();
        let offsets = vec![0, idxs.len() as IdxSize];
        return Some(SplitRows { idxs, offsets });
    }

    let mut partitions = Vec::new();
    keys.gen_partitions(partitioner, &mut partitions, true);
    // SAFETY: there is one partition per row, and the mask has one bit per row.
    let in_mask = |i: usize| mask.is_none_or(|m| unsafe { m.get_bit_unchecked(i) });

    let mut offsets: Vec<IdxSize> = vec![0; num_partitions + 1];
    for (i, p) in partitions.iter().enumerate() {
        if in_mask(i) {
            offsets[*p as usize + 1] += 1;
        }
    }
    let mut total = 0;
    for offset in &mut offsets {
        total += *offset;
        *offset = total;
    }

    let mut next = offsets[..num_partitions].to_vec();
    let mut idxs: Vec<IdxSize> = vec![0; total as usize];
    for (i, p) in partitions.iter().enumerate() {
        if in_mask(i) {
            let pos = &mut next[*p as usize];
            idxs[*pos as usize] = i as IdxSize;
            *pos += 1;
        }
    }

    Some(SplitRows { idxs, offsets })
}

This is a counting sort over the partition index, and it is worth reading closely because the data structure it produces is the entire shuffle buffer.

The output is SplitRows: one flat array of row indices, grouped by partition, with num_partitions + 1 offsets into it:

rust
/// Row indices grouped by partition: one array, increasing within each partition (first/last
/// rely on it), and `num_partitions + 1` offsets into it.
pub struct SplitRows {
    idxs: Vec<IdxSize>,
    offsets: Vec<IdxSize>,
}

impl SplitRows {
    /// The rows of partition `p`, in increasing order.
    pub fn partition(&self, p: usize) -> &[IdxSize] {
        &self.idxs[self.offsets[p] as usize..self.offsets[p + 1] as usize]
    }
}

Two passes: the first counts how many rows land in each partition and turns the counts into a prefix sum; the second places each row index at its position. The result is that partition p’s rows are idxs[offsets[p]..offsets[p+1]] — a contiguous slice, so gathering them is a single take over the columns.

Note that the row indices within a partition are in increasing order. That is a free property of the counting sort, and it matters: it means the gathered rows of a partition keep their original relative order, so an order-sensitive aggregation still works on the shuffled data.

The memory cost of the shuffle is 4 bytes per row for the index array (with IdxSize = u32) plus a tiny offsets vector. That is dramatically cheaper than materialising a sorted copy of the data.

The per-partition buffers

The frames that get shuffled are not plain DataFrames. They carry their keys with them (crates/polars-stream/src/hash_shuffle/frame_with_keys.rs:17):

rust
/// Rows with their hashed keys, and an optional mask of the rows that belong to it.
pub struct FrameWithKeys {
    pub frame: DataFrame,
    pub keys: HashKeys,
    pub mask: Option<Bitmap>,
}

The mask is an optimisation: if a frame is mostly rows that belong to the group-by, it can be stored whole with a bitmap marking the rows that do not, instead of being gathered row by row.

These frames are spillable. SpillFrameWithKeys wraps a SpillToken<FrameWithKeys>, so if the memory manager is under pressure the frame goes to disk and comes back later (crates/polars-stream/src/hash_shuffle/frame_with_keys.rs:88):

rust
/// A `FrameWithKeys` that can spill, like `SpillFrame`: one `SpillToken`, its size known while
/// spilled.
pub struct SpillFrameWithKeys {
    token: SpillToken<FrameWithKeys>,
    num_rows: usize,
    estimated_size: usize,
}

Rows are moved between frames with a builder (crates/polars-stream/src/hash_shuffle/frame_with_keys.rs:172):

rust
    /// Appends the rows `rows` of `frame` and `keys`. Panics unless the columns of `frame` are
    /// single chunks.
    ///
    /// # Safety
    /// The rows must be in-bounds of `frame` and `keys`, which have the same number of rows.
    pub unsafe fn gather_extend(&mut self, frame: &DataFrame, keys: &HashKeys, rows: &[IdxSize]) {
        unsafe {
            self.frame.gather_extend(frame, rows, ShareStrategy::Never);
            self.keys.gather_extend(keys, rows);
        }
    }

The frame and its keys are gathered together, so the keys stay row-aligned with the data. ShareStrategy::Never means string columns are copied rather than shared — the strings are moving to a new partition and must not keep the old buffer alive.

The exchange: group-by

The group-by node is the cleanest illustration of the whole mechanism, because it does the shuffle in two distinct phases.

Phase one (the sink) receives morsels and aggregates them. Rows with frequent keys go into small in-memory “hot tables”; everything else is stored cold, in spillable frames, with its keys (crates/polars-stream/src/nodes/group_by/sink.rs:152):

rust
    /// Stores the cold rows `cold_idxs` of a slice `df` of a morsel of `input_idx`, and adds
    /// their hashes to the sketches. A whole morsel that may be stored as it is and has mostly
    /// cold rows becomes a frame with a mask of its cold rows; otherwise the cold rows are
    /// gathered into the builder of the input, which becomes a frame once it holds a morsel's
    /// worth of rows.
    async fn store_cold_rows(
        &mut self,
        input_idx: usize,
        df: DataFrame,
        keys: HashKeys,
        cold_idxs: &[IdxSize],
        may_store_whole: bool,
        spill_ctx: &MostRecentSpillContext,
    ) {

Phase two (the flush) happens once all input has arrived. This is where the partition count is decided (crates/polars-stream/src/nodes/group_by/mod.rs:233):

rust
    /// Builds the results once all input is received. With at most one partition per pipeline
    /// all partitions are built here; otherwise the cold rows are stored per partition, and the
    /// partitions are built while the results are sent.
    fn start_flush(
        &self,
        sink: GroupBySink,
        state: &StreamingExecutionState,
    ) -> PolarsResult<Flush> {
        let (stats, frames, pre_aggs) = sink.finish_input(&self.params, &self.spill_ctx, state)?;
        let num_partitions = self.tuning.num_partitions(&stats, self.num_pipelines);
        let partitioner = HashPartitioner::new(num_partitions, 0);
        let pre_aggs = SplitPreAggs::new(pre_aggs, &partitioner, state)?;
        let num_frames = frames.len();
        let batches = make_batches(frames, self.tuning.batch_bytes());
        let reserve_groups = (stats.estimated_groups * 5 / 4 / num_partitions as u64) as usize;
        let build_at_once = num_partitions <= self.num_pipelines;

The number of partitions is a policy decision, and it is worth reading (crates/polars-stream/src/nodes/group_by/tuning.rs:101):

rust
    /// The number of partitions: enough for every pipeline to have work, and enough that the
    /// results of any `num_pipelines` partitions fit in memory. Above `num_pipelines` it is a
    /// multiple of `num_pipelines`.
    pub fn num_partitions(&self, stats: &InputStats, num_pipelines: usize) -> usize {
        let work = stats.work();
        if work == 0 {
            return 1;
        }
        if let Some(n) = self.num_partitions {
            return n;
        }

        let t = num_pipelines.max(1) as u64;
        let n_rows = (work / self.min_rows_per_partition).clamp(1, t);
        let result_bytes = (2 * t)
            .saturating_mul(stats.estimated_groups)
            .saturating_mul(stats.group_bytes());
        let n_memory = result_bytes.div_ceil((budget() / 2).max(1));
        if n_memory <= t {
            return n_rows.max(n_memory) as usize;
        }

        let max_partitions = MAX_PARTITIONS.min(self.batch_bytes() as u64 / MIN_STORED_FRAME_BYTES);
        let n_cap = (max_partitions / t * t).max(2 * t);
        let n = n_memory.div_ceil(t).saturating_mul(t).min(n_cap);
        usize::try_from(n).unwrap()
    }

Two forces pull in opposite directions. More partitions means more parallelism but more per-partition overhead and more total hash-table memory, because each partition has its own table. Fewer partitions means cheaper tables but idle workers. The rule of thumb is: enough partitions that every pipeline has work (at least num_pipelines), but no more than the memory budget allows. Above num_pipelines, the count is rounded up to a multiple of num_pipelines so the work divides evenly across the workers.

The actual build has two shapes, decided by build_at_once (crates/polars-stream/src/nodes/group_by/mod.rs:261):

rust
        let (outputs, stored, pre_aggs) = if build_at_once {
            let outputs =
                self.build_partitions(pre_aggs, batches, &partitioner, reserve_groups, state)?;
            actual_groups.add(outputs.iter().map(|out| out.height() as i64).sum());
            (outputs, Vec::new(), SplitPreAggs::default())
        } else {
            let stored = self.store_partitions(batches, &partitioner, state)?;
            (Vec::new(), stored, pre_aggs)
        };

If there are at most as many partitions as pipelines, every partition is built at once, in parallel, and the results are complete frames. If there are more partitions than pipelines, the partitions are stored first — one frame per partition per batch — and then built a few at a time while the results are streamed out.

The storing path is the interesting one, because it is the shuffle proper (crates/polars-stream/src/hash_shuffle/store.rs:49):

rust
    /// Gathers this partition's rows of every frame of `batch` by index into one frame per input:
    /// reserved exactly, registered for spilling without waiting. Inputs without rows here are
    /// skipped. Each input's columns are those of its first frame in the batch. The frames must
    /// be split.
    pub fn write_batch(&mut self, batch: &[LoadedFrame]) {
        let p = self.index;
        for input in 0..self.frames_per_input.len() {
            let rows_per_frame = || {
                batch
                    .iter()
                    .filter(move |f| f.input == input)
                    .map(move |f| (f, f.partition_rows(p).expect("frames must be split")))
            };
            let num_rows: usize = rows_per_frame().map(|(_, rows)| rows.len()).sum();
            if num_rows == 0 {
                continue;
            }

            let (first, _) = rows_per_frame().next().unwrap();
            let schema = first.frame.frame.schema().clone();
            let mut builder = FrameWithKeysBuilder::new(schema, &first.frame.keys);
            builder.reserve(num_rows);
            for (f, rows) in rows_per_frame() {
                if !rows.is_empty() {
                    unsafe { builder.gather_extend(&f.frame.frame, &f.frame.keys, rows) };
                }
            }

            let frame = SpillFrameWithKeys::new_unregistered(builder.freeze_reset());
            self.spill_ctx.register_no_spill_check(&frame);
            self.estimated_size += frame.estimated_size() as u64;
            self.frames_per_input[input].push_back(frame);
        }
    }

For each partition, for each input, it gathers the partition’s rows out of every frame of the batch and concatenates them into one frame. That is the physical move: one gather per column per partition.

The batching matters for memory. make_batches (crates/polars-stream/src/hash_shuffle/batches.rs:17) sorts the frames so that the ones still in memory come first, then cuts them into batches of about batch_bytes:

rust
/// Orders frames (those still in memory first, then by input) and cuts them into batches of
/// about `batch_bytes`, at least one frame each.
pub fn make_batches(
    frames: Vec<(usize, SpillFrameWithKeys)>,
    batch_bytes: usize,
) -> Vec<Vec<(usize, SpillFrameWithKeys)>> {

batch_bytes defaults to an eighth of the out-of-core memory budget (crates/polars-stream/src/hash_shuffle/batches.rs:18). So the shuffle proceeds in windows: load a window of cold frames, split each into partitions, write each partition’s share of the window into a partition-local frame, free the window, move to the next.

The build of one batch is overlapped with the loading of the next (crates/polars-stream/src/hash_shuffle/batches.rs:64):

rust
/// Calls `build` on every partition once per batch, with the frames of that batch loaded. While
/// low-priority tasks build the partitions, high-priority tasks load, prepare and split the next
/// batch (each frame once) and free the previous one.
pub fn build_from_batches<S: Send>(

High-priority tasks load and split; low-priority tasks build the partitions. The split is the cheap part and gates the build, so it gets the higher priority.

The result of the shuffle is a set of per-partition frames. Each is aggregated independently into a PartitionTable, and the per-partition results are sent downstream as morsels (crates/polars-stream/src/nodes/group_by/flush.rs:171):

rust
                TaskState::Building { table, stored } => {
                    if let Some((input, frame)) = stored.pop_frame() {
                        let frame = frame.load().await;
                        let frame = self.params.prepare_frame(input, frame, state).await?;
                        table.add_rows(input, &frame, None)?;
                    } else {
                        let frame = table.finish()?;
                        self.actual_groups.add(frame.height() as i64);
                        *task = if frame.height() > 0 {
                            TaskState::Sending { frame, offset: 0 }
                        } else {
                            TaskState::Idle
                        };
                    }
                    memory_manager().spill().await;
                },

The frames are consumed oldest-first, because the oldest ones are the most likely to still be in memory (they were the first to be written, so they were the first to be spilled, and the ones written last are the newest and therefore the most likely to be resident).

The exchange: join

The equi-join does the same shuffle with a different destination: the probe tables.

The build side is partitioned as it arrives (crates/polars-stream/src/nodes/joins/equi_join.rs:430):

rust
    async fn partition_and_sink(
        mut recv: PortReceiver,
        local: &mut LocalBuilder,
        partitioner: HashPartitioner,
        params: &EquiJoinParams,
        state: &StreamingExecutionState,
        spill_ctx: &MostRecentSpillContext,
    ) -> PolarsResult<()> {

and each pipeline’s local builder keeps, per partition, the row indices of the build morsels that belong to it (crates/polars-stream/src/nodes/joins/equi_join.rs:653):

rust
struct LocalBuilder {
    // The complete list of morsels and their computed hashes seen by this builder.
    morsels: Vec<(MorselSeq, SpillFrame, HashKeys)>,

    // A cardinality sketch per partition for the keys seen by this builder.
    sketch_per_p: Vec<CardinalitySketch>,

    // The key of each runtime filter seen by this builder.
    key_filters: Vec<KeyFilterBuilder>,

    // morsel_idxs_values_per_p[p][start..stop] contains the offsets into morsels[i]
    // for partition p, where start, stop are:
    // let start = morsel_idxs_offsets[i * num_partitions + p];
    // let stop = morsel_idxs_offsets[(i + 1) * num_partitions + p];
    morsel_idxs_values_per_p: Vec<Vec<IdxSize>>,
    morsel_idxs_offsets_per_p: Vec<usize>,
}

This is the same idea as SplitRows but stored per morsel, because the join needs to preserve the morsel boundaries: the build side’s rows must come out in the order the build input had them, because a left-outer join has to emit unmatched build rows in input order.

When the build side is complete, the partitions are turned into probe tables. Each partition is built by a separate task, and inside that task the rows are gathered from every local builder in morsel order (crates/polars-stream/src/nodes/joins/equi_join.rs:841):

rust
                            let p_morsel_idxs = &l.morsel_idxs_values_per_p[p]
                                [p_morsel_idxs_start..p_morsel_idxs_stop];
                            p_table.insert_keys_subset(keys, p_morsel_idxs, track_unmatchable);
                            p_payload.gather_extend(&payload, p_morsel_idxs, ShareStrategy::Never);

The probe side then does the mirror image: partition the probe rows by hash, and probe each partition’s table (crates/polars-stream/src/nodes/joins/equi_join.rs:1040):

rust
    async fn partition_and_probe(
        mut recv: PortReceiver,
        mut send: PortSender,
        partitions: &[ProbeTable],
        unordered_morsel_seq: &AtomicU64,
        partitioner: HashPartitioner,
        params: &EquiJoinParams,
        state: &StreamingExecutionState,
    ) -> PolarsResult<MorselSeq> {

with the partition assignment happening in one pass (crates/polars-stream/src/nodes/joins/equi_join.rs:1198):

rust
                    for p in partition_idxs.iter_mut() {
                        p.clear();
                    }
                    hash_keys.gen_idxs_per_partition(
                        &partitioner,
                        &mut partition_idxs,
                        &mut [],
                        None,
                        emit_unmatched,
                    );

Note that this is not a second shuffle of the data. The probe rows stay where they are; only their indices are grouped by partition, and each partition’s indices are used to gather from that partition’s build-side payload. The build side did the physical move; the probe side only does the logical routing.

There is also an order-preserving mode, where the probe rows cannot be reordered at all and the probe has to follow the probe morsel’s order (crates/polars-stream/src/nodes/joins/equi_join.rs:1127):

rust
                if params.preserve_order_probe {
                    // To preserve the order we can't do bulk probes per partition and must follow
                    // the order of the probe morsel. We can still group probes that are
                    // consecutively on the same partition.
                    probe_partitions.clear();
                    hash_keys.gen_partitions(&partitioner, &mut probe_partitions, emit_unmatched);

Consecutive runs of the same partition are probed together, so an ordered join still gets most of the batching benefit, just not the full one.

Why this beats a sort

The comparison, for one input of n rows with g distinct keys:

sort-basedhash shuffle
comparisonsO(n log n)O(n) hashes
extra memorywhole sorted copy4 bytes/row of indices + per-partition hash tables
first outputafter the whole sortafter the first partition is built
orderingtotal order availableonly the original order within a partition

The “extra memory” row is the one people usually get wrong. A sort does not need to hold the sorted copy if it can spill, but it does need to hold the unsorted copy plus a sort key, and it must write the sorted output in order. The shuffle only needs an index array; the rows themselves are gathered into per-partition frames which replace the originals, so the total number of live row copies stays roughly constant.

The “first output” row is the streaming argument. A sort is a barrier: nothing comes out until everything is in. The shuffle is not — the group-by flush phase starts emitting partitions as soon as the first batch is built, and the join starts probing as soon as the build side finishes.

The trade-offs

Skew. A hash shuffle assumes the key distribution is uniform. If one key has 40% of the rows, one partition gets 40% of the work and the other workers idle. Polars mitigates this in the group-by with the hot table: frequent keys are aggregated in a small in-memory table before the shuffle, so a hot key never reaches a partition as a pile of rows (crates/polars-stream/src/nodes/group_by/sink.rs:107). The join has no such mitigation — a hot key simply makes one partition slow.

Memory of the hash tables. Each partition has its own hash table, sized from the cardinality sketches, so a partition with a million distinct keys gets a table sized for a million keys.

The extra copy. The shuffle physically moves every row into a partition-local frame. That is one gather per column per row, and for wide frames it is the dominant cost of the shuffle. It is still much cheaper than a sort, but it is not free.

The bottom line

The hash shuffle is what makes the streaming group-by and join possible without a sort barrier. The pieces are:

  • A fast partition function. Multiply by an odd random seed, take the high bits, avoid the division. HashPartitioner::hash_to_partition.
  • Hashes computed once and carried with the rows. HashKeys has a specialised representation per key type, so the hash is not recomputed on every shuffle.
  • A counting sort as the shuffle buffer. split produces one flat index array plus num_partitions + 1 offsets. 4 bytes per row, and the row order within a partition is preserved for free.
  • Spillable per-partition frames. FrameWithKeys plus SpillFrameWithKeys means the shuffle can run out of core, and the batch size is derived from the memory budget rather than being fixed.
  • A policy layer on top. The number of partitions is chosen from the estimated group count, the estimated bytes per group, and the memory budget — and is always a multiple of the pipeline count.

The knobs are environment variables if you want to experiment: POLARS_GROUP_BY_NUM_PARTITIONS forces the partition count, POLARS_GROUP_BY_MIN_ROWS_PER_PARTITION sets the work floor below which extra partitions are not worth it, and POLARS_GROUP_BY_BATCH_BYTES sets the size of the shuffle window.

PolarsInternalsStreamingGroup byJoinsPerformance