Skip to main content

vortex_layout/layouts/
repartition.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::collections::VecDeque;
5use std::sync::Arc;
6
7use async_stream::try_stream;
8use async_trait::async_trait;
9use futures::StreamExt as _;
10use futures::pin_mut;
11use vortex_array::ArrayRef;
12use vortex_array::Canonical;
13use vortex_array::ExecutionCtx;
14use vortex_array::IntoArray;
15use vortex_array::VortexSessionExecute;
16use vortex_array::aggregate_fn::fns::uncompressed_size_in_bytes::UncompressedSizeInBytes;
17use vortex_array::aggregate_fn::fns::uncompressed_size_in_bytes::UncompressedSizeOpts;
18use vortex_array::aggregate_fn::fns::uncompressed_size_in_bytes::uncompressed_size_in_bytes;
19use vortex_array::arrays::ChunkedArray;
20use vortex_array::dtype::DType;
21use vortex_error::VortexExpect;
22use vortex_error::VortexResult;
23use vortex_error::vortex_err;
24use vortex_session::VortexSession;
25
26use crate::LayoutRef;
27use crate::LayoutStrategy;
28use crate::LayoutWriterContext;
29use crate::segments::SegmentSinkRef;
30use crate::sequence::SendableSequentialStream;
31use crate::sequence::SequencePointer;
32use crate::sequence::SequentialStreamAdapter;
33use crate::sequence::SequentialStreamExt;
34
35#[derive(Clone)]
36pub struct RepartitionWriterOptions {
37    /// The minimum uncompressed size in bytes for a block.
38    pub block_size_minimum: u64,
39    /// The multiple of the number of rows in each block.
40    pub block_len_multiple: usize,
41    /// Optional target uncompressed size in bytes for a block.
42    ///
43    /// The repartition writer attempts to produce partitions with this uncompressed size. This is
44    /// only a best effort attempt: the partitions may be arbitrarily larger or smaller. Reasons for
45    /// this include:
46    ///
47    /// 1. The size of one element may not perfectly divide the target size, resulting in blocks
48    ///    that are either too large or too small.
49    ///
50    /// 2. Variable length types are expensive to pack due to the need to read each element length.
51    ///
52    /// 3. View types are expensive to pack due to each view sharing an arbitrary slice of data.
53    pub block_size_target: Option<u64>,
54    pub canonicalize: bool,
55}
56
57impl RepartitionWriterOptions {
58    /// Compute the effective block length for a given [`DType`].
59    ///
60    /// For fixed-width types where [`DType::element_size`] is known and large enough that
61    /// `element_size * block_len_multiple` would exceed `block_size_target`, this reduces the
62    /// block length so each block stays close to the target byte size.
63    fn effective_block_len(&self, dtype: &DType) -> usize {
64        let Some(block_size_target) = self.block_size_target else {
65            return self.block_len_multiple;
66        };
67        match dtype.element_size() {
68            Some(elem_size) if elem_size > 0 => {
69                // `div_ceil` ensures we overshoot the block_size_target; therefore preventing
70                // `write_stream` from combining adjacent 0.9 MiB chunks into one 1.8 MiB chunk.
71                let max_rows = usize::try_from(block_size_target.div_ceil(elem_size as u64))
72                    .unwrap_or(usize::MAX);
73                self.block_len_multiple.min(max_rows).max(1)
74            }
75            _ => self.block_len_multiple,
76        }
77    }
78}
79
80/// Repartition a stream of arrays into blocks.
81///
82/// Each emitted block (except the last) is at least `block_size_minimum` bytes and contains a
83/// multiple of `block_len_multiple` rows.
84#[derive(Clone)]
85pub struct RepartitionStrategy {
86    child: Arc<dyn LayoutStrategy>,
87    options: RepartitionWriterOptions,
88}
89
90impl RepartitionStrategy {
91    pub fn new<S: LayoutStrategy>(child: S, options: RepartitionWriterOptions) -> Self {
92        Self {
93            child: Arc::new(child),
94            options,
95        }
96    }
97}
98
99#[async_trait]
100impl LayoutStrategy for RepartitionStrategy {
101    async fn write_stream(
102        &self,
103        ctx: LayoutWriterContext,
104        segment_sink: SegmentSinkRef,
105        stream: SendableSequentialStream,
106        eof: SequencePointer,
107        session: &VortexSession,
108    ) -> VortexResult<LayoutRef> {
109        // TODO(os): spawn stream below like:
110        // canon_stream = stream.map(async {to_canonical}).map(spawn).buffered(parallelism)
111        let dtype = stream.dtype().clone();
112        let stream = if self.options.canonicalize {
113            let canonicalize_session = session.clone();
114            SequentialStreamAdapter::new(
115                dtype.clone(),
116                stream.map(move |chunk| {
117                    let (sequence_id, chunk) = chunk?;
118                    let mut ctx = canonicalize_session.create_execution_ctx();
119                    let canonical = chunk.execute::<Canonical>(&mut ctx)?.into_array();
120                    VortexResult::Ok((sequence_id, canonical))
121                }),
122            )
123            .sendable()
124        } else {
125            stream
126        };
127
128        let dtype_clone = dtype.clone();
129        let options = self.options.clone();
130
131        // For fixed-width types with large per-element sizes, reduce the block_len_multiple
132        // so that each block targets block_size_target bytes rather than producing oversized
133        // segments.
134        let block_len = options.effective_block_len(&dtype);
135        let block_size_minimum = options.block_size_minimum;
136        let repartition_session = session.clone();
137
138        let repartitioned_stream = try_stream! {
139            let canonical_stream = stream.peekable();
140            pin_mut!(canonical_stream);
141
142            let mut ctx = repartition_session.create_execution_ctx();
143            let mut chunks = ChunksBuffer::new(block_size_minimum, block_len);
144            while let Some(chunk) = canonical_stream.as_mut().next().await {
145                let (sequence_id, chunk) = chunk?;
146                let mut sequence_pointer = sequence_id.descend();
147                let mut offset = 0;
148                while offset < chunk.len() {
149                    let end = (offset + block_len).min(chunk.len());
150                    let sliced = chunk.slice(offset..end)?;
151                    chunks.push_back(sliced, &mut ctx)?;
152                    offset = end;
153
154                    if chunks.have_enough() {
155                        let output_chunks = chunks.collect_exact_blocks(&mut ctx)?;
156                        assert!(!output_chunks.is_empty());
157                        let chunked =
158                            ChunkedArray::try_new(output_chunks, dtype_clone.clone())?;
159                        if !chunked.is_empty() {
160                            let canonical = chunked.into_array().execute::<Canonical>(&mut ctx)?.into_array();
161                            yield (
162                                sequence_pointer.advance(),
163                                canonical,
164                            )
165                        }
166                    }
167                }
168                if canonical_stream.as_mut().peek().await.is_none() {
169                    let to_flush = ChunkedArray::try_new(
170                        chunks.data.drain(..).map(|(arr, _)| arr),
171                        dtype_clone.clone(),
172                    )?;
173                    if !to_flush.is_empty() {
174                        let canonical = to_flush.into_array().execute::<Canonical>(&mut ctx)?.into_array();
175                        yield (
176                            sequence_pointer.advance(),
177                            canonical,
178                        )
179                    }
180                }
181            }
182        };
183
184        self.child
185            .write_stream(
186                ctx,
187                segment_sink,
188                SequentialStreamAdapter::new(dtype, repartitioned_stream).sendable(),
189                eof,
190                session,
191            )
192            .await
193    }
194}
195
196/// Buffers blocks until they hold enough rows and bytes to emit.
197///
198/// Bytes are the exact uncompressed size of each block, not the buffers it holds: a view array
199/// read from Arrow points into whole decompressed pages, which `nbytes()` would count in full.
200struct ChunksBuffer {
201    /// Each entry stores the chunk and the size snapshot taken at push time.
202    /// This avoids accounting mismatches when interior-mutable arrays (e.g. `SharedArray`)
203    /// change their reported size after being pushed.
204    data: VecDeque<(ArrayRef, u64)>,
205    row_count: usize,
206    nbytes: u64,
207    block_size_minimum: u64,
208    block_len_multiple: usize,
209}
210
211impl ChunksBuffer {
212    fn new(block_size_minimum: u64, block_len_multiple: usize) -> Self {
213        Self {
214            data: Default::default(),
215            row_count: 0,
216            nbytes: 0,
217            block_size_minimum,
218            block_len_multiple,
219        }
220    }
221
222    fn have_enough(&self) -> bool {
223        self.nbytes >= self.block_size_minimum && self.row_count >= self.block_len_multiple
224    }
225
226    fn collect_exact_blocks(&mut self, ctx: &mut ExecutionCtx) -> VortexResult<Vec<ArrayRef>> {
227        let nblocks = self.row_count / self.block_len_multiple;
228        let mut res = Vec::with_capacity(self.data.len());
229        let mut remaining = nblocks * self.block_len_multiple;
230        while remaining > 0 {
231            let (chunk, _) = self
232                .pop_front()
233                .vortex_expect("must have at least one chunk");
234            let len = chunk.len();
235
236            if len > remaining {
237                let left = chunk.slice(0..remaining)?;
238                let right = chunk.slice(remaining..len)?;
239                self.push_front(right, ctx)?;
240                res.push(left);
241                remaining = 0;
242            } else {
243                res.push(chunk);
244                remaining -= len;
245            }
246        }
247        Ok(res)
248    }
249
250    fn push_back(&mut self, chunk: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<()> {
251        let nb = exact_size(&chunk, ctx)?;
252        self.row_count += chunk.len();
253        self.nbytes += nb;
254        self.data.push_back((chunk, nb));
255        Ok(())
256    }
257
258    fn push_front(&mut self, chunk: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<()> {
259        let nb = exact_size(&chunk, ctx)?;
260        self.row_count += chunk.len();
261        self.nbytes += nb;
262        self.data.push_front((chunk, nb));
263        Ok(())
264    }
265
266    fn pop_front(&mut self) -> Option<(ArrayRef, u64)> {
267        let res = self.data.pop_front();
268        if let Some((chunk, nb)) = res.as_ref() {
269            self.row_count -= chunk.len();
270            self.nbytes -= nb;
271        }
272        res
273    }
274}
275
276/// The bytes a block's rows reference, or the bytes it holds for a dtype that cannot be
277/// measured, such as variant.
278fn exact_size(chunk: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<u64> {
279    if !UncompressedSizeInBytes::supports_dtype(chunk.dtype()) {
280        return Ok(chunk.nbytes());
281    }
282    let size = uncompressed_size_in_bytes(chunk, UncompressedSizeOpts::exact(), ctx)?;
283    u64::try_from(size).map_err(|e| vortex_err!("block size does not fit in u64: {e}"))
284}
285
286#[cfg(test)]
287mod tests {
288    use std::sync::Arc;
289
290    use vortex_array::ArrayContext;
291    use vortex_array::IntoArray;
292    use vortex_array::VortexSessionExecute;
293    use vortex_array::array_session;
294    use vortex_array::arrays::ChunkedArray;
295    use vortex_array::arrays::ConstantArray;
296    use vortex_array::arrays::FixedSizeListArray;
297    use vortex_array::arrays::PrimitiveArray;
298    use vortex_array::arrays::VarBinViewArray;
299    use vortex_array::arrays::varbinview::BinaryView;
300    use vortex_array::dtype::DType;
301    use vortex_array::dtype::Nullability::NonNullable;
302    use vortex_array::dtype::PType;
303    use vortex_array::validity::Validity;
304    use vortex_buffer::Buffer;
305    use vortex_buffer::ByteBuffer;
306    use vortex_error::VortexResult;
307    use vortex_io::runtime::single::block_on;
308    use vortex_io::session::RuntimeSessionExt;
309
310    use super::*;
311    use crate::LayoutStrategy;
312    use crate::arrays::shared::Shared;
313    use crate::layouts::chunked::writer::ChunkedLayoutStrategy;
314    use crate::layouts::flat::writer::FlatLayoutStrategy;
315    use crate::segments::TestSegments;
316    use crate::sequence::SequenceId;
317    use crate::sequence::SequentialArrayStreamExt;
318    use crate::test::new_session;
319
320    const ONE_MEG: u64 = 1 << 20;
321
322    #[test]
323    fn effective_block_len_variable_width() {
324        // Utf8 has no known element_size, so block_len_multiple is unchanged.
325        let dtype = DType::Utf8(NonNullable);
326        let options = RepartitionWriterOptions {
327            block_size_minimum: 0,
328            block_len_multiple: 8192,
329            block_size_target: Some(ONE_MEG),
330            canonicalize: false,
331        };
332        assert_eq!(options.effective_block_len(&dtype), 8192);
333    }
334
335    #[test]
336    fn effective_block_len_very_large_elements() {
337        // FixedSizeList(f64, 1_000_000) = 8_000_000 bytes/element.
338        // 1 MiB / 8_000_000 = 0, clamped to max(1) = 1.
339        let dtype = DType::FixedSizeList(
340            Arc::new(DType::Primitive(PType::F64, NonNullable)),
341            1_000_000,
342            NonNullable,
343        );
344        let options = RepartitionWriterOptions {
345            block_size_minimum: 0,
346            block_len_multiple: 8192,
347            block_size_target: Some(ONE_MEG),
348            canonicalize: false,
349        };
350        assert_eq!(options.effective_block_len(&dtype), 1);
351    }
352
353    #[test]
354    fn repartition_large_element_type_produces_small_blocks() -> VortexResult<()> {
355        // Create a FixedSizeList(f64, 1000) array with 1000 lists.
356        // Each list is 8000 bytes, so 1000 lists = 8 MiB total.
357        // With block_size_target = 1 MiB, effective block_len = 132.
358        // We expect the repartition to produce blocks of 132 rows each.
359        let list_size: u32 = 1000;
360        let num_lists: usize = 1000;
361        let total_elements = list_size as usize * num_lists;
362
363        let elements = PrimitiveArray::from_iter((0..total_elements).map(|i| i as f64));
364        let fsl = FixedSizeListArray::new(
365            elements.into_array(),
366            list_size,
367            Validity::NonNullable,
368            num_lists,
369        );
370
371        let ctx = ArrayContext::empty();
372        let segments = Arc::new(TestSegments::default());
373        let (ptr, eof) = SequenceId::root().split();
374
375        let child = ChunkedLayoutStrategy::new(FlatLayoutStrategy::default());
376        let strategy = RepartitionStrategy::new(
377            child,
378            RepartitionWriterOptions {
379                block_size_minimum: 0,
380                block_len_multiple: 8192,
381                block_size_target: Some(ONE_MEG),
382                canonicalize: false,
383            },
384        );
385
386        let stream = fsl.into_array().to_array_stream().sequenced(ptr);
387        let layout = block_on(|handle| async move {
388            let session = new_session().with_handle(handle);
389            strategy
390                .write_stream(
391                    ctx.into(),
392                    Arc::<TestSegments>::clone(&segments),
393                    stream,
394                    eof,
395                    &session,
396                )
397                .await
398        })?;
399
400        // The layout should be a ChunkedLayout with multiple children.
401        // With 1000 rows and effective block_len = 132:
402        //   - 7 full blocks of 132 rows = 924 rows
403        //   - 1 remainder block of 76 rows
404        //   - Total: 8 blocks, 1000 rows
405        assert_eq!(layout.row_count(), num_lists as u64);
406
407        // All non-last children should have 132 rows.
408        let nchildren = layout.nchildren();
409        assert!(nchildren > 1, "expected multiple chunks, got {nchildren}");
410
411        for i in 0..nchildren - 1 {
412            let child = layout.slot(i)?.vortex_expect("chunk slot present");
413            assert_eq!(
414                child.row_count(),
415                132,
416                "chunk {i} has {} rows, expected 132",
417                child.row_count()
418            );
419        }
420
421        // Last child gets the remainder.
422        let last = layout
423            .slot(nchildren - 1)?
424            .vortex_expect("chunk slot present");
425        assert_eq!(last.row_count(), 1000 - 132 * (nchildren as u64 - 1));
426
427        Ok(())
428    }
429
430    #[test]
431    fn repartition_small_element_type_unchanged() -> VortexResult<()> {
432        // For f64 (8 bytes/element), effective block_len stays at 8192.
433        // With 10000 elements and block_size_minimum=0, we get one block of 8192
434        // and one remainder of 1808.
435        let num_elements: usize = 10000;
436        let elements = PrimitiveArray::from_iter((0..num_elements).map(|i| i as f64));
437
438        let ctx = ArrayContext::empty();
439        let segments = Arc::new(TestSegments::default());
440        let (ptr, eof) = SequenceId::root().split();
441
442        let child = ChunkedLayoutStrategy::new(FlatLayoutStrategy::default());
443        let strategy = RepartitionStrategy::new(
444            child,
445            RepartitionWriterOptions {
446                block_size_minimum: 0,
447                block_len_multiple: 8192,
448                block_size_target: Some(ONE_MEG),
449                canonicalize: false,
450            },
451        );
452
453        let stream = elements.into_array().to_array_stream().sequenced(ptr);
454        let layout = block_on(|handle| async move {
455            let session = new_session().with_handle(handle);
456            strategy
457                .write_stream(
458                    ctx.into(),
459                    Arc::<TestSegments>::clone(&segments),
460                    stream,
461                    eof,
462                    &session,
463                )
464                .await
465        })?;
466
467        assert_eq!(layout.row_count(), num_elements as u64);
468        assert_eq!(layout.nchildren(), 2);
469        assert_eq!(
470            layout
471                .slot(0)?
472                .vortex_expect("chunk slot present")
473                .row_count(),
474            8192
475        );
476        assert_eq!(
477            layout
478                .slot(1)?
479                .vortex_expect("chunk slot present")
480                .row_count(),
481            1808
482        );
483
484        Ok(())
485    }
486
487    /// View blocks sharing a buffer far larger than the bytes they reference must still coalesce
488    /// up to `block_size_minimum`.
489    #[test]
490    fn repartition_coalesces_view_blocks_sharing_a_large_buffer() -> VortexResult<()> {
491        const VALUE_LEN: usize = 27;
492        const ROWS_PER_CHUNK: usize = 8192;
493        const NUM_CHUNKS: usize = 10;
494
495        // One 2 MiB buffer shared by every chunk, of which each row references 27 bytes.
496        let page = ByteBuffer::from(vec![b'x'; 2 << 20]);
497        let value = [b'x'; VALUE_LEN];
498        let dtype = DType::Utf8(NonNullable);
499        let chunks = (0..NUM_CHUNKS)
500            .map(|_| {
501                let views = (0..ROWS_PER_CHUNK)
502                    .map(|row| {
503                        BinaryView::make_view(&value, 0, u32::try_from(row * VALUE_LEN).unwrap())
504                    })
505                    .collect::<Buffer<BinaryView>>();
506                // SAFETY: every view points at `VALUE_LEN` bytes inside the shared page.
507                unsafe {
508                    VarBinViewArray::new_unchecked(
509                        views,
510                        Arc::from([page.clone()]),
511                        dtype.clone(),
512                        Validity::NonNullable,
513                    )
514                }
515                .into_array()
516            })
517            .collect::<Vec<_>>();
518        let chunked = ChunkedArray::try_new(chunks, dtype)?.into_array();
519
520        let ctx = ArrayContext::empty();
521        let segments = Arc::new(TestSegments::default());
522        let (ptr, eof) = SequenceId::root().split();
523        let strategy = RepartitionStrategy::new(
524            ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()),
525            RepartitionWriterOptions {
526                block_size_minimum: ONE_MEG,
527                block_len_multiple: ROWS_PER_CHUNK,
528                block_size_target: None,
529                canonicalize: false,
530            },
531        );
532
533        let stream = chunked.to_array_stream().sequenced(ptr);
534        let layout = block_on(|handle| async move {
535            let session = new_session().with_handle(handle);
536            strategy
537                .write_stream(
538                    ctx.into(),
539                    Arc::<TestSegments>::clone(&segments),
540                    stream,
541                    eof,
542                    &session,
543                )
544                .await
545        })?;
546
547        // Each chunk references 8192 * (16 + 27) bytes, about 344 KiB, so three chunks make the
548        // 1 MiB minimum: blocks of 3, 3, 3 chunks and a 1-chunk tail.
549        assert_eq!(layout.row_count(), (NUM_CHUNKS * ROWS_PER_CHUNK) as u64);
550        assert_eq!(layout.nchildren(), 4);
551        for i in 0..3 {
552            let child = layout.slot(i)?.vortex_expect("chunk slot present");
553            assert_eq!(child.row_count(), (3 * ROWS_PER_CHUNK) as u64);
554        }
555        Ok(())
556    }
557
558    /// Regression test: `SharedArray` slices sharing an `Arc<Mutex<SharedState>>` can
559    /// transition from Source to Cached when any one of them is canonicalized. This caused
560    /// `pop_front` to panic with `attempt to subtract with overflow` because the buffer's
561    /// running `nbytes` total was accumulated with the smaller Source-era values while
562    /// `pop_front` subtracted the larger Cached-era values.
563    #[test]
564    fn chunks_buffer_pop_front_no_panic_after_shared_execution() -> VortexResult<()> {
565        let mut ctx = array_session().create_execution_ctx();
566        let n = 20_000usize;
567        let block_len = 10_000usize;
568
569        let constant = ConstantArray::new(42i64, n);
570        let shared = Shared::new(constant.into_array());
571        let shared_handle = shared.clone();
572        let arr = shared.into_array();
573
574        let s1 = arr.slice(0..block_len)?;
575        let s2 = arr.slice(block_len..n)?;
576
577        let mut buf = ChunksBuffer::new(0, block_len);
578        buf.push_back(s1, &mut ctx)?;
579        buf.push_back(s2, &mut ctx)?;
580
581        let _output = buf.pop_front().unwrap();
582
583        // Transition SharedState from Source to Cached for ALL slices sharing this Arc.
584        use crate::arrays::shared::SharedArrayExt;
585        let _canonical =
586            shared_handle.get_or_compute(|source| source.clone().execute::<Canonical>(&mut ctx))?;
587
588        // Before the fix this panicked with "attempt to subtract with overflow".
589        let _s2 = buf.pop_front().unwrap();
590        assert_eq!(buf.nbytes, 0);
591        assert_eq!(buf.row_count, 0);
592
593        Ok(())
594    }
595}