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
진입점
// 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 체인
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과 같은 서브플랜을 캐시 읽기로 바꿔 끼움
물리 계획은 “첫 번째 후보”
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
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() 모드별로 보이는 단계:
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임.
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으로 작성함.
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 구조의 이점:
- Rule이 이전 단계 플랜을 망가뜨릴 수 없으므로
explain이 단계별 플랜을 동시에 보여줄 수 있음 - Rule이 순수 함수라 합성하고 고정점까지 반복하기 쉬움
- 바뀌지 않은 서브트리는 참조를 공유하므로 복사 비용이 작음
- 트리 패턴 비트(
treePatternBits)와ruleId로 “이 서브트리에는 매치될 노드가 없다"를 즉시 판단해 순회를 건너뜀
10.3 RuleExecutor: Batches and Fixed Points
Rule들은 Batch로 묶여 실행됨.
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]*)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를 더 내릴 수 있게 됨
디버깅용 설정:
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
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…
- Push down:
spark.sql.optimizer.excludedRules로 특정 Rule을 끌 수 있음 (필수 Rule은 끌 수 없음)
PushDownPredicates
object PushDownPredicates extends Rule[LogicalPlan] {
def apply(plan: LogicalPlan): LogicalPlan = plan.transformWithPruning(
_.containsAnyPattern(FILTER, JOIN)) {
CombineFilters.applyLocally
.orElse(PushPredicateThroughNonJoin.applyLocally)
.orElse(PushPredicateThroughJoin.applyLocally)
}
}조건을 아래로 내리는 공통 규칙:
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를 통과해 내릴 때 비싼 표현식을 두 번 계산하지 않도록 조건을 나눔
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
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
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 순서를 비용으로 고르는 기능. 기본으로 꺼져 있음.
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 방식)
- 비용 비교식
Math.pow(relativeRows, conf.joinReorderCardWeight) *
Math.pow(relativeSize, 1 - conf.joinReorderCardWeight) < 1| 설정 | 기본값 |
|---|---|
spark.sql.cbo.enabled | false |
spark.sql.cbo.joinReorder.enabled | false |
spark.sql.cbo.joinReorder.dp.threshold | 12 |
spark.sql.cbo.joinReorder.card.weight | 0.7 |
- 쓰려면 두 플래그를 켜고 테이블 통계(row count)도 있어야 함
ANALYZE TABLE orders COMPUTE STATISTICS FOR COLUMNS user_id, region- 꺼져 있으면 Join 순서는 휴리스틱 Rule(
ReorderJoin) 결과를 따름 - 통계가 없거나 틀리는 문제를 실행 중에 보정하는 것이 AQE (11장)
10.6 Physical Planning and Join Selection
전략의 우선순위
SparkPlanner.strategies
LogicalQueryStageStrategy → ... → FileSourceStrategy → ... → Aggregation
→ ... → JoinSelection → ... → BasicOperators- 각 전략은 맨 위 노드만 물리 연산자로 바꾸고, 자식은
planLater로 표시해 둠 QueryPlanner가 이 표시를 재귀적으로 치환하며 전체 트리를 완성함
Equi-Join 전략 결정 순서
// 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.def createJoinWithoutHint() = {
createBroadcastHashJoin(false)
.orElse(createShuffleHashJoin(false))
.orElse(createSortMergeJoin())
.orElse(createCartesianProduct())
.getOrElse { BroadcastNestedLoopJoinExec(...) }
}- 힌트가 있으면
BROADCAST > MERGE > SHUFFLE_HASH > SHUFFLE_REPLICATE_NL순으로 먼저 시도 - 문서 표현대로 힌트도 “보장은 아님” (Join 타입상 불가능하면 무시됨)
크기 판정
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.sizeInBytesstats.isRuntime: 실행 중 측정된 통계인지 여부. 참이면 AQE 쪽 임계값을 씀 (11.6)- 통계 출처는 세 가지: 데이터 소스 메타데이터(파일 크기 등),
ANALYZE TABLE카탈로그 통계, AQE 런타임 통계
| 설정 | 기본값 |
|---|---|
spark.sql.autoBroadcastJoinThreshold | 10MB (-1이면 끔) |
spark.sql.join.preferSortMergeJoin | true |
spark.sql.shuffledHashJoinFactor | 3 |
spark.sql.broadcastTimeout | 300s |
흔한 오해: 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 하나가 연산자의 요구 조건과 자식의 실제 분포를 비교해 끼워 넣음.
연산자의 요구 조건
// 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불일치하면 삽입
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)
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 모델의 비용
Project.next()
→ Filter.next()
→ Scan.next()- 연산자마다 Iterator가 있고, 레코드마다 가상 함수를 호출하고, 중간 Row 객체가 생김
- CPU는 실제 계산보다 함수 호출과 객체 생성에 더 많은 시간을 씀
produce / consume
Whole-Stage Codegen은 여러 연산자를 while 루프 하나짜리 Java 코드로 합침.
WholeStageCodegen Plan A FakeInput Plan B
================================================================
doExecute() ------> inputRDDs() ----> inputRDDs() ----> execute()
|
+------------> produce()
|
doProduce() ----> produce()
|
doProduce()
|
doConsume() <---- consume()
|
doConsume() <------- consume()produce: 위에서 아래로 내려가며 “데이터를 만들어 달라"고 요청- 맨 아래(입력) 노드가 while 루프를 생성함
consume: 아래에서 위로 올라가며 각 연산자가 루프 본문에 자기 코드를 끼워 넣음
입력 노드가 만드는 루프:
s"""
| while ($limitNotReachedCond $input.hasNext()) {
| InternalRow $row = (InternalRow) $input.next();
| ${consume(ctx, outputVars, ...).trim}
| ${shouldStopCheckCode}
| }
"""Filter가 끼워 넣는 코드:
s"""
|do {
| $predicateCode // 조건 불만족이면 continue
| $numOutput.add(1);
| ${consume(ctx, resultVars)}
|} while (false);
"""결과적으로 Scan → Filter → Project는 대략 이런 코드가 됨.
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은 지역 변수와 직접 메모리 읽기만 보게 되므로 레지스터 활용과 인라이닝이 잘 됨
생성 클래스와 컴파일
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로 런타임에 컴파일하고, 같은 코드는 캐시함
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
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.wholeStage | true | 전체 스위치 |
spark.sql.codegen.maxFields | 100 | 넘으면 융합 제외 |
spark.sql.codegen.fallback | true | 컴파일 실패 시 인터프리트 |
spark.sql.codegen.hugeMethodLimit | 65535 | 넘으면 해당 Stage Codegen 포기 |
spark.sql.codegen.splitConsumeFuncByOperator | true | 연산자별로 메서드 분리 |
explain()에서*이 없는 연산자는 Codegen에 포함되지 않은 것
10.9 UnsafeRow: Rows as Raw Bytes
Codegen 루프가 다루는 Row는 Java 객체가 아니라 바이트 덩어리를 가리키는 커서임.
UnsafeRow 메모리 배치
┌──────────────┬──────────────────────────┬────────────────────┐
│ null bit set │ 고정 길이 영역 │ 가변 길이 영역 │
│ 8바이트 정렬 │ 필드당 8바이트 │ 문자열·배열 본문 │
└──────────────┴──────────────────────────┴────────────────────┘
↑ 가변 필드는 여기에 (offset << 32 | length)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를 피하는 이유
컬럼 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장)