Workspace IndexAlgorithms › Day 31

Memory Allocator Design — Ideas Behind jemalloc and mimalloc TODO

Algorithms · Day 31 / 100 · B. Compilers, Runtimes & VMs (Day 20-35)

Concept

A general-purpose memory allocator has to handle arbitrary-sized requests quickly while keeping fragmentation under control, so it rounds sizes into a few dozen size classes and manages same-class blocks together using a segregated free list structure. jemalloc assigns each thread an arena and adds a thread-local cache called tcache, so most allocations and frees complete without taking a lock, and it pulls memory from the OS in large units (chunks or extents) that it then carves up. mimalloc gives each thread its own heap and each page its own free list, and it separates local frees from remote frees (frees issued by a different thread) into distinct lists — the free list sharding idea — to cut down on atomic operations. What the two designs share is thread-local caching to eliminate contention, using size classes to turn external fragmentation into internal fragmentation so it stays manageable, and treating the moment memory is returned to the OS (purge/decay) as a policy decision.

In multithreaded servers, when throughput fails to scale with core count the culprit is often allocator contention, and RSS staying far above actual usage is likewise explained by the allocator's fragmentation and memory-return policy.

Code & Formula

# 메모리 할당자 설계 — jemalloc/mimalloc 아이디어: size class segregated free list + 스레드 로컬 캐시(tcache).
# 요청 크기를 몇 개의 size class 로 반올림해 같은 클래스끼리 free list 로 묶으면, 할당/해제가 O(1) 에 가까워진다.

SIZE_CLASSES = [8, 16, 32, 64, 128, 256]

def size_class_for(n):
    for c in SIZE_CLASSES:
        if n <= c:
            return c
    raise ValueError("too large for this toy allocator")

class TinyAllocator:
    def __init__(self):
        # 클래스별 free list (한 번 반환된 블록은 재사용) — jemalloc 의 segregated free list 흉내.
        self.free_lists = {c: [] for c in SIZE_CLASSES}
        self.next_addr = 0
        self.live = {}  # addr -> size_class (누가 뭘 들고 있는지 추적)

    def alloc(self, n):
        c = size_class_for(n)
        if self.free_lists[c]:
            addr = self.free_lists[c].pop()   # 스레드 로컬 캐시 hit 에 해당 — 락 없이 즉시 재사용
        else:
            addr = self.next_addr
            self.next_addr += c                # 새 청크는 size class 단위로만 늘어남(내부 단편화로 흡수)
        self.live[addr] = c
        return addr

    def free(self, addr):
        c = self.live.pop(addr)
        self.free_lists[c].append(addr)        # OS 에 즉시 반환하지 않고 재사용 대기열에 둔다(decay 정책 흉내)

alloc = TinyAllocator()
a = alloc.alloc(10)   # 10 -> class 16
b = alloc.alloc(60)   # 60 -> class 64
alloc.free(a)
c = alloc.alloc(15)   # 같은 class 16 free list 를 즉시 재사용 → 새 청크를 늘리지 않음

print("a addr:", a, "class:", alloc.live.get(a))
print("b addr:", b, "class:", alloc.live[b])
print("c addr:", c, "reused a's slot:", c == a)
print("free list state:", {k: v for k, v in alloc.free_lists.items() if v or k in (16, 64)})
print("total bytes carved from OS:", alloc.next_addr)

Exercise

Run the same multithreaded allocation benchmark while swapping in the default malloc versus jemalloc (or mimalloc) via LD_PRELOAD, and compare throughput and peak RSS.

Practical Connection

In services like a Go-based indexer or matching engine that create tens of thousands of small objects per second, the allocation pattern directly becomes GC pressure — the same principle explains why object reuse (sync.Pool) or pre-allocating slices pays off so much.

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/.


한국어

메모리 할당자 설계 TODO

Algorithms · Day 31 / 100 · B. 컴파일러·런타임·VM (Day 20–35)

jemalloc·mimalloc의 아이디어

개념

범용 메모리 할당자는 임의 크기 요청을 빠르게 처리하면서 단편화를 억제해야 하는데, 이를 위해 크기를 몇십 개의 size class로 반올림해 같은 클래스끼리 모아 관리하는 segregated free list 구조를 쓴다. jemalloc은 스레드마다 arena를 배정하고 tcache라는 스레드 로컬 캐시를 두어 대부분의 할당·해제가 락 없이 끝나게 하며, 메모리는 큰 단위(chunk 또는 extent)로 OS에서 받아 잘라 쓴다. mimalloc은 스레드마다 heap을, 페이지마다 free list를 두고 로컬 해제와 원격(다른 스레드에서의) 해제를 분리된 리스트로 처리해 원자 연산을 줄이는 free list sharding 아이디어를 쓴다. 두 설계가 공유하는 핵심은 스레드 로컬 캐싱으로 경합을 없애고, size class로 외부 단편화를 내부 단편화로 바꿔 관리 가능하게 만들고, 해제된 메모리를 OS에 돌려주는 시점(purge/decay)을 정책으로 다루는 것이다.

멀티스레드 서버에서 처리량이 코어 수에 비례해 늘지 않을 때 원인이 할당자 경합인 경우가 흔하고, RSS가 실제 사용량보다 훨씬 크게 유지되는 현상도 할당자의 단편화·반환 정책으로 설명된다.

코드 · 수식

# 메모리 할당자 설계 — jemalloc/mimalloc 아이디어: size class segregated free list + 스레드 로컬 캐시(tcache).
# 요청 크기를 몇 개의 size class 로 반올림해 같은 클래스끼리 free list 로 묶으면, 할당/해제가 O(1) 에 가까워진다.

SIZE_CLASSES = [8, 16, 32, 64, 128, 256]

def size_class_for(n):
    for c in SIZE_CLASSES:
        if n <= c:
            return c
    raise ValueError("too large for this toy allocator")

class TinyAllocator:
    def __init__(self):
        # 클래스별 free list (한 번 반환된 블록은 재사용) — jemalloc 의 segregated free list 흉내.
        self.free_lists = {c: [] for c in SIZE_CLASSES}
        self.next_addr = 0
        self.live = {}  # addr -> size_class (누가 뭘 들고 있는지 추적)

    def alloc(self, n):
        c = size_class_for(n)
        if self.free_lists[c]:
            addr = self.free_lists[c].pop()   # 스레드 로컬 캐시 hit 에 해당 — 락 없이 즉시 재사용
        else:
            addr = self.next_addr
            self.next_addr += c                # 새 청크는 size class 단위로만 늘어남(내부 단편화로 흡수)
        self.live[addr] = c
        return addr

    def free(self, addr):
        c = self.live.pop(addr)
        self.free_lists[c].append(addr)        # OS 에 즉시 반환하지 않고 재사용 대기열에 둔다(decay 정책 흉내)

alloc = TinyAllocator()
a = alloc.alloc(10)   # 10 -> class 16
b = alloc.alloc(60)   # 60 -> class 64
alloc.free(a)
c = alloc.alloc(15)   # 같은 class 16 free list 를 즉시 재사용 → 새 청크를 늘리지 않음

print("a addr:", a, "class:", alloc.live.get(a))
print("b addr:", b, "class:", alloc.live[b])
print("c addr:", c, "reused a's slot:", c == a)
print("free list state:", {k: v for k, v in alloc.free_lists.items() if v or k in (16, 64)})
print("total bytes carved from OS:", alloc.next_addr)

연습

동일한 멀티스레드 할당 부하 벤치마크를 기본 malloc과 jemalloc(또는 mimalloc)을 LD_PRELOAD로 바꿔가며 돌려 처리량과 최대 RSS를 비교해 보라.

실무 · Verex 연결

Go로 짠 인덱서나 매칭 엔진처럼 초당 수만 건의 작은 객체를 만드는 서비스에서는 할당 패턴이 곧 GC 압력이므로, 객체 재사용(sync.Pool)이나 슬라이스 사전 할당이 왜 효과가 큰지 같은 원리로 설명된다.

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

← 30. Rust 소유권·차용 검사기 내부(NLL)와 우회 패턴32. FFI·ABI 경계와 안전성(패닉·정렬·수명) →