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