Skip to content

feat(scan): let a scan request bound the splits it decodes at once - #64

Merged
ChristopherWirt merged 3 commits into
developfrom
cwirt/scan-split-concurrency
Nov 3, 2026
Merged

ChristopherWirt merged 3 commits into
developfrom
cwirt/scan-split-concurrency

Conversation

@ChristopherWirt

Copy link
Copy Markdown
Collaborator

Closes #63. Helps with #61, but doesn't fix it.

Problem

Change

An absolute bound on splits decoded at once, settable per scan:

Layer API
Rust ScanRequest::split_concurrency → ScanBuilder::with_some_max_splits_in_flight
JNI ScanOptions.splitConcurrency()
Spark vortex.splitConcurrency read option
  • It's a cap kept apart from the per-worker with_concurrency. A later with_concurrency (as the DataFusion opener calls) leaves it in place, and concurrency() keeps its meaning.
  • JNI also bounds the Arrow conversion it runs ahead of Java (buffered(worker_count)) by the same value.
  • It doesn't bound I/O: segment reads for later splits may still be coalesced ahead of them. The docs say so.

Effect

A full scan of a 180 MB list-layout file, peak live native bytes:

splits at once peak live time
48 (default, 4 × 12 cores) 3.65 GiB 1.0 s
12 1.18 GiB 1.0 s

Tests

  • Rust: a scan of 64 one-row splits decodes exactly 1, and exactly 2, at once with the bound set, against 48 by default. A later with_concurrency keeps the bound.
  • JNI: ScanOptions validation, and a scan with the bound reads every row.
  • Spark: option parsing, validation and serialisation, plus V1 and V2 reads with the option set.
  • Suites: Rust (vortex-layout, vortex-scan, vortex-jni, vortex-file), JNI, Spark 3.5 and Spark 4.0 all pass.

🤖 Generated with Claude Code

ChristopherWirt and others added 2 commits November 2, 2026 20:40
ScanRequest gains split_concurrency: the most splits of each partition
decoded at once, in all. Both layout data sources pass it to
ScanBuilder::with_some_max_splits_in_flight, a cap kept apart from the
per-worker concurrency. JNI takes it as ScanOptions.splitConcurrency, and also
bounds the Arrow conversion it runs ahead of Java by it; Spark takes it as the
vortex.splitConcurrency read option.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Christopher Wirt <[email protected]>
Drive capped Arrow streams directly instead of feeding the runtime result queue, which can retain one extra completed batch per core. Preserve the existing queue for uncapped scans. Add paused-consumer regressions with two background workers.

Saturate per-worker concurrency scaling before applying the absolute cap and add an overflow regression. Clarify that split concurrency reduces decoded memory rather than bounding total native memory or I/O read-ahead.

Signed-off-by: Christopher Wirt <[email protected]>
@ChristopherWirt
ChristopherWirt force-pushed the cwirt/scan-split-concurrency branch from 512a010 to 31ae92c Compare November 2, 2026 20:40

@gitRasheed gitRasheed left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall. The bound applies where decoding happens, and callers that don't set it see no change.

One thing worth fixing before merge: the JNI lookahead cap doesn't bound what sits ahead of Java. block_on_stream_thread_safe (vortex-io/src/runtime/current.rs:75) sends results into a channel sized to available_parallelism, so with splitConcurrency=1 on 12 cores up to 12 converted batches, plus one blocked send, can still queue while Java is slow. The ScanOptions Javadoc promises otherwise. Either carry the bound into that channel or narrow the Javadoc. A test with several batches and a paused consumer would catch it; the Java and Spark tests only check that every row arrives.

Minor: with a filter and a limit, execute_stream buffers at two stages (repeated_scan.rs:283 and :290), so it's about 2n split arrays. The builder doc already says so, but the Spark docs row reads like a hard cap.

@ChristopherWirt

Copy link
Copy Markdown
Collaborator Author

Thanks, both points are fair. The queue issue is addressed in 31ae92c: when a split cap is set, JNI drives the stream directly instead of feeding the per-core result channel. Uncapped scans keep the existing path. That commit also adds paused-consumer regressions for caps of 1 and 2, with background workers running.

I've pushed 11e695f to make the other caveat explicit in the Spark docs and Java option Javadocs. With both a filter and a limit, the mapping stage can retain another trimmed array per split, so the option reduces concurrent decode/conversion memory but isn't a cap on total native memory or I/O read-ahead. The Java docs still said it bounded native memory; that wording is gone too.

This follow-up only changes docs; I haven't rerun the test suites.

@ChristopherWirt

ChristopherWirt commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Rust's available_parallelism() already accounts for container CPU limits where detectable, but every Spark task still sizes its scan against the whole executor's CPU count.

For our Spark workloads, we should start with spark.task.cpus=1 and split parallelism of 4 (vortex.splitConcurrency=4), then measure. Longer term, the connector should default the per-scan cap to 4 × TaskContext.get().cpus(), while preserving an explicit override.

@gitRasheed gitRasheed left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes look right. Capped scans now skip the per-core result channel, and the paused-consumer tests cover caps of 1 and 2. Agreed on starting at vortex.splitConcurrency=4 with spark.task.cpus=1 and measuring; defaulting to 4 x TaskContext.cpus() later makes sense.

@ChristopherWirt
ChristopherWirt merged commit 1aa2ad7 into develop Nov 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let a scan bound the splits it decodes at once

2 participants