Repository navigation
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25584 +/- ##
==========================================
+ Coverage 82.55% 82.57% +0.02%
==========================================
Files 1141 1143 +2
Lines 440209 441005 +796
Branches 440209 441005 +796
==========================================
+ Hits 363409 364167 +758
+ Misses 54829 54824 -5
- Partials 21971 22014 +43 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
sorry I am unlikely to have time to review substantial JOIN PRs in the near term |
jayzhan211
left a comment
There was a problem hiding this comment.
@sunchao Thanks for the detailed write-up and benchmarks. The core idea is sound. I'd like to propose a narrower shape that I think gets the same wins on the motivating queries with about a quarter of the diff: roughly 100–150 lines of production code plus tests, ~450–600 total instead of ~2,000.
1. The motivating queries only need a single cross-side comparison.
In the checked-in TPC-H Q21 plan (tpch/plans/q21.slt.part), both residuals are just l_suppkey@1 != l_suppkey@0. The inner-only l_receiptdate > l_commitdate is already pushed below the join as a FilterExec. The documents example in #25581 is also a single >. Side-local predicates are usually pushed into the inputs by push_down_filter, so guards, OR splitting, CASE/COALESCE and the clause/depth limits cover cases we haven't seen a need for yet. I'd leave them for a follow-up driven by a real query.
2. Min/max is enough for all five operators, including <>.
For non-null x and a non-empty set of non-null inner values:
exists y: x < y⇔x < max, and similarly<=exists y: x > y⇔x > min, and similarly>=exists y: x <> y⇔x <> min OR x <> max
So we don't need the representative, the multiple flag, or the overlapping-copy reservation logic.
3. Reuse the existing buffered path instead of adding a parallel one.
buffer_inner_key_group already reads, accounts for and spills the inner group. The summary can be a branch inside process_key_match_with_filter:
let (min, max) = inner_min_max(&self.inner_key_buffer, range.inner_idx)?;
let outer = outer_slice.column(range.outer_idx);
let hit = match range.op {
Operator::Lt => lt(outer, &max.to_scalar()?)?,
Operator::LtEq => lt_eq(outer, &max.to_scalar()?)?,
Operator::Gt => gt(outer, &min.to_scalar()?)?,
Operator::GtEq => gt_eq(outer, &min.to_scalar()?)?,
Operator::NotEq => or_kleene(
&neq(outer, &min.to_scalar()?)?,
&neq(outer, &max.to_scalar()?)?,
)?,
_ => unreachable!(),
};
// OR `hit` into `self.matched`, treating null as falseinner_min_maxfoldsmin_batch/max_batchfromdatafusion-functions-aggregate-common(already a dependency) over the buffered slices. These skip nulls, so an all-null group produces no match.- The residual is detected once at stream construction: exactly
Column op Column(optionally underNOT), one column per side, same non-float type. - Spilled groups keep the pairwise path.
What this removes:
existence_summary.rsand its expression compiler- the config option: the result is exact and never does more residual work than today, so I'd make this unconditional
- the separate non-spillable reservation
- the in-operator
yield_now: work between awaits is already bounded by batch size, because childnext()consumes the coop budget - the
USE_SUMMARYconst generic - the new key-group traversal loops and the five metrics
Trade-offs:
- The inner group is still buffered, whereas your version reads it without buffering. That path is already accounted and spillable, so memory is no worse than today.
- OR clauses and guards stay on the pairwise path.
Expected results:
- Large groups with no match (your 57×/106× cases) should see about the same speedup, since residual work drops from O(outer×inner) to O(outer + inner).
- Worst case: the first inner row matches every outer row. This adds one min/max pass over a group that was just buffered anyway, so at most a constant factor on work already being done.
- Your
sort_merge_join_existence_summarybenches can verify both.
Side note: I switched the current option on by default locally, and the join/subquery/tpch SLTs and all 43 join_fuzz tests (--features extended_tests) passed. The correctness work here is solid; this is only about scope and reviewability. Would you be open to reshaping the PR this way?
|
I agree with @jayzhan211's direction; a few points in addition:
|
|
Thanks @sunchao avoiding repeated check sounds very reasonable, chcking it |
comphead
left a comment
There was a problem hiding this comment.
Thanks @sunchao. The idea is sound and the write-up is clear. I agree with @jayzhan211's narrower shape. The inline comments point at existing joins/ code that either shape can reuse, plus test placement.
- Scope.
HashJoinExec, which the planner picks unlessprefer_hash_join=false, has the same pairwise residual. It enumerates every key-matching pair, filters, then dedups (hash_join/stream.rs:890-918,985). Spark (sort-merge and hash join), DuckDB, StarRocks and Velox also check these residuals pair by pair, and I did not find an engine that summarizes inside the join operator. Where engines use MIN/MAX, it is a plan rewrite of quantified comparisons: CalciteSubQueryRemoveRule, TrinoTransformQuantifiedComparisonApplyToCorrelatedJoin, DuckDBPlanExtremumRewrite(duckdb/duckdb#24090), and the open apache/spark#58424 for the Q95INself-join. A per-key MIN/MAX aggregate on the inner side of the semi/anti join would also reach hash joins. It adds an aggregate and would need a cost gate, so it fits a follow-up rather than this PR. - Flag. +1 to making it unconditional. Result-identical optimizations such as the perfect hash join shipped enabled, with thresholds. While the flag exists,
join_fuzznever runs this path althoughcol_lt_col_filterqualifies, becauserun_testbuilds the default config (core/tests/fuzz_cases/join_fuzz.rs:889). - Nits, most of which go away with the narrower shape:
USE_SUMMARYprotects no hot path. Every filtered group already reachesprocess_key_match, becausetry_process_key_matchreturnsfalsewhenever a filter exists (bitwise_stream.rs:1164). A runtimeif let Some(..)costs one branch per group and avoids a second copy of thejoinstate machine.- The
JoinSide::Nonecheck inclassifycannot fire. Filter column indices are onlyLeft/Right, and the existing path already returns an internal error forNone. - The algorithm is described three times (config doc,
bitwise_stream.rsmodule doc,existence_summary.rsmodule doc). The config doc is copied intoconfigs.mdandinformation_schema.slt, so it could describe only user-visible behavior. - In this crate "existence join" includes mark joins (
is_existence_joininjoins/utils.rs).semi_antiwould be more precise in the new names.
|
@jayzhan211 @comphead @viirya Updated in 548a662. Reshaped this around the narrow buffered design: one cross-side column comparison, optionally negated, using shared min/max accumulators and vectorized comparison. The expression compiler, configuration option, const-generic state-machine copy, separate consumer, manual yields, and five metrics are removed. The helper is now named For memory pressure, existing buffering/spilling runs first. Refused summary admission falls back without adding a resource-exhaustion error; successful reduction replaces the buffered group with owned extrema and shrinks the same reservation before further outer input. Long-string tests cover admission refusal and forced spilling, including probes across input batches. On timestamps, the timezone Arc remains shared. The conservative allowance follows the existing The final source passed 12,211 Rust tests (including all 44 default-configuration join-fuzz tests), all 525 SQL logic files, all-target/all-feature Clippy, formatting, and the full lint suite. Performance review remains open. The unfiltered semi timing difference is strongly sensitive to benchmark binary layout: it reproduced with identical production code and identical timed work, while native profiles locate the difference in unchanged Arrow key-comparison code. The adverse single-row range result did not reproduce under native sampling, which does not establish that it is fixed. SQL Q13 also has an adverse mean with substantial process variation. I have retained all raw results and diagnostic provenance, and am not claiming blanket non-regression or performance readiness. Six structural/test threads are resolved by this revision. The traversal-cost and benchmark threads remain open. The updated description records the exact base/head provenance, validation scope, and measurement limits; its obsolete performance tables have been removed. |
|
run benchmark tpch tpcds |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/oss-existence-summary (548a662) to 991fd23 (merge-base) diff Run configurationrun benchmark tpcdsResults will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/oss-existence-summary (548a662) to 991fd23 (merge-base) diff Run configurationrun benchmark tpchResults will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing codex/oss-existence-summary (548a662) to 991fd23 (merge-base) diff Run configurationrun benchmark tpchCPU Details (lscpu)Details
Resource Usagetpch — base (merge-base)
tpch — branch
File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing codex/oss-existence-summary (548a662) to 991fd23 (merge-base) diff Run configurationrun benchmark tpcdsCPU Details (lscpu)Details
Resource Usagetpcds — base (merge-base)
tpcds — branch
File an issue against this benchmark runner |
|
@sunchao thanks, are we expecting to see any benefits in TPC*? |
andygrove
left a comment
There was a problem hiding this comment.
Thanks @sunchao for the rework. The narrower shape is much easier to review, and the correctness side looks solid.
What I verified (on 548a662, merge-base 991fd23)
-
Correctness. By reading the code:
- the min/max equivalences, including
<>asx <> min OR x <> max; NOTnormalization viaOperator::negate();swap()for reversed operands;- NULL outer/inner values and all-NULL groups;
- the type gating;
IS [NOT] DISTINCT FROMis (correctly) excluded.
A 40k-case differential fuzz found no mismatches. It compared the summary path against the same filter made ineligible, across 10 types, every accepted operator including the
NOTforms, all four semi/anti join types, both null-equality modes, and random batch layouts. - the min/max equivalences, including
-
Tests. The 248 SMJ unit tests and the four SMJ
.sltfiles pass. For ablation, I injected five faults into the summary path:- drop the second extreme for
<>; - wrong extreme for
<; - wrong extreme for
>; - row probes that always match;
- skip the NULL mask.
Each one fails both
sort_merge_join_matrix.sltand the new unit tests. The matrix file builds 88 summaries across all four orientations and reuses cached ones for 69 later outer slices, so the 7-row SQL fixtures do reach the new path. - drop the second extreme for
Before I could approve
-
Bound the worst case. I agree with @jayzhan211's 10/3 comment, and I reproduced it with his
not_equal_third_witnesscase. These are your criterion cases,release-nonlto, each the minimum of three alternating rounds on a shared machine. Differences of a few percent are noise; a second run agreed within that:case base this PR + max(2, N/64)probe budget+ doubling prefix <>, witness at inner row 3, Utf8222 µs 833 µs 209 µs 226 µs <>, witness at inner row 3, Int64189 µs 231 µs 186 µs 191 µs >, no match, Utf89.7 ms 37 µs 83 µs 46 µs <>, no match, Int643.4 ms 19 µs 32 µs 24 µs 7-row groups, no match, Utf8 12.5 ms 7.5 ms 7.5 ms 7.8 ms The fixed budget removes the cliff but roughly halves the no-match wins. The last column keeps your two row probes, then grows min/max over doubling prefixes of the group (8, 16, 32, …), evaluating after each prefix. Each prefix summary is exact for the rows it covers, so the scan stops as soon as every matchable outer row has a witness. Cost then tracks where the witnesses are, with no tuning constant. That would probably also make single-row outer slices safe to include. The prototype is in the collapsed section at the end: about 100 lines on top of 548a662, and it passes the SMJ unit tests and
.sltfiles. -
Memory admission. Reserving 6× the largest parent column buffer before reducing causes measurable side effects. I measured these with 1 KiB strings, 8192-row inner batches and 7-row groups:
- The allowance is held alongside the buffered group, so
peak_mem_usedgoes from 16.2 to 64.4 MiB, and other consumers see the spike. I ran two concurrent streams on aGreedyMemoryPoolof about twice the ordinary path's worst case, with spilling disabled. In 2 of 5 runs, one of the 10 streams failed withResources exhausted ... Disk spilling disabled. The ordinary path never failed. - When the allowance never fits, every eligible group pays for the probes, then a failed
try_grow, then the ordinary path anyway. Under a bounded pool, 18,724 of 18,724 groups were refused, which is strictly more work than base. - When admission succeeds, each group does a
try_growand ashrink, and the next group grows back. That was 37,462try_growcalls versus 14 on the ordinary path, for the same input on an unbounded pool. This is the pool traffic thatclear_inner_key_groupavoids since #20729.FairSpillPooltakes its mutex on every call, and custom pools can be more expensive per call. - On main, #25933 charges a group that ends inside a batch only its per-row share, which makes the allowance even more out of proportion.
As @jayzhan211 suggested: summarize first, then
try_resizethe reservation to the summary's size. If that fails, keep the ordinary path; after #25933, the two extrema of a skewed string group can exceed the group's charged share. That also removes bothmemory_sizefunctions and the refusal branch. - The allowance is held alongside the buffered group, so
-
Rebase onto main. The textual conflict is trivial: in
sort_merge_join.slt, both sides add a block before# Config reset. But #25933 and #25986 (release_inner) change the same reservation, so the memory assertions insemi_anti_summary_tests.rsneed re-checking. -
Benchmark results in the description. For a
perf:PR, the description should show the improvement and the known adverse cases. A short base/head table would do. The provenance details (CPU pinning, verifying 1,815 source files, lockfiles) can go, since the description becomes the commit message.
Non-blocking
- Recognize once. Recognize the comparison once in
try_new(@jayzhan211's second comment) and make the per-group check fail closed. TodayNonein theOption<bool>means "allowed", so skipping spilled groups depends only on the initializer inprocess_filtered_match_loop. The comment "An unsupported filter will remain unsupported for this key" is misleading: the filter is unsupported for every key. - Named constants. Make the 7-row threshold, the 2 probes, and the 6× factor named constants, each with a sentence on how it was chosen.
- Benchmarks. The new bench function is about 360 lines, with special cases keyed on the case name. Consider keeping four or five criterion cases and adding the motivating "no newer version"
NOT EXISTSquery tobenchmarks/src/smj.rs. The existing<>semi-join queries there (Q11–Q13, Q18) have distinct inner values per key, so the first two probes always find witnesses and they only measure the overhead. - Test gaps.
- The AND cases can't detect a future "independent summaries" bug. The matrix
l.v < r.v AND l.v > r.vresult is empty either way, and thesort_merge_join.sltblock never reaches the summary gate, because outer keys are unique and inner groups have at most 3 rows. buffered_comparison_propagates_upstream_errorsfails insidebuffer_inner_key_groupbefore any summary code runs.- The new
sort_merge_join.slttables aren't dropped.
- The AND cases can't detect a future "independent summaries" bug. The matrix
- Small cleanups.
- Reuse
clear_inner_key_group(). - Use
reservation.resize(retained)instead ofshrink(size() - retained). - Drop the no-op
drop(summary). - Add one helper for the three copies of the OR-and-popcount block.
- Derive
needs_min/needs_maxfrom a singlematch, sosummarizecan't drift from whattry_newaccepts. - The module doc (lines 106–107) says temporary scalar copies use the reservation, but the probes run before admission.
- Reuse
- Possible follow-ups. Spilled groups, mark joins (the mark column is the same matched bitset), and floats (PWMJ's
extreme_keyalready handles them with the same kernels; sharing that helper would avoid two diverging implementations). - TPC question. On @comphead's question: the TPC-H and TPC-DS runs use hash joins (
prefer_hash_join=true), so they can't exercise this change../bench.sh run smjis the relevant suite. - Label. The
api changelabel looks stale for this revision.
Prototype: two row probes, then doubling-prefix min/max (applies to 548a662 with git apply)
This is the variant in the last column of the table above. It leaves summarize used only by tests; a real version would build the full summary through the same prefix helpers.
diff --git a/datafusion/physical-plan/src/joins/sort_merge_join/bitwise_stream.rs b/datafusion/physical-plan/src/joins/sort_merge_join/bitwise_stream.rs
index f7dff61f1..80f7cacef 100644
--- a/datafusion/physical-plan/src/joins/sort_merge_join/bitwise_stream.rs
+++ b/datafusion/physical-plan/src/joins/sort_merge_join/bitwise_stream.rs
@@ -801,7 +801,59 @@ impl BitwiseSortMergeJoinStream {
self.peak_mem_used.set_max(self.reservation.size());
// Keep scalar state out of the surrounding async futures.
// Admit the comparison descriptor alongside its scalars.
- let values = comparison.summarize(&self.inner_key_buffer)?;
+ // After the row probes, grow min/max over doubling
+ // prefixes (8, 16, ...). Each prefix summary is exact for
+ // the rows it covers, so stop once every matchable outer
+ // row has a witness.
+ let total: usize =
+ self.inner_key_buffer.iter().map(RecordBatch::num_rows).sum();
+ let mut prefix = comparison.new_prefix(&self.inner_key_buffer)?;
+ let mut consumed = 0;
+ let mut target = 8.min(total);
+ 'scan: for inner_batch in &self.inner_key_buffer {
+ let mut offset = 0;
+ while offset < inner_batch.num_rows() {
+ let take = (target - consumed)
+ .min(inner_batch.num_rows() - offset);
+ comparison.update_prefix(
+ &mut prefix,
+ inner_batch,
+ offset,
+ take,
+ )?;
+ offset += take;
+ consumed += take;
+ if consumed == target {
+ if consumed == total {
+ break 'scan;
+ }
+ let partial = comparison.snapshot(&mut prefix)?;
+ let result =
+ comparison.evaluate(&partial, outer_slice)?;
+ let values = result.values();
+ apply_bitwise_binary_op(
+ self.matched.as_slice_mut(),
+ outer_group_start,
+ values.inner().as_slice(),
+ values.offset(),
+ outer_group_len,
+ |a, b| a | b,
+ );
+ let matched_count = UnalignedBitChunk::new(
+ self.matched.as_slice(),
+ outer_group_start,
+ outer_group_len,
+ )
+ .count_ones();
+ if matched_count == matchable {
+ self.reservation.shrink(bytes);
+ return Ok(true);
+ }
+ target = (target * 2).min(total);
+ }
+ }
+ }
+ let values = comparison.snapshot(&mut prefix)?;
let retained = values
.memory_size()
.saturating_add(size_of::<SemiAntiComparison>());
diff --git a/datafusion/physical-plan/src/joins/sort_merge_join/semi_anti_summary.rs b/datafusion/physical-plan/src/joins/sort_merge_join/semi_anti_summary.rs
index 910c1e747..7c15f10e1 100644
--- a/datafusion/physical-plan/src/joins/sort_merge_join/semi_anti_summary.rs
+++ b/datafusion/physical-plan/src/joins/sort_merge_join/semi_anti_summary.rs
@@ -225,6 +225,51 @@ impl SemiAntiComparison {
}
}
+/// Min/max accumulated over a prefix of an inner key group.
+pub(super) struct PrefixExtrema {
+ min: MinAccumulator,
+ max: MaxAccumulator,
+}
+
+impl SemiAntiComparison {
+ pub(super) fn new_prefix(&self, batches: &[RecordBatch]) -> Result<PrefixExtrema> {
+ let data_type = batches[0].column(self.inner_column).data_type();
+ Ok(PrefixExtrema {
+ min: MinAccumulator::try_new(data_type)?,
+ max: MaxAccumulator::try_new(data_type)?,
+ })
+ }
+
+ pub(super) fn update_prefix(
+ &self,
+ prefix: &mut PrefixExtrema,
+ batch: &RecordBatch,
+ offset: usize,
+ len: usize,
+ ) -> Result<()> {
+ let values = [batch.column(self.inner_column).slice(offset, len)];
+ if matches!(self.op, Operator::NotEq | Operator::Gt | Operator::GtEq) {
+ prefix.min.update_batch(&values)?;
+ }
+ if matches!(self.op, Operator::NotEq | Operator::Lt | Operator::LtEq) {
+ prefix.max.update_batch(&values)?;
+ }
+ Ok(())
+ }
+
+ pub(super) fn snapshot(&self, prefix: &mut PrefixExtrema) -> Result<GroupSummary> {
+ let extreme = if matches!(self.op, Operator::Lt | Operator::LtEq) {
+ prefix.max.evaluate()?
+ } else {
+ prefix.min.evaluate()?
+ };
+ let other = (self.op == Operator::NotEq)
+ .then(|| prefix.max.evaluate())
+ .transpose()?;
+ Ok(GroupSummary { extreme, other })
+ }
+}
+
fn supported_type(data_type: &DataType) -> bool {
matches!(
data_type,
viirya
left a comment
There was a problem hiding this comment.
Thanks for the rework. This shape is much easier to follow, and my earlier points on spilling and the per-row scalar work are addressed.
The remaining pool-traffic and peak_mem_used concerns from my last comment both go away with the summarize-then-try_resize change @jayzhan211 and @andygrove suggested, so I'll +1 that rather than repeat it. I also agree with bounding the early-witness cost with the doubling-prefix scan: each prefix extremum is a real inner row, so a witness found on a prefix is a real witness, and MinAccumulator/MaxAccumulator::evaluate only clone their state, so the scan can take snapshots as it grows.
One gap the other comments don't cover is the singleton-outer gate (inline below). It currently keeps the common unique-outer-key NOT EXISTS shape off the fast path, and no bench case measures that direction.
|
Marked as draft after 14 days without activity: merge conflicts. Please address these items and mark Ready for review when ready. |
548a662 to
9608e5b
Compare
|
Hi @sunchao, thanks for the request (#25584 (comment)). Only whitelisted users can trigger benchmarks. Allowed users: 2010YOUY01, Dandandan, Fokko, HippoBaro, Jefffrey, Omega359, Rachelint, Rich-T-kid, adriangb, alamb, asubiotto, avantgardnerio, brunal, buraksenn, cetra3, codephage2020, coderfender, comphead, erenavsarogullari, etseidl, friendlymatthew, gabotechs, geoffreyclaude, grtlr, haohuaijin, jayzhan211, jonathanc-n, kevinjqliu, klion26, kosiew, kumarUjjawal, kunalsinghdadhwal, liamzwbao, mbutrovich, mkleen, mzabaluev, neilconway, rluvaton, sdf-jkl, timsaucer, xudong963, zhuqi-lucas. File an issue against this benchmark runner |
|
Rebased onto
|
|
Just glancing at this, I am going to voice the same concern I raised here #27278 (review)
I will take a review pass through this week. |
Which issue does this PR close?
Closes #25581.
Rationale for this change
A filtered semi/anti sort-merge join can scan the same inner key group once per outer batch to decide whether any row matches. For a query that keeps each document’s latest version, the maximum inner version answers that question for every version of the document.
What changes are included in this PR?
The join recognizes a single cross-input comparison on identical supported non-floating-point types. It supports
<,<=,>,>=, and<>, including comparisons underNOT. NULL operands never supply a witness. Spilled groups, compound filters, and mark joins use the existing evaluator.For an in-memory group with at least seven rows, range comparisons probe the first two rows and check extrema at doubling row counts. String ranges also check a changed extremum before the next string would cross a 64 KiB byte budget. Fixed-width
<>uses the minimum and maximum. String<>probes the first non-null value. A second distinct value settles the result for every non-null outer row, including later batches.When the key can continue into another outer batch, the join can retain an exact summary and release the buffered rows. String winners borrow the input until an owned copy fits alongside it. The retained summary must fit within the group's original reservation. After the first refusal, later outer batches for the same key scan the buffered values without requesting memory again. At inner EOF the join drops the exhausted key arrays and comparators before a cached summary releases the group's reservation. When the next outer key is already visible, the join skips the cache.
What is the testing strategy for this PR?
The SQL matrix covers all four semi/anti orientations, supported comparisons, NULLs, duplicates, string layouts, temporal and decimal values, and fallback for compound predicates. The operator tests cover early and late witnesses across batches, single-value and mixed string groups, spill and cancellation, key boundaries, and a second memory consumer reserving all remaining pool capacity while the join is suspended.
On
3e676a9640, the two memory regressions pass. The same tests fail oncf73b48d00. At the first anti-join output the old stream held four extra references to the inner string key buffer. When a competing consumer released its memory after the first outer batch, the old stream retried the refused string copy on the next batch. The fixed stream leaves only the test's buffer reference and continues scanning without retrying until the key changes.Workspace formatting and all-target, all-feature Clippy with warnings denied passed. The extended workspace suite passed 13,633 Rust tests (8 ignored) and all 547 SQL logic files. Rust CI for this commit
The existing Criterion uniform UTF-8
not_equal_no_matchcase compared main (c8265b6872) with3e676a9640. Both returned the expected 256 rows with zero spills. Two process medians were 197.00 and 196.39 ms on main and 0.215 and 0.221 ms on this revision. The runs used one pinned CPU, base/head/head/base order, and 20 samples per process. The physical-plan crate and benchmark harness used optimization level 2 with identical CI-profile dependencies.The earlier release-mode run on
cf73b48d00covers third-row string witnesses, wide common prefixes, singleton groups, shared-pool controls, and Q27 with full output verification. Earlier benchmarks and raw samplesAre there any user-facing changes?
Eligible semi/anti sort-merge joins use the optimization automatically. Join selection and public configuration are unchanged.