Skip to content

Commit 2148ad7

Browse files
committed
Address review: pass limit to sort_to_indices, drop Vec copies, fix spill asymmetry
Address PR #2406 review feedback from lyne7-sc: - sort_batch_in_place: pass `limit` to `sort_to_indices` (Arrow uses partial sort for Top-N; full sort when limit covers the batch) instead of sorting fully then .take(limit). - sort_batch_in_place: hand the returned UInt32Array directly to take_batch instead of copying through an intermediate Vec<u32>. - fast path: append key rows sequentially (rows are already in final order) instead of allocating an identity Vec<u32>. - benchmark: the external/spill group compared Auron spilling (2 MB MemManager limit) against DataFusion sorting fully in memory (DF does not honor the limit) — not comparable. Report Auron spill cost on its own; the in-mem group remains the fair Auron-vs-DataFusion comparison. - benchmark: add a Top-N (limit=10000) case. Top-N turns the tables: auron beats DataFusion ~3.6-4.5x, validating the partial-sort path.
1 parent 98319dc commit 2148ad7

1 file changed

Lines changed: 95 additions & 42 deletions

File tree

native-engine/datafusion-ext-plans/src/sort_exec.rs

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -661,37 +661,54 @@ impl ExternalSorter {
661661

662662
// NOTE: we use stable merge sort for longer keys due to less comparison
663663
let (keys, batch) = self.prune_sort_keys_from_batch.prune(batch)?;
664+
// On the fast path the batch is already sorted (and limited) above, so
665+
// the key rows from `prune` are in final order — we can append them
666+
// sequentially and skip allocating an identity `Vec<u32>`. The non-fast
667+
// path needs an actual sorted-index vector for both key appending and
668+
// the `take_batch` below.
664669
let sorted_indices = if is_fast_path {
665-
// the batch was already sorted (and limited) above, so the key rows
666-
// produced by `prune` are in final order — use identity indices.
667-
(0..keys.num_rows() as u32).collect::<Vec<_>>()
670+
None
668671
} else if keys.size() / keys.num_rows() <= 8 {
669-
(0..keys.num_rows() as u32)
670-
.sorted_unstable_by_key(|&row_idx| unsafe {
671-
// safety: bypass boundary and lifetime checking
672-
std::mem::transmute::<_, &'static [u8]>(
673-
keys.row_unchecked(row_idx as usize).as_ref(),
674-
)
675-
})
676-
.take(self.limit)
677-
.collect::<Vec<_>>()
672+
Some(
673+
(0..keys.num_rows() as u32)
674+
.sorted_unstable_by_key(|&row_idx| unsafe {
675+
// safety: bypass boundary and lifetime checking
676+
std::mem::transmute::<_, &'static [u8]>(
677+
keys.row_unchecked(row_idx as usize).as_ref(),
678+
)
679+
})
680+
.take(self.limit)
681+
.collect::<Vec<_>>(),
682+
)
678683
} else {
679-
(0..keys.num_rows() as u32)
680-
.sorted_by_key(|&row_idx| unsafe {
681-
// safety: bypass boundary and lifetime checking
682-
std::mem::transmute::<_, &'static [u8]>(
683-
keys.row_unchecked(row_idx as usize).as_ref(),
684-
)
685-
})
686-
.take(self.limit)
687-
.collect::<Vec<_>>()
684+
Some(
685+
(0..keys.num_rows() as u32)
686+
.sorted_by_key(|&row_idx| unsafe {
687+
// safety: bypass boundary and lifetime checking
688+
std::mem::transmute::<_, &'static [u8]>(
689+
keys.row_unchecked(row_idx as usize).as_ref(),
690+
)
691+
})
692+
.take(self.limit)
693+
.collect::<Vec<_>>(),
694+
)
688695
};
689696

690697
// build keys
691698
let mut key_collector = InMemRowsKeyCollector::default();
692699
key_collector.reserve(keys.num_rows(), keys.size());
693-
for &row_idx in &sorted_indices {
694-
key_collector.add_key(keys.row(row_idx as usize).as_ref());
700+
match sorted_indices.as_deref() {
701+
Some(indices) => {
702+
for &row_idx in indices {
703+
key_collector.add_key(keys.row(row_idx as usize).as_ref());
704+
}
705+
}
706+
// fast path: rows are already in final order, append sequentially.
707+
None => {
708+
for row_idx in 0..keys.num_rows() as u32 {
709+
key_collector.add_key(keys.row(row_idx as usize).as_ref());
710+
}
711+
}
695712
}
696713
key_collector.freeze();
697714

@@ -702,7 +719,7 @@ impl ExternalSorter {
702719
// avoid an extra `take_batch` copy.
703720
batch
704721
} else {
705-
take_batch(batch, sorted_indices)?
722+
take_batch(batch, sorted_indices.expect("non-fast-path indices"))?
706723
}
707724
} else {
708725
create_zero_column_batch(batch.num_rows())
@@ -1262,13 +1279,13 @@ impl PruneSortKeysFromBatch {
12621279
descending: key.sort_expr.options.descending,
12631280
nulls_first: key.sort_expr.options.nulls_first,
12641281
};
1265-
let indices = sort_to_indices(&array, Some(options), None)?;
1266-
let indices = indices
1267-
.values()
1268-
.iter()
1269-
.copied()
1270-
.take(limit)
1271-
.collect::<Vec<_>>();
1282+
// Pass `limit` straight to `sort_to_indices` so Arrow uses partial
1283+
// sorting (it only emits the top-`limit` indices); when `limit`
1284+
// covers the whole batch Arrow internally falls back to a full sort,
1285+
// so this is never worse than `None`. The returned `UInt32Array` is
1286+
// handed directly to `take_batch` (which accepts a `PrimitiveArray`)
1287+
// instead of being copied through an intermediate `Vec<u32>`.
1288+
let indices = sort_to_indices(&array, Some(options), Some(limit))?;
12721289
take_batch(batch, indices)
12731290
}
12741291

@@ -1718,6 +1735,18 @@ mod fuzztest {
17181735
}
17191736

17201737
async fn bench_sort_repeat(repeat: usize, mem: usize, use_auron: bool) -> Result<(usize, f64)> {
1738+
bench_sort_repeat_with_limit(repeat, mem, use_auron, None).await
1739+
}
1740+
1741+
/// Variant of `bench_sort_repeat` that can pass a Top-N `limit` to the
1742+
/// Auron `SortExec`, so the `sort_to_indices(limit)` partial-sort path
1743+
/// can be exercised (vs. a full sort when `limit` is `None`).
1744+
async fn bench_sort_repeat_with_limit(
1745+
repeat: usize,
1746+
mem: usize,
1747+
use_auron: bool,
1748+
limit: Option<usize>,
1749+
) -> Result<(usize, f64)> {
17211750
MemManager::init(mem);
17221751
let session_ctx =
17231752
SessionContext::new_with_config(SessionConfig::new().with_batch_size(10000));
@@ -1736,12 +1765,15 @@ mod fuzztest {
17361765
None,
17371766
)?);
17381767
let sort: Arc<dyn ExecutionPlan> = if use_auron {
1739-
Arc::new(SortExec::new(input, sort_exprs.clone(), None, 0))
1768+
Arc::new(SortExec::new(input, sort_exprs.clone(), limit, 0))
17401769
} else {
1741-
Arc::new(datafusion::physical_plan::sorts::sort::SortExec::new(
1742-
LexOrdering::new(sort_exprs.iter().cloned()).expect("invalid sort exprs"),
1743-
input,
1744-
))
1770+
Arc::new(
1771+
datafusion::physical_plan::sorts::sort::SortExec::new(
1772+
LexOrdering::new(sort_exprs.iter().cloned()).expect("invalid sort exprs"),
1773+
input,
1774+
)
1775+
.with_fetch(limit),
1776+
)
17451777
};
17461778
let start = Instant::now();
17471779
let output = datafusion::physical_plan::collect(sort.clone(), task_ctx.clone()).await?;
@@ -1767,18 +1799,39 @@ mod fuzztest {
17671799
Ok(())
17681800
}
17691801

1802+
#[tokio::test]
1803+
#[ignore = "manual benchmark"]
1804+
async fn bench_native_sort_topn_limit_in_mem() -> Result<()> {
1805+
// Top-N scenario: limit=10000 << num_rows=1M, so the primitive fast
1806+
// path's `sort_to_indices(Some(limit))` partial sort should be cheaper
1807+
// than a full sort. Compares Auron vs DataFusion, both with limit.
1808+
let limit = 10_000usize;
1809+
for repeat in [1usize, 100] {
1810+
let (_, elapsed_auron) =
1811+
bench_sort_repeat_with_limit(repeat, 1_000_000_000, true, Some(limit)).await?;
1812+
let (_, elapsed_df) =
1813+
bench_sort_repeat_with_limit(repeat, 1_000_000_000, false, Some(limit)).await?;
1814+
eprintln!(
1815+
"[sort top-N limit={limit}] repeat={repeat:>3}, auron={elapsed_auron:.3}s, datafusion={elapsed_df:.3}s, speedup(df/auron)={:.2}x",
1816+
elapsed_auron / elapsed_df
1817+
);
1818+
}
1819+
Ok(())
1820+
}
1821+
17701822
#[tokio::test]
17711823
#[ignore = "manual benchmark"]
17721824
async fn bench_native_sort_varying_repeat_external() -> Result<()> {
1825+
// NOTE: this measures Auron's external (spilling) sort in isolation.
1826+
// DataFusion's native SortExec has no spilling path, so under the same
1827+
// 2 MB `MemManager` limit Auron spills while DataFusion keeps sorting
1828+
// fully in memory — the two are not comparable. We therefore report
1829+
// Auron's spill cost on its own; use the in-mem group for a fair
1830+
// Auron-vs-DataFusion comparison (both stay in memory at 1 GB).
17731831
for repeat in [1usize, 4, 20, 100] {
17741832
let (_, elapsed_auron) = bench_sort_repeat(repeat, 2_000_000, true).await?;
1775-
let (_, elapsed_df) = bench_sort_repeat(repeat, 2_000_000, false).await?;
17761833
eprintln!(
1777-
"[sort external] repeat={:>3}, auron={:.3}s, datafusion={:.3}s, speedup(df/auron)={:.2}x",
1778-
repeat,
1779-
elapsed_auron,
1780-
elapsed_df,
1781-
elapsed_auron / elapsed_df
1834+
"[sort external/spill, auron-only] repeat={repeat:>3}, auron={elapsed_auron:.3}s"
17821835
);
17831836
}
17841837
Ok(())

0 commit comments

Comments
 (0)