Skip to content

Commit ae8a42a

Browse files
committed
[SPARK-59716][SQL] Skip the per-match UnsafeRow copy when the AS-OF join right-side buffer is in memory
### What changes were proposed in this pull request? `SortMergeAsOfJoinScanner` rescans the buffered right-side group once per left row, and did `bestMatch = rightRow.copy()` for every right row that improved on the match so far, although only the final one is ever used. That copy is only needed when `ExternalAppendOnlyUnsafeRowArray` has switched to its spillable backing store, whose iterator re-points a single `UnsafeRow` on every `next()`. While the buffer is in memory the iterator returns the distinct rows it stores, so a retained match stays valid. This PR exposes that as `ExternalAppendOnlyUnsafeRowArray.isSpillBacked` and copies a match only when it is true. The flag is read per scan rather than cached, because `clear()` drops the spillable backing store between equi-key groups. ### Why are the changes needed? The copies are pure overhead on the common, non-spilled path, and there are `O(matches)` of them per left row. `AsOfJoinBenchmark`, `Best Time(ms)` of the "Sort-merge AS-OF join" case, from the regenerated result files in this PR. `Improvement` is the reduction in best time. `AS-OF Join (left=10000, right=10000, groups=100)` | JDK | Before | After | Improvement | |---|---|---|---| | 17 | 53 | 44 | 17.0% | | 21 | 56 | 49 | 12.5% | | 25 | 53 | 53 | 0.0% | `AS-OF Join (left=10000, right=10000, groups=10)` | JDK | Before | After | Improvement | |---|---|---|---| | 17 | 170 | 110 | 35.3% | | 21 | 181 | 120 | 33.7% | | 25 | 180 | 122 | 32.2% | `AS-OF Join no equi-key (left=10000, right=10000)` | JDK | Before | After | Improvement | |---|---|---|---| | 17 | 1378 | 804 | 41.7% | | 21 | 1466 | 870 | 40.7% | | 25 | 1457 | 942 | 35.3% | The `groups=100` case buffers only ~100 right rows per group, so the scan is a small part of an end-to-end time dominated by the shuffle and sort, and the result sits inside its own stdev either way. The larger-group cases, where the per-left-row scan actually dominates, improve by 32-42%. Reusing one `UnsafeRow` and copying the winning row's bytes into it was measured too, and did not help: it removes the allocation but keeps the memory copy, which is what costs here. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? Pass the CIs. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Opus 5 Closes #58888 Closes #58971 from dongjoon-hyun/SPARK-59716. Authored-by: Dongjoon Hyun <dongjoon@apache.org> Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
1 parent 30dc3dc commit ae8a42a

6 files changed

Lines changed: 82 additions & 20 deletions

File tree

‎sql/core/benchmarks/AsOfJoinBenchmark-jdk21-results.txt‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,21 @@ OpenJDK 64-Bit Server VM 21.0.12+8-LTS on Linux 6.17.0-1022-azure
66
AMD EPYC 7763 64-Core Processor
77
AS-OF Join (left=10000, right=10000, groups=100): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
88
--------------------------------------------------------------------------------------------------------------------------------
9-
Correlated subquery (baseline) 37055 37454 564 0.0 3705548.9 1.0X
10-
Sort-merge AS-OF join 56 80 20 0.2 5593.8 662.4X
9+
Correlated subquery (baseline) 39014 39335 454 0.0 3901384.8 1.0X
10+
Sort-merge AS-OF join 49 69 15 0.2 4941.2 789.6X
1111

1212
OpenJDK 64-Bit Server VM 21.0.12+8-LTS on Linux 6.17.0-1022-azure
1313
AMD EPYC 7763 64-Core Processor
1414
AS-OF Join (left=10000, right=10000, groups=10): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
1515
-------------------------------------------------------------------------------------------------------------------------------
16-
Correlated subquery (baseline) 37594 37599 6 0.0 3759403.2 1.0X
17-
Sort-merge AS-OF join 181 192 11 0.1 18128.5 207.4X
16+
Correlated subquery (baseline) 40047 40121 104 0.0 4004730.2 1.0X
17+
Sort-merge AS-OF join 120 124 3 0.1 11967.3 334.6X
1818

1919
OpenJDK 64-Bit Server VM 21.0.12+8-LTS on Linux 6.17.0-1022-azure
2020
AMD EPYC 7763 64-Core Processor
2121
AS-OF Join no equi-key (left=10000, right=10000): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
2222
--------------------------------------------------------------------------------------------------------------------------------
23-
Correlated subquery (baseline) 24398 24433 50 0.0 2439779.8 1.0X
24-
Sort-merge AS-OF join 1466 1473 10 0.0 146583.2 16.6X
23+
Correlated subquery (baseline) 23948 24018 99 0.0 2394812.4 1.0X
24+
Sort-merge AS-OF join 870 876 7 0.0 86954.2 27.5X
2525

2626

‎sql/core/benchmarks/AsOfJoinBenchmark-jdk25-results.txt‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,21 @@ OpenJDK 64-Bit Server VM 25.0.4+7-LTS on Linux 6.17.0-1022-azure
66
AMD EPYC 7763 64-Core Processor
77
AS-OF Join (left=10000, right=10000, groups=100): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
88
--------------------------------------------------------------------------------------------------------------------------------
9-
Correlated subquery (baseline) 40348 40452 148 0.0 4034755.7 1.0X
10-
Sort-merge AS-OF join 53 72 12 0.2 5328.1 757.3X
9+
Correlated subquery (baseline) 39527 39582 77 0.0 3952677.7 1.0X
10+
Sort-merge AS-OF join 53 66 12 0.2 5296.1 746.3X
1111

1212
OpenJDK 64-Bit Server VM 25.0.4+7-LTS on Linux 6.17.0-1022-azure
1313
AMD EPYC 7763 64-Core Processor
1414
AS-OF Join (left=10000, right=10000, groups=10): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
1515
-------------------------------------------------------------------------------------------------------------------------------
16-
Correlated subquery (baseline) 38920 38926 9 0.0 3891989.2 1.0X
17-
Sort-merge AS-OF join 180 194 10 0.1 18010.8 216.1X
16+
Correlated subquery (baseline) 38087 38181 132 0.0 3808724.6 1.0X
17+
Sort-merge AS-OF join 122 134 10 0.1 12168.4 313.0X
1818

1919
OpenJDK 64-Bit Server VM 25.0.4+7-LTS on Linux 6.17.0-1022-azure
2020
AMD EPYC 7763 64-Core Processor
2121
AS-OF Join no equi-key (left=10000, right=10000): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
2222
--------------------------------------------------------------------------------------------------------------------------------
23-
Correlated subquery (baseline) 24916 24985 98 0.0 2491579.0 1.0X
24-
Sort-merge AS-OF join 1457 1466 13 0.0 145736.3 17.1X
23+
Correlated subquery (baseline) 26714 26883 239 0.0 2671359.5 1.0X
24+
Sort-merge AS-OF join 942 959 15 0.0 94248.7 28.3X
2525

2626

‎sql/core/benchmarks/AsOfJoinBenchmark-results.txt‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,21 @@ OpenJDK 64-Bit Server VM 17.0.20+8-LTS on Linux 6.17.0-1022-azure
66
AMD EPYC 7763 64-Core Processor
77
AS-OF Join (left=10000, right=10000, groups=100): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
88
--------------------------------------------------------------------------------------------------------------------------------
9-
Correlated subquery (baseline) 37227 37450 315 0.0 3722681.0 1.0X
10-
Sort-merge AS-OF join 53 65 9 0.2 5338.2 697.4X
9+
Correlated subquery (baseline) 37503 37642 196 0.0 3750289.2 1.0X
10+
Sort-merge AS-OF join 44 56 9 0.2 4397.5 852.8X
1111

1212
OpenJDK 64-Bit Server VM 17.0.20+8-LTS on Linux 6.17.0-1022-azure
1313
AMD EPYC 7763 64-Core Processor
1414
AS-OF Join (left=10000, right=10000, groups=10): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
1515
-------------------------------------------------------------------------------------------------------------------------------
16-
Correlated subquery (baseline) 37115 37195 114 0.0 3711469.3 1.0X
17-
Sort-merge AS-OF join 170 177 4 0.1 17028.0 218.0X
16+
Correlated subquery (baseline) 37895 38037 201 0.0 3789498.9 1.0X
17+
Sort-merge AS-OF join 110 117 5 0.1 10975.9 345.3X
1818

1919
OpenJDK 64-Bit Server VM 17.0.20+8-LTS on Linux 6.17.0-1022-azure
2020
AMD EPYC 7763 64-Core Processor
2121
AS-OF Join no equi-key (left=10000, right=10000): Best Time(ms) Avg Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
2222
--------------------------------------------------------------------------------------------------------------------------------
23-
Correlated subquery (baseline) 25422 25490 96 0.0 2542213.0 1.0X
24-
Sort-merge AS-OF join 1378 1379 2 0.0 137830.4 18.4X
23+
Correlated subquery (baseline) 23963 24038 107 0.0 2396300.0 1.0X
24+
Sort-merge AS-OF join 804 809 5 0.0 80401.4 29.8X
2525

2626

‎sql/core/src/main/scala/org/apache/spark/sql/execution/ExternalAppendOnlyUnsafeRowArray.scala‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ class ExternalAppendOnlyUnsafeRowArray(
9999

100100
def isEmpty: Boolean = numRows == 0
101101

102+
/**
103+
* Whether this array has switched to the [[UnsafeExternalSorter]] backing store (which may
104+
* not have written anything to disk yet, see [[spillSize]] for that).
105+
*
106+
* This decides the row ownership contract of [[generateIterator]]: while it is false the
107+
* iterator yields the distinct [[UnsafeRow]]s stored in the in-memory buffer, while once it
108+
* is true the iterator re-points a single [[UnsafeRow]] on every `next()`, so a caller that
109+
* retains a row past the current iteration step has to copy it.
110+
*/
111+
def isSpillBacked: Boolean = spillableArray != null
112+
102113
/**
103114
* Total number of bytes that has been spilled into disk so far.
104115
*/

‎sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SortMergeAsOfJoinExec.scala‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,18 @@ private[joins] class SortMergeAsOfJoinScanner(
376376
}
377377
}
378378

379+
/**
380+
* Retains `rightRow` as the best match found so far.
381+
*
382+
* While the right-side buffer is held in memory, its iterator yields the distinct rows stored
383+
* in the buffer and a match can be retained as is. Once the buffer is spill-backed the
384+
* iterator re-points a single [[UnsafeRow]] on every `next()`, so the match has to be copied
385+
* out before the scan advances. `needsCopy` is read once per scan by the callers.
386+
*/
387+
private def retainMatch(rightRow: UnsafeRow, needsCopy: Boolean): UnsafeRow = {
388+
if (needsCopy) rightRow.copy() else rightRow
389+
}
390+
379391
/**
380392
* Forward scan for Backward joins: last-match-wins.
381393
* Buffer is sorted ascending by as-of key. For left.t >= right.t,
@@ -385,6 +397,7 @@ private[joins] class SortMergeAsOfJoinScanner(
385397
private def findBestBackwardForward(leftRow: InternalRow): InternalRow = {
386398
var bestMatch: InternalRow = null
387399
val iter = rightGroupBuffer.generateIterator()
400+
val needsCopy = rightGroupBuffer.isSpillBacked
388401

389402
joinedRow.withLeft(leftRow)
390403
while (iter.hasNext) {
@@ -399,7 +412,7 @@ private[joins] class SortMergeAsOfJoinScanner(
399412
}
400413
if (residualSatisfied) {
401414
// Last match wins (closest right.t to left.t)
402-
bestMatch = rightRow.copy()
415+
bestMatch = retainMatch(rightRow, needsCopy)
403416
}
404417
} else if (bestMatch != null) {
405418
// as-of condition transitioned true -> false (monotone for Backward).
@@ -418,6 +431,7 @@ private[joins] class SortMergeAsOfJoinScanner(
418431
var bestMatch: InternalRow = null
419432
var bestDistance: Any = null
420433
val iter = rightGroupBuffer.generateIterator()
434+
val needsCopy = rightGroupBuffer.isSpillBacked
421435

422436
joinedRow.withLeft(leftRow)
423437
while (iter.hasNext) {
@@ -434,7 +448,7 @@ private[joins] class SortMergeAsOfJoinScanner(
434448
val distance = boundOrderExpr.eval(joinedRow)
435449
if (distance != null) {
436450
if (bestMatch == null || distanceOrdering.lt(distance, bestDistance)) {
437-
bestMatch = rightRow.copy()
451+
bestMatch = retainMatch(rightRow, needsCopy)
438452
bestDistance = distance
439453
} else {
440454
// Distance is increasing past the minimum. For Forward,

‎sql/core/src/test/scala/org/apache/spark/sql/SortMergeAsOfJoinSuite.scala‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,4 +820,41 @@ class SortMergeAsOfJoinSuite extends QueryTest
820820
)
821821
}
822822
}
823+
824+
test("backward join - in-memory group followed by a spill-backed one") {
825+
// `clear()` drops the spillable backing store between equi-key groups, so one scanner can
826+
// see an in-memory group (whose buffer iterator yields the distinct stored rows) and then a
827+
// spill-backed one (whose iterator re-points a single UnsafeRow on every next(), so that a
828+
// retained match has to be copied out). Group "A" has one right row and stays in memory,
829+
// group "B" has three and spills; in "B" the match is followed by a non-matching row, so a
830+
// match that was not copied out would be clobbered before it is emitted.
831+
withSQLConf(
832+
SQLConf.SORT_MERGE_JOIN_EXEC_BUFFER_IN_MEMORY_THRESHOLD.key -> "1",
833+
SQLConf.SORT_MERGE_JOIN_EXEC_BUFFER_SPILL_THRESHOLD.key -> "1") {
834+
val leftSchema = StructType(
835+
StructField("grp", StringType) ::
836+
StructField("ts", IntegerType) :: Nil)
837+
val rightSchema = StructType(
838+
StructField("grp", StringType) ::
839+
StructField("ts", IntegerType) ::
840+
StructField("right_val", StringType) :: Nil)
841+
// Values of differing lengths so a clobbered match stands out in the answer.
842+
val bestVal = "b" * 40
843+
val left = spark.createDataFrame(
844+
List(Row("A", 8), Row("B", 8)).asJava, leftSchema)
845+
val right = spark.createDataFrame(
846+
List(Row("A", 3, "aa"), Row("B", 1, "x"), Row("B", 5, bestVal), Row("B", 12, "y")).asJava,
847+
rightSchema)
848+
checkAnswerAndSpill(
849+
left.joinAsOf(
850+
right, left.col("ts"), right.col("ts"), usingColumns = Seq("grp"),
851+
joinType = "inner", tolerance = null,
852+
allowExactMatches = true, direction = "backward"),
853+
Seq(
854+
Row("A", 8, "A", 3, "aa"),
855+
Row("B", 8, "B", 5, bestVal)
856+
)
857+
)
858+
}
859+
}
823860
}

0 commit comments

Comments
 (0)