Trawling

Go map 100만 엔트리는 16MB가 아닌 38MB — Swiss Table map의 실제 메모리 비용 분석

Go 1.24부터 기본이 된 Swiss Table 기반 map에서 map[int64]int64 100만 엔트리가 이론값 16MB가 아닌 37.8MB를 차지하는 이유를 강제 GC와 HeapAlloc 측정으로 분석한 글입니다. 그룹·테이블 구조와 7/8 로드 팩터, 할당자의 크기 클래스 올림 때문에 엔트리당 비용이 21~44바이트 사이를 오가며, struct{} set의 패딩 회귀와 삭제 시 메모리 미회수 같은 실무 영향도 함께 다룹니다.

본문

map[int64]int64는 이론상 엔트리당 16바이트(키 8 + 값 8)로 계산되지만, Go 1.27.1에서 100만 개를 넣으면 실제 힙에는 약 37.8MB, 즉 엔트리당 37.8바이트가 잡힙니다. 저자는 강제 GC 후 HeapAlloc을 읽는 방식으로 새 Swiss Table 기반 map의 실제 메모리 구조를 측정했고, 부족한 약 22MB의 대부분이 '비어 있는 슬롯'에서 나온다고 설명합니다.

버킷에서 Swiss Table로

Go 1.24부터 기본 map은 전통적인 버킷 구조를 버리고 Swiss Table 기반으로 바뀌었습니다. 엔트리는 8개 슬롯과 8바이트 컨트롤 워드로 이뤄진 그룹(group)에 저장되고, 각 컨트롤 바이트는 슬롯이 비었는지·삭제됐는지·찼는지와 해시 하위 7비트를 담아 조회 시 8개 슬롯을 한 번에 비교할 수 있게 합니다. map[int64]int64의 슬롯은 16바이트이므로 그룹 하나는 136바이트인데, 할당자에 해당 크기가 없어 144바이트 크기 클래스로 올림됩니다. 8개 이하의 작은 map은 그룹 하나에 48바이트 Map 헤더가 붙어 총 192바이트입니다.

톱니 모양의 엔트리당 비용

9번째 엔트리부터 16슬롯 테이블이 되고, 테이블은 7/8(maxAvgGroupLoad, Abseil과 같은 로드 팩터)이 찰 때마다 두 배로 자랍니다. 성장 직후에는 7/16만 차서 절반 이상이 비고, 다시 채워지면서 엔트리당 비용이 내려갑니다. 테이블은 최대 1024슬롯(maxTableCapacity, 'Completely made up value'라는 솔직한 TODO 주석이 붙어 있음)까지 자란 뒤 두 개로 분할되고, 디렉터리가 해시 상위 비트로 테이블을 고릅니다. 1024슬롯 테이블은 17,408바이트지만 18,432바이트 클래스로 올림되어, 가득 찬 테이블은 엔트리당 20.6바이트, 막 분할한 테이블은 그 두 배가 듭니다. 해시가 키를 고르게 퍼뜨려 모든 테이블이 비슷한 시점에 분할되므로 이 톱니는 map이 커져도 평탄해지지 않습니다. 100만 엔트리는 917,504에서 일어난 분할 직후라 37.8바이트가 나오고, 896엔트리에서는 20.7바이트로 16바이트에 가장 근접했습니다.

타입별 슬롯 크기와 128바이트 경계

슬롯은 키 뒤에 값이 오며 둘 중 큰 쪽의 정렬로 패딩됩니다. map[int32]int64는 슬롯당 4바이트 패딩 때문에 map[int64]int64와 같은 37.8바이트를 씁니다. 키나 값이 128바이트를 넘으면(MapMaxKeyBytes·MapMaxElemBytes) 슬롯에는 포인터만 두고 별도 할당합니다. 흥미로운 점은 인라인 [128]byte 값이 빈 슬롯에서도 136바이트를 통째로 쓰는 반면, 포인터 방식의 [129]byte는 빈 슬롯에서 16바이트만 쓰기 때문에 100만 엔트리에서는 오히려 181.8 대 301.8바이트로 포인터 쪽이 훨씬 작다는 것입니다.

Set 관용구의 역전

전통적인 조언인 map[string]struct{}는 Go 1.24~1.27에서 bool보다 메모리를 단 1바이트도 아끼지 못합니다. 슬롯 구조체가 제로 크기 필드로 끝나면 1바이트 패딩이 붙고(2014년 issue 9401의 규칙), 정렬이 이를 8바이트로 올리기 때문에 struct{}와 bool이 정확히 같은 비용이 됩니다. Go 1.23 버킷 map에서 int64 set은 22.3바이트였지만 신형에서는 37.8바이트로 모든 측정 크기에서 1.5~1.7배 커졌습니다. 그룹을 KVKV가 아닌 KKKKVVVV로 배치하는 GOEXPERIMENT=mapsplitgroup이 이미 머지됐고, 개발 브랜치에서 기본으로 켜져 Go 1.28 마일스톤으로 들어갈 예정입니다.

미리 할당과 삭제의 비대칭

make(map[K]V, n)은 최종 크기를 줄이지 않습니다. 크기 힌트는 7/8 로드 기준 슬롯을 확보하고 2의 거듭제곱으로 올림해 성장이 도달하는 것과 같은 레이아웃에 수렴합니다. 대신 채우는 속도가 1,000개에서 4.4배, 10만 개에서 2.9배, 100만 개에서 1.4배 빠르고 할당량은 절반입니다. 삭제는 정반대입니다. 100만 키를 전부 delete하거나 clear(m)을 해도 힙은 1바이트도 줄지 않았고, 1만 개만 남겨도 37.8MB를 그대로 유지했습니다. 소스에서도 Clear 루프 뒤에 'TODO: shrink directory?' 주석만 있고, 살아있는 map에서 테이블을 반납하는 코드는 없습니다. 다만 삭제된 슬롯은 재사용되어 10만 라이브 키 슬라이딩 윈도우 실험에서는 초기 크기의 약 2배에서 안정됐습니다. 메모리를 돌려받는 유일한 방법은 2017년부터 알려진 그대로, 남은 엔트리를 새 map에 복사하고 옛 것을 버리는 것입니다.

실무 권고

크기를 알면 미리 할당하고, 대량 삭제 후에는 map을 재구축하며, 조밀한 정수 키의 set은 다른 자료구조가 낫습니다. 100만 가능 ID의 bitset은 128KB, []int64 인덱싱은 8MB로 map의 37.8MB와 차이가 큽니다. 또한 힌트 없이 만든 map은 pprof에서 make 줄이 아니라 insert 줄에 메모리가 잡히므로 힙 프로파일을 읽을 때 주의가 필요합니다.

원문: dev.to / 번역·요약: Trawling

참고 자료

오류 제보