Skip to content

Commit 11a82f1

Browse files
committed
Split oversized batches so every frame fits its ring.
Operators emit offset slices of their accumulated state, and arrow-ipc writes a sliced variable-length array's whole values buffer, so one frame balloons to the state's size no matter the batch size. Compacting and halving on the oversized path bounds frames by the ring instead of asking embedders to size rings for their widest state.
1 parent db9f3bf commit 11a82f1

5 files changed

Lines changed: 380 additions & 14 deletions

File tree

src/shm/in_process.rs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ use std::alloc::Layout;
4444
use std::ffi::c_void;
4545
use std::sync::{Arc, Mutex};
4646

47-
use datafusion::arrow::array::{Int32Array, RecordBatch};
47+
use datafusion::arrow::array::{Int32Array, RecordBatch, StringArray};
4848
use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
4949
use datafusion::catalog::memory::DataSourceExec;
5050
use datafusion::common::runtime::JoinSet;
@@ -594,6 +594,42 @@ mod tests {
594594
ctx.register_table("t", Arc::new(table)).unwrap();
595595
}
596596

597+
fn wide_table_schema() -> SchemaRef {
598+
Arc::new(Schema::new(vec![
599+
Field::new("val", DataType::Int32, false),
600+
Field::new("s", DataType::Utf8, false),
601+
]))
602+
}
603+
604+
/// High-cardinality groups over ~1 KiB strings: each worker's partial-aggregate state runs
605+
/// to hundreds of KiB, so its emit slices balloon past a tiny ring through arrow-ipc.
606+
fn wide_table_partitions() -> Vec<Vec<RecordBatch>> {
607+
let schema = wide_table_schema();
608+
const ROWS_PER_PART: i32 = 700;
609+
(0..N_WORKERS as i32)
610+
.map(|p| {
611+
let vals =
612+
Int32Array::from_iter_values((0..ROWS_PER_PART).map(|i| p * ROWS_PER_PART + i));
613+
let strings = StringArray::from_iter_values(
614+
(0..ROWS_PER_PART).map(|i| format!("{p:02}-{i:06}-{}", "x".repeat(1024))),
615+
);
616+
let batch =
617+
RecordBatch::try_new(schema.clone(), vec![Arc::new(vals), Arc::new(strings)])
618+
.unwrap();
619+
vec![batch]
620+
})
621+
.collect()
622+
}
623+
624+
/// Swap the session's `t` for the wide-string variant. `build_session` registers the
625+
/// standard table; the swap only matters on the leader (fragments travel as shared plan
626+
/// Arcs, so worker sessions never re-resolve the table).
627+
fn register_wide_table(ctx: &SessionContext) {
628+
let _ = ctx.deregister_table("t").unwrap();
629+
let table = MemTable::try_new(wide_table_schema(), wide_table_partitions()).unwrap();
630+
ctx.register_table("t", Arc::new(table)).unwrap();
631+
}
632+
597633
/// A worker is a consumer too (it reads shuffle inputs), so when one of its input streams drops
598634
/// early it has to cancel that stream's producer, not just the leader. This checks the wiring
599635
/// end-to-end: a worker proc's `cancel_stream` reaches the producing proc's inbox through the
@@ -846,15 +882,19 @@ mod tests {
846882
}
847883

848884
fn bootstrap_mesh(n_procs: u32) -> Bootstrap {
849-
let region_total = dsm_region_bytes(n_procs, IN_PROCESS_QUEUE_BYTES, 0).unwrap();
885+
bootstrap_mesh_with_queue(n_procs, IN_PROCESS_QUEUE_BYTES)
886+
}
887+
888+
fn bootstrap_mesh_with_queue(n_procs: u32, queue_bytes: usize) -> Bootstrap {
889+
let region_total = dsm_region_bytes(n_procs, queue_bytes, 0).unwrap();
850890
let region = HeapRegion::new(region_total);
851891
let base = SharedBase(region.base());
852892
let wakeup: Arc<dyn Wakeup> = Arc::new(NoopWakeup);
853893
let leader_mesh = unsafe {
854894
leader_setup(
855895
base.0,
856896
n_procs,
857-
IN_PROCESS_QUEUE_BYTES,
897+
queue_bytes,
858898
&[],
859899
Arc::clone(&wakeup),
860900
receiver_token(0),
@@ -952,6 +992,69 @@ mod tests {
952992
);
953993
}
954994

995+
/// An aggregate emits offset slices of its accumulated state, and arrow-ipc writes a
996+
/// sliced variable-length array's whole values buffer, so raw frames balloon to the
997+
/// state's size regardless of batch size. With rings far smaller than that state, the
998+
/// send path's compact-and-split has to carry the query; the result must still match the
999+
/// serial reference exactly.
1000+
#[tokio::test(flavor = "current_thread")]
1001+
async fn oversized_aggregate_frames_split_across_tiny_rings() {
1002+
let query = "SELECT val, max(s) AS m FROM t GROUP BY val ORDER BY val";
1003+
1004+
let serial_ctx = SessionContext::new();
1005+
register_wide_table(&serial_ctx);
1006+
let expected = serial_ctx
1007+
.sql(query)
1008+
.await
1009+
.unwrap()
1010+
.collect()
1011+
.await
1012+
.unwrap();
1013+
1014+
// 64 KiB rings against ~700 KiB of per-worker aggregate state.
1015+
let boot = bootstrap_mesh_with_queue(N_WORKERS + 1, 64 * 1024);
1016+
let captured = new_captured_plans();
1017+
let leader_ctx = build_session(Arc::clone(&boot.leader_mesh), Some(Arc::clone(&captured)));
1018+
register_wide_table(&leader_ctx);
1019+
let physical = leader_ctx
1020+
.sql(query)
1021+
.await
1022+
.unwrap()
1023+
.create_physical_plan()
1024+
.await
1025+
.unwrap();
1026+
let entries = collect_dispatched_stages(&physical, N_WORKERS);
1027+
1028+
let mut workers = JoinSet::new();
1029+
for (proc_idx, mesh, outbound) in boot.workers {
1030+
let fragments = fragments_for_proc(&entries, proc_idx, N_WORKERS);
1031+
let session = build_session(Arc::clone(&mesh), None);
1032+
workers.spawn(run_worker_proc(
1033+
fragments,
1034+
outbound,
1035+
mesh,
1036+
session,
1037+
N_WORKERS,
1038+
Arc::clone(&captured),
1039+
));
1040+
}
1041+
1042+
let leader_task_ctx = leader_ctx.task_ctx();
1043+
let stream = physical.execute(0, leader_task_ctx).unwrap();
1044+
let got: Vec<RecordBatch> = stream.try_collect().await.unwrap();
1045+
1046+
while let Some(res) = workers.join_next().await {
1047+
res.expect("worker task panicked").expect("worker proc");
1048+
}
1049+
1050+
use datafusion::arrow::util::pretty::pretty_format_batches;
1051+
assert_eq!(
1052+
pretty_format_batches(&expected).unwrap().to_string(),
1053+
pretty_format_batches(&got).unwrap().to_string(),
1054+
"distributed wide aggregate != serial"
1055+
);
1056+
}
1057+
9551058
/// A producer that attaches and then goes away without sending its EOFs must fail the
9561059
/// gather, not hang it: the drain fails the channels the dead receiver fed once the ring
9571060
/// detaches.

src/shm/mesh.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ impl BatchChannelSender for DsmInboxSender {
115115
fn send_lock(&self) -> &tokio::sync::Mutex<()> {
116116
&self.send_lock
117117
}
118+
119+
fn max_frame_bytes(&self) -> Option<usize> {
120+
Some(self.inner.max_frame_bytes())
121+
}
118122
}
119123

120124
/// DSM MPSC ring as a `BatchChannelReceiver`. The scratch `Vec<u8>` lives behind a `Mutex` so a

src/shm/mpsc_ring.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,15 @@ impl DsmMpscSender {
663663
}
664664
}
665665

666+
/// Largest frame `try_send` can accept: every slot's payload capacity combined. Senders
667+
/// consult this before encoding, so an oversized batch can be split to fit instead of
668+
/// erroring with `MessageTooLarge`.
669+
pub(super) fn max_frame_bytes(&self) -> usize {
670+
let header = unsafe { self.ring.as_ref() };
671+
(header.slot_capacity as usize).saturating_sub(SLOT_HEADER_BYTES)
672+
* header.ring_size as usize
673+
}
674+
666675
/// Wake the registered consumer, if any. Reads the token the consumer stored via
667676
/// [`DsmMpscReceiver::set_receiver`] and hands it to the injected [`Wakeup`]; skips when no
668677
/// consumer is registered ([`NO_RECEIVER_TOKEN`]).

src/shm/self_hosted.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,14 +1175,12 @@ mod tests {
11751175
}
11761176

11771177
async fn run(ctx: &SessionContext) -> Result<(String, Vec<String>)> {
1178-
// Shaped so every ring frame stays bounded by `shuffle_batch_size`. The strings cross
1179-
// the shuffle inside `max`'s partial state, which the repartition rebuilds with `take`
1180-
// into fresh per-batch arrays; the projection then reduces them to a length before the
1181-
// gather. Shipping `s` itself out of a sort or an aggregate would not work: those emit
1182-
// offset slices of their accumulated state, a sliced variable-length array ships its
1183-
// whole values buffer through arrow-ipc, and a single frame balloons to the size of the
1184-
// partition's state no matter the batch size.
1185-
let query = "SELECT val, length(max(s)) AS l FROM t GROUP BY val";
1178+
// Ships `s` itself out of the aggregate on purpose: the emit is an offset slice of the
1179+
// partition's accumulated state, and arrow-ipc writes a sliced variable-length array's
1180+
// whole values buffer, so the raw frame balloons to the state's size no matter the
1181+
// batch size. The send path's compact-and-split is what keeps every frame within these
1182+
// tiny rings; this query is its end-to-end exercise.
1183+
let query = "SELECT val, max(s) AS m FROM t GROUP BY val";
11861184
let plan = ctx.sql(query).await?.create_physical_plan().await?;
11871185
let display = display_plan_ascii(plan.as_ref(), false);
11881186
let batches: Vec<_> = execute_stream(plan, ctx.task_ctx())?.try_collect().await?;

0 commit comments

Comments
 (0)