Skip to content

feat(core): range shuffle reads only the bytes a consumer's range covers - #2364

Draft
avantgardnerio wants to merge 7 commits into
apache:mainfrom
avantgardnerio:brent/range-shuffle-writer
Draft

feat(core): range shuffle reads only the bytes a consumer's range covers#2364
avantgardnerio wants to merge 7 commits into
apache:mainfrom
avantgardnerio:brent/range-shuffle-writer

Conversation

@avantgardnerio

@avantgardnerio avantgardnerio commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Draft. The public RangeShuffleWriter / RangeShuffleReader traits and registration point @phillipleblanc asked for on #2204 are still to come, and the numbers below are from a single box where the network is free. Not for review yet.

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 RangeFilterExec above 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 GET with a Range: 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

RangeShuffleReaderExec does 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:

scale reader emits halo trim keeps read amplification
1e7 20.5 M / 626 MB 12.1 M / 372 MB 1.69×
1e8 275 M / 8.2 GB 121 M / 3.6 GB 2.27×

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. RangeShuffleWriterExec writes the Arrow IPC file format, whose footer records a Block { offset, metadata_length, body_length } per record batch. A distinct operator rather than a flag on ShuffleWriterExec, 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:

<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's footer
byte_len           UInt64
num_rows           UInt64, nullable

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:

scale fetched before fetched now what the window needs amplification
1e7 626 MB 373 MB 372 MB 1.69× → 1.01×
1e8 8.2 GB 3.6 GB 3.6 GB 2.27× → 1.00×

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:

  1. fetch the index for a source — one small object
  2. binary-search it locally for its own [lo, hi)
  3. ask for the byte ranges that search produced

Step 3 is a GET with a Range: 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_VERSION 2 → 3. FetchPartition gains layout, file_kind, and byte_ranges; is_sort_shuffle is gone, because that bool selected a path shape and misreads as data-vs-index once a file_kind sits beside it.

Coordinates address a logical partition's bytes; byte_ranges narrows 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.
  • any layout + ranges → exactly those absolute offsets, resolving nothing.
  • any layout + 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 delete stream_sort_shuffle_block rather 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

  • Round trip driving RangeShuffleWriterExec into fetch_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.
  • Byte offsets are checked by slicing the file at each recorded range and decoding the message, not by checking the offsets are self-consistent. Arithmetic agreed with itself while being wrong.
  • One test covers dictionary-encoded data, the only shape that distinguishes footer-derived offsets from byte-counted ones.
  • The writer asserts its footer's record-block count equals the batches it wrote, so a future re-chunking fails loudly instead of producing an index that addresses the wrong batches.
  • h2o Q8 verified against single-process DataFusion at both scales.

What this is not

  • Wall-clock evidence. Every number here is bytes or rows. The measurements run on one box where the network is free, so a bandwidth saving does not become a time saving. A real-cluster run is owed before any latency or throughput claim, and will replace these figures.
  • The public interface. @phillipleblanc's request on feat(core): ValueIndexExec + ValueIndexReader + shuffle FileWriter switch #2204 — public RangeShuffleWriter / RangeShuffleReader traits 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.
  • The end of the read amplification. What remains at K=8 is the halo the window genuinely needs plus one batch of granularity at each edge. Larger K makes selection less effective, not more — a cut eventually becomes narrower than a single 8192-row batch, and nothing can be skipped below one batch. The direction that helps is the opposite one: fewer, larger sorted files per task, where every file straddles every consumer's range and the index has room to work.

…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>
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 24, 2026
avantgardnerio and others added 4 commits August 24, 2026 16:13
…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 phillipleblanc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>
@github-actions github-actions Bot removed the documentation Improvements or additions to documentation label Aug 25, 2026
@avantgardnerio avantgardnerio changed the title feat(core): RangeShuffleWriterExec writing the seekable Arrow IPC file format feat(core): range shuffle reads only the bytes a consumer's range covers Aug 25, 2026
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>
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.

2 participants