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.partitions200이 데이터에 비해 너무 많거나 적음- 특정 Key에 몰린 파티션을 미리 알 수 없음
- 그런데 Shuffle 경계는 자연스러운 “중간 점검 지점"임
- 앞 Stage가 끝나면 Shuffle 출력의 파티션별 크기를 정확히 앎 (8.5의
MapOutputTracker) - 그 숫자로 남은 계획을 다시 세우면 됨
- 앞 Stage가 끝나면 Shuffle 출력의 파티션별 크기를 정확히 앎 (8.5의
이게 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)
- 내용: Reduce 파티션별 바이트 수 (
- 끝난 물리 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는 그대로 두고 런타임 전환만 끔
- AQE 쪽만
이미 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.enabled | true (3.2부터) | 전체 스위치 |
spark.sql.adaptive.advisoryPartitionSizeInBytes | 64MB | 병합 목표·Skew 분할 목표의 기준 |
spark.sql.adaptive.coalescePartitions.enabled | true | |
spark.sql.adaptive.coalescePartitions.parallelismFirst | true | 바쁜 클러스터에서는 false 권장 |
spark.sql.adaptive.coalescePartitions.minPartitionSize | 1MB | |
spark.sql.adaptive.coalescePartitions.initialPartitionNum | 없음 (shuffle.partitions를 씀) | 병합 전 상한, 크게 줄 것 |
spark.sql.adaptive.localShuffleReader.enabled | true | BHJ 전환 후 Local Read |
spark.sql.adaptive.skewJoin.enabled | true | |
spark.sql.adaptive.skewJoin.skewedPartitionFactor | 5.0 | × 중앙값 |
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes | 256MB | advisory보다 크게 둘 것 |
spark.sql.adaptive.autoBroadcastJoinThreshold | 없음 (10MB를 씀) | 런타임 BHJ 전환 기준 |
spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold | 0 | SMJ를 SHJ로 전환 (끔) |
spark.sql.adaptive.forceOptimizeSkewedJoin | false | Shuffle이 늘어도 Skew 처리 |
실전에서 조정할 설정:
text
파티션이 너무 잘게 나뉨 → parallelismFirst = false
파티션이 아직도 큼 / Spill → initialPartitionNum을 크게
Skew가 감지 안 됨 → skewedPartitionThresholdInBytes 낮추기 (advisory보다는 크게)
Skew가 감지됐는데 안 쪼개짐 → 단일 Map 블록이 큰지 확인 → Salting11.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