해시맵 직접 구현하기

← SIMD 프로그래밍에서 배운 내용을 실제로 씁니다. 이 문서는 완성된 코드를 한 번에 보여주는 게 아니라, "왜 이렇게 만들어야 하는가"를 먼저 이해하고 그 이유가 코드로 어떻게 표현되는지 따라가는 방식으로 진행합니다.

1. 해시맵이 O(1)인 이유

배열에서 값을 찾으려면 처음부터 끝까지 훑어야 합니다. N개면 N번. 해시맵은 이걸 다르게 접근합니다: "키를 숫자로 바꾸면 배열 인덱스로 쓸 수 있지 않을까?"

키 "apple"을 저장하고 싶다 ↓ hash("apple") → 8,392,741,923,847 (아무 u64 숫자) ↓ 8,392,741,923,847 % 16 = 3 ← 버킷 크기가 16이면 인덱스 3번에 저장 ↓ buckets[3] = "apple" ← 딱 한 번의 계산으로 위치 결정 나중에 "apple" 있어? → hash → %16 → [3] 확인 → O(1)

문제는 두 키가 같은 인덱스를 가리킬 때입니다. 이걸 충돌(collision)이라고 합니다. 해시맵의 설계 차이는 거의 다 "충돌을 어떻게 처리하는가"에 있습니다.

2. 해시 함수 — FxHash

Rust 표준 라이브러리는 DoS 공격 방어를 위해 느린 SipHash를 씁니다. 우리는 신뢰할 수 있는 데이터를 다룬다고 가정하고 훨씬 빠른 FxHash를 씁니다.

왜 DoS 공격이 문제인가
공격자가 해시값이 전부 같아지도록 키를 고르면, 모든 값이 같은 버킷에 몰려 O(1)이 O(n)이 됩니다. SipHash는 난수 시드를 써서 이걸 막습니다. 하지만 그만큼 느립니다. 내부 데이터(캐시, 파싱 결과 등)엔 FxHash가 낫습니다.
// FxHash 핵심: 곱셈 + XOR 두 번
// 이 상수는 φ(황금비)의 비트 표현 — 비트 패턴을 골고루 섞어줌
const K: u64 = 0x517cc1b727220a95;

fn fxhash(key: u64) -> u64 {
    key.rotate_left(5).bitxor(key).wrapping_mul(K)
}

이 해시값을 두 부분으로 쪼개서 쓰는 게 Swiss Table의 핵심입니다.

hash = 0b1011_0010_1101_0110_..._(상위 57비트)010_1101 ↑ ↑ h1: 버킷 그룹 결정 h2: 제어 바이트 값 (하위 7비트) "어느 그룹의 슬롯을 볼까" "그 그룹에서 어떤 h2를 찾을까"
fn h1(hash: u64, num_groups: usize) -> usize {
    (hash >> 7) as usize % num_groups
}

fn h2(hash: u64) -> u8 {
    // 하위 7비트만 (MSB는 항상 0 = FULL 상태를 의미)
    (hash & 0x7F) as u8
}

3. Swiss Table 구조 — "제어 바이트"가 전부다

기존 해시맵은 버킷마다 "비었나요?" 플래그를 키-값 옆에 함께 뒀습니다. "apple"이 있는지 확인하려면 버킷을 열어서 키를 직접 비교해야 했죠.

Swiss Table은 발상을 뒤집습니다: 버킷은 열지 않고, 버킷 앞에 1바이트짜리 요약 정보(제어 바이트)만 먼저 봅니다. 그리고 그 제어 바이트 16개를 SIMD로 한 번에 검사합니다.

메모리 레이아웃: ┌─ ctrl 배열 (버킷 1개당 1바이트) ─────────────────────────────┐ │ [0xFF][0x2A][0xFF][0x1B][0x80][0xFF][0x2A][0xFF] ··· │ └───────────────────────────────────────────────────────────────┘ 빈 FULL 빈 FULL DEL 빈 FULL 빈 ↕ ↕ ↕ ↕ ┌─ buckets 배열 (버킷 1개당 (K, V)) ───────────────────────────┐ │ [???][K,V][???][K,V][???][???][K,V][???] ··· │ └───────────────────────────────────────────────────────────────┘ ↑ ↑ ctrl=0x2A이면 ctrl=0x2A면 실제 키 비교 필요 실제 키 비교 필요 ctrl 값의 의미: 0xFF (1111 1111) → EMPTY (비어있음, 프로빙 여기서 중단 가능) 0x80 (1000 0000) → DELETED (삭제됨, 프로빙은 계속해야 함) 0b0xxx xxxx → FULL (하위 7비트 = h2값, MSB는 0)

핵심: SIMD로 16개 ctrl을 동시에 읽어 h2와 비교 → 일치하는 인덱스만 실제 키 비교. 대부분의 경우 h2가 다르므로 버킷 자체는 거의 열지 않습니다.

4. 삽입 과정 — 직접 따라가보기

아래 시뮬레이터에서 Step을 눌러 삽입이 어떻게 진행되는지 한 단계씩 확인하세요.

// INSERT 시뮬레이터 — "rust" 키 삽입 (hash h1=2, h2=0x3A)
EMPTY (0xFF) FULL DELETED 현재 탐색 중 삽입 위치
Step을 눌러 시작하세요.

5. 탐색 과정

탐색은 삽입과 거의 같습니다. 차이는 EMPTY를 만나면 "없다"고 확정한다는 것입니다. DELETED는 통과합니다 — 삭제 전에 그 너머에 데이터가 삽입됐을 수 있기 때문입니다.

// LOOKUP 시뮬레이터 — "rust" 탐색 (h1=2, h2=0x3A)
EMPTY FULL DELETED h2 비교 중 발견!
Step을 눌러 시작하세요.

6. Rust 구현 — 메모리 할당부터

구조체를 설계합니다. ctrl과 buckets는 raw 포인터로 가집니다. Rust의 소유권 시스템이 raw 포인터엔 자동으로 작동하지 않으므로 Drop을 직접 구현해야 합니다.

SwissMap<K, V> 구조: SwissMap { ctrl: *mut u8, ← ctrl 배열의 시작 주소 (16바이트 정렬) buckets: *mut(K,V), ← 버킷 배열의 시작 주소 cap: usize, ← 전체 슬롯 수 (항상 16의 배수) len: usize, ← 실제 저장된 항목 수 } cap = 16일 때 메모리: ctrl: [FF][FF][FF][FF][FF][FF][FF][FF][FF][FF][FF][FF][FF][FF][FF][FF] buckets: [(미초기화)×16] ← ctrl이 EMPTY면 여기는 읽지 않음
use std::alloc::{alloc, dealloc, Layout};
use std::ptr;

const EMPTY:   u8 = 0xFF;
const DELETED: u8 = 0x80;

pub struct SwissMap<K, V> {
    ctrl:    *mut u8,
    buckets: *mut (K, V),
    cap:     usize,
    len:     usize,
}

impl<K, V> SwissMap<K, V> {
    pub fn new() -> Self {
        Self::with_capacity(16)
    }

    pub fn with_capacity(cap: usize) -> Self {
        assert!(cap >= 16 && cap.is_power_of_two(),
                "cap은 16 이상의 2의 거듭제곱이어야 합니다");
        unsafe {
            // ctrl: 16바이트 정렬, 전부 EMPTY(0xFF)로 초기화
            let ctrl_layout = Layout::from_size_align(cap, 16).unwrap();
            let ctrl = alloc(ctrl_layout);
            ptr::write_bytes(ctrl, EMPTY, cap);

            // buckets: 초기화 하지 않음 — ctrl이 EMPTY인 슬롯은 절대 읽지 않음
            let bucket_layout = Layout::array::<(K, V)>(cap).unwrap();
            let buckets = alloc(bucket_layout) as *mut (K, V);

            Self { ctrl, buckets, cap, len: 0 }
        }
    }
}
왜 buckets를 초기화하지 않나요?
ctrl 배열이 EMPTY(0xFF)인 슬롯은 코드가 절대 읽지 않습니다. 초기화하지 않는 메모리를 읽으면 UB지만, ctrl 체크가 그걸 막아주는 불변식 역할을 합니다. 초기화하지 않으면 cap이 클 때 상당한 시간을 아낄 수 있습니다.
// Drop: ctrl이 FULL인 슬롯의 값만 직접 drop
impl<K, V> Drop for SwissMap<K, V> {
    fn drop(&mut self) {
        unsafe {
            for i in 0..self.cap {
                // MSB가 0이면 FULL (0xFF나 0x80은 MSB가 1)
                if *self.ctrl.add(i) < 0x80 {
                    ptr::drop_in_place(self.buckets.add(i));
                }
            }
            let ctrl_layout = Layout::from_size_align(self.cap, 16).unwrap();
            dealloc(self.ctrl, ctrl_layout);

            let bucket_layout = Layout::array::<(K, V)>(self.cap).unwrap();
            dealloc(self.buckets as *mut u8, bucket_layout);
        }
    }
}

7. SIMD 그룹 스캔

16개의 ctrl 바이트를 한 번에 비교합니다. u8x16::simd_eq()는 16개를 동시에 비교해 일치 위치를 비트마스크로 돌려줍니다.

예: h2 = 0x3A 탐색 ctrl[그룹 2]: [FF][FF][3A][FF][1B][80][3A][FF][FF][FF][FF][FF][FF][FF][FF][FF] 0 1 2 3 4 5 6 7 8 ... simd_eq(ctrl, splat(0x3A)): [00][00][FF][00][00][00][FF][00][00][00][00][00][00][00][00][00] ↑ ↑ 인덱스 2 인덱스 6 to_bitmask() → 0b0000_0000_0100_0100 (비트 2, 6이 1) ↑↑ trailing_zeros() → 2 (첫 번째 후보 = 슬롯 2) 실제 키 비교 후 일치하면 완료, 아니면 다음 비트 6 확인
#![feature(portable_simd)]
use std::simd::prelude::*;

struct Group(*const u8);

impl Group {
    // 메모리에서 16개 ctrl 바이트를 SIMD 레지스터 하나로 로드
    unsafe fn load(&self) -> u8x16 {
        u8x16::from_slice(std::slice::from_raw_parts(self.0, 16))
    }

    // target과 같은 바이트의 위치를 비트마스크로 반환
    // 비트 i가 1 → i번 슬롯의 ctrl == target
    unsafe fn match_byte(&self, target: u8) -> u16 {
        self.load().simd_eq(u8x16::splat(target)).to_bitmask()
    }

    // EMPTY(0xFF) 슬롯 위치
    unsafe fn match_empty(&self) -> u16 {
        self.match_byte(EMPTY)
    }

    // EMPTY 또는 DELETED 슬롯 (삽입 가능한 위치)
    // 두 값 모두 MSB = 1이므로, ctrl & 0x80 != 0 이면 삽입 가능
    unsafe fn match_available(&self) -> u16 {
        (self.load() & u8x16::splat(0x80))
            .simd_ne(u8x16::splat(0))
            .to_bitmask()
    }
}

// 비트마스크의 set 비트 인덱스를 하나씩 꺼내는 이터레이터
fn each_set_bit(mut mask: u16) -> impl Iterator<Item = usize> {
    std::iter::from_fn(move || {
        if mask == 0 { return None; }
        let i = mask.trailing_zeros() as usize;
        mask &= mask - 1;  // 최하위 set 비트 제거 (비트 트릭)
        Some(i)
    })
}

8. get — 탐색 구현

흐름: 그룹 결정 → SIMD 스캔 → h2 일치 슬롯에서 실제 키 비교 → EMPTY 만나면 없음 확정

get("rust") 전체 흐름: hash = fxhash("rust") = 0xABCD... h1 = hash >> 7 % (cap/16) → 그룹 2 h2 = hash & 0x7F → 0x3A 그룹 2의 ctrl 16바이트 로드 (SIMD 1회) ↓ simd_eq(0x3A) → 비트마스크 ↓ 비트 2가 1 → buckets[2*16+2].key == "rust"? → YES → Some(&value) 아니면 다음 비트 ↓ 비트 6이 1 → buckets[2*16+6].key == "rust"? → ... ↓ 모든 비트 확인 후 EMPTY 있으면 → None EMPTY 없으면 → 다음 그룹으로 (선형 프로빙)
impl<K: Eq, V> SwissMap<K, V> {
    pub fn get(&self, key: &K, hash: u64) -> Option<&V> {
        let h2val     = h2(hash);
        let num_groups = self.cap / 16;
        let start      = h1(hash, num_groups);

        for probe in 0..num_groups {
            let g        = (start + probe) % num_groups;
            let ctrl_ptr = unsafe { self.ctrl.add(g * 16) };
            let group    = Group(ctrl_ptr);

            // ① SIMD로 h2 일치 위치 탐색
            for slot in each_set_bit(unsafe { group.match_byte(h2val) }) {
                let idx = g * 16 + slot;
                // ② h2가 같아도 실제 키가 다를 수 있음 (hash 충돌)
                //    키를 직접 비교해서 최종 확인
                let (k, v) = unsafe { &*self.buckets.add(idx) };
                if k == key {
                    return Some(v);
                }
            }

            // ③ 이 그룹에 EMPTY가 하나라도 있으면
            //    삽입 시 여기서 멈췄을 것이므로 더 이상 없음
            if unsafe { group.match_empty() } != 0 {
                return None;
            }
            // ④ 이 그룹이 꽉 차있으면 다음 그룹으로
        }
        None
    }
}

9. insert — 삽입 구현

삽입 전에 로드 팩터를 확인합니다. 테이블이 87.5% 이상 차면 크기를 2배로 늘립니다. 왜 100%가 아니라 87.5%냐면, 꽉 차면 EMPTY가 없어져서 탐색이 무한 루프에 빠지기 때문입니다.

로드 팩터 = len / cap cap=16, len=0 → 0% → 여유 있음 cap=16, len=8 → 50% → 여유 있음 cap=16, len=14 → 87.5% → resize 트리거! (cap=32로 증가) cap=16, len=16 → 100% → 이러면 안 됨 — EMPTY가 없어 탐색 무한루프
impl<K: Eq, V> SwissMap<K, V> {
    pub fn insert(&mut self, key: K, value: V, hash: u64) -> Option<V> {
        // 87.5% = 7/8 이상이면 resize
        if self.len * 8 >= self.cap * 7 {
            self.resize();
        }

        let h2val      = h2(hash);
        let num_groups = self.cap / 16;
        let start      = h1(hash, num_groups);

        for probe in 0..num_groups {
            let g        = (start + probe) % num_groups;
            let ctrl_ptr = unsafe { self.ctrl.add(g * 16) };
            let group    = Group(ctrl_ptr);

            // ① 이미 같은 키가 있으면 값을 교체
            for slot in each_set_bit(unsafe { group.match_byte(h2val) }) {
                let idx = g * 16 + slot;
                let (k, v) = unsafe { &mut *self.buckets.add(idx) };
                if k == &key {
                    return Some(std::mem::replace(v, value));
                }
            }

            // ② 삽입 가능한 슬롯(EMPTY 또는 DELETED) 찾기
            let available = unsafe { group.match_available() };
            if available != 0 {
                let slot = available.trailing_zeros() as usize;
                let idx  = g * 16 + slot;
                unsafe {
                    // ctrl에 h2 기록 (이 슬롯이 FULL임을 표시)
                    *self.ctrl.add(idx) = h2val;
                    // 버킷에 값 기록 (이전엔 미초기화 메모리였음)
                    self.buckets.add(idx).write((key, value));
                }
                self.len += 1;
                return None;
            }
            // ③ 이 그룹이 꽉 차있으면 다음 그룹으로
        }
        unreachable!("resize가 됐는데 빈 슬롯이 없음 — 버그");
    }

    fn resize(&mut self) {
        let new_cap = self.cap * 2;
        let mut new_map = SwissMap::with_capacity(new_cap);

        // 모든 FULL 슬롯을 새 맵으로 이동
        for i in 0..self.cap {
            unsafe {
                if *self.ctrl.add(i) < 0x80 {
                    // read()는 소유권을 이동시킴 (원본은 초기화 안 된 상태로 남김)
                    let (k, v) = self.buckets.add(i).read();
                    // 새 해시를 계산해서 재삽입
                    let hash = fxhash_of(&k);
                    new_map.insert(k, v, hash);
                }
            }
        }
        // self와 new_map을 교체
        // swap 후 new_map이 drop → 이전 ctrl/buckets 메모리 해제
        std::mem::swap(self, &mut new_map);
    }
}

10. remove — 삭제 구현

삭제는 슬롯을 EMPTY로 되돌리지 않습니다. DELETED(0x80)로 마킹하는 이유를 먼저 이해해야 합니다.

왜 EMPTY 대신 DELETED를 쓰는가: 삽입 순서: "apple"(h1=0) → "banana"(h1=0, 충돌) → [0]에 apple, [1]에 banana ctrl: [3A][2B][FF][FF]... ↑ ↑ apple banana (h1=0이었지만 [0]이 차서 [1]에 들어감) 이때 "apple" 삭제: ❌ EMPTY로 마킹하면: ctrl: [FF][2B][FF][FF]... get("banana") → h1=0 → [0] = EMPTY → 없음! (거짓 음성) ✅ DELETED로 마킹: ctrl: [80][2B][FF][FF]... get("banana") → h1=0 → [0] = DELETED → 계속 탐색 → [1] = 발견!
impl<K: Eq, V> SwissMap<K, V> {
    pub fn remove(&mut self, key: &K, hash: u64) -> Option<V> {
        let h2val      = h2(hash);
        let num_groups = self.cap / 16;
        let start      = h1(hash, num_groups);

        for probe in 0..num_groups {
            let g        = (start + probe) % num_groups;
            let ctrl_ptr = unsafe { self.ctrl.add(g * 16) };
            let group    = Group(ctrl_ptr);

            for slot in each_set_bit(unsafe { group.match_byte(h2val) }) {
                let idx = g * 16 + slot;
                let (k, _) = unsafe { &*self.buckets.add(idx) };
                if k == key {
                    unsafe {
                        // DELETED 마킹 (EMPTY로 두면 뒤에 있는 항목을 못 찾음)
                        *self.ctrl.add(idx) = DELETED;
                        // 값의 소유권을 꺼내서 반환
                        let (_, v) = self.buckets.add(idx).read();
                        self.len -= 1;
                        return Some(v);
                    }
                }
            }
            if unsafe { group.match_empty() } != 0 { return None; }
        }
        None
    }
}

11. 왜 실제로는 hashbrown을 써야 하는가

위 구현은 원리를 이해하기 위한 코드입니다. 실제 프로젝트에서는 hashbrown을 써야 합니다. 이유는 단순히 "더 최적화돼서"가 아닙니다. 우리 구현에는 조용히 메모리를 오염시키거나 데이터를 잃어버리는 버그가 숨어 있습니다.

문제 1 — DELETED 슬롯이 쌓이면 O(n)으로 퇴화한다

삽입 10만 번, 삭제 9만 번 후: ctrl: [80][80][80][80][80]...(9만 개의 DELETED)...[3A][80][80]... ↑ ↑ 전부 DELETED 겨우 1개 남은 FULL get("rust") → [0]은 DELETED → 계속 → [1]은 DELETED → 계속 → ... → 9만 번

DELETED가 EMPTY를 대체하면 프로빙이 전체 테이블을 순회합니다. O(1)이 사실상 O(n)으로 퇴화합니다.

hashbrown의 해결책: growth_left 카운터로 DELETED 누적을 추적. 임계치를 넘으면 크기는 그대로 두고 DELETED만 제거하는 rehash_in_place() 실행.

문제 2 — wrap-around마다 나눗셈이 일어난다

우리 구현: let g = (start + probe) % num_groups; ↑ 매번 UDIV 명령어 (20~40 사이클) hashbrown의 mirror 트릭: cap=64일 때, ctrl을 cap+16 = 80바이트 할당: [G0..G15][G16..G31][G32..G47][G48..G63][G0..G15] ← 처음 16바이트 복사 ↑ "거울" G48에서 시작해도 포인터 앞으로 읽으면 자동으로 G0..G15가 나옴 → % 연산 없이 순차 읽기만으로 wrap-around 처리

문제 3 — 두 번의 메모리 할당이 캐시를 낭비한다

우리 구현 (두 번 할당): ctrl → 주소 0x1000 ┐ 서로 멀리 떨어진 메모리 buckets → 주소 0x8000 ┘ → ctrl 로드 후 bucket 접근 시 캐시 미스 hashbrown (한 번 할당, 연속 배열): [ ← buckets (K,V)... → ][ ← ctrl + mirror → ] 단 하나의 연속 메모리 블록 → 캐시 효율 극대화

문제 4 — resize 중 패닉이 나면 double free

resize() 흐름: for i in 0..self.cap { let (k, v) = self.buckets.add(i).read(); // ← 소유권 이동 new_map.insert(k, v, hash); // ← 여기서 Hash::hash() 패닉! } // new_map drop → 일부만 들어간 채로 해제 // self의 원본 buckets도 drop → 이미 .read()로 뺀 것들 double free!

hashbrown의 해결책: 이동한 슬롯의 ctrl을 즉시 DELETED로 마킹. 패닉 발생 시 guard가 DELETED가 아닌 슬롯만 drop해서 이중 해제를 막습니다.

문제 5 — Entry API가 없으면 탐색을 두 번 한다

// 우리 구현: 탐색 2번 (없으면 삽입, 있으면 수정)
let hash = fxhash_of(&key);
if let Some(v) = map.get(&key, hash) {
    map.insert(key, v + 1, hash);  // 탐색 또 한 번
} else {
    map.insert(key, 1, hash);      // 탐색 또 한 번
}

// hashbrown / std HashMap: 탐색 1번
// Entry가 "이미 찾은 슬롯 포인터"를 들고 있음
*map.entry(key).or_insert(0) += 1;

요약

항목우리 구현hashbrown
DELETED 누적O(n) 퇴화rehash_in_place 자동 실행
wrap-around% 나눗셈 (느림)mirror 트릭 (순차 읽기)
메모리 할당2회, 분리1회, 연속 블록
패닉 안전성double free 가능guard 패턴
Entry API없음있음
ZST 지원UB특수 경로