NoahKusaba opened a new pull request, #2430:
URL: https://github.com/apache/datafusion-ballista/pull/2430
# Which issue does this PR close?
Closes #2319
# Rationale for this change
`bias` and `round-robin` pick an executor by free vcores alone, so a reduce
task routinely fetches its input over Arrow Flight from an executor that had it
on local disk. `ShuffleReaderExec` already carries the `PartitionLocation`s
naming who wrote each input partition and how many bytes, so the scheduler has
what it needs to place the task where its bytes are. It just doesn't use it.
This adds a policy that does. It is off by default, and it reports the share
of input it kept local, because the gain is real only when data placement and
free capacity disagree.
# What changes are included in this PR?
**The policy** (`ballista/scheduler/src/cluster/affinity.rs`, new) is
selected with `--task-distribution shuffle-affinity`. Per stage, binding runs
two passes:
1. **Affinity.** Every `(pending partition, holder)` pair is ranked by bytes
and taken greedily while holders have vcores free, so a scarce slot goes to the
partition with most to gain, and a partition whose best holder is full settles
for its second best.
2. **Fallback.** Whatever is left is bound `bias`-style onto the remaining
vcores, so no partition idles waiting for a full holder.
An executor must hold 20% of a partition to count as a home for it. Below
that the preference is a guess: in an even shuffle each of `E` executors holds
`1/E`, so past five nothing qualifies and the partition is bound like any
other. Spark uses the same 0.2 in
`MapOutputTracker.getLocationsWithLargestOutputs`.
Attribution follows what one task actually reads. A collapse stage (one
task, every partition) goes whole to the executor holding most of the stage.
`UnionExec` children occupy disjoint partition ranges. Co-partitioned fan-ins
sum into the same partition. Broadcast input counts toward the stage total but
steers no individual partition.
**Plumbing.** `bind_one_where` is a `bind_one` variant that pulls a filtered
subset of the pending queue instead of the front slice, supported by
`PendingPartitions::{queued, next_slice_where}`. Dispatch arms were added to
`InMemoryClusterState::bind_schedulable_tasks` and `poll_work`. Locality scans
are memoized per `(job, stage)` and dropped when a stage succeeds or a job
stops running.
**Observability.** Six `shuffle_locality_*` Prometheus counters,
`ShuffleAffinityPolicy::stats()` for embedded schedulers, and a per-round
`debug!` line. The number to compare between policies is
`shuffle_locality_local_bytes_total / shuffle_locality_input_bytes_total`.
`shuffle_locality_imputed_bytes` flags when a producer reported no size and the
byte counts are padded.
**Docs.** A "Choosing a task distribution" section in the config guide, the
new metrics in the metrics guide, a note in the shuffle contributor guide, and
a fix to the `scheduler-policy` default, which was documented as `pull-staged`
but is `push-staged`.
Two limitations are documented rather than papered over:
- The policy needs `--scheduler-policy push-staged`. Under `pull-staged` the
scheduler sees one executor's capacity at a time and has no placement to
choose, so it warns at startup.
- Locality is executor-level, not host-level. The local-read check tests for
the file in the reader's own work dir, so co-located executors cannot read each
other's output however tasks are placed. Spark's preferences are host-level;
matching that needs a change to the read path, not the scheduler.
## Benchmark
Measured with a harness that drives a throwaway cluster once per policy. It
is kept out of this PR to hold the diff to the scheduler; it lives on the
`shuffle-affinity-benchmark` branch if anyone wants to reproduce these numbers.
4 executors on one machine, 16 shuffle partitions, 15 runs per policy.
`local_share` is the fraction of shuffle reads served from a local file.
**`collapse`**, a global aggregate whose single task reads every partition.
This is the case the policy is for:
| vcores/executor | bias | round-robin | shuffle-affinity |
|---|---|---|---|
| 3 | 25.0% | 18.8% | **37.5%** |
| 4 | 25.0% | 25.0% | 25.0% |
At 3 vcores the 8 map tasks land unevenly and one executor holds 6 of 16
partitions. Affinity finds it every run, the others reach it by luck, and 37.5%
is the ceiling. At 4 vcores the map tasks spread 2/2/2/2, 25% is the ceiling,
and all three tie because there is nothing to win.
**`aggregate`**, a plain hash shuffle. This is the control, showing the
policy costs nothing where it cannot help. Two invocations of the same
benchmark:
| policy | median A | median B | local_share |
|---|---|---|---|
| bias | 1.425s | 1.664s | 24.0% to 27.1% |
| round-robin | 1.363s | 1.467s | 26.0% |
| shuffle-affinity | 1.338s | 1.397s | 24.0% to 26.0% |
Every producer writes every partition at roughly equal size, so all three
read about `1/E`. Both invocations are shown on purpose: the medians move more
between repeat runs of one policy than they do between policies. On a single
machine a "remote" read is a loopback gRPC hop, so wall clock is not the signal
here. The local share is.
## The open questions from #2319
**How much locality is actually available?** Usually none, and the
exceptions are identifiable. On a plain hash shuffle every partition ranks the
executors identically, so affinity degenerates into "pack onto the biggest
holder", which is what `bias` already does, since capacity is what placed the
producers to begin with. That is the tie at `1/E` above. The issue's guess that
the best candidate may hold only `1/N` is correct, and it is now encoded rather
than left implicit: past five holders the policy declines to have an opinion
instead of ranking interchangeable candidates. The 4-vcore collapse row is
worth as much as the 3-vcore one, since it is the same query on the same
cluster, differing only in whether map placement happened to be even.
**What is the cost of the slot idling?** None. This policy never waits: if a
task's preferred executor is full, the task runs elsewhere in the same round.
I built the waiting version (hold a task back until its preferred executor
frees up) and dropped it. It adds a deadline per partition and a rule for how
much locality is worth waiting for, and nothing measured here showed that
paying off. If you revisit it: Spark rechecks held tasks every second, while
Ballista only rechecks when something happens, such as a task finishing.
Usually that is enough, but a wait can run long on an idle cluster.
**Interaction with stragglers and `max_bytes_in_flight`?** Local reads
acquire none of the governor's three semaphores, so a partition moved local
drops its permit demand outright. That much is structural, but it is not
visible in these measurements. `permit_wait_time` came in at 348µs for affinity
against 384µs for `bias` and 297µs for `round-robin`, under 0.03% of a 1.4s
query and well inside the noise. The 48 MiB default is simply not contended by
this workload at this scale, so the question is worth revisiting on a workload
that does contend it.
Ballista has no speculative execution, so there is no duplicate-task
mechanism for affinity to interact with. Retry is the real interaction, and the
cache is tagged with `stage_attempt_num` so a retried stage rescans rather than
placing tasks from a stale view. The residual risk is that affinity
concentrates work on the biggest data holder and turns it into a straggler. The
greedy assignment bounds this by never giving an executor more partitions than
it has free vcores, but this PR does not measure it.
# Tests
36 unit and integration tests.
**Unit.** 26 in `affinity.rs` cover the scan (holder ranking, the 20%
threshold and its exact boundary, union offsets, co-partitioned sums, broadcast
attribution, unsized producers), assignment (scarce slot to the strongest
locality, full first choice falling to the second best), the cache (reused per
stage attempt, invalidated on retry, pruned per job and per stage), and
measurement. Plus 2 for the new `PendingPartitions` methods, 2 for the config
accessor, and 2 for the Prometheus counters.
**Integration.** The push path through `bind_schedulable_tasks` (placement,
that `bias` on the same input differs, and that stats reach the configured
policy instance), the pull path through `poll_work`, and an end-to-end run
asserting locality reaches the metrics collector.
# Are there any user-facing changes?
Yes, all opt-in. Defaults are unchanged, so an existing deployment behaves
exactly as before.
**New config value.** `--task-distribution` accepts `shuffle-affinity`
alongside `bias` and `round-robin`. The default is still `bias`.
**New metrics.** Six `shuffle_locality_*` counters appear on `/api/metrics`,
and only when the new policy is selected. Existing metrics are untouched.
**New log line.** Selecting `shuffle-affinity` together with
`--scheduler-policy pull-staged` logs a warning at startup, because the policy
cannot do anything useful in that mode.
**New public API** for embedded schedulers: the `cluster::affinity` module,
`ShuffleAffinityPolicy` with `new`, `stats` and `attach_metrics`,
`LocalityStats`, and `TaskDistributionPolicy::{shuffle_affinity,
shuffle_affinity_policy}`.
**One source-breaking change.** `TaskDistributionPolicy` is public and gains
a `ShuffleAffinity` variant, so code that matches on it without a wildcard arm
will no longer compile. Adding `_ => ...` fixes it. Nothing else breaks: the
new `SchedulerMetricsCollector::record_shuffle_locality` has a default body, so
existing implementations keep compiling untouched.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]