9. Memory Management Internals
Unified Memory의 차용 규칙·Task별 배분·TaskMemoryManager·BlockManager
- Haram Lee
- 2026-08-20
- studies / Topics / Spark
- 5.1에서 Executor 메모리를 Heap과 Overhead, Execution과 Storage로 나눠 봤고 “Execution이 Storage를 밀어낼 수 있지만 반대는 안 된다"고 정리했음
- 이번 장은 그 규칙을 숫자와 코드로 확인함
- Unified Memory의 크기를 정확히 어떻게 계산하나
- 서로 빌려 쓸 때 어디까지 빌릴 수 있나
- 같은 Executor 안의 여러 Task가 메모리를 어떻게 나눠 갖나
- 캐시 블록은 어디에, 어떤 순서로 쌓이고 밀려나나
- 근거는 Apache Spark 4.2.0 소스 (
core/.../memory/,core/.../storage/)
flowchart TD umm["UnifiedMemoryManager<br/>Executor JVM당 1개"] --> exec["ExecutionMemoryPool<br/>on-heap · off-heap"] umm --> stor["StorageMemoryPool<br/>on-heap · off-heap"] exec --> tmm["TaskMemoryManager<br/>Task당 1개"] tmm --> consumers["MemoryConsumer<br/>Sorter · HashMap · ..."] stor --> ms["MemoryStore<br/>캐시 블록"] bm["BlockManager<br/>모든 노드"] --> ms bm --> ds["DiskStore"] bm -- "상태 보고" --> master["BlockManagerMaster<br/>Driver"]
9.1 Sizing Unified Memory
usable = (heap − 300MB) × spark.memory.fraction
// if we have a 1GB JVM, then the memory used for execution and storage
// will be (1024 - 300) * 0.6 = 434MB by default.
private val RESERVED_SYSTEM_MEMORY_BYTES = 300 * 1024 * 1024private def getMaxMemory(conf: SparkConf): Long = {
...
val minSystemMemory = (reservedMemory * 1.5).ceil.toLong // 450MB 미만이면 시작 실패
...
val usableMemory = systemMemory - reservedMemory
(usableMemory * conf.get(config.MEMORY_FRACTION)).toLong
}JVM Heap
├─ Reserved 300MB (하드코딩)
├─ Unified (M) (Heap − 300MB) × 0.6
│ ├─ Storage 보호 구역 (R) = M × 0.5
│ └─ 나머지는 Execution·Storage가 경쟁
└─ User Memory (Heap − 300MB) × 0.4 ← Spark가 관리하지 않음예: Executor Heap 4GB
M = (4096 − 300) × 0.6 ≈ 2277MB
R = 2277 × 0.5 ≈ 1138MB
User Memory ≈ 1518MB- Execution 풀 크기와 Storage 풀 크기의 합은 항상 M으로 유지됨 (경계만 움직이고 합은 일정)
- User Memory 40%는 Spark가 추적하지 않음
- UDF 안에서 만든 리스트, 사용자 자료구조 등이 여기 들어감
- Spark가 관리하는 영역은 부족하면 Spill하지만, 여기가 넘치면 그냥
OutOfMemoryError가 남 (12.2)
R의 의미
spark.memory.storageFraction(0.5)은 Storage의 “최소 보장"이지 “상한"이 아님- R 안에 들어 있는 캐시 블록은 Execution이 밀어낼 수 없음
- R을 넘어 Storage가 빌려 쓴 부분만 Execution이 회수할 수 있음
| 설정 | 기본값 |
|---|---|
spark.memory.fraction | 0.6 |
spark.memory.storageFraction | 0.5 |
spark.memory.offHeap.enabled / .size | false / 0 (별도 예산, Heap을 줄이지 않음) |
spark.storage.unrollMemoryThreshold | 1MB |
| 예약 시스템 메모리 | 300MB 고정 |
9.2 Borrowing Between Execution and Storage
5.1의 “밀고 당김"을 방향별로 나눠 보면 비대칭이 분명해짐.
Execution이 더 필요할 때
def maybeGrowExecutionPool(extraMemoryNeeded: Long): Unit = {
if (extraMemoryNeeded > 0) {
// Storage의 빈 공간, 또는 R을 넘어 빌려 간 부분만 회수 가능
val memoryReclaimableFromStorage = math.max(
storagePool.memoryFree,
storagePool.poolSize - storageRegionSize)
if (memoryReclaimableFromStorage > 0) {
val spaceToReclaim = storagePool.freeSpaceToShrinkPool(
math.min(extraMemoryNeeded, memoryReclaimableFromStorage))
storagePool.decrementPoolSize(spaceToReclaim)
executionPool.incrementPoolSize(spaceToReclaim)
}
}
}- 회수 대상: Storage의 빈 공간과 R을 넘어선 부분
- 필요하면
freeSpaceToShrinkPool이 실제로 캐시 블록을 쫓아냄(evict) (9.6)
Storage가 더 필요할 때
override def acquireStorageMemory(blockId: BlockId, numBytes: Long, memoryMode: MemoryMode)
: Boolean = synchronized {
...
if (numBytes > storagePool.memoryFree) {
// Execution의 "비어 있는" 메모리만 빌림
val memoryBorrowedFromExecution = Math.min(executionPool.memoryFree,
numBytes - storagePool.memoryFree)
executionPool.decrementPoolSize(memoryBorrowedFromExecution)
storagePool.incrementPoolSize(memoryBorrowedFromExecution)
}
storagePool.acquireMemory(blockId, numBytes)
}- Execution이 쓰고 있는 메모리는 절대 빼앗지 않음
- 공식 문서의 표현: “Storage may not evict execution due to complexities in implementation”
- Execution 메모리는 정렬 중인 배열, 해시 테이블처럼 Task 실행 도중의 자료구조라 중간에 뺏을 방법이 없음
- 대신 Storage 풀 안에서는 다른 캐시 블록을 밀어낼 수 있음
flowchart LR e["Execution 요청"] -- "빈 공간 + R 초과분 회수<br/>캐시 evict 가능" --> s1["Storage"] s2["Storage 요청"] -- "Execution의 빈 공간만" --> e2["Execution"] s2 -- "같은 풀 안의 다른 캐시 evict" --> s3["Storage 내부"]
4.x의 unmanaged memory
- RocksDB State Store처럼 Spark 메모리 관리자 밖에서 네이티브 메모리를 쓰는 구성 요소가 있음
- 4.x에서 이 사용량을 주기적으로 읽어 Execution 최대치에서 빼는 기능이 추가됨 (
spark.memory.unmanagedMemoryPollingInterval, 기본 0s는 꺼짐)
9.3 Dividing Execution Memory Among Tasks
Executor 하나에서 Task 여러 개가 동시에 돌면 Execution 풀을 어떻게 나누나.
If there are N tasks, it ensures that each task can acquire at least 1 / 2N of the memory before it has to spill, and at most 1 / N. —
ExecutionMemoryPool.scala
while (true) {
val numActiveTasks = memoryForTask.keys.size
val curMem = memoryForTask(taskAttemptId)
maybeGrowPool(numBytes - memoryFree) // 먼저 Storage에서 회수 시도
val maxPoolSize = computeMaxPoolSize()
val maxMemoryPerTask = maxPoolSize / numActiveTasks // 상한 1/N
val minMemoryPerTask = poolSize / (2 * numActiveTasks) // 하한 1/2N
val maxToGrant = math.min(numBytes, math.max(0, maxMemoryPerTask - curMem))
val toGrant = math.min(maxToGrant, memoryFree)
if (toGrant < numBytes && curMem + toGrant < minMemoryPerTask) {
lock.wait() // 1/2N도 못 채우면 다른 Task가 반납할 때까지 대기
} else {
memoryForTask(taskAttemptId) += toGrant
return toGrant
}
}- N은 지금 메모리를 쓰고 있는 활성 Task 수 (보통 Executor Core 수만큼)
- 각 Task는 최대 1/N까지 받을 수 있음
- 1/2N까지는 보장: 그보다 적게 가진 상태에서 모자라면 Spill하지 않고 기다림
- 1/2N 이상이면 받을 수 있는 만큼만 주고 바로 반환함. 모자란 부분은 요청한 쪽이 Spill로 해결
- Task가 끝나면 N이 줄고, 기다리던 Task들이 깨어나 더 받음
Executor: 4 Core, Execution 풀 2GB
Task 1개만 실행 중 → 최대 2GB
Task 4개 실행 중 → Task당 최대 512MB, 최소 256MB 보장실무에서의 의미:
spark.executor.cores를 늘리면 Task당 메모리 몫이 줄어듦- 같은 Heap에서 Core를 늘리면 Spill이 늘어날 수 있음
- 반대로 파티션 수를 늘리면 몫은 그대로지만 Task당 입력이 줄어 Spill이 줄어듦
9.4 TaskMemoryManager and Memory Consumers
Task 하나 안에서도 메모리를 쓰는 주체가 여럿임 (정렬기, 해시 맵, Shuffle Writer 등).
이들을 중재하는 것이 Task당 하나씩 있는 TaskMemoryManager.
페이지 테이블과 주소 인코딩
// on-heap 모드: 상위 13비트 = 페이지 번호, 하위 51비트 = 페이지 안 offset
private static final int PAGE_NUMBER_BITS = 13;
static final int OFFSET_BITS = 64 - PAGE_NUMBER_BITS; // 51
private final MemoryBlock[] pageTable = new MemoryBlock[PAGE_TABLE_SIZE];
public static long encodePageNumberAndOffset(int pageNumber, long offsetInPage) {
return (((long) pageNumber) << OFFSET_BITS) | (offsetInPage & MASK_LONG_LOWER_51_BITS);
}- Tungsten은 데이터를 Java 객체가 아니라 큰 메모리 페이지(
long[]또는 off-heap 주소)에 바이트로 저장함 - on-heap에서는 GC가 배열을 옮길 수 있으므로 실제 주소 대신 페이지 번호와 offset을 저장함
- 8.3의
PackedRecordPointer가 이 13비트 페이지 번호를 그대로 재사용함 - 10.9의
UnsafeRow도 같은 페이지 위에 놓임
누구를 Spill시킬 것인가
public long acquireExecutionMemory(long required, MemoryConsumer requestingConsumer) {
long got = memoryManager.acquireExecutionMemory(required, taskAttemptId, mode);
if (got < required) {
// 필요한 양 이상을 가진 소비자 중 가장 작은 것부터 Spill
TreeMap<Long, List<MemoryConsumer>> sortedConsumers = new TreeMap<>();
for (MemoryConsumer c: consumers) {
if (c.getUsed() > 0 && c.getMode() == mode) {
long key = c == requestingConsumer ? 0 : c.getUsed(); // 요청자 자신은 최후순위
sortedConsumers.computeIfAbsent(key, k -> new ArrayList<>(1)).add(c);
}
}
while (got < required && !sortedConsumers.isEmpty()) {
Map.Entry<Long, List<MemoryConsumer>> currentEntry =
sortedConsumers.ceilingEntry(required - got);
if (currentEntry == null) currentEntry = sortedConsumers.lastEntry(); // 없으면 가장 큰 것
got += trySpillAndAcquire(...);
}
}
...
}- 휴리스틱: 부족분 이상을 가진 소비자 중 가장 작은 것을 Spill
- 너무 큰 것을 Spill하면 불필요하게 많은 데이터를 디스크로 씀
- 너무 작은 것 여러 개를 Spill하면 Spill 파일이 잘게 쪼개짐
- 그런 소비자가 없으면 가장 큰 것부터 Spill
- 요청한 소비자 자신은 마지막에 Spill
- Spill한 뒤 다시 요청할 때 방금 풀린 메모리를 다른 Task가 먼저 가져갈 수 있으므로 루프로 재시도함
MemoryConsumer 계약
/** Spill some data to disk to release memory, which will be called by TaskMemoryManager
* when there is not enough memory for the task.
* Note: In order to avoid possible deadlock, should not call acquireMemory() from spill(). */
public abstract long spill(long size, MemoryConsumer trigger) throws IOException;구현체 예:
ShuffleExternalSorter(8.3),UnsafeExternalSorter(SQL 정렬)BytesToBytesMap(SQL 해시 집계)- Scala 쪽
Spillable→ExternalSorter,ExternalAppendOnlyMap(8.2, 8.6)
flowchart TD req["Consumer A: 64MB 요청"] --> pool["ExecutionMemoryPool<br/>1/2N ~ 1/N 규칙"] pool -- "충분" --> ok["승인"] pool -- "부족" --> pick["TaskMemoryManager<br/>Spill 대상 선택"] pick --> b["Consumer B spill()"] b --> retry["다시 요청"] retry --> pool
on-heap vs off-heap
spark.memory.offHeap.enabled=true면 Tungsten 페이지를 JVM 밖에서 할당함- GC 부담이 줄지만 그만큼 컨테이너 메모리 한도 계산에 따로 더해야 함 (5.1의 Overhead와 별개)
- 기본 페이지 크기는 Core 수와 풀 크기로 계산함 (1MB–64MB 사이)
9.5 BlockManager
역할
Manager running on every node (driver and executors) which provides interfaces for putting and retrieving blocks both locally and remotely into various stores (memory, disk, and off-heap).
한 BlockManager가 세 종류의 블록을 다룸.
BlockManager
├─ RDD 캐시 블록 → MemoryStore / DiskStore
├─ Shuffle 블록 → 8.4의 data/index 파일 구간
└─ Broadcast 조각 → TorrentBroadcast (5.3)Driver–Executor 구조
flowchart LR master["BlockManagerMasterEndpoint<br/>Driver"] <-->|"등록 · 상태 보고 · 위치 조회"| bm1["BlockManager<br/>Executor 1"] master <--> bm2["BlockManager<br/>Executor 2"] bm1 <-->|"블록 전송"| bm2
- Driver에 마스터가 하나 있고, 모든 노드에 BlockManager가 있음
- 각 노드는 시작할 때 마스터에 등록하고, 블록 상태가 바뀔 때마다
updateBlockInfo로 보고함 - 그래서 Driver가 “Partition 3이 어느 Executor에 캐시돼 있나"를 알고, 7.4의 캐시 위치 선호가 가능함
RDD 캐시 경로: getOrElseUpdate
3.4·4.10에서 RDD의 iterator()는 캐시가 지정돼 있으면 BlockManager를 먼저 확인한다고 했음.
private def getOrElseUpdate[T](...): Either[BlockResult, Iterator[T]] = {
if (isCacheVisible) {
get[T](blockId)(classTag) match { // 로컬 → 원격 순서로 조회
case Some(block) => return Left(block)
case _ =>
}
}
doPutIterator(blockId, iterator, level, classTag, keepReadLock = true) match {
case None => // 저장 성공 → 읽기 락을 쥔 채로 값 반환
...
case Some(iter) => // 저장 실패 → 계산된 Iterator를 그대로 돌려줌
Right(iter)
}
}- 캐시에 있으면 그걸 반환
- 없으면 계산하면서 저장을 시도함
- 메모리가 부족하고 디스크도 쓸 수 없어 저장에 실패해도 계산 결과는 호출자에게 넘김. 이번 Task는 정상으로 진행되고, 다음에 다시 계산함
- 4.x에는 아직 끝나지 않은 Task가 만든 블록을 보고하기 전까지 다른 Task에 보이지 않게 하는 옵션이 있음 (
spark.rdd.cache.visibilityTracking.enabled)
9.6 MemoryStore: LRU, Eviction, and Unrolling
진짜 LRU
private val entries = new LinkedHashMap[BlockId, MemoryEntry[_]](32, 0.75f, true)- 세 번째 인자가
accessOrder = true라서 삽입 순서가 아니라 접근 순서를 유지함 - 블록을 읽을 때마다 맨 뒤로 옮기고, 축출은 맨 앞(가장 오래 안 쓴 것)부터 함
- 저장 형태는 역직렬화된 객체 배열과 직렬화된 바이트 버퍼 두 가지
evictBlocksToFreeSpace
def blockIsEvictable(blockId: BlockId, entry: MemoryEntry[_]): Boolean = {
entry.memoryMode == memoryMode && (rddToAdd.isEmpty || rddToAdd != getRddId(blockId))
}
entries.synchronized {
val iterator = entries.entrySet().iterator() // LRU 순서
while (freedMemory < space && iterator.hasNext) {
...
if (blockIsEvictable(blockId, entry)) {
// 기다리지 않는 쓰기 락 → 누가 읽고 있는 블록은 건너뜀
if (blockInfoManager.lockForWriting(blockId, blocking = false).isDefined) {
selectedBlocks += blockId
freedMemory += entry.size
}
}
}
}축출 후보 조건 세 가지:
- 같은 memory mode (on-heap끼리, off-heap끼리)
- 지금 넣으려는 블록과 같은 RDD의 블록은 제외
- 같은 RDD의 Partition 0을 넣으려고 Partition 1을 밀어내고, 다음엔 1을 넣으려고 0을 밀어내는 순환을 막음
- 지금 아무도 읽고 있지 않음
- 후보를 다 모아도 필요한 공간에 못 미치면 아무것도 축출하지 않고 새 블록을 캐시하는 것을 포기함
- 축출되는 블록은 StorageLevel에 디스크가 있으면 디스크로 내려가고, 없으면 그냥 사라짐
Unroll: 조금씩 펼치며 확인
Iterator를 한 번에 메모리에 펼치면 다 들어갈지 모르는 채로 OOM이 날 수 있음. 그래서 조금씩 펼치면서 메모리를 예약함.
keepUnrolling = reserveUnrollMemoryForThisTask(blockId, initialMemoryThreshold, memoryMode) // 1MB
while (values.hasNext && keepUnrolling) {
valuesHolder.storeValue(values.next())
if (elementsUnrolled % memoryCheckPeriod == 0) { // 16개마다
val currentSize = valuesHolder.estimatedSize()
if (currentSize >= memoryThreshold) {
val amountToRequest = (currentSize * memoryGrowthFactor - memoryThreshold).toLong // ×1.5
keepUnrolling = reserveUnrollMemoryForThisTask(blockId, amountToRequest, memoryMode)
}
}
elementsUnrolled += 1
}- 1MB에서 시작해 16개마다 크기를 추정하고, 모자라면 1.5배로 추가 예약함
- 다 펼치고 나면 Unroll 메모리를 Storage 메모리로 원자적으로 옮김
- Unified 모델에서는 Unroll 메모리 요청이 곧 Storage 메모리 요청이므로 Unroll 자체가 다른 캐시를 밀어낼 수 있음
- 중간에 실패하면 펼친 부분과 남은 Iterator를 묶어 반환함 (
PartiallyUnrolledIterator)
9.7 StorageLevel in Action
5.2의 StorageLevel 표가 실제로 어떻게 동작하는지 봄.
if (level.useMemory) {
// Put it in memory first, even if it also has useDisk set to true;
// We will drop it to disk later if the memory store can't hold it.
memoryStore.putIteratorAsValues(...) match {
case Right(s) => size = s
case Left(iter) =>
if (level.useDisk) {
diskStore.put(blockId) { channel => ... serialize stream ... }
} else {
iteratorFromFailedMemoryStorePut = Some(iter) // MEMORY_ONLY → 저장 안 함
}
}
}| 레벨 | 메모리에 안 들어가면 |
|---|---|
MEMORY_ONLY (RDD cache()) | 저장 자체를 하지 않고, 다음에 접근할 때 Lineage로 재계산 |
MEMORY_AND_DISK (DataFrame cache()) | 바로 디스크에 씀 |
- 디스크가 포함된 레벨도 항상 메모리부터 시도함
- 나중에 축출될 때
dropFromMemory가 디스크에 쓰고 메모리에서 제거함
전체 축출 체인:
Task가 Execution 메모리 요청
→ maybeGrowExecutionPool
→ StorageMemoryPool.freeSpaceToShrinkPool
→ MemoryStore.evictBlocksToFreeSpace (LRU, 같은 RDD 제외)
→ BlockManager.dropFromMemory
├─ 디스크 레벨이면 DiskStore.put
└─ MemoryStore.remove
→ releaseStorageMemory → 풀 크기 재조정
→ Driver에 블록 상태 보고9.8 Tuning Notes
공식 튜닝 가이드와 위 구조를 연결하면:
- Kryo 직렬화: Java 직렬화보다 최대 10배 빠르고 작음. 클래스를 등록해야 함. 2.0부터 단순 타입 Shuffle에는 내부적으로 이미 Kryo를 씀
- 객체 오버헤드: Java 객체 헤더가 16바이트,
String은 약 40바이트의 오버헤드가 있음. 직렬화 저장 레벨(*_SER)이 메모리를 아끼는 이유 - GC 비용은 객체 수에 비례
- 4.0부터 JDK 17과 G1GC가 기본
- Old Generation이 꽉 차면
spark.memory.fraction을 낮추라는 것이 가이드의 권고: “캐시를 줄이는 편이 Task가 느려지는 것보다 낫다”
- Reduce 쪽 OOM은 캐시가 아니라 Task working set 문제
- 8.6의 해시 테이블이 원인인 경우가 많음
- 처방은 병렬도를 올리는 것: Task 수가 늘면 1/N 몫은 줄지만 Task당 입력이 더 크게 줄어듦
flowchart TD heap["Executor Heap"] --> reserved["Reserved 300MB"] heap --> user["User Memory 40%<br/>Spark 관리 밖 · 넘치면 OOM"] heap --> m["Unified M 60%"] m --> r["Storage 보호 구역 R<br/>evict 면제"] m --> shared["경쟁 구역"] shared --> execution["Execution<br/>Task당 1/2N ~ 1/N<br/>부족하면 Spill"] shared --> storage["Storage<br/>LRU evict · MEMORY_AND_DISK면 디스크로"]
- 한 줄 요약
- Spark가 관리하는 메모리는 모자라면 Spill하거나 캐시를 버릴 뿐 죽지 않음
- 죽는 건 대부분 관리 밖(User Memory, Driver, Overhead)에서 일어남 (12장)