fix: size the AQE broadcast decision by bytes, not row count#2084
Draft
andygrove wants to merge 2 commits into
Draft
fix: size the AQE broadcast decision by bytes, not row count#2084andygrove wants to merge 2 commits into
andygrove wants to merge 2 commits into
Conversation
`supports_collect_by_thresholds` compared a row count against `hash_join_single_partition_threshold_rows` whenever `total_byte_size` was unknown, so a build side of up to a million arbitrarily wide rows could be broadcast to every probe task without the byte threshold ever applying. Unknown `total_byte_size` is the common case, not an edge case: DataFusion discards it on every join, and rebuilding it in `Statistics::calculate_total_byte_size` only works when every column has a fixed width, so a single `Utf8` column loses it permanently. In TPC-H that covers most dimension-side join results. Estimate the size instead and hold it to the same byte threshold. Each column contributes its own `byte_size` statistic when present -- a total for that column's output, already scaled for filters and limits -- otherwise its fixed width times the row count, otherwise a default width mirroring Spark's `StringType`/`BinaryType` defaults. An overflowing estimate declines the broadcast rather than wrapping to a small number. The row threshold is retained as a ceiling, so this can only reject a broadcast the row rule would have allowed, never introduce a new one. Closes apache#2081.
The broadcast-vs-partitioned decision is a function of statistics, but the tests around it could only describe tables they were willing to materialise, so the sizes it actually turns on had no coverage: a build side of hundreds of thousands of rows, or one whose `total_byte_size` is unknown. The existing tests instead toggle the decision by zeroing the threshold, which shows the rule is self-consistent but not that the shipped thresholds behave. Add `StatsTable`, a table that declares its statistics and holds no rows, so a fixture can say "800,000 rows of unknown size" in one line. Its scan reports the declared figures and cannot be executed, which is enough for the planner tests, and it deliberately does not recompute `total_byte_size` on projection, since an unknown size is the case these fixtures exist to express. Add tests covering the decision at both edges -- wide rows of unknown size are not broadcast, while small dimensions, narrow rows, and known sizes under the threshold still are -- run under `SessionConfig::new_with_ballista` so they exercise the 10 MB / 1,000,000 row thresholds a deployment ships with rather than DataFusion's defaults, plus a test pinning those defaults directly. `wide_rows_of_unknown_size_are_not_broadcast` fails on the rule that preceded the previous commit and passes with it. The rest pass either way: they guard against the estimate rejecting broadcasts it should allow. Part of apache#2081.
Contributor
|
One note, while trying AQE on TPCDS Q72 for some reason bite size is not available in all joins (not sure why) hence AQE makes wrong decisions. |
Member
Author
Yes, that's exactly what I am seeing. I am getting up to speed with the statistics code now. |
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.
Which issue does this PR close?
Closes #2081.
Waiting for #2086 to merge before marking this as ready for review.
Rationale for this change
DynamicJoinSelectionExec::supports_collect_by_thresholdsdecides whether to collect a join's build side into aCollectLeftbroadcast. Whentotal_byte_sizeis known it compares that againsthash_join_single_partition_threshold(10 MB), which is the intent. Whentotal_byte_sizeisAbsentit instead compares the row count againsthash_join_single_partition_threshold_rows(1,000,000), and a row count says nothing about size. A build side of up to a million arbitrarily wide rows is replicated to every probe task without the byte threshold ever applying.Unknown
total_byte_sizeis the common case, not an edge case:estimate_join_statisticshardcodestotal_byte_size: Precision::Absent, keeping only an estimated row count.Statistics::calculate_total_byte_sizesumsDataType::primitive_width()across the schema; that isNoneforUtf8/LargeUtf8/Utf8View/Binary(andBoolean), so it falls back tototal_byte_size.to_inexact()— andto_inexact()onAbsentis stillAbsent.In TPC-H that covers most dimension-side join results, which carry names, addresses, or comments.
Observed on TPC-H SF1000 (2 executors x 8 cores / 56 GiB,
target_partitions=32,prefer_hash_join=false, adaptive planner on) withRUST_LOG=info,ballista_scheduler::state::aqe=debug. Q2 alone:Both are broadcast on the row rule alone, at 799,140 and 639,312 rows of unknown size. They are
part/supplier-derived rows carrying string columns, so the payload replicated to all 32 tasks is plausibly hundreds of MB rather than the 10 MB the threshold is meant to cap.The same run shows how easily the size is lost — two decisions over the same 25-row
nationtable:Exact(25) | Exact(400)where the projection is all-numeric, so bytes are recomputed as rows x width.Exact(25) | Absentwhere the schema carries a string column, so the recompute gives up.What changes are included in this PR?
1. Size the decision by bytes (
dynamic_join.rs). Whentotal_byte_sizeis absent, estimate it and hold it to the same byte threshold. Each column contributes the best figure available: its ownColumnStatistics::byte_size(a total for that column's output, already scaled for filters and limits), else its fixed width times the row count, else a default width mirroring Spark'sStringType(20)/BinaryType(100) defaults.Booleanis handled explicitly sinceprimitive_width()reports it asNone. An estimate that overflows declines the broadcast rather than wrapping to a small number.The row threshold is retained as a ceiling, which gives the change a useful property: it can only ever reject a broadcast the row rule would have allowed, never introduce a new one. The worst case is a shuffle where we previously broadcast.
For reference, Spark drives broadcast selection from
spark.sql.autoBroadcastJoinThresholdin bytes and estimates a per-row size from the schema (EstimationUtils.getSizePerRow) when it must; it has no row-count threshold standing in for size.2. Make this class of decision testable without a cluster (
test/stats_table.rs,test/broadcast_thresholds.rs). This bug was only visible on a deployed cluster, which is the more serious problem: the decision is a deterministic function of statistics, yet nothing could test it.The existing tests build
MemTables of ~40Int32rows, whose statistics are small and always exact, and toggle the decision by zeroing the threshold. Nothing in the suite can express "800,000 rows of unknown size over a string column" — the shape the bug lives in — andmake_ctxuses a bareSessionConfig::new(), so the tests never exercise the thresholds Ballista actually ships.StatsTabledeclares its statistics and holds no rows, so that shape is one line. Its scan reports the declared figures and cannot be executed, which is all the planner tests need. It deliberately does not recomputetotal_byte_sizeon projection, since an unknown size is the case the fixtures exist to express.The new tests run under
SessionConfig::new_with_ballista(), so they exercise the 10 MB / 1,000,000-row thresholds a deployment ships with, and one test pins those defaults directly — a test that sets its own thresholds would pass even if the shipped default were zero, which it was until recently.Are these changes tested?
Yes.
wide_rows_of_unknown_size_are_not_broadcastfails on the previous rule and passes with this one — it is a regression test for the reported bug, and it runs in well under a second.cargo clippy --all-targets -- -D warningsandcargo fmt --checkare clean.Performance: no measured benefit, and I want to be explicit about that.
Measured on TPC-H SF1000 against an adaptive-planner baseline of 6320.7 s (2 executors x 8 cores / 56 GiB, node-local data, 1 iteration). The run was stopped after 6 queries because the result was not interpretable:
The two join-free queries moved by +15% and -6%, so the noise floor on this cluster is wider than any effect here. q5 improved 8%, but this PR does not change q5's plan — its unknown-size broadcasts are a 25-row
nationand a 1-row aggregate, which pass the estimate — so that improvement cannot be credited to this change. The one query whose plan does change got marginally slower.So this is offered as a correctness/safety fix, not a performance improvement: it removes an unbounded broadcast that the byte threshold is supposed to cap. It is reasonable to expect it to matter more at wider schemas or higher partition counts, but that is an expectation, not a measurement.
The decision-level effect is verified. On Q2 at SF1000 this PR turns the 799,140-row unknown-size
CollectLeftintoPartitionedwhile keeping every genuinely small broadcast (nationat 25 rows,regionat 5, 1-row aggregates).Are there any user-facing changes?
No API or configuration changes. Under the adaptive planner, a build side whose size is unknown and estimated above
hash_join_single_partition_thresholdis now repartitioned instead of broadcast.hash_join_single_partition_threshold_rowskeeps its meaning as a ceiling.