Go 1.25에 새로 추가된 실험적 가비지 컬렉터 Green Tea를 소개하고, 기존 마크-스윕(tracing) 방식의 병목을 페이지 단위 스캔과 벡터 가속으로 해결하는 접근과 성능 평가, 사용 방법을 설명한다.
Michael Knyszek and Austin Clements
2025년 10월 29일
Go 1.25에는 Green Tea라는 새로운 실험적(Experimental) 가비지 컬렉터가 포함되어 있으며, 빌드 시 GOEXPERIMENT=greenteagc를 설정하면 사용할 수 있습니다. 많은 워크로드에서 가비지 컬렉터에 소비되는 시간이 약 10% 감소하지만, 어떤 워크로드에서는 최대 40%까지 줄어들기도 합니다!
이 기능은 프로덕션 수준으로 준비되어 있으며 이미 Google에서 사용 중이므로, 여러분도 사용해 보시길 권합니다. 다만 일부 워크로드는 효과가 크지 않거나 전혀 없을 수도 있으므로, 앞으로 나아가기 위해 여러분의 피드백이 매우 중요합니다. 현재까지의 데이터를 바탕으로, Go 1.26에서는 이를 기본값으로 만들 계획입니다.
문제가 있으면 새 이슈를 등록해 주세요.
성공 사례를 공유하려면 기존 Green Tea 이슈에 댓글로 남겨 주세요.
이 글은 Michael Knyszek의 GopherCon 2025 발표를 바탕으로 작성되었습니다.
Green Tea를 이야기하기 전에, 가비지 컬렉션에 대한 공통된 이해를 맞춰 봅시다.
가비지 컬렉션의 목적은 프로그램이 더 이상 사용하지 않는 메모리를 자동으로 회수하여 재사용하는 것입니다.
이를 위해 Go 가비지 컬렉터는 객체(objects) 와 포인터(pointers) 에 관심을 둡니다.
Go 런타임의 맥락에서 객체란, 기반 메모리가 힙(heap)에서 할당된 Go 값입니다. 힙 객체는 Go 컴파일러가 어떤 값에 대한 메모리를 다른 방식으로 할당할 수 없다고 판단할 때 생성됩니다. 예를 들어 다음 코드는 단 하나의 힙 객체(포인터 슬라이스의 backing store)를 할당합니다.
govar x = make([]*int, 10) // global
Go 컴파일러는 슬라이스의 backing store를 힙이 아닌 곳에 할당할 수 없습니다. 왜냐하면 x가 그 객체를 얼마나 오래 참조할지 알기 매우 어렵고, 어쩌면 불가능하기 때문입니다.
포인터는 메모리에서 Go 값의 위치를 나타내는 숫자일 뿐이며, Go 프로그램이 객체를 참조하는 방식입니다. 예를 들어 앞 코드에서 할당된 객체의 시작 주소에 대한 포인터를 얻으려면 다음처럼 쓸 수 있습니다.
go&x[0] // 0xc000104000
Go의 가비지 컬렉터는 흔히 추적(tracing) 가비지 컬렉션이라 불리는 전략을 따릅니다. 즉, 프로그램 안의 포인터들을 따라가며(추적하며) 프로그램이 여전히 사용 중인 객체를 식별합니다.
더 구체적으로 Go 가비지 컬렉터는 마크-스윕(mark-sweep) 알고리즘을 구현합니다. 생각보다 단순합니다. 객체와 포인터를 (컴퓨터 과학에서 말하는) 그래프 형태로 떠올려 보세요. 객체는 정점(node), 포인터는 간선(edge)입니다.
마크-스윕 알고리즘은 이 그래프에서 동작하며, 이름처럼 두 단계로 진행됩니다.
첫 단계인 마크(mark) 단계에서는 루트(roots) 라 불리는 잘 정의된 시작 간선들에서부터 객체 그래프를 걷습니다. 전역 변수나 지역 변수를 떠올리면 됩니다. 그리고 탐색 과정에서 발견한 모든 것을 방문됨(visited) 으로 마크하여, 같은 곳을 반복해서 돌지 않게 합니다. 일반적인 그래프 플러드(flood) 알고리즘(DFS/BFS 등)과 비슷합니다.
다음은 스윕(sweep) 단계입니다. 그래프 탐색에서 방문되지 않은 객체는 프로그램이 사용하지 않는, 즉 프로그램에서 도달 불가능(unreachable) 한 객체입니다. 이 상태를 도달 불가능이라고 부르는 이유는, 언어의 의미론상 정상적인 안전한 Go 코드로는 더 이상 그 메모리에 접근할 방법이 없기 때문입니다. 스윕 단계에서는 방문되지 않은 모든 노드를 순회하며 그 메모리를 “free”로 표시하여, 메모리 할당기가 재사용할 수 있게 합니다.
너무 단순화한 것 아니냐고 생각할 수 있습니다. 가비지 컬렉터는 흔히 마법이나 블랙박스라고 불리기도 하니까요. 부분적으로는 맞습니다. 더 복잡한 점들이 있습니다.
예를 들어, 이 알고리즘은 실제로는 일반 Go 코드와 동시에(concurrently) 실행됩니다. 변하는 그래프 위를 걷는 것은 도전 과제를 가져옵니다. 또한 이 알고리즘은 병렬화(parallelize) 되어 실행되는데, 이 디테일은 뒤에서 다시 등장합니다.
하지만 핵심 알고리즘과는 대체로 별개의 문제입니다. 중심에는 정말로 단순한 그래프 플러드가 있습니다.
예제로 함께 따라가 봅시다. 아래 슬라이드 쇼를 넘기며 확인할 수 있습니다.
← 이전 다음 →

여기에는 전역 변수 몇 개와 Go 힙을 나타낸 도식이 있습니다. 하나씩 뜯어보죠.

왼쪽에는 루트가 있습니다. 전역 변수 x와 y이며, 그래프 탐색의 시작점입니다. 범례(좌하단)에 따르면 파란색으로 표시된 것은 현재 작업 목록(work list)에 올라가 있다는 뜻입니다.

오른쪽에는 힙이 있습니다. 아직 아무 것도 방문하지 않았으므로 힙의 모든 것이 회색으로 표시되어 있습니다.

각 직사각형은 하나의 객체를 나타냅니다. 각 객체에는 타입 라벨이 붙어 있습니다. 특히 이 객체는 T 타입이며, 좌상단의 타입 정의를 보면 children 배열에 대한 포인터와 어떤 값을 갖고 있습니다. 즉 재귀적인 트리 자료구조로 보입니다.

T 타입 객체 외에도 *T를 담는 배열 객체가 있습니다. 이는 T 타입 객체의 children 필드가 가리킵니다.

각 사각형 칸은 8바이트 메모리를 의미합니다. 점이 있는 칸은 포인터입니다. 화살표가 있으면 nil이 아닌 포인터로, 다른 객체를 가리킵니다.

화살표가 없으면 nil 포인터입니다.

점선 사각형은 빈 공간, 즉 free “슬롯(slot)”입니다. 그 위치에 객체를 둘 수 있지만 현재는 없습니다.

객체들이 라벨이 붙은 점선의 둥근 사각형으로 묶여 있는 것도 보일 겁니다. 각각은 페이지(page) 를 나타내며, 고정 크기/정렬된 연속 메모리 블록입니다. Go에서 페이지는 하드웨어의 가상 메모리 페이지 크기와 무관하게 8 KiB입니다. 이 페이지들은 A, B, C, D로 라벨되어 있으며 이후 그렇게 부르겠습니다.

이 도식에서 각 객체는 어떤 페이지에 속해 할당됩니다. 실제 구현처럼, 각 페이지는 특정 크기의 객체만 담습니다. 이것이 Go 힙의 조직 방식입니다.

페이지는 객체별 메타데이터를 조직하는 단위이기도 합니다. 여기에서는 페이지 A에 있는 7개의 객체 슬롯에 대응하는 7개의 박스가 보입니다.

각 박스는 한 비트(bit)의 정보(해당 객체를 이전에 본 적이 있는지)를 나타냅니다. 실제 런타임이 방문 여부를 관리하는 방식이기도 하며, 나중에 중요한 디테일이 됩니다.

설명이 길었네요. 읽어주셔서 감사합니다. 이 모든 것은 나중에 다시 쓰입니다. 이제 이 그림에 그래프 플러드가 어떻게 적용되는지 봅시다.

작업 목록에서 루트 하나를 꺼냅니다. 빨간색은 현재 처리 중(active)임을 뜻합니다.

그 루트의 포인터를 따라가면 T 타입 객체를 찾고, 이를 작업 목록에 추가합니다. 범례에 따라 작업 목록에 있음을 파란색으로 표시합니다. 또한 메타데이터에서 해당 객체에 대응하는 seen 비트를 설정합니다.

다음 루트도 마찬가지입니다.

이제 모든 루트를 처리했으니 작업 목록에는 객체 2개가 남습니다. 작업 목록에서 객체를 하나 꺼냅시다.

이제 객체의 포인터를 따라가며 더 많은 객체를 찾습니다. 참고로 객체의 포인터를 따라가는 일을 그 객체를 스캔(scan) 한다고 부릅니다.

유효한 배열 객체를 찾고…

…작업 목록에 추가합니다.

이제 재귀적으로 진행합니다.

배열의 포인터들을 걷습니다.



더 많은 객체를 찾고…



그 배열 객체가 참조한 객체들을 스캔합니다!

그리고 포인터가 nil이더라도 모든 포인터를 전부 확인해야 합니다. 미리 nil인지 알 수 없기 때문입니다.

이 가지에서 하나 더…


이제 다른 가지로 넘어갑니다. 앞서 루트에서 찾았던 페이지 A의 객체에서 시작합니다.

작업 목록이 LIFO(후입선출)처럼 동작하는 것을 눈치챘을 수도 있습니다. 즉 작업 목록이 스택(stack)이고, 그래프 플러드가 대략 DFS에 가깝다는 뜻입니다. 이는 의도된 것이며 Go 런타임의 실제 플러드 알고리즘을 반영합니다.

계속해 봅시다…

또 다른 배열 객체를 찾고…

스캔합니다…





작업 목록에 남은 객체는 이제 하나…

스캔합시다…


마크 단계가 끝났습니다! 현재 처리 중인 것도, 작업 목록에 남은 것도 없습니다. 검은색으로 그려진 객체는 도달 가능(reachable)이고, 회색 객체는 도달 불가능(unreachable)입니다. 이제 도달 불가능 객체들을 한 번에 스윕합시다.

이 객체들은 새로운 객체를 담을 수 있는 free 슬롯으로 바뀌었습니다.
여기까지 보면 Go 가비지 컬렉터가 실제로 무엇을 하는지 이해했을 겁니다. 이 과정은 오늘날에도 충분히 잘 동작하는 것처럼 보이는데, 무엇이 문제일까요?
일부 프로그램에서는 이 알고리즘을 실행하는 데 엄청난 시간이 들고, 거의 모든 Go 프로그램에 상당한 오버헤드를 더합니다. Go 프로그램이 CPU 시간의 20% 이상을 가비지 컬렉터에서 소비하는 것도 드물지 않습니다.
어디에서 시간이 쓰이는지 나눠 봅시다.
큰 틀에서 가비지 컬렉터 비용은 두 부분으로 나뉩니다. 첫째는 얼마나 자주 실행되는가, 둘째는 한 번 실행될 때 얼마나 많은 일을 하는가입니다. 이 둘을 곱하면 총 비용이 됩니다.
총 GC 비용 = GC 사이클 수 × GC 사이클당 평균 비용
수년 동안 우리는 이 식의 양쪽을 모두 다뤄 왔습니다. 가비지 컬렉터가 얼마나 자주 실행되는지에 대해서는 메모리 제한을 다룬 Michael의 2022년 GopherCon EU 발표를 참고하세요. 또한 Go 가비지 컬렉터 가이드도 이 주제에 대해 많은 내용을 담고 있으니 더 깊이 파고들고 싶다면 읽어볼 만합니다.
하지만 여기서는 두 번째 항, 즉 사이클당 비용만 보겠습니다.
수년간 CPU 프로파일을 뜯어보며 성능 개선을 시도해온 결과, Go 가비지 컬렉터에 대해 두 가지 큰 사실을 알고 있습니다.
첫째, 가비지 컬렉터 비용의 약 90%가 마킹(marking)에서 발생하고 스위핑(sweeping)은 약 10%에 불과합니다. 스위핑은 최적화가 훨씬 쉽고, Go는 수년 동안 매우 효율적인 스위퍼를 갖고 있었습니다.
둘째, 마킹에 소비되는 시간 중 상당 부분(보통 최소 35%)이 단순히 힙 메모리 접근에서 정체(stalled) 되는 데 쓰입니다. 이것만으로도 나쁘지만, 현대 CPU를 빠르게 만드는 핵심을 완전히 방해합니다.
여기서 “방해한다”는 게 무슨 뜻일까요? 현대 CPU의 세부는 복잡하니 비유를 써 봅시다.
CPU가 도로 위를 달린다고 상상해 보세요. 그 도로가 바로 여러분의 프로그램입니다. CPU는 속도를 올리고 싶어 하고, 그러려면 멀리 내다볼 수 있어야 하며 길이 뚫려 있어야 합니다. 하지만 그래프 플러드 알고리즘은 CPU에게 도심의 골목길을 운전하는 것과 같습니다. CPU는 코너 너머를 볼 수 없고 다음에 무엇이 일어날지 예측하기 어렵습니다. 계속 진행하려면 코너를 돌기 위해 감속하고, 신호등에서 멈추고, 보행자를 피해야 합니다. 엔진이 아무리 빨라도 달릴 기회가 없습니다.
좀 더 구체적으로, 아까 예제를 다시 봅시다. 힙 위에 우리가 따라간 경로를 덧그려 보았습니다. 좌→우 화살표는 스캔 작업 조각 하나를 나타내고, 점선 화살표는 작업 조각 사이에서 여기저기 점프한 것을 보여줍니다.

그래프 플러드 예제에서 가비지 컬렉터가 힙을 통과한 경로.
여기저기 메모리를 뛰어다니며 각 위치에서 아주 작은 작업만 하고 있음을 볼 수 있습니다. 특히 페이지 사이를 자주 오가고, 페이지 내부에서도 서로 다른 부분으로 점프합니다.
현대 CPU는 캐싱을 많이 합니다. 메인 메모리에 접근하는 것은 캐시 접근보다 최대 100배 느릴 수 있습니다. CPU 캐시는 최근 접근한 메모리와, 그 근처 메모리를 담습니다. 하지만 서로를 가리키는 두 객체가 메모리에서도 서로 가깝다는 보장은 없습니다. 그래프 플러드는 이를 전혀 고려하지 않습니다.
짧은 곁가지: 만약 우리가 단지 메인 메모리 페치가 느려서 멈추는 것뿐이라면, 그나마 덜 나쁠 수도 있습니다. CPU는 메모리 요청을 비동기적으로 발행하므로, CPU가 충분히 앞을 내다볼 수만 있다면 느린 요청도 서로 겹쳐 진행할 수 있습니다. 하지만 그래프 플러드에서는 각 작업이 작고 예측 불가능하며 직전 작업에 강하게 의존하므로, CPU는 거의 모든 개별 메모리 페치를 기다려야 합니다.
불행히도 이 문제는 시간이 갈수록 악화되고 있습니다. 업계에는 “2년만 기다리면 코드가 더 빨라진다”는 격언이 있습니다.
하지만 마크-스윕에 의존하는 가비지 컬렉션 언어인 Go는 반대 위험이 있습니다. “2년만 기다리면 코드가 더 느려진다.” 현대 CPU 하드웨어의 추세가 GC 성능에 새로운 도전을 만들고 있기 때문입니다.
비균일 메모리 접근(Non-uniform memory access). 메모리는 이제 CPU 코어 일부 집합과 더 밀접하게 연관되는 경향이 있습니다. 다른 코어가 그 메모리에 접근하면 예전보다 느립니다. 즉 메인 메모리 접근 비용이 어떤 CPU 코어가 접근하느냐에 따라 달라집니다. 이를 NUMA(Non-Uniform Memory Access)라고 부릅니다.
감소하는 메모리 대역폭. CPU당 가용 메모리 대역폭은 시간이 지나며 감소하는 추세입니다. 코어 수는 늘지만, 코어당 메인 메모리로 보낼 수 있는 요청 수는 상대적으로 줄어들어, 캐시되지 않은 요청은 예전보다 더 오래 기다리게 됩니다.
계속 늘어나는 코어 수. 위에서는 순차적 마킹을 봤지만, 실제 가비지 컬렉터는 이를 병렬로 수행합니다. 일정 수준까지는 잘 스케일하지만, 스캔할 객체를 담는 공유 큐(shared queue)는 설계를 잘 해도 병목이 됩니다.
현대 하드웨어 기능. 새 하드웨어에는 벡터 명령처럼 한 번에 많은 데이터를 처리하는 기능이 있습니다. 큰 속도 향상을 낼 잠재력이 있지만, 마킹은 불규칙하고 작은 작업이 많아 벡터 하드웨어를 어떻게 활용해야 할지 명확하지 않았습니다.
이제 새로운 접근인 Green Tea, 즉 마크-스윕 알고리즘에 대한 새로운 방식으로 넘어가겠습니다. Green Tea의 핵심 아이디어는 놀랄 만큼 단순합니다.
객체가 아니라 페이지로 작업하자.
사소해 보이죠? 하지만 이를 실제로 잘 동작하게 만들려면, 객체 그래프 탐색 순서를 어떻게 구성하고 무엇을 추적해야 하는지 알아내는 데 많은 작업이 필요했습니다.
좀 더 구체적으로 말하면 다음과 같습니다.
같은 예제 힙을 다시 보되, 이번에는 단순 그래프 플러드 대신 Green Tea를 실행해 보겠습니다.
앞과 마찬가지로, 주석이 달린 슬라이드 쇼를 넘기며 확인할 수 있습니다.
← 이전 다음 →

이전과 같은 힙이지만, 이제 객체당 메타데이터 비트가 1개가 아니라 2개입니다. 마찬가지로 각 비트(박스)는 페이지 내 객체 슬롯 하나에 대응합니다. 즉 페이지 A의 7개 슬롯에 대응하는 총 14비트가 생겼습니다.

위쪽 비트는 이전과 동일하게 “객체에 대한 포인터를 본 적이 있는가”를 나타냅니다. 이를 seen 비트라고 부르겠습니다. 아래쪽 비트는 새로 추가된 것으로, 객체를 스캔했는지를 추적합니다. 이를 scanned 비트라고 부르겠습니다.

이 새 메타데이터가 필요한 이유는 Green Tea에서는 작업 목록이 객체가 아니라 페이지를 추적하기 때문입니다. 그래도 어느 수준에서는 객체를 추적해야 하며, 그 역할을 이 비트들이 수행합니다.

시작은 이전과 같고, 루트에서 객체를 따라갑니다.


하지만 이번에는 객체를 작업 목록에 넣는 대신, 해당 객체가 있는 페이지 전체(여기서는 페이지 A)를 작업 목록에 넣습니다. 페이지 전체를 파란색으로 음영 처리해 표시합니다.

찾은 객체도 파란색으로 표시되는데, 이는 나중에 이 페이지를 작업 목록에서 꺼냈을 때 그 객체를 살펴봐야 함을 의미합니다. 객체의 파란색 농도는 페이지 A 메타데이터를 그대로 반영합니다. 즉 seen 비트는 설정되었지만 scanned 비트는 아직 설정되지 않았습니다.

다음 루트를 따라 다른 객체를 찾고, 이번에도 그 객체가 속한 페이지 전체(페이지 C)를 작업 목록에 넣고 객체의 seen 비트를 설정합니다.

루트 추적이 끝났으니 작업 목록을 봅니다. 작업 목록에서 페이지 A를 꺼냅니다.

seen/scanned 비트를 사용하면 페이지 A에서 스캔할 객체가 1개임을 알 수 있습니다.

그 객체를 스캔해 포인터를 따라갑니다. 그 결과, 페이지 A의 첫 번째 객체가 페이지 B의 객체를 가리키므로 페이지 B를 작업 목록에 추가합니다.

페이지 A 처리가 끝났습니다. 다음으로 작업 목록에서 페이지 C를 꺼냅니다.

페이지 A와 마찬가지로, 페이지 C에서도 스캔할 객체는 1개입니다.

페이지 B의 또 다른 객체에 대한 포인터를 찾았습니다. 페이지 B는 이미 작업 목록에 있으므로, 새로 추가할 필요는 없습니다. 대상 객체의 seen 비트만 설정하면 됩니다.

이제 페이지 B 차례입니다. 페이지 B에는 스캔해야 할 객체가 2개 누적되어 있고, 우리는 이 두 객체를 메모리 순서대로 연속해서 처리할 수 있습니다!

첫 번째 객체의 포인터를 걷고…



페이지 A의 객체로 향하는 포인터를 찾습니다. 페이지 A는 이전에 작업 목록에 있었지만 지금은 아니므로, 다시 작업 목록에 넣습니다. 원래의 마크-스윕에서는 객체가 마크 단계 동안 작업 목록에 최대 한 번만 추가되지만, Green Tea에서는 한 페이지가 마크 단계 중 여러 번 작업 목록에 다시 등장할 수 있습니다.


첫 번째 객체 다음으로, 페이지 안에서 두 번째로 seen된 객체를 즉시 스캔합니다.



페이지 A에서 몇 개의 객체를 더 찾고…




페이지 B 스캔이 끝났으니, 작업 목록에서 페이지 A를 꺼냅니다.

이번에는 첫 번째 객체는 이미 스캔했으므로, 4개가 아니라 3개만 스캔하면 됩니다. 어떤 객체를 스캔해야 하는지는 “seen 비트”와 “scanned 비트”의 차이를 보면 알 수 있습니다.

이 객체들을 순서대로 스캔합니다.






끝났습니다! 작업 목록에 페이지가 더 이상 없고, 현재 보고 있는 것도 없습니다. 이제 모든 도달 가능한 객체는 seen과 scanned가 모두 설정되어 메타데이터가 깔끔하게 정렬된 것을 볼 수 있습니다.

또한 탐색 중 작업 목록의 순서가 그래프 플러드와 약간 다르다는 것도 눈치챘을 수 있습니다. 그래프 플러드는 LIFO(스택) 순서였지만, 여기서는 페이지 작업 목록에 FIFO(큐) 순서를 사용합니다.

이는 의도된 것입니다. 페이지가 큐에 있는 동안 그 페이지에서 seen된 객체들이 누적되도록 하여, 가능한 한 많은 객체를 한 번에 처리합니다. 그래서 페이지 A에서 많은 객체를 한꺼번에 처리할 수 있었습니다. 때로는 “게으름”이 미덕입니다.

마지막으로 이전처럼 방문되지 않은 객체를 스윕합니다.
운전 비유로 돌아가 봅시다. 이제 고속도로에 올라탄 걸까요?
먼저 이전의 그래프 플러드 그림을 떠올려 봅시다.

원래 그래프 플러드가 힙을 통과한 경로는 총 7번의 개별 스캔이 필요했습니다.
여기저기 뛰어다니며 각기 다른 곳에서 작은 작업을 했습니다. 반면 Green Tea가 취하는 경로는 매우 다릅니다.

Green Tea가 취하는 경로는 단 4번의 스캔만 필요합니다.
Green Tea는 페이지 A와 B 위를 더 적게 점프하고, 더 길게 좌→우로 훑습니다. 이 화살표가 길수록 좋고, 힙이 커질수록 이 효과는 더 강해질 수 있습니다. 이것이 Green Tea의 마법입니다.
그리고 이것이 바로 고속도로를 탈 기회입니다.
이 모든 것은 마이크로아키텍처와 더 잘 맞아떨어지도록 해 줍니다. 이제 서로 가까운 객체들을 더 높은 확률로 연속 스캔할 수 있어, 캐시를 활용하고 메인 메모리 접근을 피할 가능성이 커집니다. 페이지 단위 메타데이터 또한 캐시에 있을 가능성이 높습니다. 객체 대신 페이지를 추적하면 작업 목록이 더 작아지고, 작업 목록 부담이 줄면 경쟁(contension)도 줄고 CPU 정체도 줄어듭니다.
그리고 고속도로 얘기가 나왔으니 말인데, 이제 벡터 하드웨어도 사용할 수 있어 우리가 이전에는 쓰지 못했던 “기어”를 쓸 수 있습니다!
벡터 하드웨어에 익숙하지 않다면 여기서 어떻게 벡터를 쓰는지 감이 안 올 수 있습니다. 하지만 최근 벡터 하드웨어는 일반적인 산술/삼각 함수 연산뿐 아니라 Green Tea에 유용한 두 가지도 지원합니다. 매우 넓은 레지스터와 정교한 비트 단위 연산입니다.
대부분의 현대 x86 CPU는 AVX-512를 지원하며, 512비트 폭의 벡터 레지스터를 갖습니다. 이는 한 페이지 전체의 메타데이터를 단 두 개의 레지스터에 담기에 충분합니다. 즉 Green Tea는 페이지 전체를 CPU 위의 레지스터로 올려서, 소수의 직선적인(straight-line) 명령으로 처리할 수 있습니다. 벡터 하드웨어는 오랫동안 레지스터 전체에 대한 기본 비트 연산을 지원해 왔지만, AMD Zen 4와 Intel Ice Lake부터는 새로운 비트 벡터 “스위스 아미 나이프” 같은 명령도 지원하여, Green Tea 스캔 과정의 핵심 단계를 몇 사이클 만에 처리할 수 있게 해 줍니다. 이 둘이 합쳐져 Green Tea 스캔 루프를 크게 가속합니다.
이는 그래프 플러드에서는 가능하지 않았습니다. 그래프 플러드는 서로 크기가 제각각인 객체들을 점프하며 스캔해야 했고, 어떤 때는 메타데이터 비트가 2개면 충분했지만 어떤 때는 1만 개가 필요하기도 했습니다. 벡터 하드웨어를 활용할 만큼의 예측 가능성과 규칙성이 없었습니다.
디테일을 더 파고들고 싶다면 계속 읽어주세요. 아니면 평가로 건너뛰셔도 됩니다.
AVX-512 기반 GC 스캔이 어떻게 생겼는지 감을 잡기 위해, 아래 그림을 봅시다.
AVX-512 벡터 스캔 커널.
여기에는 많은 일이 일어나고 있으며, 이것만으로도 블로그 글 한 편을 쓸 수 있을 정도입니다. 여기서는 높은 수준에서만 나눠 보겠습니다.
먼저 페이지의 “seen” 비트와 “scanned” 비트를 가져옵니다. 각 비트는 페이지 내 객체 하나에 해당하며, 페이지 안의 객체들은 모두 같은 크기입니다.
다음으로 두 비트 집합을 비교합니다. 둘의 합집합(union)은 새로운 “scanned” 비트가 되고, 차집합(difference)은 “활성 객체(active objects)” 비트맵이 됩니다. 이 비트맵은 이번 페이지 패스에서 스캔해야 하는 객체가 무엇인지(이전 패스에서 스캔한 객체와 구분하여) 알려 줍니다.
활성 객체 비트맵의 차이를 “확장(expand)”하여 객체당 1비트가 아니라 페이지의 워드(8바이트)당 1비트가 되게 합니다. 이를 “활성 워드(active words)” 비트맵이라고 부릅니다. 예를 들어 페이지가 6워드(48바이트) 객체를 저장한다면, 활성 객체 비트맵의 각 비트는 활성 워드 비트맵에서 6비트로 복제됩니다.
0 0 1 1 ... → 000000 000000 111111 111111 ...
다음으로 페이지의 포인터/스칼라(pointer/scalar) 비트맵을 가져옵니다. 여기서도 각 비트는 페이지의 워드(8바이트) 하나에 대응하며, 그 워드가 포인터를 저장하는지 알려 줍니다. 이 데이터는 메모리 할당기가 관리합니다.
포인터/스칼라 비트맵과 활성 워드 비트맵의 교집합(intersection)을 구합니다. 결과는 “활성 포인터(active pointer)” 비트맵이며, 아직 스캔하지 않은 live 객체들 안에 존재하는 페이지 전체의 모든 포인터 위치를 알려 줍니다.
마지막으로 페이지의 메모리를 순회하며 포인터들을 수집할 수 있습니다. 논리적으로는 활성 포인터 비트맵에서 설정된 비트마다 해당 워드의 포인터 값을 로드하고, 나중에 객체를 seen 처리하고 페이지를 작업 목록에 넣는 데 쓰일 버퍼에 기록합니다. 벡터 명령을 사용하면 이를 64바이트씩, 몇 개의 명령만으로 처리할 수 있습니다.
이를 빠르게 만드는 핵심 중 하나는 VGF2P8AFFINEQB 명령입니다. 이는 x86 확장인 “Galois Field New Instructions”의 일부이며, 앞서 말한 스위스 아미 나이프 같은 비트 조작 명령입니다. 이 명령은 스캔 커널의 (3) 단계를 매우 효율적으로 수행해 주므로 진정한 “주인공”입니다. 벡터의 각 바이트를 8비트의 수학적 벡터로 보고 8×8 비트 행렬을 곱하는 비트 단위 아핀 변환(affine transformation) 을 수행합니다. 이 연산은 Galois field GF(2) 위에서 수행되는데, 이는 곱셈이 AND이고 덧셈이 XOR라는 뜻입니다. 핵심은, 객체 크기별로 몇 가지 8×8 비트 행렬을 정의하여 우리가 원하는 1:n 비트 확장을 정확히 수행하게 할 수 있다는 점입니다.
전체 어셈블리 코드는 이 파일을 참고하세요. “확장기(expanders)”는 크기 클래스(size class)별로 다른 행렬과 다른 퍼뮤테이션을 사용하므로, 코드 생성기가 작성하는 별도의 파일에 있습니다. 확장 함수들을 제외하면 실제 코드 양은 그리 많지 않습니다. 위 연산 대부분을 레지스터에만 있는 데이터로 수행할 수 있다는 사실 덕분에 많은 부분이 극적으로 단순해졌습니다. 그리고 바라건대 머지않아 이 어셈블리 코드는 Go 코드로 대체될 예정입니다!
이 과정을 고안한 Austin Clements에게 공을 돌립니다. 정말 멋지고, 정말 빠릅니다!
작동 원리는 여기까지입니다. 실제로 얼마나 도움이 될까요?
도움이 꽤 큽니다. 벡터 강화 없이도, 벤치마크 스위트에서 가비지 컬렉션 CPU 비용이 10%~40% 감소하는 것을 확인했습니다. 예를 들어 어떤 애플리케이션이 시간의 10%를 가비지 컬렉터에서 쓴다면, 이는 워크로드에 따라 전체 CPU가 1%~4% 감소하는 효과로 이어집니다. GC CPU 시간 10% 감소가 대략 가장 흔한(모달) 개선폭입니다. (자세한 내용은 GitHub 이슈를 참고하세요.)
Google 내부에서도 Green Tea를 롤아웃했으며, 대규모 환경에서도 유사한 결과를 보고 있습니다.
벡터 강화는 아직 계속 롤아웃 중이지만, 벤치마크와 초기 결과는 GC CPU가 추가로 약 10% 더 줄어들 것임을 시사합니다.
대부분의 워크로드는 어느 정도 이점을 얻지만, 그렇지 않은 경우도 있습니다.
Green Tea는 “한 번의 페이지 패스에서 스캔할 객체를 한 페이지에 충분히 누적할 수 있으면, 누적 과정의 비용을 상쇄할 수 있다”는 가설에 기반합니다. 힙이 매우 규칙적인 구조(객체 크기가 같고 객체 그래프에서 깊이가 비슷한 경우)라면 이 가설은 분명히 성립합니다. 그러나 어떤 워크로드는 페이지마다 한 번에 객체 1개만 스캔해야 하는 경우가 자주 발생합니다. 이런 경우 페이지에 객체를 누적하려다 실패하면서 이전보다 더 많은 일을 하게 되어 그래프 플러드보다 나쁠 수도 있습니다.
Green Tea 구현에는 “스캔할 객체가 하나뿐인 페이지”에 대한 특수 처리(special case)가 있어 회귀(regression)를 줄이지만, 완전히 없애지는 못합니다.
다만 그래프 플러드를 이기기 위해 필요한 페이지 단위 누적량은 생각보다 훨씬 적습니다. 놀라운 결과 중 하나는, 페이지의 단 2%만을 한 번에 스캔하더라도 그래프 플러드보다 개선이 나타날 수 있다는 점이었습니다.
Green Tea는 최신 Go 1.25 릴리스에서 실험 기능으로 이미 사용 가능하며, 빌드 시 환경 변수 GOEXPERIMENT를 greenteagc로 설정하면 활성화됩니다. 여기에는 앞서 언급한 벡터 가속은 포함되지 않습니다.
Go 1.26에서는 이를 기본 가비지 컬렉터로 만들 예정이지만, 빌드 시 GOEXPERIMENT=nogreenteagc로 옵트아웃할 수 있게 할 계획입니다. Go 1.26은 또한 최신 x86 하드웨어에서 벡터 가속을 추가하고, 지금까지 수집한 피드백을 바탕으로 다양한 조정과 개선을 포함할 것입니다.
가능하다면 Go tip-of-tree(최신 개발 버전)에서 사용해 보시길 권합니다! Go 1.25를 선호하더라도 피드백을 환영합니다. 공유 가능하다면 어떤 진단 정보에 관심이 있는지, 피드백을 보고할 권장 채널이 무엇인지에 대한 내용은 이 GitHub 댓글을 참고하세요.
마무리하기 전에, 여기까지 오게 된 여정—기술의 인간적인 면—을 잠깐 이야기해 봅시다.
Green Tea의 핵심은 단 하나의 단순한 아이디어처럼 보일 수도 있습니다. 누군가 한 사람이 번뜩 떠올린 영감의 불꽃처럼요.
하지만 전혀 그렇지 않습니다. Green Tea는 여러 해에 걸쳐 많은 사람들이 함께 만든 작업과 아이디어의 결과물입니다. Go 팀의 여러 사람이 아이디어에 기여했는데, Michael Pratt, Cherry Mui, David Chase, Keith Randall이 포함됩니다. 당시 Intel에 있던 Yves Vandriessche가 제공한 마이크로아키텍처적 통찰도 설계 탐색의 방향을 잡는 데 큰 도움이 되었습니다. 작동하지 않았던 아이디어도 많았고, 이 단순한 아이디어를 실제로 가능하게 만들기 위해 풀어야 할 디테일도 많았습니다.

오늘에 이르기까지, 비슷한 방향으로 시도했던 아이디어들의 일부를 나타내는 타임라인.
이 아이디어의 씨앗은 2018년까지 거슬러 올라갑니다. 재미있는 점은 팀원 모두가 “초기 아이디어는 누군가 다른 사람이 먼저 생각해냈다”고 믿는다는 것입니다.
Green Tea라는 이름은 2024년에 붙었습니다. Austin이 일본에서 카페를 돌아다니며(카페 크롤링) 엄청난 양의 말차(matcha)를 마시던 중, 더 이른 버전의 프로토타입을 만들어냈기 때문입니다! 이 프로토타입은 Green Tea의 핵심 아이디어가 실현 가능함을 보여주었고, 그때부터 본격적으로 속도가 붙었습니다.
2025년 내내 Michael이 Green Tea를 구현하고 프로덕션에 넣는 동안에도, 아이디어는 더 진화하고 변화했습니다.
이렇게 많은 공동 탐색이 필요했던 이유는 Green Tea가 단순한 알고리즘이 아니라 하나의 설계 공간(design space) 이기 때문입니다. 그 누구도 혼자서는 이 공간을 탐색하기 어려웠을 거라 생각합니다. 아이디어를 갖는 것만으로는 충분하지 않고, 디테일을 밝혀내고 증명해야 합니다. 그리고 이제 우리는 그것을 해냈고, 드디어 반복(iterate)할 수 있게 되었습니다.
Green Tea의 미래는 밝습니다.
다시 한 번, GOEXPERIMENT=greenteagc를 설정해 꼭 사용해 보시고 결과를 알려주세요! 이 작업에 매우 기대가 크며 여러분의 이야기를 듣고 싶습니다!
다음 글: Go’s Sweet 16
이전 글: Go 1.25의 Flight Recorder
go.dev는 Google의 쿠키를 사용하여 서비스의 품질을 제공/개선하고 트래픽을 분석합니다. 자세히 알아보기.