Skip to content

Commit 3a71981

Browse files
Refactor network exchange assignments
1 parent 68bb8e1 commit 3a71981

11 files changed

Lines changed: 743 additions & 110 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
//! Assignment rules for one network exchange boundary.
2+
//!
3+
//! Boundary planning decides where the boundary belongs and how many tasks run on each side.
4+
//! Assignment then decides which producer task/partition each consumer-local output slot should
5+
//! read. This module only owns the read-assignment decision. Static planning can build it in
6+
//! `prepare_network_boundaries`, while adaptive planning can build the same assignment later after
7+
//! it has selected the consumer task count.
8+
9+
use datafusion::common::{Result, plan_err};
10+
use std::ops::Range;
11+
use std::sync::Arc;
12+
13+
/// Upstream read target for one consumer-local output partition.
14+
#[derive(Debug, Clone, PartialEq)]
15+
pub(crate) enum SlotReadPlan {
16+
/// Read the same partition from each producer task.
17+
Fanout {
18+
producer_tasks: Range<usize>,
19+
producer_partition: usize,
20+
},
21+
/// Read one partition from one producer task.
22+
Single {
23+
producer_task: usize,
24+
producer_partition: usize,
25+
},
26+
}
27+
28+
/// Every consumer reads its assigned logical partition range from every producer task.
29+
#[derive(Debug, Clone, PartialEq)]
30+
pub(crate) struct ShuffleExchangeAssignment {
31+
producer_task_count: usize,
32+
consumer_task_count: usize,
33+
partitions_per_consumer: usize,
34+
}
35+
36+
impl ShuffleExchangeAssignment {
37+
fn resolve_slot(
38+
&self,
39+
consumer_task_idx: usize,
40+
local_partition_idx: usize,
41+
) -> Option<SlotReadPlan> {
42+
if consumer_task_idx >= self.consumer_task_count
43+
|| local_partition_idx >= self.partitions_per_consumer
44+
{
45+
return None;
46+
}
47+
48+
Some(SlotReadPlan::Fanout {
49+
producer_tasks: 0..self.producer_task_count,
50+
producer_partition: consumer_task_idx * self.partitions_per_consumer
51+
+ local_partition_idx,
52+
})
53+
}
54+
}
55+
56+
/// Consumers divide producer tasks into contiguous groups and pad uneven groups with empty slots.
57+
#[derive(Debug, Clone, PartialEq)]
58+
pub(crate) struct CoalesceExchangeAssignment {
59+
producer_task_count: usize,
60+
consumer_task_count: usize,
61+
partitions_per_producer_task: usize,
62+
producer_task_ranges: Vec<Range<usize>>,
63+
}
64+
65+
impl CoalesceExchangeAssignment {
66+
/// Maximum number of producer tasks assigned to any one consumer.
67+
fn max_input_task_count_per_consumer(&self) -> usize {
68+
self.producer_task_ranges
69+
.iter()
70+
.map(|range| range.len())
71+
.max()
72+
.unwrap_or(0)
73+
}
74+
75+
/// Output partitions needed by every consumer, including padded empty slots.
76+
fn max_partition_count_per_consumer(&self) -> usize {
77+
self.max_input_task_count_per_consumer() * self.partitions_per_producer_task
78+
}
79+
80+
fn resolve_slot(
81+
&self,
82+
consumer_task_idx: usize,
83+
local_partition_idx: usize,
84+
) -> Option<SlotReadPlan> {
85+
let producer_task_range = self.producer_task_ranges.get(consumer_task_idx)?;
86+
let producer_task_offset = local_partition_idx / self.partitions_per_producer_task;
87+
let producer_partition = local_partition_idx % self.partitions_per_producer_task;
88+
let producer_task = producer_task_range.clone().nth(producer_task_offset)?;
89+
90+
Some(SlotReadPlan::Single {
91+
producer_task,
92+
producer_partition,
93+
})
94+
}
95+
}
96+
97+
/// Broadcast uses the same fanout shape as shuffle, but over broadcast-expanded partitions.
98+
#[derive(Debug, Clone, PartialEq)]
99+
pub(crate) struct BroadcastExchangeAssignment {
100+
producer_task_count: usize,
101+
consumer_task_count: usize,
102+
partitions_per_consumer: usize,
103+
}
104+
105+
impl BroadcastExchangeAssignment {
106+
fn resolve_slot(
107+
&self,
108+
consumer_task_idx: usize,
109+
local_partition_idx: usize,
110+
) -> Option<SlotReadPlan> {
111+
if consumer_task_idx >= self.consumer_task_count
112+
|| local_partition_idx >= self.partitions_per_consumer
113+
{
114+
return None;
115+
}
116+
117+
Some(SlotReadPlan::Fanout {
118+
producer_tasks: 0..self.producer_task_count,
119+
producer_partition: consumer_task_idx * self.partitions_per_consumer
120+
+ local_partition_idx,
121+
})
122+
}
123+
}
124+
125+
/// Concrete read assignment for one prepared network boundary.
126+
#[derive(Debug, Clone, PartialEq)]
127+
pub(crate) enum ExchangeAssignment {
128+
Shuffle(ShuffleExchangeAssignment),
129+
Coalesce(CoalesceExchangeAssignment),
130+
Broadcast(BroadcastExchangeAssignment),
131+
}
132+
133+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134+
pub(crate) enum ExchangeAssignmentKind {
135+
Shuffle,
136+
Coalesce,
137+
Broadcast,
138+
}
139+
140+
impl ExchangeAssignment {
141+
pub(crate) fn try_shuffle(
142+
producer_task_count: usize,
143+
consumer_task_count: usize,
144+
partitions_per_consumer: usize,
145+
) -> Result<Arc<Self>> {
146+
if producer_task_count == 0 {
147+
return plan_err!("shuffle exchange requires producer_task_count > 0");
148+
}
149+
if consumer_task_count == 0 {
150+
return plan_err!("shuffle exchange requires consumer_task_count > 0");
151+
}
152+
if partitions_per_consumer == 0 {
153+
return plan_err!("shuffle exchange requires partitions_per_consumer > 0");
154+
}
155+
156+
Ok(Arc::new(Self::Shuffle(ShuffleExchangeAssignment {
157+
producer_task_count,
158+
consumer_task_count,
159+
partitions_per_consumer,
160+
})))
161+
}
162+
163+
pub(crate) fn try_coalesce(
164+
producer_task_count: usize,
165+
consumer_task_count: usize,
166+
partitions_per_producer_task: usize,
167+
) -> Result<Arc<Self>> {
168+
if consumer_task_count == 0 {
169+
return plan_err!("coalesce exchange requires consumer_task_count > 0");
170+
}
171+
if partitions_per_producer_task == 0 {
172+
return plan_err!("coalesce exchange requires partitions_per_producer_task > 0");
173+
}
174+
175+
Ok(Arc::new(Self::Coalesce(CoalesceExchangeAssignment {
176+
producer_task_count,
177+
consumer_task_count,
178+
partitions_per_producer_task,
179+
producer_task_ranges: split_ranges(producer_task_count, consumer_task_count),
180+
})))
181+
}
182+
183+
pub(crate) fn try_broadcast(
184+
producer_task_count: usize,
185+
consumer_task_count: usize,
186+
partitions_per_consumer: usize,
187+
) -> Result<Arc<Self>> {
188+
if producer_task_count == 0 {
189+
return plan_err!("broadcast exchange requires producer_task_count > 0");
190+
}
191+
if consumer_task_count == 0 {
192+
return plan_err!("broadcast exchange requires consumer_task_count > 0");
193+
}
194+
if partitions_per_consumer == 0 {
195+
return plan_err!("broadcast exchange requires partitions_per_consumer > 0");
196+
}
197+
198+
Ok(Arc::new(Self::Broadcast(BroadcastExchangeAssignment {
199+
producer_task_count,
200+
consumer_task_count,
201+
partitions_per_consumer,
202+
})))
203+
}
204+
205+
pub(crate) fn producer_task_count(&self) -> usize {
206+
match self {
207+
Self::Shuffle(assignment) => assignment.producer_task_count,
208+
Self::Coalesce(assignment) => assignment.producer_task_count,
209+
Self::Broadcast(assignment) => assignment.producer_task_count,
210+
}
211+
}
212+
213+
pub(crate) fn kind(&self) -> ExchangeAssignmentKind {
214+
match self {
215+
Self::Shuffle(_) => ExchangeAssignmentKind::Shuffle,
216+
Self::Coalesce(_) => ExchangeAssignmentKind::Coalesce,
217+
Self::Broadcast(_) => ExchangeAssignmentKind::Broadcast,
218+
}
219+
}
220+
221+
pub(crate) fn consumer_task_count(&self) -> usize {
222+
match self {
223+
Self::Shuffle(assignment) => assignment.consumer_task_count,
224+
Self::Coalesce(assignment) => assignment.consumer_task_count,
225+
Self::Broadcast(assignment) => assignment.consumer_task_count,
226+
}
227+
}
228+
229+
pub(crate) fn max_partition_count_per_consumer(&self) -> usize {
230+
match self {
231+
Self::Shuffle(assignment) => assignment.partitions_per_consumer,
232+
Self::Coalesce(assignment) => assignment.max_partition_count_per_consumer(),
233+
Self::Broadcast(assignment) => assignment.partitions_per_consumer,
234+
}
235+
}
236+
237+
pub(crate) fn partitions_per_producer_task(&self) -> usize {
238+
match self {
239+
Self::Shuffle(assignment) => {
240+
assignment.partitions_per_consumer * assignment.consumer_task_count
241+
}
242+
Self::Coalesce(assignment) => assignment.partitions_per_producer_task,
243+
Self::Broadcast(assignment) => {
244+
assignment.partitions_per_consumer * assignment.consumer_task_count
245+
}
246+
}
247+
}
248+
249+
/// Returns the per-kind partition count needed to reconstruct this assignment.
250+
pub(crate) fn assignment_partition_count(&self) -> usize {
251+
match self {
252+
Self::Shuffle(assignment) => assignment.partitions_per_consumer,
253+
Self::Coalesce(assignment) => assignment.partitions_per_producer_task,
254+
Self::Broadcast(assignment) => assignment.partitions_per_consumer,
255+
}
256+
}
257+
258+
/// Returns the advertised output partition IDs owned by one consumer task.
259+
pub(crate) fn partition_range_for_consumer(
260+
&self,
261+
consumer_task_idx: usize,
262+
) -> Option<Range<usize>> {
263+
if consumer_task_idx >= self.consumer_task_count() {
264+
return None;
265+
}
266+
267+
match self {
268+
Self::Shuffle(assignment) => {
269+
let start = consumer_task_idx * assignment.partitions_per_consumer;
270+
Some(start..start + assignment.partitions_per_consumer)
271+
}
272+
Self::Coalesce(_) => Some(0..self.partitions_per_producer_task()),
273+
Self::Broadcast(assignment) => {
274+
let start = consumer_task_idx * assignment.partitions_per_consumer;
275+
Some(start..start + assignment.partitions_per_consumer)
276+
}
277+
}
278+
}
279+
280+
/// Maps a consumer-local output partition to the upstream data it must read.
281+
pub(crate) fn resolve_slot(
282+
&self,
283+
consumer_task_idx: usize,
284+
local_partition_idx: usize,
285+
) -> Option<SlotReadPlan> {
286+
match self {
287+
Self::Shuffle(assignment) => {
288+
assignment.resolve_slot(consumer_task_idx, local_partition_idx)
289+
}
290+
Self::Coalesce(assignment) => {
291+
assignment.resolve_slot(consumer_task_idx, local_partition_idx)
292+
}
293+
Self::Broadcast(assignment) => {
294+
assignment.resolve_slot(consumer_task_idx, local_partition_idx)
295+
}
296+
}
297+
}
298+
}
299+
300+
/// Splits producer task ids into contiguous consumer groups as evenly as possible.
301+
fn split_ranges(total: usize, groups: usize) -> Vec<Range<usize>> {
302+
if groups == 0 {
303+
return Vec::new();
304+
}
305+
306+
let base = total / groups;
307+
let extra = total % groups;
308+
let mut ranges = Vec::with_capacity(groups);
309+
let mut start = 0;
310+
311+
for idx in 0..groups {
312+
let len = base + usize::from(idx < extra);
313+
ranges.push(start..start + len);
314+
start += len;
315+
}
316+
317+
ranges
318+
}
319+
320+
#[cfg(test)]
321+
mod tests {
322+
use super::*;
323+
324+
#[test]
325+
fn shuffle_assignment_preserves_scaled_fanout() {
326+
let assignment = ExchangeAssignment::try_shuffle(3, 2, 4).unwrap();
327+
328+
assert_eq!(assignment.producer_task_count(), 3);
329+
assert_eq!(assignment.consumer_task_count(), 2);
330+
assert_eq!(assignment.max_partition_count_per_consumer(), 4);
331+
assert_eq!(assignment.partitions_per_producer_task(), 8);
332+
assert_eq!(assignment.partition_range_for_consumer(1), Some(4..8));
333+
assert_eq!(
334+
assignment.resolve_slot(1, 2),
335+
Some(SlotReadPlan::Fanout {
336+
producer_tasks: 0..3,
337+
producer_partition: 6,
338+
})
339+
);
340+
assert_eq!(assignment.resolve_slot(1, 4), None);
341+
}
342+
343+
#[test]
344+
fn coalesce_assignment_assigns_contiguous_task_groups() {
345+
let assignment = ExchangeAssignment::try_coalesce(3, 2, 4).unwrap();
346+
347+
assert_eq!(assignment.producer_task_count(), 3);
348+
assert_eq!(assignment.consumer_task_count(), 2);
349+
assert_eq!(assignment.max_partition_count_per_consumer(), 8);
350+
assert_eq!(assignment.partitions_per_producer_task(), 4);
351+
assert_eq!(assignment.partition_range_for_consumer(0), Some(0..4));
352+
assert_eq!(
353+
assignment.resolve_slot(0, 4),
354+
Some(SlotReadPlan::Single {
355+
producer_task: 1,
356+
producer_partition: 0,
357+
})
358+
);
359+
assert_eq!(
360+
assignment.resolve_slot(1, 3),
361+
Some(SlotReadPlan::Single {
362+
producer_task: 2,
363+
producer_partition: 3,
364+
})
365+
);
366+
assert_eq!(assignment.resolve_slot(1, 4), None);
367+
}
368+
369+
#[test]
370+
fn broadcast_assignment_preserves_scaled_fanout() {
371+
let assignment = ExchangeAssignment::try_broadcast(2, 3, 4).unwrap();
372+
373+
assert_eq!(assignment.producer_task_count(), 2);
374+
assert_eq!(assignment.consumer_task_count(), 3);
375+
assert_eq!(assignment.max_partition_count_per_consumer(), 4);
376+
assert_eq!(assignment.partitions_per_producer_task(), 12);
377+
assert_eq!(assignment.partition_range_for_consumer(2), Some(8..12));
378+
assert_eq!(
379+
assignment.resolve_slot(2, 1),
380+
Some(SlotReadPlan::Fanout {
381+
producer_tasks: 0..2,
382+
producer_partition: 9,
383+
})
384+
);
385+
assert_eq!(assignment.resolve_slot(3, 0), None);
386+
}
387+
}

0 commit comments

Comments
 (0)