10. Catalyst and Whole-Stage Codegen

쿼리 계획 단계·Rule 기반 최적화·Join 선택·Exchange 삽입·코드 생성·UnsafeRow

  • Haram Lee
  • 2026-08-21
  • studies / Topics / Spark
  • 2.2에서 DataFrame과 SQL이 “Unresolved → Analyzed → Optimized Logical Plan → Physical Plan"을 거쳐 Spark Core로 내려간다고 봤음
  • 이번 장은 그 화살표 하나하나가 실제로 무엇인지 봄
    • 각 단계는 어디서, 언제 계산되나
    • 최적화 Rule은 어떻게 생겼고 어떻게 반복 적용되나
    • Join 전략은 어떤 순서로 정해지나
    • explain()에 보이는 Exchange, Sort, *(1)은 어디서 왔나
  • 근거는 Apache Spark 4.2.0 소스 (sql/catalyst/, sql/core/)
flowchart TD
  sql["SQL / DataFrame"] --> parsed["Parsed Plan<br/>sqlParser"]
  parsed --> analyzed["Analyzed Plan<br/>Analyzer: 이름·타입 해석"]
  analyzed --> cached["withCachedData<br/>캐시된 서브플랜 치환"]
  cached --> optimized["Optimized Plan<br/>Optimizer: Rule 배치"]
  optimized --> spark["SparkPlan<br/>SparkPlanner: 전략"]
  spark --> executed["Executed Plan<br/>preparations: AQE · Exchange · Codegen"]
  executed --> rdd["toRdd<br/>RDD[InternalRow]"]
  rdd --> core["Spark Core<br/>Job · Stage · Task"]

10.1 The Query Lifecycle

진입점

scala
// SparkSession.sql (4.x에서 구현체는 classic 패키지)
val plan = tracker.measurePhase(QueryPlanningTracker.PARSING) {
  sessionState.sqlParser.parsePlanWithParameters(sqlText, paramContext)
}
Dataset.ofRows(self, plan, tracker)
  • Dataset.ofRows가 QueryExecution을 만들고 분석만 즉시 수행함 (qe.assertAnalyzed())
  • 그래서 존재하지 않는 컬럼을 참조하면 Action 전에, DataFrame을 만드는 순간 에러가 남
  • 최적화와 물리 계획은 Action이 호출될 때까지 미뤄짐

QueryExecution의 lazy 체인

scala
private val lazyAnalyzed = LazyTry {
  val plan = executePhase(QueryPlanningTracker.ANALYSIS) {
    analyzer.executeAndCheck(sqlScriptExecuted, tracker)
  }
  tracker.setAnalyzed(plan); plan
}

private val lazyOptimizedPlan = LazyTry {
  executePhase(QueryPlanningTracker.OPTIMIZATION) {
    // clone the plan to avoid sharing the plan instance between different stages
    sparkSession.sessionState.optimizer.executeAndTrack(withCachedData.clone(), tracker)
  }
}
  • 각 단계는 한 번만 계산되고 결과(또는 예외)가 저장됨 (lazy val과 같은 효과)
  • withCachedData: 최적화 전에 cache()된 DataFrame과 같은 서브플랜을 캐시 읽기로 바꿔 끼움

물리 계획은 “첫 번째 후보”

scala
def createSparkPlan(planner: SparkPlanner, plan: LogicalPlan): SparkPlan = {
  // TODO: We use next(), i.e. take the first plan returned by the planner
  planner.plan(ReturnAnswer(plan)).next()
}
  • 여러 물리 계획 후보를 비용으로 비교하지 않음
  • 전략 우선순위대로 첫 번째로 나온 계획을 씀. 비용 판단은 Rule과 크기 임계값에 녹아 있음

Executed Plan = SparkPlan + Preparation Rules

scala
adaptiveExecutionRule.toSeq ++     // InsertAdaptiveSparkPlan (11장)
Seq(
  CoalesceBucketsInJoin,
  PlanDynamicPruningFilters(sparkSession),
  PlanSubqueries(sparkSession),
  RemoveRedundantProjects,
  EnsureRequirements(),            // Exchange · Sort 삽입 (10.7)
  ...
  CollapseCodegenStages()) ++      // Whole-Stage Codegen (10.8)
(if (subquery) Nil else Seq(ReuseExchangeAndSubquery))
  • 각 Rule을 순서대로 딱 한 번씩 적용함
  • AQE가 켜져 있으면(기본) 첫 Rule이 플랜 전체를 AdaptiveSparkPlanExec로 감쌈
    • 이후 Rule들은 감싼 안쪽을 보지 못하므로, 각 Query Stage가 실행될 때 AQE가 이 Rule들을 다시 적용함

explain() 모드별로 보이는 단계:

python
df.explain("extended")   # Parsed / Analyzed / Optimized Logical + Physical
df.explain("formatted")  # Physical Plan을 노드별 상세와 함께
df.explain("codegen")    # 생성된 Java 코드

10.2 Trees and Rules

Catalyst의 모든 플랜과 표현식은 불변 트리(TreeNode)이고, 최적화는 트리를 새 트리로 바꾸는 Rule임.

scala
abstract class Rule[TreeType <: TreeNode[_]] extends SQLConfHelper with Logging {
  protected lazy val ruleId = RuleIdCollection.getRuleId(this.ruleName)
  def apply(plan: TreeType): TreeType
}

Rule 본문은 보통 Scala의 partial function으로 작성함.

scala
def transformDownWithPruning(cond: TreePatternBits => Boolean, ruleId: RuleId = UnknownRuleId)
    (rule: PartialFunction[BaseType, BaseType]): BaseType = {
  if (!cond.apply(this) || isRuleIneffective(ruleId)) return this   // 볼 필요 없는 서브트리 건너뜀
  val afterRule = rule.applyOrElse(this, identity[BaseType])        // 매치 안 되면 그대로
  if (this fastEquals afterRule) {
    val rewritten = mapChildren(_.transformDownWithPruning(cond, ruleId)(rule))
    if (this eq rewritten) { markRuleAsIneffective(ruleId); this }  // 효과 없던 Rule 기억
    else rewritten
  } else {
    afterRule.mapChildren(_.transformDownWithPruning(cond, ruleId)(rule))
  }
}
  • transform은 transformDown(부모 먼저)과 같고, transformUp은 자식 먼저
  • 노드가 전부 case class라서 copy·withNewChildren이 어느 노드에서나 동작함

불변 트리와 Rule 구조의 이점:

  1. Rule이 이전 단계 플랜을 망가뜨릴 수 없으므로 explain이 단계별 플랜을 동시에 보여줄 수 있음
  2. Rule이 순수 함수라 합성하고 고정점까지 반복하기 쉬움
  3. 바뀌지 않은 서브트리는 참조를 공유하므로 복사 비용이 작음
  4. 트리 패턴 비트(treePatternBits)와 ruleId로 “이 서브트리에는 매치될 노드가 없다"를 즉시 판단해 순회를 건너뜀

10.3 RuleExecutor: Batches and Fixed Points

Rule들은 Batch로 묶여 실행됨.

scala
case object Once extends Strategy { val maxIterations = 1 }
case class FixedPoint(override val maxIterations: Int, ...) extends Strategy
case class Batch(name: String, strategy: Strategy, rules: Rule[TreeType]*)
scala
while (continue) {
  curPlan = batch.rules.foldLeft(curPlan) { case (plan, rule) => rule(plan) }
  iteration += 1
  if (iteration > batch.strategy.maxIterations) continue = false
  if (curPlan.fastEquals(lastPlan)) continue = false     // 고정점 도달
  lastPlan = curPlan
}
  • Once: 한 번만 실행
  • FixedPoint: 플랜이 더 이상 바뀌지 않을 때까지 반복 (상한 spark.sql.optimizer.maxIterations = 100)
  • 한 Rule의 결과가 다른 Rule이 적용될 기회를 만들기 때문에 반복이 필요함
    • 예: 컬럼 제거로 Project가 사라지면 그 아래로 Filter를 더 내릴 수 있게 됨

디버깅용 설정:

text
spark.sql.planChangeLog.level   = WARN
spark.sql.planChangeLog.rules   = <Rule 이름 목록>
spark.sql.planChangeLog.batches = <Batch 이름 목록>
  • 어떤 Rule이 플랜을 어떻게 바꿨는지 로그로 출력함

10.4 Logical Optimization Rules

Batch 구성: Push-down, Filter 추론, 다시 Push-down

scala
val operatorOptimizationBatch: Seq[Batch] = Seq(
  Batch("Operator Optimization before Inferring Filters", fixedPoint, operatorOptimizationRuleSet: _*),
  Batch("Infer Filters", Once, InferFiltersFromGenerate, InferFiltersFromConstraints),
  Batch("Operator Optimization after Inferring Filters", fixedPoint, operatorOptimizationRuleSet: _*),
  Batch("Push extra predicate through join", fixedPoint, PushExtraPredicateThroughJoin, PushDownPredicates))
  • 같은 Rule 묶음을 Filter 추론 전후로 두 번 돌림
    • 예: a.id = b.id AND a.id = 5에서 추론으로 b.id = 5가 생기고, 두 번째 묶음이 이 조건을 b 스캔 쪽으로 내림
  • operatorOptimizationRuleSet에 들어 있는 것
    • Push down: PushDownPredicates, ColumnPruning, LimitPushDown, ReorderJoin, EliminateOuterJoin …
    • 합치기: CollapseProject, CollapseRepartition, CombineUnions …
    • 상수 처리와 단순화: ConstantFolding, ConstantPropagation, BooleanSimplification, NullPropagation, OptimizeIn …
  • spark.sql.optimizer.excludedRules로 특정 Rule을 끌 수 있음 (필수 Rule은 끌 수 없음)

PushDownPredicates

scala
object PushDownPredicates extends Rule[LogicalPlan] {
  def apply(plan: LogicalPlan): LogicalPlan = plan.transformWithPruning(
    _.containsAnyPattern(FILTER, JOIN)) {
    CombineFilters.applyLocally
      .orElse(PushPredicateThroughNonJoin.applyLocally)
      .orElse(PushPredicateThroughJoin.applyLocally)
  }
}

조건을 아래로 내리는 공통 규칙:

scala
val (candidates, nonDeterministic) =
  splitConjunctivePredicates(filter.condition).partition(_.deterministic)
val (pushDown, rest) = candidates.partition { cond =>
  cond.references.subsetOf(grandchild.outputSet)
}
val stayUp = rest ++ nonDeterministic
  • 조건을 AND 단위로 쪼갬
  • 결정적(deterministic)이고, 참조하는 컬럼이 아래 노드에 다 있는 조건만 내림
  • rand() 같은 비결정적 조건, 아래에서 계산할 수 없는 조건은 위에 남김

4.2에서 바뀐 점: Project를 통과해 내릴 때 비싼 표현식을 두 번 계산하지 않도록 조건을 나눔

text
Filter(udf_col > 0 AND region = 'Seoul')
  Project(expensive_udf(x) AS udf_col, region)

→ region = 'Seoul'만 Project 아래로 내리고
→ expensive_udf가 들어간 조건은 Project 위에 남김
  • spark.sql.optimizer.avoidDoubleFilterEval 기본 true (4.2.0에서 추가)

ColumnPruning

scala
case p @ Project(_, p2: Project) if !p2.outputSet.subsetOf(p.references) =>
  p.copy(child = p2.copy(projectList = p2.projectList.filter(p.references.contains)))

/** Applies a projection only when the child is producing unnecessary attributes */
private def prunedChild(c: LogicalPlan, allReferences: AttributeSet) =
  if (!c.outputSet.subsetOf(allReferences)) {
    Project(c.output.filter(allReferences.contains), c)
  } else { c }
  • 위에서 쓰지 않는 컬럼을 아래 노드 출력에서 제거함
  • 끝까지 내려가면 스캔 단계의 컬럼 선택이 되므로, Parquet 같은 컬럼 포맷에서는 읽는 양 자체가 줄어듦
  • Filter를 내리는 데 방해가 되는 Project도 정리함. 그래서 두 Rule이 같은 FixedPoint 배치에 있음

ConstantFolding

scala
private def tryFold(expr: Expression, isConditionalBranch: Boolean): Expression = {
  try {
    Literal.create(expr.freshCopyIfContainsStatefulExpression().eval(EmptyRow), expr.dataType)
  } catch {
    case NonFatal(_) if isConditionalBranch =>
      // 실행되지 않을 수도 있는 CASE WHEN 분기에서 실패하면 쿼리 실패로 보지 않음
      expr.setTagValue(FAILED_TO_EVALUATE, ()); expr
  }
}
  • 입력 없이 계산 가능한(foldable) 표현식을 계획 단계에서 미리 계산해 Literal로 바꿈
  • 같은 계열: ConstantPropagation (i = 5 AND j = i + 3 → j = 8), BooleanSimplification, NullPropagation

10.5 Cost-Based Join Reorder

여러 테이블의 Join 순서를 비용으로 고르는 기능. 기본으로 꺼져 있음.

scala
if (!conf.cboEnabled || !conf.joinReorderEnabled) { plan } else {
  if (items.size > 2 && items.size <= conf.joinReorderDPThreshold && conditions.nonEmpty &&
      items.forall(_.stats.rowCount.isDefined)) {
    JoinReorderDP.search(conf, items, conditions, output)
  }
}
  • 동적 계획법으로 Join 대상 부분집합을 레벨별로 열거하고 메모이제이션함 (Selinger 방식)
  • 비용 비교식
scala
Math.pow(relativeRows, conf.joinReorderCardWeight) *
  Math.pow(relativeSize, 1 - conf.joinReorderCardWeight) < 1
설정기본값
spark.sql.cbo.enabledfalse
spark.sql.cbo.joinReorder.enabledfalse
spark.sql.cbo.joinReorder.dp.threshold12
spark.sql.cbo.joinReorder.card.weight0.7
  • 쓰려면 두 플래그를 켜고 테이블 통계(row count)도 있어야 함
text
ANALYZE TABLE orders COMPUTE STATISTICS FOR COLUMNS user_id, region
  • 꺼져 있으면 Join 순서는 휴리스틱 Rule(ReorderJoin) 결과를 따름
  • 통계가 없거나 틀리는 문제를 실행 중에 보정하는 것이 AQE (11장)

10.6 Physical Planning and Join Selection

전략의 우선순위

text
SparkPlanner.strategies
LogicalQueryStageStrategy → ... → FileSourceStrategy → ... → Aggregation
→ ... → JoinSelection → ... → BasicOperators
  • 각 전략은 맨 위 노드만 물리 연산자로 바꾸고, 자식은 planLater로 표시해 둠
  • QueryPlanner가 이 표시를 재귀적으로 치환하며 전체 트리를 완성함

Equi-Join 전략 결정 순서

scala
// If there is no hint or the hints are not applicable, we follow these rules one by one:
//   1. Pick broadcast hash join if one side is small enough to broadcast ...
//   2. Pick shuffle hash join if one side is small enough to build local hash map, and is
//      much smaller than the other side, and `spark.sql.join.preferSortMergeJoin` is false.
//   3. Pick sort merge join if the join keys are sortable.
//   4. Pick cartesian product if join type is inner like.
//   5. Pick broadcast nested loop join as the final solution. It may OOM but we don't have
//      other choice.
scala
def createJoinWithoutHint() = {
  createBroadcastHashJoin(false)
    .orElse(createShuffleHashJoin(false))
    .orElse(createSortMergeJoin())
    .orElse(createCartesianProduct())
    .getOrElse { BroadcastNestedLoopJoinExec(...) }
}
  • 힌트가 있으면 BROADCAST > MERGE > SHUFFLE_HASH > SHUFFLE_REPLICATE_NL 순으로 먼저 시도
  • 문서 표현대로 힌트도 “보장은 아님” (Join 타입상 불가능하면 무시됨)

크기 판정

scala
def canBroadcastBySize(plan: LogicalPlan, conf: SQLConf): Boolean = {
  val autoBroadcastJoinThreshold = if (plan.stats.isRuntime) {
    conf.getConf(SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD)
      .getOrElse(conf.autoBroadcastJoinThreshold)
  } else { conf.autoBroadcastJoinThreshold }
  plan.stats.sizeInBytes >= 0 && plan.stats.sizeInBytes <= autoBroadcastJoinThreshold
}

private def canBuildLocalHashMapBySize(plan: LogicalPlan, conf: SQLConf): Boolean =
  plan.stats.sizeInBytes < conf.autoBroadcastJoinThreshold * conf.numShufflePartitions

private def muchSmaller(a: LogicalPlan, b: LogicalPlan, conf: SQLConf): Boolean =
  a.stats.sizeInBytes * conf.getConf(SQLConf.SHUFFLE_HASH_JOIN_FACTOR) <= b.stats.sizeInBytes
  • stats.isRuntime: 실행 중 측정된 통계인지 여부. 참이면 AQE 쪽 임계값을 씀 (11.6)
  • 통계 출처는 세 가지: 데이터 소스 메타데이터(파일 크기 등), ANALYZE TABLE 카탈로그 통계, AQE 런타임 통계
설정기본값
spark.sql.autoBroadcastJoinThreshold10MB (-1이면 끔)
spark.sql.join.preferSortMergeJointrue
spark.sql.shuffledHashJoinFactor3
spark.sql.broadcastTimeout300s

흔한 오해: Shuffle Hash Join은 기본으로 거의 안 나옴

  • 2번 조건이 preferSortMergeJoin = false를 요구하는데 기본값이 true
  • 그래서 기본 설정에서 크기만으로 Shuffle Hash Join이 선택되는 일은 없음
  • SHJ가 나오는 경로는 힌트, preferSortMergeJoin=false, AQE의 별도 임계값 (11.6) 셋 중 하나
  • 실질적인 기본 순서: Broadcast Hash Join(≤ 10MB) → Sort Merge Join → Cartesian → Broadcast Nested Loop Join
flowchart TD
  start["Equi-Join"] --> hint{"힌트?"}
  hint -- "있음" --> hinted["힌트 순서대로 시도"]
  hint -- "없음" --> bhj{"한쪽 ≤ 10MB?"}
  bhj -- "예" --> b["BroadcastHashJoin"]
  bhj -- "아니오" --> shj{"preferSortMergeJoin = false<br/>그리고 크기 조건?"}
  shj -- "예" --> s["ShuffledHashJoin"]
  shj -- "아니오 (기본)" --> smj{"Key 정렬 가능?"}
  smj -- "예" --> m["SortMergeJoin"]
  smj -- "아니오" --> cart{"Inner 계열?"}
  cart -- "예" --> c["CartesianProduct"]
  cart -- "아니오" --> bnlj["BroadcastNestedLoopJoin<br/>OOM 위험"]

10.7 EnsureRequirements: Where Exchanges Come From

explain()에 보이는 Exchange(Shuffle)와 Sort는 Join 전략이 직접 만드는 것이 아님. EnsureRequirements라는 Rule 하나가 연산자의 요구 조건과 자식의 실제 분포를 비교해 끼워 넣음.

연산자의 요구 조건

scala
// ShuffledJoin (SMJ·SHJ 공통): 양쪽이 Join Key 기준으로 모여 있어야 함
override def requiredChildDistribution: Seq[Distribution] =
  ClusteredDistribution(leftKeys) :: ClusteredDistribution(rightKeys) :: Nil

// SortMergeJoinExec: 파티션 안에서 Key 순으로 정렬돼 있어야 함
override def requiredChildOrdering: Seq[Seq[SortOrder]] =
  requiredOrders(leftKeys) :: requiredOrders(rightKeys) :: Nil

불일치하면 삽입

scala
distribution match {
  case BroadcastDistribution(mode) => BroadcastExchangeExec(mode, child)
  case _ => ShuffleExchangeExec(distribution.createPartitioning(numPartitions), child, shuffleOrigin)
}
...
// Now that we've performed any necessary shuffles, add sorts:
children = children.zip(requiredChildOrderings).map { case (child, requiredOrdering) =>
  if (SortOrder.orderingSatisfies(child.outputOrdering, requiredOrdering)) child
  else SortExec(requiredOrdering, global = false, child = child)
}
  • 아래부터 올라가며 child.outputPartitioning이 요구하는 Distribution을 만족하는지 확인함
  • 만족하지 않으면 ShuffleExchangeExec(또는 BroadcastExchangeExec)를 넣음
  • 그다음 정렬 요구를 확인하고, 부족하면 SortExec를 넣음
  • 새 Shuffle의 파티션 수는 spark.sql.shuffle.partitions (기본 200)
text
SortMergeJoin [user_id]
├─ Sort [user_id]                         ← EnsureRequirements
│   └─ Exchange hashpartitioning(user_id) ← EnsureRequirements
│       └─ Scan orders
└─ Sort [user_id]
    └─ Exchange hashpartitioning(user_id)
        └─ Scan users
  • 이미 같은 Key로 파티셔닝된 자식(버킷 테이블, 앞선 Shuffle 결과 등)이면 Exchange가 생기지 않음
  • 4.6에서 본 “Shuffle = Stage 경계"가 SQL에서는 이 Exchange 노드로 나타남

10.8 Whole-Stage Code Generation

1.6의 “여러 연산을 하나의 실행 흐름으로 연결한다"는 생각을 CPU 수준까지 밀어붙인 것. Tungsten 프로젝트의 핵심임.

Volcano 모델의 비용

text
Project.next()
  → Filter.next()
     → Scan.next()
  • 연산자마다 Iterator가 있고, 레코드마다 가상 함수를 호출하고, 중간 Row 객체가 생김
  • CPU는 실제 계산보다 함수 호출과 객체 생성에 더 많은 시간을 씀

produce / consume

Whole-Stage Codegen은 여러 연산자를 while 루프 하나짜리 Java 코드로 합침.

text
WholeStageCodegen       Plan A             FakeInput        Plan B
================================================================
doExecute() ------>  inputRDDs() ----> inputRDDs() ----> execute()
     |
     +------------>  produce()
                        |
                     doProduce() ----> produce()
                                          |
                                       doProduce()
                                          |
                     doConsume() <---- consume()
                        |
doConsume() <-------  consume()
  • produce: 위에서 아래로 내려가며 “데이터를 만들어 달라"고 요청
  • 맨 아래(입력) 노드가 while 루프를 생성함
  • consume: 아래에서 위로 올라가며 각 연산자가 루프 본문에 자기 코드를 끼워 넣음

입력 노드가 만드는 루프:

scala
s"""
   | while ($limitNotReachedCond $input.hasNext()) {
   |   InternalRow $row = (InternalRow) $input.next();
   |   ${consume(ctx, outputVars, ...).trim}
   |   ${shouldStopCheckCode}
   | }
 """

Filter가 끼워 넣는 코드:

scala
s"""
   |do {
   |  $predicateCode        // 조건 불만족이면 continue
   |  $numOutput.add(1);
   |  ${consume(ctx, resultVars)}
   |} while (false);
 """

결과적으로 Scan → Filter → Project는 대략 이런 코드가 됨.

java
while (input.hasNext()) {
  InternalRow row = (InternalRow) input.next();
  long amount = row.getLong(2);
  if (!(amount > 100)) continue;           // Filter
  // Project: 필요한 컬럼만 결과 버퍼에 기록
  rowWriter.write(0, row.getUTF8String(1));
  rowWriter.write(1, amount);
  append(rowWriter.getRow());
}
  • 연산자별 Iterator, 가상 호출, 중간 Row 객체가 사라짐
  • JIT은 지역 변수와 직접 메모리 읽기만 보게 되므로 레지스터 활용과 인라이닝이 잘 됨

생성 클래스와 컴파일

scala
final class $className extends BufferedRowIterator {
  private Object[] references;
  private scala.collection.Iterator[] inputs;
  ...
  protected void processNext() throws java.io.IOException { ${code.trim} }
}
  • 클래스 이름은 GeneratedIteratorForCodegenStage<N>이고, explain()의 *(N)이 이 N임
  • Janino로 런타임에 컴파일하고, 같은 코드는 캐시함
scala
val (_, compiledCodeStats) = try {
  CodeGenerator.compile(cleanedSource)
} catch {
  case NonFatal(_) if !Utils.isTesting && conf.codegenFallback =>
    return child.execute()               // 컴파일 실패 → 인터프리트 방식으로
}
if (compiledCodeStats.maxMethodCodeSize > conf.hugeMethodLimit) {
  return child.execute()                 // 메서드가 너무 크면 JIT이 안 됨 → 포기
}
  • HotSpot JIT은 바이트코드 8,000바이트가 넘는 메서드를 컴파일하지 않음
  • 컬럼이 아주 많은 쿼리에서 Codegen이 오히려 느려지는 이유

어디서 끊기나: CollapseCodegenStages

scala
private def supportCodegen(plan: SparkPlan): Boolean = plan match {
  case plan: CodegenSupport if plan.supportCodegen =>
    val willFallback = plan.expressions.exists(_.exists(e => !supportCodegen(e)))
    val hasTooManyOutputFields = WholeStageCodegenExec.isTooManyFields(conf, plan.schema)
    ...
    !willFallback && !hasTooManyOutputFields && !hasTooManyInputFields
  case _ => false
}

Codegen Stage가 끊기는 지점:

  • Exchange (Shuffle 경계, 당연히)
  • Codegen을 지원하지 않는 연산자나 표현식 (일부 UDF 등 CodegenFallback 표현식)
  • 출력이나 입력 필드가 100개를 넘을 때 (spark.sql.codegen.maxFields)
  • Sort Merge Join·Shuffle Hash Join의 자식은 별도 Stage로 분리됨
설정기본값효과
spark.sql.codegen.wholeStagetrue전체 스위치
spark.sql.codegen.maxFields100넘으면 융합 제외
spark.sql.codegen.fallbacktrue컴파일 실패 시 인터프리트
spark.sql.codegen.hugeMethodLimit65535넘으면 해당 Stage Codegen 포기
spark.sql.codegen.splitConsumeFuncByOperatortrue연산자별로 메서드 분리
  • explain()에서 *이 없는 연산자는 Codegen에 포함되지 않은 것

10.9 UnsafeRow: Rows as Raw Bytes

Codegen 루프가 다루는 Row는 Java 객체가 아니라 바이트 덩어리를 가리키는 커서임.

text
UnsafeRow 메모리 배치

┌──────────────┬──────────────────────────┬────────────────────┐
│ null bit set │ 고정 길이 영역            │ 가변 길이 영역      │
│ 8바이트 정렬  │ 필드당 8바이트            │ 문자열·배열 본문    │
└──────────────┴──────────────────────────┴────────────────────┘
               ↑ 가변 필드는 여기에 (offset << 32 | length)
java
private Object baseObject;
private long baseOffset;

private long getFieldOffset(int ordinal) {
  return baseOffset + bitSetWidthInBytes + ordinal * 8L;
}
public long getLong(int ordinal) {
  return Platform.getLong(baseObject, getFieldOffset(ordinal));   // 주소 계산 한 번, boxing 없음
}
public UTF8String getUTF8String(int ordinal) {
  final long offsetAndSize = getLong(ordinal);
  final int offset = (int) (offsetAndSize >> 32);
  final int size = (int) offsetAndSize;
  return UTF8String.fromAddress(baseObject, baseOffset + offset, size);  // 같은 버퍼를 가리키는 view
}
  • equals와 hashCode도 바이트 비교와 Murmur3 해시로 처리함
  • Shuffle이나 Spill로 쓸 때도 바이트를 그대로 복사하므로 8.1의 “직렬화 객체 재배치 가능” 조건을 만족함

GC를 피하는 이유

text
컬럼 3개짜리 Row를 Java 객체로 표현하면
→ Row 객체 + 필드 객체(Long, String, ...) 4~5개
→ 객체마다 헤더 12~16바이트, 전부 GC가 추적

UnsafeRow
→ 큰 메모리 페이지 위의 연속된 바이트
→ GC가 보는 객체 수: Row 수 × 컬럼 수 → 페이지 수
  • 9.4의 페이지 테이블, 8.3의 Packed Pointer와 같은 Tungsten 설계의 다른 단면
  • Codegen 루프와 합쳐지면 JIT은 primitive 지역 변수와 직접 메모리 읽기만 보게 됨

10.10 Summary

flowchart TD
  q["Query"] --> a["Analyzer<br/>즉시 실행"]
  a --> o["Optimizer<br/>Batch · FixedPoint 최대 100회"]
  o --> rules["PushDownPredicates<br/>ColumnPruning<br/>ConstantFolding<br/>(CBO는 기본 off)"]
  o --> p["SparkPlanner<br/>첫 번째 후보 채택"]
  p --> js["JoinSelection<br/>BHJ → SMJ → Cartesian → BNLJ"]
  p --> prep["Preparation Rules"]
  prep --> aqe["InsertAdaptiveSparkPlan"]
  prep --> er["EnsureRequirements<br/>Exchange · Sort 삽입"]
  prep --> cg["CollapseCodegenStages<br/>*(N) 융합"]
  cg --> row["UnsafeRow 위에서<br/>while 루프 하나로 실행"]
  • explain()을 읽는 법
    • Exchange: Shuffle, 곧 Stage 경계 (10.7)
    • Join 아래의 Sort: Sort Merge Join의 정렬 요구 (10.7)
    • *(N): N번 Codegen Stage에 융합됨. *이 없으면 융합에서 빠진 것 (10.8)
    • BroadcastExchange와 BroadcastHashJoin: 작은 쪽을 통째로 배포함 (5.3, 10.6)
    • AdaptiveSparkPlan isFinalPlan=false: 아직 실행 전 계획. 실제 계획은 실행 후 SQL 탭에서 확인 (11장)
Discussion