Workspace IndexAlgorithms › Day 80

External Sorting, Merge Strategies, and Parallel Sort (TAOCP Vol. 3) — The Real Bottleneck in Indexer Rebuilds TODO

Algorithms · Day 80 / 100 · E. Data & Storage Engines (Day 69-81)

Concept

External sorting is used when data doesn't fit in memory: it splits into a run-generation phase, where memory-sized runs are sorted and written to disk, and a merge phase, where those runs are combined via k-way merging. Raising the merge fan-in k shrinks the number of passes needed — it's log base k of the number of runs — but each run needs its own input buffer, so a bigger k means smaller per-run buffers and worse sequential-read efficiency; that's the tradeoff. Using replacement selection during run generation can produce runs longer than memory on average, which cuts the number of runs outright. Parallel sorting either splits the data, sorts each part, and merges, or uses sample sort — sampling to pick partition boundaries and sorting each partition independently — and in both cases the real bottleneck is memory bandwidth and data movement, not comparisons. In other words, the cost model for external and parallel sorting should be built on blocks and bytes transferred, not comparison count.

Sorts that exceed memory come up constantly in practice — index rebuilds, large joins, log reprocessing — and what actually decides performance there is buffer size and merge fan-in, not algorithm choice.

Code & Formula

# 외부 정렬 — 메모리에 다 못 올리는 데이터를 런(run)으로 쪼개 정렬 후 k-way 병합한다.
# 비용은 비교 횟수가 아니라 "몇 번 디스크를 훑는가(패스 수)"로 세는 게 핵심이다.

import heapq
import random

random.seed(1)
data = random.sample(range(1000), 37)     # "디스크에 있는" 전체 데이터
MEMORY_CAPACITY = 6                        # 메모리에 한 번에 올릴 수 있는 크기

def make_sorted_runs(data, capacity):
    runs = []
    for i in range(0, len(data), capacity):
        chunk = data[i:i + capacity]
        runs.append(sorted(chunk))         # 런 하나 = 메모리에 올려 정렬 후 "디스크"에 기록
    return runs

def k_way_merge(runs):
    # heapq.merge 는 여러 정렬된 이터러블을 O(N log k) 로 병합한다 (k = 런 개수)
    return list(heapq.merge(*runs))

runs = make_sorted_runs(data, MEMORY_CAPACITY)
merged = k_way_merge(runs)

import math
num_passes = math.ceil(math.log(len(runs), MEMORY_CAPACITY)) if len(runs) > 1 else 1

print("input size:", len(data), "| memory capacity:", MEMORY_CAPACITY)
print("number of sorted runs:", len(runs))
for i, r in enumerate(runs):
    print(f"  run {i}: {r}")
print("merged result is sorted:", merged == sorted(data))
print("approx merge passes needed (log_k of run count):", num_passes)

Exercise

Implement external sort over a file larger than memory using a fixed buffer, then vary the merge fan-in and measure total runtime and actual bytes read/written to find the optimum.

Practical Connection

Re-indexing chain events from genesis makes sorting block/log keys a real bottleneck, and tuning batch size and merge fan-in there determines the total re-sync time.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

외부 정렬·병합 전략과 병렬 정렬 (TAOCP 3권) TODO

Algorithms · Day 80 / 100 · E. 데이터·스토리지 엔진 (Day 69–81)

인덱서 재구성의 실제 병목

개념

외부 정렬은 데이터가 메모리보다 클 때 쓰는 방법으로, 메모리에 들어가는 크기의 런(run)을 만들어 정렬해 디스크에 쓰는 단계와 이 런들을 다방향 병합으로 합치는 단계로 나뉜다. 병합 차수 k를 키우면 필요한 패스 수가 런 개수에 대한 log_k로 줄지만, 런마다 입력 버퍼를 나눠 가져야 해서 버퍼가 작아지면 순차 읽기 효율이 떨어지는 트레이드오프가 있다. 런 생성 단계에서 replacement selection을 쓰면 평균적으로 메모리 크기보다 긴 런을 만들 수 있어 런 개수 자체를 줄일 수 있다. 병렬 정렬은 데이터를 나눠 각각 정렬한 뒤 병합하거나, 샘플링으로 분할 경계를 정해 각 파티션을 독립적으로 정렬하는 sample sort 방식을 쓰며, 이때 실제 병목은 비교 연산이 아니라 메모리 대역폭과 데이터 이동량이다. 즉 외부·병렬 정렬의 비용 모델은 비교 횟수가 아니라 전송한 블록 수와 바이트 수로 세워야 한다.

인덱스 재구축, 대용량 조인, 로그 재처리처럼 메모리를 넘는 정렬은 실무에서 자주 나오고, 여기서 성능을 가르는 것은 알고리즘 선택보다 버퍼 크기와 병합 차수 설정이다.

코드 · 수식

# 외부 정렬 — 메모리에 다 못 올리는 데이터를 런(run)으로 쪼개 정렬 후 k-way 병합한다.
# 비용은 비교 횟수가 아니라 "몇 번 디스크를 훑는가(패스 수)"로 세는 게 핵심이다.

import heapq
import random

random.seed(1)
data = random.sample(range(1000), 37)     # "디스크에 있는" 전체 데이터
MEMORY_CAPACITY = 6                        # 메모리에 한 번에 올릴 수 있는 크기

def make_sorted_runs(data, capacity):
    runs = []
    for i in range(0, len(data), capacity):
        chunk = data[i:i + capacity]
        runs.append(sorted(chunk))         # 런 하나 = 메모리에 올려 정렬 후 "디스크"에 기록
    return runs

def k_way_merge(runs):
    # heapq.merge 는 여러 정렬된 이터러블을 O(N log k) 로 병합한다 (k = 런 개수)
    return list(heapq.merge(*runs))

runs = make_sorted_runs(data, MEMORY_CAPACITY)
merged = k_way_merge(runs)

import math
num_passes = math.ceil(math.log(len(runs), MEMORY_CAPACITY)) if len(runs) > 1 else 1

print("input size:", len(data), "| memory capacity:", MEMORY_CAPACITY)
print("number of sorted runs:", len(runs))
for i, r in enumerate(runs):
    print(f"  run {i}: {r}")
print("merged result is sorted:", merged == sorted(data))
print("approx merge passes needed (log_k of run count):", num_passes)

연습

메모리보다 큰 파일을 제한된 버퍼로 정렬하는 외부 정렬을 직접 구현하고, 병합 차수를 바꿔가며 총 실행 시간과 실제 읽고 쓴 바이트 수를 측정해 최적점을 찾아보라.

실무 · Verex 연결

체인 이벤트를 제네시스부터 재인덱싱할 때 블록·로그 키 정렬이 실제 병목이 되며, 배치 크기와 병합 차수 튜닝이 전체 재구성 소요 시간을 결정한다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 79. 캐시 일관성·무효화·스탬피드 방지81. [복습] 데이터 모델이 성능을 정한다 →