11. Adaptive Query Execution

실행 중 재최적화 루프·파티션 병합·Skew Join 분할·Join 전략 전환

  • Haram Lee
  • 2026-08-22
  • studies / Topics / Spark
  • 5.5에서 AQE가 하는 일 세 가지(파티션 병합, Skew Join 분할, Join 전략 전환)를 봤음
  • 이번 장은 그 세 가지가 어떤 루프 안에서, 어떤 숫자를 근거로 일어나는지 봄
    • 계획을 언제 다시 세우나
    • “실제 크기"는 어디서 얻나
    • 새 계획이 더 나은지는 무엇으로 판단하나
    • 각 최적화의 공식과 한계는 무엇인가
  • 근거는 Apache Spark 4.2.0 소스 (sql/core/.../execution/adaptive/)
flowchart TD
  plan["초기 물리 계획"] --> split["Exchange 기준으로<br/>Query Stage 분해"]
  split --> run["실행 가능한 Stage 제출<br/>Broadcast 먼저"]
  run --> wait["Stage 하나 완료 대기"]
  wait --> stats["MapOutputStatistics<br/>파티션별 실제 바이트"]
  stats --> reopt["논리 계획 재최적화<br/>+ 물리 계획 재생성"]
  reopt --> cost{"비용 비교<br/>Shuffle 수"}
  cost -- "같거나 더 쌈" --> adopt["새 계획 채택"]
  cost -- "더 비쌈" --> keep["기존 계획 유지"]
  adopt --> more{"남은 Stage?"}
  keep --> more
  more -- "있음" --> split
  more -- "없음" --> final["최종 Result Stage 실행"]

11.1 Why Static Plans Go Wrong

10장의 계획은 전부 실행 전 추정 통계로 세워짐.

text
스캔 크기     → 파일 크기로 대략 앎
Filter 이후  → 선택도를 추정해야 함 (틀리기 쉬움)
Join 이후    → 카디널리티 추정 (더 틀리기 쉬움)
UDF 이후     → 사실상 모름
  • 추정이 틀리면
    • Broadcast할 수 있는 크기인데 Sort Merge Join을 고름
    • spark.sql.shuffle.partitions 200이 데이터에 비해 너무 많거나 적음
    • 특정 Key에 몰린 파티션을 미리 알 수 없음
  • 그런데 Shuffle 경계는 자연스러운 “중간 점검 지점"임
    • 앞 Stage가 끝나면 Shuffle 출력의 파티션별 크기를 정확히 앎 (8.5의 MapOutputTracker)
    • 그 숫자로 남은 계획을 다시 세우면 됨

이게 AQE의 전부임. 10.6의 크기 판정 코드에 있던 plan.stats.isRuntime이 이 “측정된 통계"임을 나타내는 표시.


11.2 AdaptiveSparkPlanExec and the Re-Optimization Loop

삽입 지점

  • 10.1의 Preparation Rule 중 첫 번째가 InsertAdaptiveSparkPlan
  • 이 Rule이 플랜 전체를 AdaptiveSparkPlanExec로 감쌈
  • 이 노드가 Leaf 노드처럼 행동하므로, 뒤따르는 Preparation Rule들은 안쪽을 보지 못하고 AQE가 Stage마다 직접 적용함
  • 적용 조건
    • Exchange가 있거나 생길 수 있는 플랜에만 적용 (Shuffle이 없으면 다시 계획할 지점이 없음)
    • 모든 노드가 논리 계획과 연결(logicalLink)돼 있어야 함
    • Stateful Streaming은 제외

AQE 안에서 쓰는 Rule 묶음 세 가지:

묶음언제대표 Rule
queryStagePreparationRules재계획할 때마다EnsureRequirements, OptimizeSkewedJoin
queryStageOptimizerRules새 Query Stage를 만들 때마다CoalesceShufflePartitions, OptimizeShuffleWithLocalRead
AQEOptimizer논리 계획을 재최적화할 때DynamicJoinSelection 등

루프

scala
var result = createQueryStages(fun, currentPhysicalPlan, firstRun = true)
val events = new LinkedBlockingQueue[StageMaterializationEvent]()
while (!result.allChildStagesMaterialized) {
  currentPhysicalPlan = result.newPlan
  if (result.newStages.nonEmpty) {
    // SPARK-33933: Broadcast Stage를 먼저 제출 (Broadcast timeout 방지)
    val reorderedNewStages = result.newStages.sortWith { ... BroadcastQueryStageExec 우선 ... }
    reorderedNewStages.foreach { stage =>
      stage.materialize().onComplete { res => ... }   // 비동기 제출
    }
  }
  // 다음 Stage가 끝날 때까지 대기 → 새 런타임 통계
  val nextMsg = events.take()
  events.drainTo(rem)                                  // 비슷한 시점에 끝난 Stage도 함께 처리
  (Seq(nextMsg) ++ rem.asScala).foreach {
    case StageSuccess(stage, res) => stage.resultOption.set(Some(res))   // = MapOutputStatistics
    case StageFailure(stage, ex) => ...
  }

  val afterReOptimize = reOptimize(logicalPlan)
  if (afterReOptimize.isDefined) {
    val (newPhysicalPlan, newLogicalPlan) = afterReOptimize.get
    val origCost = costEvaluator.evaluateCost(currentPhysicalPlan)
    val newCost = costEvaluator.evaluateCost(newPhysicalPlan)
    if (newCost < origCost ||
        (newCost == origCost && currentPhysicalPlan != newPhysicalPlan)) {
      currentPhysicalPlan = newPhysicalPlan
      currentLogicalPlan = newLogicalPlan
    }
  }
  result = createQueryStages(...)
}
  • 실행 가능한 Stage(자식 Stage가 모두 끝난 Exchange)를 비동기로 제출함
  • 그중 하나라도 끝나면 깨어나서 재최적화함
  • 루프는 마지막 결과 Stage(ResultQueryStageExec)만 남으면 종료됨

Query Stage 분해

  • 아래에서 위로 순회하며 Exchange를 만나면 Query Stage로 만듦
  • 단, 자식 Stage가 모두 실행 완료된 Exchange만 새 Stage가 됨
  • 같은 Exchange가 여러 곳에 있으면 Stage 캐시를 이용해 한 번만 실행함

11.3 Runtime Statistics and the Cost Model

통계는 어디서 오나

scala
def computeStats(): Option[Statistics] = if (isMaterialized) {
  val runtimeStats = getRuntimeStatistics
  Some(Statistics(dataSize, numOutputRows, attributeStats, isRuntime = true))
} else { None }

override protected def doMaterialize(): Future[Any] = shuffle.submitShuffleJob()
  • Shuffle Stage를 끝까지 실행한 결과가 곧 MapOutputStatistics
    • 내용: Reduce 파티션별 바이트 수 (bytesByPartitionId)
  • 끝난 물리 Stage는 논리 계획 안에 LogicalQueryStage로 이식됨
  • 이 노드의 통계는 추정이 아니라 측정값 (isRuntime = true)
  • reOptimize는 통계 캐시를 비우고 논리 최적화, 물리 계획, Preparation Rule을 차례로 다시 돌림

비용 = Shuffle 개수

scala
case class SimpleCostEvaluator(forceOptimizeSkewedJoin: Boolean) extends CostEvaluator {
  override def evaluateCost(plan: SparkPlan): Cost = {
    val numShuffles = plan.collect { case s: ShuffleExchangeLike => s }.size
    if (forceOptimizeSkewedJoin) {
      val numSkewJoins = plan.collect { case j: ShuffledJoin if j.isSkewJoin => j }.size
      // Skew Join 수를 상위 32비트에 음수로 → Skew 처리가 많을수록 저비용
      SimpleCost(-numSkewJoins.toLong << 32 | numShuffles)
    } else {
      SimpleCost(numShuffles)
    }
  }
}
  • 의도적으로 단순하게 만든 모델: Shuffle이 적은 계획이 이김
  • 놓치기 쉬운 핵심: 비용이 같아도 계획이 다르면 새 계획을 채택함
    • Sort Merge Join을 Broadcast Hash Join으로 바꿔도 Shuffle 수가 줄지 않는 경우가 많음 (이미 Shuffle이 끝났으므로)
    • 비용이 같을 때 채택하는 규칙이 없으면 전환이 일어나지 않음

11.4 Coalescing Shuffle Partitions

AQE는 합치기만 하고 쪼개지는 못함

scala
def numShufflePartitions: Int = {
  if (adaptiveExecutionEnabled && coalesceShufflePartitionsEnabled) {
    getConf(COALESCE_PARTITIONS_INITIAL_PARTITION_NUM).getOrElse(defaultNumShufflePartitions)
  } else { defaultNumShufflePartitions }
}
  • Map 쪽이 쓰는 Shuffle 파티션 수는 실행 전에 정해지므로, 실행 후에는 줄일 수만 있음
  • 상한을 정하는 설정: spark.sql.adaptive.coalescePartitions.initialPartitionNum (없으면 spark.sql.shuffle.partitions = 200)
  • 정석: 처음에 크게(예: 2000) 주고 AQE가 줄이게 함

목표 크기 공식

scala
val minNumPartitions = conf.getConf(SQLConf.COALESCE_PARTITIONS_MIN_PARTITION_NUM).getOrElse {
  if (conf.getConf(SQLConf.COALESCE_PARTITIONS_PARALLELISM_FIRST)) {
    session.sparkContext.defaultParallelism   // 기본: 병렬도 우선
  } else { 1 }
}

val totalPostShuffleInputSize = mapOutputStatistics.flatMap(_.map(_.bytesByPartitionId.sum)).sum
val maxTargetSize = math.ceil(totalPostShuffleInputSize / minNumPartitions.toDouble).toLong
val targetSize = maxTargetSize.min(advisoryTargetSize).max(minPartitionSize)
text
targetSize = clamp(전체 크기 / minNumPartitions, 1MB, 64MB)
  • parallelismFirst = true(기본)면 minNumPartitions = 클러스터 전체 Core 수
  • 클러스터가 크면 전체 크기를 Core 수로 나눈 값이 64MB보다 훨씬 작아져서 생각보다 덜 합쳐짐
  • 공식 문서 권고: 바쁜 클러스터에서는 false로 두어 작은 Task가 너무 많아지지 않게 할 것
text
예: Shuffle 출력 전체 10GB, 클러스터 Core 400개

parallelismFirst = true  → 10GB / 400 = 25MB → 목표 25MB → 약 400개 파티션
parallelismFirst = false → 목표 64MB (advisory) → 약 160개 파티션

그리디 패킹

scala
while (i < end) {
  var totalSizeOfCurrentPartition = 0L
  var j = 0
  while (j < mapOutputStatistics.length) {     // 같은 그룹의 모든 Shuffle에서 i번 파티션 크기 합산
    totalSizeOfCurrentPartition += mapOutputStatistics(j).bytesByPartitionId(i)
    j += 1
  }
  if (i > latestSplitPoint && coalescedSize + totalSizeOfCurrentPartition > targetSize) {
    if (coalescedSize < minPartitionSize) {
      // 지금 묶음이 1MB 미만이면 앞뒤 중 작은 쪽에 붙임
      ...
    } else {
      createPartitionSpec()                     // 새 묶음 시작
    }
  } else {
    coalescedSize += totalSizeOfCurrentPartition
  }
  i += 1
}
  • 왼쪽에서 오른쪽으로 한 번 훑는 그리디 방식
  • 연속된 파티션 번호만 합침
    • Join 양쪽에서 같은 번호끼리 만나야 하므로 순서를 섞을 수 없음
  • Join 양쪽 Shuffle은 같은 그룹으로 묶여 크기를 합산하므로 양쪽이 똑같이 병합됨
  • 실제로 개수가 줄어드는 경우에만 적용되고, 이때 플랜에 AQEShuffleRead (coalesced)가 보임
text
예: target 128MiB

shuffle1: [100, 20, 100, 10, 30] MiB
shuffle2: [ 10, 10,  70,  5,  5] MiB
합계    : [110, 30, 170, 15, 35]

→ [0] 110
→ [1] 30 (110+30 > 128이라 분리)
→ [2] 170 (혼자서도 초과)
→ [3,4] 15+35 = 50
→ 파티션 5개 → 4개

한계: 파일 수 문제는 절반만 해결

  • 병합은 Shuffle 읽기 파티션에만 적용됨
  • 마지막 Stage가 Shuffle 없이 끝나면 출력 파일 수에는 영향이 없음 (12.4)

11.5 Skew Join Optimization

위치: Preparation Rule

  • OptimizeSkewedJoin은 Stage 최적화 Rule이 아니라 Preparation Rule
  • 파티션을 쪼개면 출력 분포가 바뀌므로 EnsureRequirements 검증보다 먼저 실행돼야 하기 때문

감지 공식

scala
/** A partition is considered as a skewed partition if its size is larger than the median
 *  partition size * SKEW_JOIN_SKEWED_PARTITION_FACTOR and also larger than
 *  SKEW_JOIN_SKEWED_PARTITION_THRESHOLD. */
def getSkewThreshold(medianSize: Long): Long = {
  conf.getConf(SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD).max(
    (medianSize * conf.getConf(SQLConf.SKEW_JOIN_SKEWED_PARTITION_FACTOR)).toLong)
}

private def targetSize(sizes: Array[Long], skewThreshold: Long): Long = {
  val nonSkewSizes = sizes.filter(_ <= skewThreshold)
  math.max(advisorySize, nonSkewSizes.sum / nonSkewSizes.length)
}
text
Skew 기준   = max(256MB, 5 × 중앙값)       ← 두 조건을 모두 넘어야 Skew
분할 목표   = max(64MB, Skew 아닌 파티션 평균)
  • 5.4에서 “Max ≫ Median이면 Skew"라고 눈으로 판단하던 기준을 그대로 수식으로 옮긴 것
  • 중앙값이 작아도 256MB를 넘지 않으면 Skew로 보지 않음

쪼개고, 반대편은 복제

scala
val leftParts = if (isLeftSkew) {
  ShufflePartitionsUtil.createSkewPartitionSpecs(left.mapStats.get.shuffleId, partitionIndex, leftTargetSize)
  ...
} else { leftNoSkewPartitionSpec }
...
for {
  leftSidePartition <- leftParts
  rightSidePartition <- rightParts
} {
  leftSidePartitions += leftSidePartition
  rightSidePartitions += rightSidePartition
}
text
왼쪽 파티션 2가 Skew → 3조각으로 분할, 오른쪽 파티션 2는 그대로

Task A: (L2-1, R2)
Task B: (L2-2, R2)
Task C: (L2-3, R2)
  • for의 곱이 곧 복제: 오른쪽 R2를 세 번 읽어서 결과를 빠뜨리지 않고 Join함
  • Join 타입에 따라 쪼갤 수 있는 쪽이 정해짐
    • 왼쪽 분할 가능: Inner, Cross, LeftSemi, LeftAnti, LeftOuter
    • 오른쪽 분할 가능: Inner, Cross, RightOuter
    • 예: Left Outer Join에서 오른쪽만 Skew면 처리할 수 없음 (오른쪽을 쪼개면 왼쪽 행이 여러 번 “매칭 없음"으로 나올 수 있음)

분할 단위 = 연속된 Map Task 범위

scala
def createSkewPartitionSpecs(shuffleId: Int, reducerId: Int, targetSize: Long, ...)
    : Option[Seq[PartialReducerPartitionSpec]] = {
  val mapPartitionSizes = getMapSizesForReduceId(shuffleId, reducerId)
  val mapStartIndices = splitSizeListByTargetSize(mapPartitionSizes, targetSize, ...)
  if (mapStartIndices.length > 1) {
    Some(... PartialReducerPartitionSpec(reducerId, startMapIndex, endMapIndex, dataSize) ...)
  } else { None }
}
  • 쪼개는 단위는 임의의 행이 아니라 “Map Task i번부터 j번까지가 이 Reduce 파티션에 보낸 블록”
  • 8.5의 Read path에 있던 startMapIndex·endMapIndex 파라미터가 여기서 쓰임
  • 한계: Map Task 하나가 보낸 블록 하나가 거대하면 더 쪼갤 수 없음
    • 예: 입력 파일 하나에 특정 Key가 몰려 있는 경우
    • 이럴 때는 여전히 Salting(5.4)처럼 사람이 직접 손을 써야 함

안전장치: Shuffle이 늘면 전부 취소

scala
val optimized = optimizeSkewJoin(plan)
val requirementSatisfied = ValidateRequirements.validate(optimized, ...)
if (requirementSatisfied) { optimized... }
else if (conf.getConf(SQLConf.ADAPTIVE_FORCE_OPTIMIZE_SKEWED_JOIN)) { ... }
else { plan }      // 추가 Shuffle이 필요해지면 Skew 최적화를 통째로 포기
  • 쪼갠 결과 출력 분포가 깨져서 위쪽 연산자를 위해 Shuffle이 하나 더 필요해지면 원래 계획으로 되돌림
  • spark.sql.adaptive.forceOptimizeSkewedJoin=true면 Shuffle이 늘어도 적용함
    • 11.3의 비용 모델에 Skew Join 수 항이 들어가는 이유가 이것

11.6 Switching Join Strategies at Runtime

SMJ에서 BHJ로의 전환은 “Rule"이 아니라 결과

이런 일을 하는 전용 Rule은 없음. 루프의 각 부분이 맞물려 생기는 현상임.

text
1. 계획: Sort Merge Join → 양쪽 Shuffle Stage 제출
2. 한쪽 Stage 완료, 측정 결과 8MB
3. LogicalQueryStage가 isRuntime = true인 8MB 통계 보고
4. reOptimize → 10.6의 canBroadcastBySize 통과 → 플래너가 BroadcastHashJoin 선택
5. Shuffle 수 동률이지만 계획이 다름 → 채택 (11.3)
6. OptimizeShuffleWithLocalRead가 큰 쪽의 Shuffle 읽기를 Local Read로 전환
  • 6번: 큰 쪽은 이미 Shuffle Write를 했으므로, Reduce 파티션 단위로 네트워크를 거쳐 fetch할 필요 없이 각 Executor가 자기 Map 출력을 그대로 읽음
  • 임계값: spark.sql.adaptive.autoBroadcastJoinThreshold (없으면 spark.sql.autoBroadcastJoinThreshold = 10MB)
    • AQE 쪽만 -1로 두면 정적 계획의 Broadcast는 그대로 두고 런타임 전환만 끔

이미 Broadcast한 것은 되돌리지 않음

  • 작은 쪽이 Broadcast로 이미 실행됐는데 큰 쪽이 먼저 끝나 통계가 바뀌어도 Shuffle Join으로 되돌리지 않음
  • 이미 쓴 비용을 버리지 않기 위한 안정성 장치

DynamicJoinSelection: 반대 방향 조정

scala
private def hasManyEmptyPartitions(mapStats: MapOutputStatistics): Boolean = {
  val partitionCnt = mapStats.bytesByPartitionId.length
  val nonZeroCnt = mapStats.bytesByPartitionId.count(_ > 0)
  partitionCnt > 0 && nonZeroCnt > 0 &&
    (nonZeroCnt * 1.0 / partitionCnt) < conf.nonEmptyPartitionRatioForBroadcastJoin  // 0.2
}
  • 크기는 작지만 비어 있지 않은 파티션이 20% 미만이면 Broadcast를 오히려 피하도록 힌트를 붙임
    • 대부분의 파티션이 비어 있으면 Shuffle Join에 비용이 거의 들지 않음 (빈 파티션 Task는 금방 끝남)
  • 반대로 각 파티션이 로컬 해시 맵에 들어갈 만큼 작으면 Shuffle Hash Join을 선호하게 할 수 있음
    • spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold (기본 0이면 꺼짐)

11.7 AQE Configuration

설정기본값메모
spark.sql.adaptive.enabledtrue (3.2부터)전체 스위치
spark.sql.adaptive.advisoryPartitionSizeInBytes64MB병합 목표·Skew 분할 목표의 기준
spark.sql.adaptive.coalescePartitions.enabledtrue
spark.sql.adaptive.coalescePartitions.parallelismFirsttrue바쁜 클러스터에서는 false 권장
spark.sql.adaptive.coalescePartitions.minPartitionSize1MB
spark.sql.adaptive.coalescePartitions.initialPartitionNum없음 (shuffle.partitions를 씀)병합 전 상한, 크게 줄 것
spark.sql.adaptive.localShuffleReader.enabledtrueBHJ 전환 후 Local Read
spark.sql.adaptive.skewJoin.enabledtrue
spark.sql.adaptive.skewJoin.skewedPartitionFactor5.0× 중앙값
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes256MBadvisory보다 크게 둘 것
spark.sql.adaptive.autoBroadcastJoinThreshold없음 (10MB를 씀)런타임 BHJ 전환 기준
spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold0SMJ를 SHJ로 전환 (끔)
spark.sql.adaptive.forceOptimizeSkewedJoinfalseShuffle이 늘어도 Skew 처리

실전에서 조정할 설정:

text
파티션이 너무 잘게 나뉨      → parallelismFirst = false
파티션이 아직도 큼 / Spill   → initialPartitionNum을 크게
Skew가 감지 안 됨            → skewedPartitionThresholdInBytes 낮추기 (advisory보다는 크게)
Skew가 감지됐는데 안 쪼개짐  → 단일 Map 블록이 큰지 확인 → Salting

11.8 AQE under Spark Connect

2.11·6.5에서 본 Spark Connect에서도 AQE는 그대로 동작함.

  • Client는 분석되지 않은 논리 계획을 Protobuf로 보내는 얇은 라이브러리임
text
message Relation {
  RelationCommon common = 1;
  oneof rel_type {
    Read read = 2;
    Project project = 3;
    Filter filter = 4;
    Join join = 5;
    ...
    Aggregate aggregate = 9;
    SQL sql = 10;
  • Server는 이 Relation을 재귀적으로 Catalyst 논리 계획으로 바꿈 (SparkConnectPlanner.transformRelation)
  • 그다음은 평범한 경로를 따름
scala
val dataframe = Dataset.ofRows(
  sessionHolder.session,
  planner.transformRelation(request.getPlan.getRoot, cachePlan = true),
  tracker, shuffleCleanupMode)
processAsArrowBatches(dataframe, responseObserver, executeHolder)
  • Dataset.ofRows, QueryExecution, InsertAdaptiveSparkPlan 순서로 이어짐 (10.1)
  • 즉 Connect 쿼리도 classic과 같은 Catalyst·AQE를 거침. AQE는 전부 서버 쪽이라 프로토콜에는 드러나지 않음
  • Connect에서만 따로 고려할 점은 Shuffle 파일 수명 정도
    • 세션이 개별 쿼리보다 오래 살기 때문에 쿼리가 끝난 Shuffle 파일을 정리하는 모드가 따로 있음
flowchart LR
  client["Client<br/>Python · Scala · Go ..."] -- "Unresolved Relation<br/>gRPC" --> planner["SparkConnectPlanner<br/>proto → LogicalPlan"]
  planner --> qe["QueryExecution<br/>Catalyst (10장)"]
  qe --> aqe["AdaptiveSparkPlanExec<br/>(11장)"]
  aqe --> exec["Executors"]
  exec -- "Arrow batch stream" --> client
Discussion