feat(core): range shuffle reads only the bytes a consumer's range covers - #2364
Draft
avantgardnerio wants to merge 7 commits into
Draft
feat(core): range shuffle reads only the bytes a consumer's range covers#2364avantgardnerio wants to merge 7 commits into
avantgardnerio wants to merge 7 commits into
Conversation
…e format A stage routed by value range hands each consumer producer files it reads whole and mostly discards. Measured on h2o Q8 at 1e8 rows, the consumer fetches 8.2 GB to feed a window needing 3.6 GB. Skipping the excess means seeking, and seeking needs a chunk index, which the IPC stream format the passthrough shuffle writes does not have. This adds a writer that emits the IPC file format, whose footer indexes every record batch. Reads stay whole-file, so volume is unchanged — what lands is the substrate: a footer to seek against, plus the per-batch byte offsets captured at write time, which are the content of the value index that follows. A separate operator rather than a flag on ShuffleWriterExec: the two IPC framings are not interchangeable on the read side, so a mode flag would put every existing reader one config change away from failing to decode. As a distinct writer the existing shuffle keeps its format untouched, and only the readers taught to recognise the new one will open it. The pair is planted in the AQE adapter, the only place that sees both sides, under the same condition that plants RangeShuffleReaderExec — the stage's child declares an output ordering. The static planner never plants it, since it plants the arrival-order reader. Off by default behind `ballista.shuffle.range.enabled`. Readers pick a decoder by sniffing the file's magic rather than by a flag on PartitionLocation, keeping the format out of the wire protocol; the local reader and the Flight do_get path both do this. Remote reads route over do_get while the flag is on, because the IO_BLOCK_TRANSPORT action ships raw bytes to a stream-only decoder. Verified against DataFusion on h2o Q8 with the flag on and off, and the files on disk confirm the split: the range-routed stage is ARROW1, the result stage stays a stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ig_docs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te counter The offsets a value index hands to a range read have to name a record batch. Counting bytes around `FileWriter::write` cannot produce them: one such call emits the batch's dictionary blocks *and* its record block, so the counter reports an offset pointing at a dictionary and a length spanning both. Data with no dictionary columns hides this — the two coincide — which is why the h2o benchmark and the previous tests were green. The footer already holds what the counter was reconstructing, separated by kind and computed by the writer that emitted the bytes. Read it back after `finish`, at the cost of one tail read per shuffle file, and drop the counter. `read_file_layout` returns dictionary and record blocks as `FileLayout`, and the writer asserts the record-block count equals the batches written, so a future re-chunking fails loudly instead of producing an index that addresses the wrong batches. Tests now slice the file at each recorded range and decode it, rather than checking the offsets are self-consistent — arithmetic agreed with itself while being wrong. One covers dictionary-encoded data, which is the only shape that can tell the two schemes apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One row per IPC message in the data file, in file order:
<sort_expr_0..n> key types, nullable first row's key in this batch
is_dict Boolean row describes a dictionary
byte_offset UInt64 absolute, from the data file footer
byte_len UInt64
num_rows UInt64, nullable rows in the batch
A consumer assigned `[lo, hi)` reads this instead of the data file, binary
searches the key columns, and fetches only the covering byte ranges. Measured
on h2o Q8: 5 KB of index against 19 MB of data.
Keys are one typed column per ORDER BY expression rather than an encoded
blob, so multi-column keys, DESC and mixed types work by construction, and
the schema names what the file is indexed on. They are nullable for two
independent reasons — a dictionary row has no key, and a first key can
genuinely be NULL under NULLS FIRST — so `is_dict` carries that distinction
rather than nullability implying it.
Dictionaries get rows because a record batch referencing one cannot be
decoded from its own bytes alone. The IPC footer does not record which
dictionary a block carries, so a reader takes every dictionary row preceding
its range: conservative, and correct under replacement and delta alike.
`num_rows` is what lets a ROWS-frame halo be computed from indexes before
any data is fetched — sum counts below a boundary until N rows are covered,
rather than fetching and decoding to find out. Its summation across files
assumes each row lives in exactly one file, which the unexplained stage-0
write amplification has to settle before a consumer relies on it.
Keys are collected by a stream adapter on the way to the writer, so a key
and the block describing it come from the same batch rather than from two
passes that could disagree. The writer now requires an ordered input, since
a child declaring no ordering has nothing to index.
Nothing reads the index yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vers
The reader consults each source's value index, binary searches its assigned
range, and reads only the batches that can hold a row in it. Selection is at
batch granularity and the RangeFilterExec above still does the exact trim, so
this changes the volume read and not the rows produced.
h2o Q8 at 1e7, read amplification (reader rows / rows the halo trim keeps):
K before after
8 1.69 1.41
32 1.38 1.22
128 1.20 1.17
About half the available saving, because only local sources seek today;
remote sources still come back whole over `do_get`.
Bounds come from the RangeFilterExec above the reader, which has already
widened the cuts by its own halos — reading them off the filter rather than
recomputing from cuts is what stops the two disagreeing about halo width.
They are per output partition, so `restrict_plan_to_partitions` slices them
alongside the locations; dropping them there left every task reading whole
files, which is how this was first found to be doing nothing.
Removes `ballista.shuffle.range.enabled`. Whether a source can be seeked is a
fact about the source, and the reader establishes it by opening the file — a
flag was a second answer to the same question, free to disagree with the
first. It did: with the flag off the writer emitted IPC stream while the
reader still received bounds, so it looked for a footer that was not there.
The range writer is now planted whenever the range reader is, which is the
condition that already had to hold for the two to agree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
phillipleblanc
approved these changes
Aug 25, 2026
phillipleblanc
left a comment
Contributor
There was a problem hiding this comment.
This makes sense to me, but I do wonder if we should try to come up with a more generic shuffle writer that could handle a file format that is registered in the ballista system somehow? i.e. there isn't anything in the RangeShuffleWriterExecNode protobut message itself that couldn't be made generic, its just the message type itself.
Given that we already have the RangeShuffleReader though, I don't think we need to change anything on this PR - but something to think about for making this handle different shuffle formats in the future.
Read amplification on h2o Q8 at 1e7, reader rows over rows the halo trim
keeps:
K before local seek only this
8 1.69 1.41 1.01
32 1.38 1.22 1.07
128 1.20 1.17 1.19
At K=8 the consumer fetches 373 MB where it needs 372 MB, against 626 MB
before — within half a percent of the floor. K=128 gains nothing because a
cut is 0.77 wide against a halo of 3, so one 8192-row batch already spans
more than the band a partition wants and selection cannot go below a batch.
The consumer fetches the index, searches it, and asks for byte ranges. The
executor resolves nothing: no index read, no comparison, no notion of what a
range means. That is what makes the same two steps work against object
storage, where there is no executor to ask, and it is why the index carries
byte offsets rather than leaving the lookup to whoever holds the file.
Sort shuffle keeps its single round trip: coordinates with no ranges still ask
the executor to resolve a partition through its own index. Moving that lookup
to the caller needs no further protocol change — fetch the index, read two
i64s, ask for the bytes — and would delete `stream_sort_shuffle_block` rather
than add a path beside it.
Protocol version 2 -> 3. `FetchPartition.is_sort_shuffle` becomes `layout`,
because the bool selected a path shape and reads as data-vs-index once a
`file_kind` sits next to it. `byte_ranges` is empty for "whatever these
identifiers address" and otherwise absolute file offsets.
The schema message is written by the consumer from the schema it already
knows, not sliced out of the data file: arrow-rs pads the file magic to its
write alignment, 64 bytes, so slicing the header encodes another
implementation's padding rule into an offset. That cost a debugging cycle —
offset 8 held padding, and the decoder read the zeros as end-of-stream.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ranged read built the whole requested range in memory and sent it as one gRPC message. At 1e8 rows that is ~29 MB against a 16 MB receive limit, so the consumer failed with a transport error wearing an IpcError's clothing: `execute_do_action` maps transport failures through ArrowError, which made a message-size rejection read as a corrupt stream. Chunked at BLOCK_BUFFER_CAPACITY like a whole-file read, and streamed rather than collected — buffering held a consumer's entire share of a partition in the serving executor's memory, which is the same defect measured in bytes instead of in errors. Found by measuring 1e8 to fill in a table, which is the argument for filling in tables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two goals
Read less. A stage that routes rows by value range hands each consumer a set of producer files, of which the consumer wants only the rows in its own cut range. Today it reads them whole and the
RangeFilterExecabove it discards the rest.Make persistent shuffle possible. Ballista shuffle files live and die with the executor that wrote them. Serving them from object storage instead is what survives an executor rotating out — which matters on spot infrastructure, and is where both Coralogix and Spice already flush to S3. An object store has no process to run a lookup for you: it answers
GETwith aRange:header and nothing else. So the seek has to be expressible as a byte range decided by the reader, or it does not port at all.The second goal is what shapes the design. It would be easier to have the serving side resolve a value range to bytes, and it would also be a dead end.
What the waste is
RangeShuffleReaderExecdoes no filtering, so the ratio between the rows it emits and the rows the halo trim keeps is the over-fetch — no new instrumentation needed:At 1e8 that is 56% of the shuffle read fetched, decoded, and thrown away.
All numbers here are at K=8 on 2 executors × 4 vcores — one output partition per vcore, which is the shape worth optimising: every core busy through every stage, and no more partitions than that.
What this PR does
Skipping bytes means seeking, and seeking means the data file needs a chunk index. The Arrow IPC stream format the passthrough shuffle writes has none: finding the bytes that hold a given value means walking every message from the head.
Three pieces, each useless without the next:
A writer that emits a seekable format.
RangeShuffleWriterExecwrites the Arrow IPC file format, whose footer records aBlock { offset, metadata_length, body_length }per record batch. A distinct operator rather than a flag onShuffleWriterExec, because the two IPC framings are not interchangeable on the read side — a mode flag would put every existing reader one config change away from failing to decode what it is handed. The existing shuffle's format never moves.A value index beside each data file.
data-{file_id}.rangeidx.arrow, one row per IPC message in file order:5 KB of index against 19 MB of data. Keys are one typed column per ORDER BY expression rather than an encoded blob, so multi-column keys,
DESC, and mixed types work by construction, and the schema names what the file is indexed on.A reader that fetches only what its range covers. Locally it seeks. Remotely it fetches the index, searches it, and asks for byte ranges — which is the step that ports to object storage unchanged.
Result
K=8, h2o Q8, verified against single-process DataFusion:
The reader now fetches what the window consumes and essentially nothing else. At 1e8 that is 4.6 GB of the 8.2 GB no longer crossing the shuffle boundary.
Who does the searching, and why it matters
The serving side could resolve a value range to bytes itself. It deliberately does not.
If it did, the index file would be pointless — a server holding the data can binary-search it directly — and none of this would port to object storage. Byte offsets exist in the index precisely so a reader can turn a value range into a
Range:header. Keeping the lookup on the reading side means swapping Arrow Flight for an object store changes the transport and nothing else, because the selection was never on the serving side to begin with.Concretely, a consumer task does:
[lo, hi)Step 3 is a
GETwith aRange:header wherever the bytes live. The Flight service that answers it opens a file, seeks, and copies: it reads no index, compares no value, and has no notion of what a range means.The cost is two fetches per source rather than one. Batched across sources that is one extra round trip per read, not one per source, because the dependency is within a file and not across them. On a single box that cost is invisible and the bandwidth saving is fully visible — the reverse of a real network, which is why the caveat at the top matters.
Protocol
BALLISTA_PROTOCOL_VERSION2 → 3.FetchPartitiongainslayout,file_kind, andbyte_ranges;is_sort_shuffleis gone, because that bool selected a path shape and misreads as data-vs-index once afile_kindsits beside it.Coordinates address a logical partition's bytes;
byte_rangesnarrows them:SORT+DATA+ no ranges → the Flight service resolves the partition through its own index. Unchanged, one fetch.PASSTHROUGH+DATA+ no ranges → the whole file, as today.INDEX→ the sidecar, whose encoding follows from the layout.Sort-based shuffle therefore keeps its single fetch and its existing behaviour. Moving its lookup to the reader later needs no further protocol change — fetch the index, read two
i64s, ask for the bytes — and would deletestream_sort_shuffle_blockrather than add a path beside it. That is also what would make sort shuffle object-store-native, for free.Scope, and what is untouched
The range writer is planted only where the consumer is a
RangeShuffleReaderExec, which the AQE adapter chooses when a stage's child declares an output ordering. In practice that is the parallel-window rewrites and a sort above a shuffle boundary. It requires adaptive planning; the static planner never produces the pair.Everything else is byte-for-byte as before: the passthrough shuffle still writes IPC stream, sort-based shuffle still writes its own consolidated format and still serves it in one fetch, and no existing reader's path changes.
Within that scope, one property is worth naming: an IPC stream file is readable up to its last complete message, while an IPC file with no footer does not open at all. A range-shuffle task killed mid-write therefore leaves a file that fails on open rather than one that reads partially. Task-level retry covers it, and it applies only to files this PR's writer creates.
Testing
RangeShuffleWriterExecintofetch_partition_local, rather than fabricating files and asserting the reader agrees with itself — the specific gap called out on feat(core): ValueIndexExec + ValueIndexReader + shuffle FileWriter switch #2204.What this is not
RangeShuffleWriter/RangeShuffleReadertraits with a registration point, so a Vortex implementation can live outside this repo — is not in this diff. The format-specific parts are already confined to two modules, so it is a matter of naming the seam rather than moving the work.