55//! the corresponding IVF partitions.
66
77use std:: ops:: Range ;
8- use std:: sync:: atomic:: AtomicU64 ;
9- use std:: sync:: { Arc , Mutex } ;
8+ use std:: sync:: Arc ;
109
1110use arrow:: compute:: concat_batches;
1211use arrow:: datatypes:: UInt64Type ;
1312use arrow:: { array:: AsArray , compute:: sort_to_indices} ;
1413use arrow_array:: { RecordBatch , UInt32Array , UInt64Array } ;
1514use arrow_schema:: { DataType , Field , Schema } ;
1615use futures:: { future:: try_join_all, prelude:: * } ;
17- use lance_arrow:: stream:: rechunk_stream_by_size;
18- use lance_arrow:: { RecordBatchExt , SchemaExt } ;
16+ use lance_arrow:: { RecordBatchExt , SchemaExt , interleave_batches} ;
1917use lance_core:: {
2018 Error , Result ,
2119 cache:: LanceCache ,
@@ -341,6 +339,11 @@ pub fn create_ivf_shuffler(
341339
342340const DEFAULT_SHUFFLE_BATCH_BYTES : usize = 128 * 1024 * 1024 ;
343341
342+ /// Number of rows per output batch when streaming sorted data via interleave.
343+ /// Small enough to keep the output chunk's memory footprint modest relative to
344+ /// the accumulated source data.
345+ const SHUFFLE_WRITE_CHUNK_ROWS : usize = 8 * 1024 ;
346+
344347/// Limit of how much transformed data we accumulate before spilling to disk.
345348///
346349/// A larger value will use more RAM but require less random access during the
@@ -407,15 +410,59 @@ impl TwoFileShuffler {
407410 }
408411}
409412
413+ /// `(batch_idx, row_idx)` pairs produced by [`sort_to_interleave_indices`], paired with
414+ /// per-partition row counts.
415+ type InterleaveResult = ( Vec < ( usize , usize ) > , Vec < u64 > ) ;
416+
417+ /// Sorts rows from multiple batches by partition ID and returns interleave indices.
418+ ///
419+ /// Builds a sort key of `(part_id, batch_idx, row_idx)` for every row across all
420+ /// batches, sorts by `part_id`, then emits `(batch_idx, row_idx)` pairs in that
421+ /// order. This avoids concatenating the full data: only the `UInt32` partition-ID
422+ /// columns are touched here.
423+ ///
424+ /// Also returns per-partition row counts (derived from the same sorted keys at no
425+ /// extra cost).
426+ ///
427+ /// Returns an error if any partition ID is out of range `[0, num_partitions)`.
428+ fn sort_to_interleave_indices (
429+ part_id_columns : & [ & UInt32Array ] ,
430+ num_partitions : usize ,
431+ ) -> Result < InterleaveResult > {
432+ let total_rows: usize = part_id_columns. iter ( ) . map ( |a| a. len ( ) ) . sum ( ) ;
433+ let mut keys: Vec < ( u32 , u32 , u32 ) > = Vec :: with_capacity ( total_rows) ;
434+ for ( batch_idx, col) in part_id_columns. iter ( ) . enumerate ( ) {
435+ let batch_idx = batch_idx as u32 ;
436+ for ( row_idx, & part_id) in col. values ( ) . iter ( ) . enumerate ( ) {
437+ keys. push ( ( part_id, batch_idx, row_idx as u32 ) ) ;
438+ }
439+ }
440+ keys. sort_unstable_by_key ( |k| k. 0 ) ;
441+
442+ let mut partition_counts = vec ! [ 0u64 ; num_partitions] ;
443+ let mut interleave_indices = Vec :: with_capacity ( total_rows) ;
444+ for ( part_id, batch_idx, row_idx) in & keys {
445+ let pid = * part_id as usize ;
446+ if pid >= num_partitions {
447+ return Err ( Error :: invalid_input ( format ! (
448+ "partition ID {} is out of range [0, {})" ,
449+ pid, num_partitions
450+ ) ) ) ;
451+ }
452+ partition_counts[ pid] += 1 ;
453+ interleave_indices. push ( ( * batch_idx as usize , * row_idx as usize ) ) ;
454+ }
455+ Ok ( ( interleave_indices, partition_counts) )
456+ }
457+
410458#[ async_trait:: async_trait]
411459impl Shuffler for TwoFileShuffler {
412460 async fn shuffle (
413461 & self ,
414462 data : Box < dyn RecordBatchStream + Unpin + ' static > ,
415463 ) -> Result < Box < dyn ShuffleReader > > {
416464 let num_partitions = self . num_partitions ;
417- let full_schema = Arc :: new ( data. schema ( ) . as_ref ( ) . clone ( ) ) ;
418- // No need to write partition ids since we can infer this
465+ // No need to write partition ids since we can infer this from offsets
419466 let schema = data. schema ( ) . without_column ( PART_ID_COLUMN ) ;
420467 let offsets_schema = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
421468 "offset" ,
@@ -424,28 +471,6 @@ impl Shuffler for TwoFileShuffler {
424471 ) ] ) ) ;
425472 let batch_size_bytes = self . batch_size_bytes ;
426473
427- // Extract loss from batch metadata before rechunking (concat_batches drops metadata)
428- let total_loss = Arc :: new ( Mutex :: new ( 0.0f64 ) ) ;
429- let loss_ref = total_loss. clone ( ) ;
430- let loss_stream = data. map ( move |result| {
431- result. inspect ( |batch| {
432- let loss = batch
433- . metadata ( )
434- . get ( LOSS_METADATA_KEY )
435- . and_then ( |s| s. parse :: < f64 > ( ) . ok ( ) )
436- . unwrap_or ( 0.0 ) ;
437- * loss_ref. lock ( ) . unwrap ( ) += loss;
438- } )
439- } ) ;
440-
441- // Rechunk to target batch size
442- let rechunked = rechunk_stream_by_size (
443- loss_stream,
444- full_schema,
445- batch_size_bytes,
446- batch_size_bytes * 2 ,
447- ) ;
448-
449474 // Create data file writer
450475 let data_path = self . output_dir . clone ( ) . join ( "shuffle_data.lance" ) ;
451476 let spill_path = self . output_dir . clone ( ) . join ( "shuffle_data.spill" ) ;
@@ -468,72 +493,63 @@ impl Shuffler for TwoFileShuffler {
468493 ) ?
469494 . with_page_metadata_spill ( self . object_store . clone ( ) , spill_path) ;
470495
471- let num_batches = Arc :: new ( AtomicU64 :: new ( 0 ) ) ;
472- let num_batches_ref = num_batches. clone ( ) ;
496+ let mut num_batches: u64 = 0 ;
473497 let mut partition_counts: Vec < u64 > = vec ! [ 0 ; num_partitions] ;
474498 let mut global_row_count: u64 = 0 ;
475499 let mut rows_processed: u64 = 0 ;
500+ let mut total_loss = 0.0f64 ;
501+ let mut accumulated: Vec < RecordBatch > = Vec :: new ( ) ;
502+ let mut acc_bytes: usize = 0 ;
476503
477- let mut rechunked = std:: pin:: pin!( rechunked) ;
478- while let Some ( batch) = rechunked. next ( ) . await {
479- num_batches_ref. fetch_add ( 1 , std:: sync:: atomic:: Ordering :: Relaxed ) ;
504+ let mut data = std:: pin:: pin!( data) ;
505+ while let Some ( batch) = data. next ( ) . await {
480506 let batch = batch?;
481- let np = num_partitions;
482- let num_rows = batch. num_rows ( ) as u64 ;
483-
484- // Sort by partition ID and compute offsets on CPU
485- let ( sorted_batch, batch_offsets) = spawn_cpu ( move || {
486- let part_ids: & UInt32Array = batch[ PART_ID_COLUMN ] . as_primitive ( ) ;
487- let indices = sort_to_indices ( part_ids, None , None ) ?;
488- let batch = batch. take ( & indices) ?;
489-
490- let part_ids: & UInt32Array = batch[ PART_ID_COLUMN ] . as_primitive ( ) ;
491- let batch = batch. drop_column ( PART_ID_COLUMN ) ?;
492-
493- // Count rows per partition by scanning sorted part IDs
494- let mut partition_counts = vec ! [ 0u64 ; np] ;
495- for i in 0 ..part_ids. len ( ) {
496- let pid = part_ids. value ( i) as usize ;
497- if pid < np {
498- partition_counts[ pid] += 1 ;
499- } else {
500- log:: warn!( "Partition ID {} is out of range [0, {})" , pid, np) ;
501- }
502- }
503-
504- // Build cumulative offsets (end positions) for this batch
505- let mut batch_offsets = Vec :: with_capacity ( np) ;
506- let mut running = 0u64 ;
507- for count in & partition_counts {
508- running += count;
509- batch_offsets. push ( running) ;
507+ total_loss += batch
508+ . metadata ( )
509+ . get ( LOSS_METADATA_KEY )
510+ . and_then ( |s| s. parse :: < f64 > ( ) . ok ( ) )
511+ . unwrap_or ( 0.0 ) ;
512+ acc_bytes += batch. get_array_memory_size ( ) ;
513+ accumulated. push ( batch) ;
514+
515+ if acc_bytes >= batch_size_bytes {
516+ let ( total_rows, counts) = flush_shuffle_batch (
517+ std:: mem:: take ( & mut accumulated) ,
518+ & mut file_writer,
519+ & mut offsets_writer,
520+ offsets_schema. clone ( ) ,
521+ num_partitions,
522+ global_row_count,
523+ )
524+ . await ?;
525+ acc_bytes = 0 ;
526+ for ( p, c) in counts. iter ( ) . enumerate ( ) {
527+ partition_counts[ p] += c;
510528 }
529+ global_row_count += total_rows;
530+ rows_processed += total_rows;
531+ num_batches += 1 ;
532+ self . progress
533+ . stage_progress ( "shuffle" , rows_processed)
534+ . await ?;
535+ }
536+ }
511537
512- Ok :: < ( RecordBatch , Vec < u64 > ) , Error > ( ( batch, batch_offsets) )
513- } )
538+ if !accumulated. is_empty ( ) {
539+ let ( total_rows, counts) = flush_shuffle_batch (
540+ accumulated,
541+ & mut file_writer,
542+ & mut offsets_writer,
543+ offsets_schema,
544+ num_partitions,
545+ global_row_count,
546+ )
514547 . await ?;
515-
516- // Write sorted batch to data file
517- file_writer. write_batch ( & sorted_batch) . await ?;
518-
519- // Record offsets adjusted by global row count
520- let mut adjusted_offsets = Vec :: with_capacity ( batch_offsets. len ( ) ) ;
521- let mut last_offset = 0 ;
522- for ( idx, offset) in batch_offsets. iter ( ) . enumerate ( ) {
523- adjusted_offsets. push ( global_row_count + offset) ;
524- partition_counts[ idx] += offset - last_offset;
525- last_offset = * offset;
548+ for ( p, c) in counts. iter ( ) . enumerate ( ) {
549+ partition_counts[ p] += c;
526550 }
527- global_row_count += sorted_batch. num_rows ( ) as u64 ;
528-
529- // Write offsets to offsets file
530- let offsets_batch = RecordBatch :: try_new (
531- offsets_schema. clone ( ) ,
532- vec ! [ Arc :: new( UInt64Array :: from( adjusted_offsets) ) ] ,
533- ) ?;
534- offsets_writer. write_batch ( & offsets_batch) . await ?;
535-
536- rows_processed += num_rows;
551+ rows_processed += total_rows;
552+ num_batches += 1 ;
537553 self . progress
538554 . stage_progress ( "shuffle" , rows_processed)
539555 . await ?;
@@ -543,22 +559,76 @@ impl Shuffler for TwoFileShuffler {
543559 file_writer. finish ( ) . await ?;
544560 offsets_writer. finish ( ) . await ?;
545561
546- let num_batches = num_batches. load ( std:: sync:: atomic:: Ordering :: Relaxed ) ;
547-
548- let total_loss_val = * total_loss. lock ( ) . unwrap ( ) ;
549-
550562 TwoFileShuffleReader :: try_new (
551563 self . object_store . clone ( ) ,
552564 self . output_dir . clone ( ) ,
553565 num_partitions,
554566 num_batches,
555567 partition_counts,
556- total_loss_val ,
568+ total_loss ,
557569 )
558570 . await
559571 }
560572}
561573
574+ /// Sorts `accumulated` batches by partition ID and writes the result to the data
575+ /// and offsets files.
576+ ///
577+ /// Returns `(total_rows_written, per_partition_row_counts)`.
578+ async fn flush_shuffle_batch (
579+ accumulated : Vec < RecordBatch > ,
580+ file_writer : & mut FileWriter ,
581+ offsets_writer : & mut FileWriter ,
582+ offsets_schema : Arc < Schema > ,
583+ num_partitions : usize ,
584+ global_row_count : u64 ,
585+ ) -> Result < ( u64 , Vec < u64 > ) > {
586+ let total_rows: u64 = accumulated. iter ( ) . map ( |b| b. num_rows ( ) as u64 ) . sum ( ) ;
587+
588+ // Clone part-id columns into the CPU task (cheap: Arc ref bump, not data copy).
589+ let part_id_cols: Vec < UInt32Array > = accumulated
590+ . iter ( )
591+ . map ( |b| {
592+ let col: & UInt32Array = b[ PART_ID_COLUMN ] . as_primitive ( ) ;
593+ col. clone ( )
594+ } )
595+ . collect ( ) ;
596+
597+ let np = num_partitions;
598+ let ( interleave_indices, batch_partition_counts) =
599+ spawn_cpu ( move || sort_to_interleave_indices ( & part_id_cols. iter ( ) . collect :: < Vec < _ > > ( ) , np) )
600+ . await ?;
601+
602+ // Drop part-id column from source batches before interleaving.
603+ let source_batches: Vec < RecordBatch > = accumulated
604+ . into_iter ( )
605+ . map ( |b| b. drop_column ( PART_ID_COLUMN ) . map_err ( Error :: from) )
606+ . collect :: < Result < _ > > ( ) ?;
607+
608+ // Stream sorted output to the data file in fixed-size chunks so the peak
609+ // memory for the interleave output stays small relative to the source data.
610+ for chunk in interleave_indices. chunks ( SHUFFLE_WRITE_CHUNK_ROWS ) {
611+ let out = interleave_batches ( & source_batches, chunk) ?;
612+ file_writer. write_batch ( & out) . await ?;
613+ }
614+
615+ // Compute cumulative end-row offsets (adjusted by global position) and write
616+ // one offsets batch for this flush group.
617+ let mut adjusted_offsets = Vec :: with_capacity ( num_partitions) ;
618+ let mut running = 0u64 ;
619+ for count in & batch_partition_counts {
620+ running += count;
621+ adjusted_offsets. push ( global_row_count + running) ;
622+ }
623+ let offsets_batch = RecordBatch :: try_new (
624+ offsets_schema,
625+ vec ! [ Arc :: new( UInt64Array :: from( adjusted_offsets) ) ] ,
626+ ) ?;
627+ offsets_writer. write_batch ( & offsets_batch) . await ?;
628+
629+ Ok ( ( total_rows, batch_partition_counts) )
630+ }
631+
562632pub struct TwoFileShuffleReader {
563633 _scheduler : Arc < ScanScheduler > ,
564634 file_reader : FileReader ,
@@ -934,4 +1004,65 @@ mod tests {
9341004
9351005 assert ! ( ( reader. total_loss( ) . unwrap( ) - 6.0 ) . abs( ) < 1e-10 ) ;
9361006 }
1007+
1008+ #[ tokio:: test]
1009+ async fn test_two_file_shuffler_multi_batch_single_flush ( ) {
1010+ // All three batches fit within the default batch_size_bytes, so they
1011+ // accumulate and are interleaved in a single flush group. This exercises
1012+ // the cross-batch interleave path.
1013+ let dir = TempStrDir :: default ( ) ;
1014+ let output_dir = Path :: from ( dir. as_ref ( ) ) ;
1015+ let num_partitions = 3 ;
1016+
1017+ let batch1 = make_batch ( & [ 0 , 1 , 2 ] , & [ 10 , 20 , 30 ] , None ) ;
1018+ let batch2 = make_batch ( & [ 2 , 0 , 1 ] , & [ 40 , 50 , 60 ] , None ) ;
1019+ let batch3 = make_batch ( & [ 1 , 2 , 0 ] , & [ 70 , 80 , 90 ] , None ) ;
1020+
1021+ // Large batch_size_bytes so all three batches flush together.
1022+ let shuffler =
1023+ TwoFileShuffler :: new ( output_dir, num_partitions) . with_batch_size_bytes ( 1024 * 1024 ) ;
1024+ let stream = batches_to_stream ( vec ! [ batch1, batch2, batch3] ) ;
1025+ let reader = shuffler. shuffle ( stream) . await . unwrap ( ) ;
1026+
1027+ assert_eq ! ( reader. partition_size( 0 ) . unwrap( ) , 3 ) ;
1028+ assert_eq ! ( reader. partition_size( 1 ) . unwrap( ) , 3 ) ;
1029+ assert_eq ! ( reader. partition_size( 2 ) . unwrap( ) , 3 ) ;
1030+
1031+ let p0 = collect_partition ( reader. as_ref ( ) , 0 ) . await . unwrap ( ) ;
1032+ let vals: & Int32Array = p0. column_by_name ( "val" ) . unwrap ( ) . as_primitive ( ) ;
1033+ let mut v: Vec < i32 > = vals. iter ( ) . map ( |x| x. unwrap ( ) ) . collect ( ) ;
1034+ v. sort ( ) ;
1035+ assert_eq ! ( v, vec![ 10 , 50 , 90 ] ) ;
1036+
1037+ let p1 = collect_partition ( reader. as_ref ( ) , 1 ) . await . unwrap ( ) ;
1038+ let vals: & Int32Array = p1. column_by_name ( "val" ) . unwrap ( ) . as_primitive ( ) ;
1039+ let mut v: Vec < i32 > = vals. iter ( ) . map ( |x| x. unwrap ( ) ) . collect ( ) ;
1040+ v. sort ( ) ;
1041+ assert_eq ! ( v, vec![ 20 , 60 , 70 ] ) ;
1042+
1043+ let p2 = collect_partition ( reader. as_ref ( ) , 2 ) . await . unwrap ( ) ;
1044+ let vals: & Int32Array = p2. column_by_name ( "val" ) . unwrap ( ) . as_primitive ( ) ;
1045+ let mut v: Vec < i32 > = vals. iter ( ) . map ( |x| x. unwrap ( ) ) . collect ( ) ;
1046+ v. sort ( ) ;
1047+ assert_eq ! ( v, vec![ 30 , 40 , 80 ] ) ;
1048+ }
1049+
1050+ #[ tokio:: test]
1051+ async fn test_two_file_shuffler_out_of_range_partition_id ( ) {
1052+ let dir = TempStrDir :: default ( ) ;
1053+ let output_dir = Path :: from ( dir. as_ref ( ) ) ;
1054+
1055+ // Row with partition ID 5 is out of range for num_partitions=3.
1056+ let batch = make_batch ( & [ 0 , 5 , 1 ] , & [ 10 , 20 , 30 ] , None ) ;
1057+
1058+ let shuffler = TwoFileShuffler :: new ( output_dir, 3 ) ;
1059+ let stream = batches_to_stream ( vec ! [ batch] ) ;
1060+ let Err ( err) = shuffler. shuffle ( stream) . await else {
1061+ panic ! ( "expected an error for out-of-range partition ID" ) ;
1062+ } ;
1063+ assert ! (
1064+ err. to_string( ) . contains( "partition ID 5 is out of range" ) ,
1065+ "unexpected error: {err}"
1066+ ) ;
1067+ }
9371068}
0 commit comments