Skip to content

Remove the hash-partitioning shuffle writer - #2106

Merged
andygrove merged 14 commits into
apache:mainfrom
andygrove:remove-hash-shuffle
Jul 21, 2026
Merged

Remove the hash-partitioning shuffle writer#2106
andygrove merged 14 commits into
apache:mainfrom
andygrove:remove-hash-shuffle

Conversation

@andygrove

@andygrove andygrove commented Jul 20, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

N/A

Rationale for this change

Ballista had two shuffle writer implementations selected by a session config
(ballista.shuffle.sort_based.enabled, default true):

  • Sort-based (SortShuffleWriterExec) — writes one consolidated data file plus an
    index file per input partition (2 x N files), coalesces small batches, and bounds
    memory via spilling.
  • Hash-based (ShuffleWriterExec's hash path) — keeps one open Arrow StreamWriter
    per output partition and writes one file per (input_partition, output_partition)
    pair (N x M files). This per-output-partition state does not scale to high output
    partition counts.

Sort-based has been the default and supersedes the hash path for the case that matters
(hash-repartition stages). Keeping the non-scaling hash writer and a config toggle to
select it added complexity and a foot-gun without a real use case. This PR removes it.

What changes are included in this PR?

  • Planner: hash-repartition stages (Partitioning::Hash) now always use
    SortShuffleWriterExec; the ballista.shuffle.sort_based.enabled gate is gone.
    create_shuffle_writer_with_config errors on any other Some(..) partitioning
    instead of silently building a writer that could not be encoded.
  • Writer: removed ShuffleWriterExec's hash write path (the per-output-partition
    WriteTracker array + BatchPartitioner) and the unused repart_time metric.
    ShuffleWriterExec is retained for single-partition (None) output — the final
    output stage, CoalescePartitionsExec, SortPreservingMergeExec, and broadcast-build
    stages. Since it no longer repartitions at all, its Option<Partitioning> is gone
    outright: try_new drops the argument, the struct drops the field and derives its
    properties from the child plan, and the ShuffleWriter trait impl returns None
    unconditionally. ShuffleWriterVariant::Hash is renamed to Passthrough to match.
  • Reader / Flight: sort-shuffle reads are gated purely on the on-disk index file
    (is_sort_shuffle_output), which was already the authoritative signal; the redundant
    config-derived boolean and the ballista_sort_shuffle_enabled trait method are removed.
  • Config: removed the ballista.shuffle.sort_based.enabled key, its ConfigEntry,
    and its accessor. Sort-shuffle tuning keys (batch_size,
    memory_limit_per_task_bytes, writer channel capacity) are unchanged.
  • Benchmark: shuffle_bench is now sort-only (dropped --writer hash).
  • Tests: removed the hash-vs-sort comparison tests and the sort-shuffle enable-toggle
    tests; adapted writer/reader unit tests that constructed hash-partitioned writers to the
    single-partition path.
  • Docs: removed the "Hash-based shuffle (opt-in)" documentation and the
    sort_based.enabled config rows.

TPC-H plan-stability golden files are unchanged: they already show
SortShuffleWriterExec for hash-repartition stages and ShuffleWriterExec: partitioning: None for terminal stages, so query plan shape does not move under the default
configuration.

Are there any user-facing changes?

Yes — breaking. The ballista.shuffle.sort_based.enabled config key and the public
SessionConfigExt::ballista_sort_shuffle_enabled trait method are removed, and the
hash-based shuffle writer is no longer available. BallistaConfig does not reject unknown
keys, so a config that still sets the removed key will be accepted and silently ignored
(sort-based shuffle is used unconditionally) rather than erroring.

Two further public API breaks for downstream projects that build plans directly:
ShuffleWriterExec::try_new loses its trailing shuffle_output_partitioning argument,
and ShuffleWriterVariant::Hash is renamed to ShuffleWriterVariant::Passthrough.
The api change label is applied.

Note on wire compatibility: scheduler and executor deploy together and serialized plans
are ephemeral, but for completeness — a legacy physical plan carrying a hash
output_partitioning inside a ShuffleWriterExecNode now fails to decode, since
hash-repartition stages are always emitted as SortShuffleWriterExec.

andygrove added 12 commits July 20, 2026 08:18
Guard try_new() to reject Some(Partitioning::Hash(..)) and delete the
now-unreachable Hash match arm from execute_shuffle_write, along with
WriteTracker and the repart_time metric it used. Hash-repartition
stages are handled by SortShuffleWriterExec instead; ShuffleWriterExec
now only ever writes the single-file (None) passthrough path.

Update the module's existing tests that exercised the Hash arm to
exercise the surviving None path instead (test, test_partitioned
folded into test, display_renders_child_operator_metrics,
test_hash_repart_write_failure_propagates renamed to
test_create_dir_failure_propagates), and add
try_new_rejects_hash_partitioning to cover the new guard.
@github-actions github-actions Bot added documentation Improvements or additions to documentation python labels Jul 20, 2026
@milenkovicm

Copy link
Copy Markdown
Contributor

should we merge this after 54.1 has been released (if we assume releasing current main) as it will bring breaking change ?

@andygrove
andygrove marked this pull request as ready for review July 20, 2026 15:42
@andygrove
andygrove requested review from avantgardnerio, metegenez and milenkovicm and removed request for milenkovicm July 20, 2026 15:42
@andygrove

Copy link
Copy Markdown
Member Author

@phillipleblanc could you help review?

@andygrove

Copy link
Copy Markdown
Member Author

should we merge this after 54.1 has been released (if we assume releasing current main) as it will bring breaking change ?

I'm planning on cherry picking commits to the 54 release branch

@avantgardnerio avantgardnerio 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.

I think this conflicts with the direction I was heading. I'm "requesting changes" so I have a chance to look it over even if someone else approves. I'll respond with real feedback shortly.

@avantgardnerio

avantgardnerio commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

I see this removes ShuffleWriter(Hash) which I think is a good thing. I see it doesn't remove ShuffleWriter(Passthrough) which was my main concern. I'm about to start adding RangeRepartitionExec and that will require the passthrough mode. So I think my biggest concern is addressed. Looking over the rest now.

@avantgardnerio avantgardnerio 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.

Changing to "comment" so I don't hold up the works, now that I know my critical concern was a non-issue.

@avantgardnerio
avantgardnerio dismissed their stale review July 20, 2026 16:19

Trying to retract my change request.

@avantgardnerio

Copy link
Copy Markdown
Contributor

I'm considering case B here, now that we have multi-partition-per-task, there's some instances where it could be better to hash parition:

  Case A: 1 input partition per task (4 tasks total)
  - Sort-shuffle: 4 tasks × 1 input each = 4 files total. Downstream reader for output k opens 4 files, seeks to index[k] in each.
  - Hash-writer (M×K layout): 4 tasks × 4 outputs each = 16 files total. Downstream reader for output k opens 4 files (one per producer task), reads each whole.
  
  Case B: 4 input partitions per task (1 task total, the multi-partition-tasks payoff)
  - Sort-shuffle: 1 task × 4 inputs = 4 files total. Downstream reader for output k still opens 4 files with 4 index seeks — same file count as Case A.
  - Hash-writer: 1 task × 4 outputs = 4 files total. Downstream reader for output k opens 1 file, reads whole.

But what I was working towards anyway was removing that hashing from ShuffleWriter entirely:

RepartitionExec(Hash) → ShuffleWriterExec(None)

Which I think this PR still allows.

@andygrove

Copy link
Copy Markdown
Member Author

I'm considering case B here, now that we have multi-partition-per-task, there's some instances where it could be better to hash parition:

  Case A: 1 input partition per task (4 tasks total)
  - Sort-shuffle: 4 tasks × 1 input each = 4 files total. Downstream reader for output k opens 4 files, seeks to index[k] in each.
  - Hash-writer (M×K layout): 4 tasks × 4 outputs each = 16 files total. Downstream reader for output k opens 4 files (one per producer task), reads each whole.
  
  Case B: 4 input partitions per task (1 task total, the multi-partition-tasks payoff)
  - Sort-shuffle: 1 task × 4 inputs = 4 files total. Downstream reader for output k still opens 4 files with 4 index seeks — same file count as Case A.
  - Hash-writer: 1 task × 4 outputs = 4 files total. Downstream reader for output k opens 1 file, reads whole.

But what I was working towards anyway was removing that hashing from ShuffleWriter entirely:

RepartitionExec(Hash) → ShuffleWriterExec(None)

Which I think this PR still allows.

If there are valid cases for keeping hash-based partition where there are tiny number of output partitions, we can keep it in. I am targeting use cases that could have very high number of output partitions (hundreds/thousands).

@avantgardnerio avantgardnerio 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.

I was also looking to remove ShuffleWriter(Hash), but was looking at this from a slightly different perspective: I'd love to remove all shuffling from writers and only have ShuffleWriter(None) (I'm not sure if this is feasible or not yet for SortShuffleWriter.

The end goal for me is something like:

ArbitraryPartitioner -> ShuffleWriter(Passthrough)

Which allows for the RangeRepartitioning I was about to put up a PR for. I think this PR preserves that ability, but I'd like to explicitly call it out, and even go so far as to say standard hash partitioning is better for some cases where partitions_per_task > 1 (see comments for an example).

It's possible that we'll hit some friction down the road by conflating partitioning with writing, but we can deal with that when we get there (shortly). On the whole I think this PR is a step in the right direction, by deleting the Hash branch.

Comment thread ballista/core/src/execution_plans/shuffle_writer.rs Outdated
Comment thread ballista/scheduler/src/planner.rs Outdated
@avantgardnerio

avantgardnerio commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

very high number of output partitions (hundreds/thousands).

Let's say there's a company with a 250 node cluster, each with 32 vcores. Currently, with paritions_per_task = 32, that would create 8000 sort shuffle output files. Stage N+1 would need to read a small chunk of all 8000 files. Compare that with Hash partitioning, which would output 250 files. I believe this table is correct:

image

IIUC, that's a manageable number of files, but a very high amount of network requests.

@avantgardnerio

Copy link
Copy Markdown
Contributor

To put this more simply:

N*M has turned into N*M/vcores but only for Hash partitioning, and the denominator can be significant (we have up to 45 cores). But it doesn't apply to SortShuffleWriter and it would be interesting to see if we could make it apply, longer term.

@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.

Looks good to me - we should probably put the api change label on this PR since its a breaking change.

@andygrove

Copy link
Copy Markdown
Member Author

Thanks for the reviews/feedback. I will aim to address feedback tomorrow.

`ShuffleWriterExec` no longer repartitions, so its `Option<Partitioning>`
was dead in every real path: the planner passes `None` at all call sites,
the AQE adapter passes `None` or `Hash` (which routes to
`SortShuffleWriterExec`), and proto decode can only produce `None` or
`Hash`. Rather than reject `Some(Partitioning::Hash(..))` at runtime,
remove the parameter and field entirely so the writer is unambiguously
the passthrough writer.

- `ShuffleWriterExec::try_new` drops its `shuffle_output_partitioning`
  argument; the struct drops the field and derives its properties from
  the child plan.
- The `ShuffleWriter` trait impl returns `None` unconditionally.
- Proto decode errors on a `ShuffleWriterExecNode` that still carries an
  `output_partitioning`; encode always writes `None`.
- `create_shuffle_writer_with_config` errors on a non-hash `Some(..)`
  instead of silently building a writer that could not be encoded.
- `ShuffleWriterVariant::Hash` is renamed to `Passthrough`, which is what
  the variant has actually meant since the hash write path was removed.

Plan display keeps `partitioning: None` so TPC-H plan-stability golden
files are unchanged.
@andygrove

Copy link
Copy Markdown
Member Author

@avantgardnerio @phillipleblanc I addressed feedback. PTAL when you can.

@avantgardnerio

Copy link
Copy Markdown
Contributor

@avantgardnerio @phillipleblanc I addressed feedback. PTAL when you can.

I stand by my previous ✔️ . Thanks for doing this! It's funny we were both working on the same thing at the same time.

@andygrove
andygrove merged commit 6540d08 into apache:main Jul 21, 2026
31 checks passed
@andygrove
andygrove deleted the remove-hash-shuffle branch July 21, 2026 17:58
andygrove added a commit that referenced this pull request Aug 15, 2026
* fix(core): report the real partitioning from ShuffleWriterExec

`ShuffleWriterExec`'s `DisplayAs` wrote the string literal
"partitioning: None". It used to print a value, until #2106 removed the
`shuffle_output_partitioning` field along with the hash-partitioning
writer; the token was then frozen as a literal so the TPC-H plan-stability
goldens would not churn in the same diff.

The result carried no information and was kept because it carried none: a
constant cannot drift, so it cannot break a golden. It was also misleading,
since the `None` refers to the absence of a repartitioning scheme, not to
the writer's output partitioning. This writer preserves its input's, and
`try_new` already sets its `PlanProperties` accordingly.

Report `self.properties().output_partitioning()`, matching how
`SortShuffleWriterExec` renders its own. Stage plans now show either the
inherited hash partitioning or the collapsed single partition, both
verifiable against the child plan directly below.

Goldens regenerated with BALLISTA_GENERATE_GOLDEN=1.

* test: update plan expectations for the real ShuffleWriterExec partitioning

The display change left three sets of expectations still asserting the old
"partitioning: None" literal:

- `context_checks.rs` EXPLAIN / EXPLAIN ANALYZE tables, which now show the
  hash partitioning the final stage actually carries
- eight `assert_plan!` inline snapshots in the scheduler (planner and AQE
  tests), regenerated with `cargo insta`
- stale `/* Expected result: */` comment blocks in `planner.rs` and the
  sample stage plan in `dev/bin/showplan.sh`

CI only reported the client failures because `cargo test` stops at the
first failing test binary, so the scheduler snapshots never ran.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-change documentation Improvements or additions to documentation python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants