Skip to content

Commit 1e436df

Browse files
adriangbclaude
andcommitted
Bounded streaming: open streamed requests concurrently; scale windows by projected column count
- AsyncFileReader::get_bytes_stream now returns an Option of a 'static future so streamed requests can be opened concurrently with each other and with the materialized batch (previously opens were serialized on the reader borrow, paying one request latency per stream). None (the default) falls back to materialization. - Decode windows now have a floor of min_window_bytes_per_column (default 2MiB) per projected column so per-window fixed costs (array reader construction, boundary-page and dictionary-page re-decode) stay amortized; projections wide enough that one window covers the row group fall back to the non-windowed path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGnT9oETcFMydc9cp95HLG
1 parent 51eb276 commit 1e436df

6 files changed

Lines changed: 93 additions & 53 deletions

File tree

parquet/src/arrow/arrow_reader/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,13 @@ pub struct BoundedStreamingOptions {
185185
///
186186
/// [`AsyncFileReader::get_byte_ranges`]: https://docs.rs/parquet/latest/parquet/arrow/async_reader/trait.AsyncFileReader.html
187187
pub coalesce_gap: u64,
188+
/// A decode window must hold at least this many bytes per projected
189+
/// column, so that per-window fixed costs (rebuilding one array reader
190+
/// per column, re-decompressing one boundary page per column) stay
191+
/// amortized. Wide projections therefore get proportionally larger
192+
/// windows; projections so wide that one window would cover the whole
193+
/// row group fall back to the non-windowed path. Default: 2 MiB.
194+
pub min_window_bytes_per_column: u64,
188195
}
189196

190197
impl Default for BoundedStreamingOptions {
@@ -193,6 +200,7 @@ impl Default for BoundedStreamingOptions {
193200
stream_threshold: 16 * 1024 * 1024,
194201
window_bytes: 8 * 1024 * 1024,
195202
coalesce_gap: 1024 * 1024,
203+
min_window_bytes_per_column: 2 * 1024 * 1024,
196204
}
197205
}
198206
}

parquet/src/arrow/async_reader/adaptive.rs

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,8 @@ where
315315
///
316316
/// # Errors
317317
///
318-
/// Returns an error if called outside a tokio runtime context.
318+
/// Returns an error if called outside a tokio runtime context. To supply
319+
/// an explicit runtime, use [`Self::new`].
319320
pub fn try_new(options: BoundedStreamingOptions, input: T) -> Result<Self> {
320321
let runtime = Handle::try_current().map_err(|e| {
321322
ParquetError::General(format!(
@@ -363,8 +364,9 @@ where
363364
.await
364365
}
365366

366-
/// `runtime` is used to spawn body-draining tasks
367-
pub(crate) fn new(options: BoundedStreamingOptions, runtime: Handle, input: T) -> Self {
367+
/// Create a new fetcher over `input`. `runtime` is used to spawn
368+
/// body-draining tasks.
369+
pub fn new(options: BoundedStreamingOptions, runtime: Handle, input: T) -> Self {
368370
Self {
369371
options,
370372
runtime,
@@ -435,9 +437,11 @@ where
435437
return Ok(());
436438
}
437439

438-
// Can only issue requests while the input is idle. If a request is
439-
// already in flight, plan again once it resolves (`poll_sources`
440-
// returns progress when it does).
440+
// Streamed requests can be opened at any time (they own their own
441+
// connections), but the materialize batch borrows the input, so it
442+
// can only be issued while the input is idle. If a materialize
443+
// request is already in flight, plan again once it resolves
444+
// (`poll_sources` returns progress when it does).
441445
if matches!(self.input, InputState::Busy(_)) {
442446
return Ok(());
443447
}
@@ -519,6 +523,38 @@ where
519523
unreachable!("checked idle above");
520524
};
521525

526+
// Open all streamed requests concurrently: each open future is
527+
// 'static (owns its connection), so it is spawned immediately and
528+
// runs in parallel with the other opens and with the materialize
529+
// batch below. Readers that do not support streamed requests
530+
// (`get_bytes_stream` returns None) fall back to materialization.
531+
for spec in specs {
532+
let StreamSpec { span, tx } = spec;
533+
match input.get_bytes_stream(span.clone()) {
534+
Some(open) => {
535+
self.runtime.spawn(async move {
536+
match open.await {
537+
Ok(body) => pump_body(body, tx).await,
538+
Err(e) => {
539+
let _ = tx.send(Err(e)).await;
540+
}
541+
}
542+
});
543+
}
544+
None => {
545+
// Unsupported: drop the just-registered stream and
546+
// materialize its members instead
547+
let idx = self
548+
.streams
549+
.iter()
550+
.position(|s| s.members.front().map(|m| m.start) == Some(span.start))
551+
.expect("stream registered above");
552+
let stream = self.streams.swap_remove(idx);
553+
materialize.extend(stream.members);
554+
}
555+
}
556+
}
557+
522558
for range in &materialize {
523559
self.planned.insert(key(range));
524560
}
@@ -528,26 +564,14 @@ where
528564
}
529565
}
530566

531-
let runtime = self.runtime.clone();
567+
if materialize.is_empty() {
568+
// nothing to materialize: the input stays available
569+
self.input = InputState::Idle(input);
570+
return Ok(());
571+
}
572+
532573
let future = async move {
533-
// Open the streamed requests first so their bodies start flowing
534-
// while the materialized ranges download
535-
for spec in specs {
536-
let StreamSpec { span, tx } = spec;
537-
match input.get_bytes_stream(span).await {
538-
Ok(body) => {
539-
runtime.spawn(pump_body(body, tx));
540-
}
541-
Err(e) => {
542-
let _ = tx.send(Err(e)).await;
543-
}
544-
}
545-
}
546-
let data = if materialize.is_empty() {
547-
vec![]
548-
} else {
549-
input.get_byte_ranges(materialize.clone()).await?
550-
};
574+
let data = input.get_byte_ranges(materialize.clone()).await?;
551575
Ok((input, materialize, data))
552576
}
553577
.boxed();

parquet/src/arrow/async_reader/mod.rs

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ mod metadata;
5252
pub use adaptive::AdaptiveFetcher;
5353
pub use metadata::*;
5454

55-
use futures::stream::{BoxStream, StreamExt as _};
55+
use futures::stream::BoxStream;
5656

5757
#[cfg(feature = "object_store")]
5858
mod store;
@@ -95,33 +95,28 @@ pub trait AsyncFileReader: Send {
9595
}
9696

9797
/// Retrieve the bytes in `range` as a stream of chunks that can be
98-
/// consumed incrementally, without buffering the whole range in memory.
98+
/// consumed incrementally, without buffering the whole range in memory,
99+
/// or `None` if this reader does not support streamed requests.
99100
///
100101
/// Used by the bounded streaming mode (see
101102
/// [`BoundedStreamingOptions`](crate::arrow::arrow_reader::BoundedStreamingOptions))
102103
/// to consume large column chunks incrementally: the returned future
103104
/// issues the request, and the resulting stream yields the body as it
104-
/// arrives. The stream is `'static` so the request can remain in flight
105-
/// while the reader issues other requests.
105+
/// arrives. Both the future and the stream are `'static` (owning their
106+
/// connection) so several streamed requests can be opened concurrently
107+
/// and remain in flight while the reader issues other requests.
106108
///
107-
/// The default implementation materializes the entire range via
108-
/// [`Self::get_bytes`] and yields it as a single chunk, so implementations
109-
/// that do not override this method behave exactly as before (correct, but
109+
/// The default implementation returns `None`, which makes callers fall
110+
/// back to [`Self::get_byte_ranges`] (existing implementors keep working,
110111
/// without the memory benefit). Implementations backed by a network store
111112
/// should return the response body stream directly; see the
112113
/// `ParquetObjectReader` implementation.
113114
fn get_bytes_stream(
114115
&mut self,
115116
range: Range<u64>,
116-
) -> BoxFuture<'_, Result<BoxStream<'static, Result<Bytes>>>> {
117-
let fut = self.get_bytes(range);
118-
async move {
119-
let bytes = fut.await?;
120-
let stream: BoxStream<'static, Result<Bytes>> =
121-
futures::stream::once(async move { Ok(bytes) }).boxed();
122-
Ok(stream)
123-
}
124-
.boxed()
117+
) -> Option<BoxFuture<'static, Result<BoxStream<'static, Result<Bytes>>>>> {
118+
let _ = range;
119+
None
125120
}
126121

127122
/// Return a future which results in the [`ParquetMetaData`] for this Parquet file.
@@ -159,7 +154,7 @@ impl AsyncFileReader for Box<dyn AsyncFileReader + '_> {
159154
fn get_bytes_stream(
160155
&mut self,
161156
range: Range<u64>,
162-
) -> BoxFuture<'_, Result<BoxStream<'static, Result<Bytes>>>> {
157+
) -> Option<BoxFuture<'static, Result<BoxStream<'static, Result<Bytes>>>>> {
163158
self.as_mut().get_bytes_stream(range)
164159
}
165160

@@ -2193,15 +2188,15 @@ mod tests {
21932188
fn get_bytes_stream(
21942189
&mut self,
21952190
range: Range<u64>,
2196-
) -> BoxFuture<'_, Result<BoxStream<'static, Result<Bytes>>>> {
2191+
) -> Option<BoxFuture<'static, Result<BoxStream<'static, Result<Bytes>>>>> {
21972192
self.stream_requests.lock().unwrap().push(range.clone());
21982193
let data = self.data.slice(range.start as usize..range.end as usize);
21992194
let chunk_size = self.chunk_size;
22002195
let chunks: Vec<Result<Bytes>> = (0..data.len())
22012196
.step_by(chunk_size)
22022197
.map(|offset| Ok(data.slice(offset..(offset + chunk_size).min(data.len()))))
22032198
.collect();
2204-
futures::future::ready(Ok(futures::stream::iter(chunks).boxed())).boxed()
2199+
Some(futures::future::ready(Ok(futures::stream::iter(chunks).boxed())).boxed())
22052200
}
22062201

22072202
fn get_metadata<'a>(
@@ -2289,6 +2284,7 @@ mod tests {
22892284
stream_threshold: 256 * 1024,
22902285
window_bytes: 128 * 1024,
22912286
coalesce_gap: 1024 * 1024,
2287+
min_window_bytes_per_column: 16 * 1024,
22922288
}
22932289
}
22942290

@@ -2403,6 +2399,7 @@ mod tests {
24032399
stream_threshold: 64 * 1024,
24042400
window_bytes: 32 * 1024,
24052401
coalesce_gap: 4 * 1024,
2402+
min_window_bytes_per_column: 8 * 1024,
24062403
};
24072404

24082405
let (baseline, _) = read_all_with_options(
@@ -2460,6 +2457,7 @@ mod tests {
24602457
stream_threshold: 64 * 1024,
24612458
window_bytes: 32 * 1024,
24622459
coalesce_gap: 1024 * 1024,
2460+
min_window_bytes_per_column: 8 * 1024,
24632461
})
24642462
.build()
24652463
.unwrap();

parquet/src/arrow/async_reader/store.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,23 +199,26 @@ impl AsyncFileReader for ParquetObjectReader {
199199
fn get_bytes_stream(
200200
&mut self,
201201
range: Range<u64>,
202-
) -> BoxFuture<'_, Result<futures::stream::BoxStream<'static, Result<Bytes>>>> {
202+
) -> Option<BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<Bytes>>>>>
203+
{
203204
use futures::{StreamExt as _, TryStreamExt};
205+
let store = Arc::clone(&self.store);
206+
let path = self.path.clone();
204207
let options = GetOptions {
205208
range: Some(GetRange::Bounded(range)),
206209
..Default::default()
207210
};
208-
self.spawn(|store, path| {
211+
Some(
209212
async move {
210-
let result = store.get_opts(path, options).await?;
213+
let result = store.get_opts(&path, options).await?;
211214
// Note: for file-backed stores this streams in small (8KiB)
212215
// chunks; wrap the store in `object_store::chunked::ChunkedStore`
213216
// for larger chunks if that matters
214217
let stream = result.into_stream().map_err(ParquetError::from).boxed();
215218
Ok::<_, ParquetError>(stream)
216219
}
217-
.boxed()
218-
})
220+
.boxed(),
221+
)
219222
}
220223

221224
// This method doesn't directly call `self.spawn` because all of the IO that is done down the

parquet/src/arrow/push_decoder/reader_builder/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,8 +746,7 @@ impl RowGroupReaderBuilder {
746746
&self.projection,
747747
plan_builder.selection(),
748748
self.row_selection_policy,
749-
streaming.stream_threshold,
750-
streaming.window_bytes,
749+
streaming,
751750
) {
752751
return Ok(NextState::again(RowGroupDecoderState::DecodingWindows {
753752
windowed: Box::new(windowed),

parquet/src/arrow/push_decoder/reader_builder/windows.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ pub(super) fn plan_windows(
133133
projection: &ProjectionMask,
134134
selection: Option<&RowSelection>,
135135
row_selection_policy: RowSelectionPolicy,
136-
stream_threshold: u64,
137-
window_bytes: u64,
136+
options: &crate::arrow::arrow_reader::BoundedStreamingOptions,
138137
) -> Option<WindowedRead> {
138+
let stream_threshold = options.stream_threshold;
139139
if batch_size == 0 || row_count == 0 {
140140
return None;
141141
}
@@ -172,6 +172,14 @@ pub(super) fn plan_windows(
172172
streamable.push(start..start + len);
173173
}
174174
}
175+
// See BoundedStreamingOptions::min_window_bytes_per_column: windows must
176+
// amortize per-window, per-column fixed costs
177+
let projected_columns = (0..num_columns)
178+
.filter(|idx| projection.leaf_included(*idx))
179+
.count() as u64;
180+
let window_bytes = options
181+
.window_bytes
182+
.max(projected_columns * options.min_window_bytes_per_column);
175183
if streamable.is_empty() || total_requested <= window_bytes {
176184
return None;
177185
}

0 commit comments

Comments
 (0)