[Ideas] Adaptive join: correct a wrong Broadcast decision at runtime #1962
Unanswered
leborchuk
asked this question in
Ideas / Feature Requests
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Description
The planner picks
Broadcast Motionfor the inner side of a join when itestimates that side is small. When the estimate is wrong by 10× or 100×, every
segment receives all inner rows and builds the entire hash table — N× the
network traffic, N× the build CPU, N× the memory, and often a spill to disk that
redistributing would have avoided. The query does not fail; it just runs far
slower than the alternative plan, and today nothing notices.
Anser (
gpcontrib/anser/) already establishes a rendezvous at exactly the rightmoment: the build side is fully scanned before probing starts, producers
publish, the coordinator aggregates, consumers receive. This issue is about
using that channel to carry cardinality instead of (or as well as) a bloom
filter, and deciding — with numbers — when the difference is large enough to act
on.
The requirement that drives the whole design: the switch boundary must be a
region where one algorithm is unambiguously better. Not "they are within 10%
and the trend favours one" — a zone where switching is obviously right, with a
deliberate no-switch band around it.
What we can reuse
An Anser channel is a
palloc'd accumulator in the coordinator backend runningthe query, fed by parts arriving over the dispatch connection and folded by
anser_disp_apply_part()(src/anserdispatch.c:302). Only the fold isbloom-specific —
AnserBloomFoldPartInPlace()(src/anserfilter.c:126) checksthe
ABF1magic. Statistics fold just as easily:So milestone 1 needs a second payload type, not a new transport. The wire
already carries a
kindand aflagsfield (ANSER_WIRE_KIND_PART,ANSER_WIRE_F_CANCELLEDininclude/ansersideband.h:59-63) and the parsertakes fields positionally, so adding a payload-type tag plus a fold callback is
a contained change — bump
ANSER_WIRE_TAGif the header layout changes. Designthat generalization deliberately, because milestone 3 depends on it.
One thing already learned the hard way. The coordinator originally took
expected_partsfrom the first part that arrived, and a part claiming adifferent count could lower it — completing a channel early and delivering a
filter missing another segment's keys. That is a false negative, i.e. silently
dropped join rows. It now takes the maximum any producer claims and logs
disagreement (
anser_disp_apply_part()). Any new payload type inherits thathazard: a merge that completes early is a correctness bug, not a performance
bug.
Milestone 0 — feasibility and correctness (do this first, do not skip)
Before any code or benchmarks, answer this in a design note. It is entirely
possible that the honest answer to part of this issue is "not feasible without
planner changes", and finding that out in week 1 is a success.
Facts to start from:
MOTIONTYPE_HASHvsMOTIONTYPE_BROADCAST(
src/include/nodes/plannodes.h:1880-1886) differ, at the sender, only inhow each tuple's destination is chosen. The receiving slice is the same set
of processes either way, and the gang is already running. So flipping the
sender's routing is not obviously impossible.
compatibly. Broadcast-inner works with the outer left wherever it is.
Redistribute-inner requires the outer to also be hashed on the join key. If
the plan broadcast the inner precisely so the outer would not need a motion,
you cannot flip one side in isolation — you would silently produce wrong
results, which is far worse than being slow.
That yields one case where a runtime switch is provably safe:
Milestone 0 deliverable — a note answering:
key? How often does that happen in TPC-DS at SF100? (Count it — this bounds
the value of the whole issue.)
them? (They must agree, or tuples for the same key land on different segments
and rows are lost.)
side need re-scanning, and if so what does that cost?
nodeMotion.cat thesender, or by choosing between two pre-planned alternatives at slice start?
alternatives and choosing at execution start, or aborting and re-dispatching
with corrected cardinality (what Spark AQE does at shuffle boundaries)?
Do not begin milestone 2 until a reviewer has agreed with this note.
Milestone 1 — the cost model and where the boundary is (the core research)
What the system already believes
The Postgres-planner motion cost (
cdbpath_cost_motion(),src/backend/cdb/cdbpath.c):With R inner rows and N segments: redistribute has
recvrows ≈ R, broadcast hasrecvrows = R × N. So the model says broadcast-inner beats redistributing bothsides when, roughly:
i.e. at N=3 the outer must merely exceed the inner; at N=48 it must exceed it
23×. ORCA carries a separate, blunter rule:
optimizer_penalize_broadcast_threshold= 100 000 rows by default(
src/backend/utils/misc/guc_gp.c:4531).Your first job is to find out whether this linear model is true. It has no
term for any of the following, and at least two of them are non-linear.
The four cost components — measure each
work_memon every segmentThe strongest candidate for an unambiguous boundary
Component 4 is a step function, and steps are exactly what "no doubt" looks
like:
That reframes the trigger from "how many rows" to "which side of the spill
boundary each alternative lands on", with the row count as the input to that
test. ORCA's flat 100 000 rows is a crude proxy for the same thing that ignores
row width,
work_mem, and segment count.Verify this before building on it. Measure query time for broadcast and
redistribute while sweeping inner rows across the point where broadcast starts
spilling, at fixed
work_mem. If the curve shows a sharp knee, that is yourboundary and you can defend it. If it degrades smoothly, say so — the
recommendation then has to come from the K-factor rule below alone.
Required experiments
Use the Level-2 harness from the bloom-performance issue (
bench_build/bench_probe), with the inner side made deliberately misestimated — e.g.ANALYZE, then insert 100× more rows without re-analyzing, so the planner stillbelieves the old estimate.
Sweep, and plot broadcast vs redistribute as two curves:
work_memPer point record: query time (median of 5), bytes moved, peak memory per
segment, whether the hash join spilled (
EXPLAIN ANALYZEbatches / workfilelines), and the Anser publish/deliver timings (
anser.debuggives these today).The switch rule you must propose
Express it as a rule with a deliberate dead zone, and state every constant
with the measurement that produced it:
the value you pick from the spread of your own measurements: K must be larger
than your measurement noise by a comfortable margin.
inner side must be re-scanned, the switch cost is a full inner scan, and the
threshold must exceed it. Include this term with a measured value, not a guess.
Deliver the rule as: formula, every constant with its measurement, the dead zone
drawn on the crossover plot, and predicted vs actual outcome for at least 10
points (does the rule fire when it should, and stay quiet when it should not?).
Report false positives — cases where the rule would switch and be wrong.
Those matter more than the wins.
Milestone 2 — implement the safe case
Only the case established in milestone 0: outer already hash-distributed on
the join key. Even then:
coordinator sums them; the decision is taken once, centrally, and delivered to
all senders through the existing consumer delivery path
(
anser_disp_push(),src/anserdispatch.c:394).motion — the existing fail-open discipline. Never let an adaptation failure
change results or raise an error.
EXPLAIN ANALYZEmust show that the switch happened, what the estimate was,and what the actual was. An invisible adaptation is undebuggable.
anser.adaptive_join), default off.A note on timing. The measured Anser exchange on a 3-segment cluster is
~34 ms for a 1 MB payload, of which ~60% is the coordinator picking parts up one
at a time. A row count is a handful of bytes, so the payload cost vanishes but
the rendezvous latency does not — budget on the order of tens of milliseconds
for the round trip, and check that against the saving you are chasing. A switch
that saves 20 ms is not worth a 30 ms barrier.
Milestone 3 — skewed join (likely its own issue)
Once cardinality feedback works, the same channel can carry a skew profile.
Detection. Each segment builds a mergeable heavy-hitter summary of the join
key (Space-Saving / Misra-Gries top-K, or a Count-Min sketch); the coordinator
merges them — both merge by addition, so they fit the fold model directly. A key
is "heavy" when its frequency exceeds roughly
total_rows / N(one segmentwould receive more than its fair share); measure the right multiple.
Routing. Heavy keys broadcast, everything else redistributes.
MOTIONTYPE_EXPLICIT(destination taken from a column) already providesper-tuple routing and is worth studying as the mechanism.
Correctness argument you must write down before coding: for a hash join, if
the build rows of a heavy key are broadcast to every segment while the probe
rows of that key are spread arbitrarily, each probe row still meets every build
row for its key, so no match is lost and none is duplicated. Prove the same for
the non-heavy keys and for outer joins (the null-extended side is where this
usually breaks).
Threshold, same discipline as milestone 1: only act when skew is extreme
enough that the imbalance is unambiguous — e.g. the heaviest key alone exceeds
some multiple of the per-segment average — and quantify the cost of being wrong.
Benchmarks
plus a skew generator for milestone 3 (e.g. a Zipf key distribution; state the
parameter, and include a case where one key is 30% of all rows).
requires (injected subset in detail, non-injected regression check, geomean).
TPC-DS is especially relevant here: several queries have known cardinality
misestimations, and count how many queries broadcast an inner side that
turns out large — that count is the business case for this whole issue, so
measure it early and report it even before any implementation exists.
optimizer=on/off) — the motion decisions, andtheir mistakes, differ.
shared_preload_libraries='anser',anser.enable=on. NoCREATE EXTENSIONneeded.Definition of done
of how often the safe case occurs.
dead zone, the switch-cost term, and predicted-vs-actual for ≥10 points
including false positives.
EXPLAIN ANALYZE,fail-open on any adaptation failure, results verified identical to the
non-adaptive plan on the full regression suite plus TPC-DS row counts.
Out of scope / traps
different row set is a data-corruption bug. Every routing change needs the
correctness argument first, and a test that compares full result sets (not
counts) against the non-adaptive plan. See the
expected_partshistory abovefor how easily this happens.
requires planner-level alternatives or re-dispatch and is a much bigger
project; scope it only after milestone 2 works.
optimizer_penalize_broadcast_thresholdand call it adaptive.Changing a planner constant is a different (and much cheaper) change; if your
measurements show the default 100 000 is simply wrong, that is a valuable
one-line finding — report it separately rather than folding it in here.
the decision has to be made from the merged picture at the coordinator.
Use case/motivation
No response
Related issues
#1942
Are you willing to submit a PR?
All reactions