Skip to content

Commit f24ec20

Browse files
authored
Merge branch 'main' into ragnorc/two-phase-index-segments
2 parents 5cfc088 + 5f7f6c9 commit f24ec20

3 files changed

Lines changed: 354 additions & 94 deletions

File tree

rust/lance-index/src/vector/v3/shuffler.rs

Lines changed: 221 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,15 @@
55
//! the corresponding IVF partitions.
66
77
use std::ops::Range;
8-
use std::sync::atomic::AtomicU64;
9-
use std::sync::{Arc, Mutex};
8+
use std::sync::Arc;
109

1110
use arrow::compute::concat_batches;
1211
use arrow::datatypes::UInt64Type;
1312
use arrow::{array::AsArray, compute::sort_to_indices};
1413
use arrow_array::{RecordBatch, UInt32Array, UInt64Array};
1514
use arrow_schema::{DataType, Field, Schema};
1615
use 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};
1917
use lance_core::{
2018
Error, Result,
2119
cache::LanceCache,
@@ -341,6 +339,11 @@ pub fn create_ivf_shuffler(
341339

342340
const 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]
411459
impl 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+
562632
pub 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

Comments
 (0)