Skip to content

perf: share each broadcast read between the tasks on an executor - #2807

Merged
andygrove merged 3 commits into
apache:mainfrom
andygrove:perf/broadcast-read-cache
Nov 10, 2026
Merged

andygrove merged 3 commits into
apache:mainfrom
andygrove:perf/broadcast-read-cache

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

No issue. Split out of #2792.

Rationale for this change

The build side of a broadcast hash join is a broadcast ShuffleReaderExec that reads every output partition of the upstream stage. Each task of the joining stage runs its own copy of the plan, so every task on an executor fetches and decodes the same data. Spark broadcasts a relation once per executor.

Each task fetches every upstream output (M map outputs × N partitions), so the cost grows with partition count. In TPC-H SF100 q2 at 128 partitions on two executors, a 16-task broadcast stage made 16,384 fetch requests and decoded the same 318K-row table 16 times. The stage took 3.0 s of task time.

What changes are included in this PR?

  • A new executor-wide BroadcastReadCache (execution_plans/broadcast_cache.rs). The first task on an executor to run a broadcast read fetches it. Tasks that ask while that fetch is running wait for it, and later tasks reuse the decoded batches.
    • Entries are keyed by job, upstream stage, and a hash of the exact locations read. A re-run upstream stage therefore gets a new entry.
    • A failed fetch is not cached. Every task that asks retries it and reports its own error, so fetch-failure recovery works as before.
  • ShuffleReaderExec::with_broadcast_cache attaches the cache. execute uses it only for broadcast readers that have locations, when ballista.shuffle.cache_broadcast_reads is on. The existing fetch body moves into an inherent fetch method unchanged.
  • DefaultExecutionEngine owns one cache and attaches it to every broadcast reader in the stages it creates. ExecutionEngine gains remove_job, with a default that does nothing, which the executor calls when it removes a job's data.
  • New config ballista.shuffle.cache_broadcast_reads, default true. Six unit tests cover:
    • concurrent waiters sharing one fetch
    • reuse after the fetch completes
    • failures not cached
    • idle expiry
    • oversized reads not kept
    • remove_job

Results:

Memory. The cached batches are not charged to the memory pool. That matches the fetched batches of a single read, which never were; the pool sees each task's hash table built from them. What changes is how long they live. Without the cache, a task's decoded build side is freed once its hash table is built. With the cache, one decoded copy per broadcast read is kept until it has been unused for 30 s, or until the executor removes the job's data. Idle entries are dropped lazily, on the next cache lookup, and reads above 1 GiB are not kept. So an executor holds at most one extra copy of each recent broadcast read, in exchange for no longer decoding a copy per task. There is no overall cap across entries. Setting ballista.shuffle.cache_broadcast_reads=false restores the previous behavior.

Are there any user-facing changes?

  • New config key ballista.shuffle.cache_broadcast_reads, enabled by default.
  • Additive API: ExecutionEngine::remove_job (with a default implementation), DefaultExecutionEngine::broadcast_cache and ShuffleReaderExec::with_broadcast_cache.

&self.metrics,
partition,
)))
self.fetch(partition, context)

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.

nit: can we add else branch here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, it's an if/else now.

}
Ok::<_, datafusion::error::DataFusionError>(Arc::new(batches))
})
.await?;

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.

would it make sense to capture last used here, after fetch has finished ?

*entry.last_used.lock().unwrap() = now;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, it was stamped before the fetch, so a fetch slower than the idle TTL left an entry that looked idle right away and got swept. It's now set in stream once the batches are ready, which covers the fetching task and the ones waiting on it. Added a_slow_fetch_is_not_idle_when_it_finishes.

})
})
.clone();
*entry.last_used.lock().unwrap() = now;

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.

should we move last used to stream ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved, see the reply above.

);
let reader = self.clone();
return Ok(
cache.stream(key, self.schema.clone(), move || reader.fetch(0, context))

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.

metrics will be updated only once, when reader.fetch is triggered (if no cache entries) do we need to update metrics when broadcast is read from cache ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, cache hits recorded zero output_rows. Tasks served from the cache now record their own output rows, so every task's reader reports what it produced. Fetch metrics stay at zero on a hit since nothing was fetched. Added broadcast_cache_hits_record_output_rows.

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

thanks @andygrove in general it looks good, few comments please consider them before merging

sim added 3 commits November 10, 2026 04:46
The build side of a broadcast hash join is a broadcast ShuffleReaderExec
that reads every partition of the upstream stage. Each task runs its own
plan, so every task on an executor fetched and decoded the same build
side: at 128 partitions a 16-task broadcast stage made 16,384 fetch
requests for one 318K-row table.

Add an executor-wide BroadcastReadCache. The first task to run a
broadcast read fetches it; tasks that ask meanwhile wait for that fetch,
and later ones reuse the decoded batches. Entries are keyed by job,
stage and the exact locations read, expire after 30 s unused, are not
kept above 1 GiB, and are dropped with the job's data. A failed fetch is
not cached, so each task reports its own fetch error as before.

Controlled by ballista.shuffle.cache_broadcast_reads (default true).
@andygrove
andygrove force-pushed the perf/broadcast-read-cache branch from c5982d8 to 25405b7 Compare November 10, 2026 11:51
@andygrove
andygrove added this pull request to the merge queue Nov 10, 2026
Merged via the queue into apache:main with commit 5bd230e Nov 10, 2026
24 checks passed
@andygrove
andygrove deleted the perf/broadcast-read-cache branch November 10, 2026 13:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants