Skip to content

Commit d95f1da

Browse files
committed
[SPARK-55973][SS] LeftSemi optimization for stream-stream join
### What changes were proposed in this pull request? This PR proposes to apply a couple optimizations which stream-stream join with LeftSemi join type currently misses, mostly for state store. The current implementation has below issues: 1. The operator bypasses storing the left side of the row when there is a match in the right side. But since the operator processes left side -> right side, we can't leverage this optimization when there is a match in the same batch. 2. The operator keeps the rows in the left side till watermark passes by, even though they were already matched and they won't be used as output ever again. This PR proposes the below changes based on the observation: 1. The operator processes right side first, and then left side for LeftSemi join type. It actually doesn't matter for other join types to change the order, but they don't get the benefit so we don't introduce that change. 2. The operator aggressively removes the rows in the state on left side as long as it realizes they are matched with new row in the right side, rather than waiting for watermark to pass by. The code change for the verification of the metrics from the tests for LeftSemi shows the impact of this optimization. ### Why are the changes needed? Explained the above section. ### Does this PR introduce _any_ user-facing change? It's mostly an internal change. There is user-facing change if they ever change the join type during the restart, but that is an area of "undocumented" and we do not define any behavior with it. ### How was this patch tested? Existing tests and new tests. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude 4.6 opus (co-authored) Closes #54769 from HeartSaVioR/SPARK-55973. Authored-by: Jungtaek Lim <kabhwan.opensource@gmail.com> Signed-off-by: Jungtaek Lim <kabhwan.opensource@gmail.com>
1 parent 5fa8dc7 commit d95f1da

4 files changed

Lines changed: 488 additions & 136 deletions

File tree

sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/join/StreamingSymmetricHashJoinExec.scala

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@ case class StreamingSymmetricHashJoinExec(
394394
// - Left Semi Join: generates all stored left input rows, from matching new right input
395395
// with stored left input, and also stores all the right input. Note only first-time
396396
// matched left input rows will be generated, this is to guarantee left semi semantics.
397+
//
398+
// For Left Semi Join, we process the right side first so that new right input is stored
399+
// before left input probes against it. This way, new left rows that match new right rows
400+
// in the same microbatch are emitted immediately without being buffered in state.
397401
val leftOutputIter =
398402
joinerManager.leftSideJoiner.storeAndJoinWithOtherSide(joinerManager.rightSideJoiner) {
399403
(input: InternalRow, matched: InternalRow) => joinedRow.withLeft(input).withRight(matched)
@@ -412,8 +416,13 @@ case class StreamingSymmetricHashJoinExec(
412416
// This is the iterator which produces the inner and left semi join rows. For other joins,
413417
// this will be prepended to a second iterator producing other rows; for inner and left semi
414418
// joins, this is the full output.
419+
//
420+
// For left semi join, right side is processed first so new right rows are available in
421+
// state when left rows probe, avoiding unnecessary left-side state buffering.
415422
val hashJoinOutputIter = CompletionIterator[InternalRow, Iterator[InternalRow]](
416-
leftOutputIter ++ rightOutputIter, onHashJoinOutputCompletion())
423+
if (joinType == LeftSemi) rightOutputIter ++ leftOutputIter
424+
else leftOutputIter ++ rightOutputIter,
425+
onHashJoinOutputCompletion())
417426

418427
val outputIter: Iterator[InternalRow] = joinType match {
419428
case Inner | LeftSemi =>
@@ -535,6 +544,11 @@ case class StreamingSymmetricHashJoinExec(
535544
math.max(NANOSECONDS.toMillis(System.nanoTime - hashJoinOutputCompletionTimeNs), 0)
536545
}
537546

547+
// Count rows removed from the other side's state during the join phase
548+
// (e.g., left semi join optimization removes matched left-side rows while processing
549+
// right-side input, instead of a separate eviction pass).
550+
numRemovedStateRows += joinerManager.numRemovedFromOtherSideDuringJoin
551+
538552
allRemovalsTimeMs += timeTakenMs {
539553
// Remove any remaining state rows which aren't needed because they're below the watermark.
540554
//
@@ -664,6 +678,7 @@ case class StreamingSymmetricHashJoinExec(
664678
}
665679

666680
private[this] var updatedStateRowsCount = 0
681+
private[this] var numRemovedFromOtherSideDuringJoinCount = 0
667682
private[this] val allowMultipleStatefulOperators: Boolean =
668683
conf.getConf(SQLConf.STATEFUL_OPERATOR_ALLOW_MULTIPLE)
669684

@@ -699,8 +714,6 @@ case class StreamingSymmetricHashJoinExec(
699714
case _ => (_: InternalRow) => Iterator.empty
700715
}
701716

702-
val excludeRowsAlreadyMatched = joinType == LeftSemi && joinSide == RightSide
703-
704717
val generateOutputIter: (InternalRow, Iterator[JoinedRow]) => Iterator[InternalRow] =
705718
joinSide match {
706719
case LeftSide if joinType == LeftSemi =>
@@ -719,6 +732,9 @@ case class StreamingSymmetricHashJoinExec(
719732
case _ => (_: InternalRow, joinedRowIter: Iterator[JoinedRow]) => joinedRowIter
720733
}
721734

735+
val removeMatchedFromOtherSideState =
736+
joinType == LeftSemi && joinSide == RightSide
737+
722738
nonLateRows.flatMap { row =>
723739
val thisRow = row.asInstanceOf[UnsafeRow]
724740
// If this row fails the pre join filter, that means it can never satisfy the full join
@@ -727,11 +743,23 @@ case class StreamingSymmetricHashJoinExec(
727743
// the case of inner join).
728744
if (preJoinFilter(thisRow)) {
729745
val key = keyGenerator(thisRow)
730-
val joinedRowIter: Iterator[JoinedRow] = otherSideJoiner.joinStateManager.getJoinedRows(
731-
key,
732-
thatRow => generateJoinedRow(thisRow, thatRow),
733-
postJoinFilter,
734-
excludeRowsAlreadyMatched)
746+
// If the join type is Left Semi and this is the right side, we can remove the matched
747+
// row from the other (left) side's state, since the row won't be produced anymore for
748+
// the following input rows.
749+
val joinedRowIter: Iterator[JoinedRow] = if (removeMatchedFromOtherSideState) {
750+
otherSideJoiner.joinStateManager.getJoinedRowsAndRemoveMatched(
751+
key,
752+
thatRow => generateJoinedRow(thisRow, thatRow),
753+
postJoinFilter).map { row =>
754+
numRemovedFromOtherSideDuringJoinCount += 1
755+
row
756+
}
757+
} else {
758+
otherSideJoiner.joinStateManager.getJoinedRows(
759+
key,
760+
thatRow => generateJoinedRow(thisRow, thatRow),
761+
postJoinFilter)
762+
}
735763
val outputIter = generateOutputIter(thisRow, joinedRowIter)
736764
new AddingProcessedRowToStateCompletionIterator(key, thisRow, outputIter)
737765
} else {
@@ -877,6 +905,8 @@ case class StreamingSymmetricHashJoinExec(
877905
}
878906

879907
def numUpdatedStateRows: Long = updatedStateRowsCount
908+
909+
def numRemovedFromOtherSideDuringJoin: Long = numRemovedFromOtherSideDuringJoinCount
880910
}
881911

882912
/**
@@ -886,6 +916,11 @@ case class StreamingSymmetricHashJoinExec(
886916
private case class OneSideHashJoinerManager(
887917
leftSideJoiner: OneSideHashJoiner, rightSideJoiner: OneSideHashJoiner) {
888918

919+
def numRemovedFromOtherSideDuringJoin: Long = {
920+
leftSideJoiner.numRemovedFromOtherSideDuringJoin +
921+
rightSideJoiner.numRemovedFromOtherSideDuringJoin
922+
}
923+
889924
def removeOldState(): Long = {
890925
leftSideJoiner.removeOldState() + rightSideJoiner.removeOldState()
891926
}

0 commit comments

Comments
 (0)