Go 프로그램을 더 빠르게 만들기 위해 힙 할당을 줄이고 스택 할당을 늘리는 컴파일러 최적화(Go 1.25~1.26)를 살펴본다.
우리는 항상 Go 프로그램을 더 빠르게 만들 방법을 찾고 있습니다. 지난 두 번의 릴리스에서는 느려짐의 특정 원인인 힙 할당을 완화하는 데 집중해 왔습니다. Go 프로그램이 힙에서 메모리를 할당할 때마다 그 할당을 만족시키기 위해 꽤 큰 코드 덩어리가 실행되어야 합니다. 게다가 힙 할당은 가비지 컬렉터에 추가 부담을 줍니다. Green Tea 같은 최근 개선이 있더라도, 가비지 컬렉터는 여전히 상당한 오버헤드를 유발합니다.
그래서 우리는 힙 대신 스택에서 더 많은 할당을 수행하는 방법을 연구해 왔습니다. 스택 할당은 수행 비용이 훨씬 저렴합니다(때로는 완전히 무료이기도 합니다). 또한 스택 할당은 스택 프레임 자체와 함께 자동으로 회수될 수 있으므로 가비지 컬렉터에 아무런 부담을 주지 않습니다. 스택 할당은 빠른 재사용도 가능하게 하는데, 이는 캐시 친화적입니다.
처리할 작업(task) 슬라이스를 만드는 일을 생각해 봅시다:
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
채널 c에서 작업을 꺼내 슬라이스 tasks에 추가할 때 런타임에서 어떤 일이 벌어지는지 살펴봅시다.
첫 번째 루프 반복에서 tasks에는 백킹 스토어(backing store)가 없으므로 append는 백킹 스토어를 할당해야 합니다. 슬라이스가 최종적으로 얼마나 커질지 모르기 때문에 너무 공격적으로 잡을 수 없습니다. 현재는 크기 1의 백킹 스토어를 할당합니다.
두 번째 루프 반복에서는 백킹 스토어가 이미 존재하지만, 꽉 찼습니다. append는 다시 새 백킹 스토어를 할당해야 하는데, 이번에는 크기 2입니다. 기존 크기 1의 백킹 스토어는 이제 가비지가 됩니다.
세 번째 루프 반복에서는 크기 2의 백킹 스토어가 꽉 찼습니다. append는 다시 새 백킹 스토어를 할당해야 하며, 이번에는 크기 4입니다. 기존 크기 2의 백킹 스토어는 이제 가비지가 됩니다.
네 번째 루프 반복에서는 크기 4의 백킹 스토어에 3개 항목만 들어 있으므로, append는 기존 백킹 스토어에 항목을 배치하고 슬라이스 길이만 늘리면 됩니다. 야호! 이 반복에서는 할당자를 호출하지 않습니다.
다섯 번째 루프 반복에서는 크기 4의 백킹 스토어가 꽉 차서 append가 다시 새 백킹 스토어를 할당해야 하는데, 이번에는 크기 8입니다.
이런 식으로 계속됩니다. 일반적으로 가득 찰 때마다 할당 크기를 두 배로 늘리므로, 결국 대부분의 새 task는 할당 없이 슬라이스에 추가할 수 있게 됩니다. 하지만 슬라이스가 작을 때의 “시작(Startup)” 단계에서는 꽤 많은 오버헤드가 있습니다. 이 시작 단계 동안 우리는 할당자에서 많은 시간을 쓰고, 결국 가비지가 되는 많은 객체를 만들어내는데, 꽤 낭비처럼 보입니다. 그리고 여러분의 프로그램에서는 슬라이스가 실제로 크게 자라지 않을 수도 있습니다. 그 경우 여러분이 경험하는 것은 이 시작 단계뿐일지도 모릅니다.
만약 이 코드가 프로그램에서 정말 뜨거운(hot) 부분이라면, 이런 모든 할당을 피하기 위해 슬라이스를 더 큰 크기로 시작하고 싶어질 수 있습니다.
func process2(c chan task) {
tasks := make([]task, 0, 10) // 아마도 최대 10개의 task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
이것은 합리적인 최적화입니다. 결코 잘못이 아닙니다. 프로그램은 여전히 올바르게 동작합니다. 추측이 너무 작으면 이전처럼 append에서 할당이 일어납니다. 추측이 너무 크면 메모리를 조금 낭비합니다.
task 개수에 대한 추측이 정확하다면, 이 프로그램에는 할당 지점이 하나만 남습니다. make 호출이 올바른 크기의 슬라이스 백킹 스토어를 할당하고, append는 재할당을 전혀 할 필요가 없습니다.
놀라운 점은, 채널에 10개 요소가 들어 있는 상황에서 이 코드를 벤치마크해 보면, 할당 횟수를 1로 줄인 것이 아니라 0으로 줄였다는 것입니다!
그 이유는 컴파일러가 백킹 스토어를 스택에 할당하기로 결정했기 때문입니다. 필요한 크기(작업(task) 크기의 10배)를 알고 있으므로, 힙이 아니라 process2의 스택 프레임에 저장 공간을 둘 수 있습니다1. 이는 백킹 스토어가 processAll 내부에서 힙으로 escape하지 않는다는 사실에 달려 있다는 점을 유의하세요.
하지만 물론, 크기 추측을 하드코딩하는 것은 다소 경직되어 있습니다. 예상 길이를 인자로 넘길 수는 없을까요?
func process3(c chan task, lengthGuess int) {
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
이렇게 하면 호출자가 tasks 슬라이스에 적절한 크기를 선택할 수 있고, 이 코드를 어디서 호출하느냐에 따라 그 값이 달라질 수도 있습니다.
불행히도 Go 1.24에서는 백킹 스토어 크기가 상수가 아니기 때문에 컴파일러가 더 이상 백킹 스토어를 스택에 할당할 수 없습니다. 결국 힙에 놓이게 되어, 0-할당 코드가 1-할당 코드로 바뀝니다. 여전히 append가 중간 할당을 잔뜩 하는 것보다는 낫지만, 아쉽습니다.
하지만 걱정 마세요. Go 1.25가 왔습니다!
추측값이 작을 때만 스택 할당을 얻기 위해 다음과 같이 하기로 했다고 상상해 봅시다:
func process4(c chan task, lengthGuess int) {
var tasks []task
if lengthGuess <= 10 {
tasks = make([]task, 0, 10)
} else {
tasks = make([]task, 0, lengthGuess)
}
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
좀 못생겼지만, 동작은 합니다. 추측이 작을 때는 상수 크기의 make를 사용하므로 스택에 할당된 백킹 스토어를 쓰고, 추측이 클 때는 가변 크기의 make를 사용해 힙에서 백킹 스토어를 할당합니다.
하지만 Go 1.25에서는 이런 못생긴 길로 갈 필요가 없습니다. Go 1.25 컴파일러가 이런 변환을 여러분 대신 해줍니다! 특정 슬라이스 할당 위치에 대해, 컴파일러는 작은(현재는 32바이트) 슬라이스 백킹 스토어를 자동으로 스택에 할당하고, 요청된 크기가 충분히 작다면 make 결과에 그 백킹 스토어를 사용합니다. 그렇지 않으면 평소처럼 힙 할당을 사용합니다.
Go 1.25에서 process3는 lengthGuess가 충분히 작아서 그 길이의 슬라이스가 32바이트에 들어갈 수 있다면 힙 할당을 0번 수행합니다. (물론 lengthGuess가 채널 c에 들어 있는 항목 수에 대한 올바른 추측이라는 전제하에요.)
우리는 Go 성능을 계속 개선하고 있으니, 최신 Go 릴리스로 업그레이드하고 여러분의 프로그램이 얼마나 더 빠르고 메모리 효율적으로 변하는지 놀라워해 보세요!
좋습니다. 하지만 여전히 이런 이상한 길이 추측을 API에 추가하느라 코드를 바꾸고 싶지는 않을 수 있습니다. 다른 방법이 있을까요?
Go 1.26으로 업그레이드하세요!
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
Go 1.26에서는 같은 종류의 작은 “추측적(speculative)” 백킹 스토어를 스택에 할당하지만, 이제는 append 지점에서 직접 이를 사용할 수 있습니다.
첫 번째 루프 반복에서는 tasks에 백킹 스토어가 없으므로 append가 첫 할당으로 작은 스택-할당 백킹 스토어를 사용합니다. 예를 들어 그 백킹 스토어에 task 4개를 담을 수 있다면, 첫 append는 길이 4의 백킹 스토어를 스택에서 할당합니다.
다음 3번의 루프 반복은 스택 백킹 스토어에 직접 append 하므로 할당이 필요 없습니다.
4번째 반복에서 스택 백킹 스토어가 마침내 꽉 차면, 더 큰 백킹 스토어를 위해 힙으로 가야 합니다. 하지만 앞에서 설명한 시작 단계 오버헤드의 거의 대부분을 피했습니다. 크기 1, 2, 4의 힙 할당도 없고, 결국 가비지가 되는 그 객체들도 없습니다. 슬라이스가 작다면, 힙 할당을 전혀 하지 않을 수도 있습니다.
좋습니다. tasks 슬라이스가 escape하지 않을 때는 아주 좋습니다. 하지만 슬라이스를 반환한다면 어떨까요? 그러면 스택에 할당할 수 없겠죠?
맞습니다! 아래 extract가 반환하는 슬라이스의 백킹 스토어는 스택에 할당될 수 없습니다. extract가 반환되면 extract의 스택 프레임은 사라지기 때문입니다.
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
return tasks
}
하지만 이렇게 생각할 수도 있습니다. 반환되는 슬라이스는 스택에 할당할 수 없지만, 그 과정에서 생겼다가 가비지가 되는 중간 슬라이스들은 스택에 할당할 수 있지 않을까?
func extract2(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks2 := make([]task, len(tasks))
copy(tasks2, tasks)
return tasks2
}
그러면 tasks 슬라이스는 extract2를 escape하지 않습니다. 앞서 설명한 모든 최적화의 혜택을 받을 수 있습니다. 그리고 extract2의 맨 마지막에서 슬라이스의 최종 크기를 알게 되면, 필요한 크기만큼 딱 한 번 힙 할당을 하고 task들을 그곳으로 복사한 뒤 복사본을 반환합니다.
하지만 정말로 이런 추가 코드를 쓰고 싶을까요? 실수하기 쉬워 보입니다. 어쩌면 컴파일러가 이 변환을 대신 해줄 수 있을까요?
Go 1.26에서는 가능합니다!
escape하는 슬라이스에 대해, 컴파일러는 원래의 extract 코드를 대략 다음처럼 변환합니다:
func extract3(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks = runtime.move2heap(tasks)
return tasks
}
runtime.move2heap은 컴파일러+런타임의 특별한 함수로, 이미 힙에 할당된 슬라이스에 대해서는 항등 함수(identity function)입니다. 스택에 있는 슬라이스라면 힙에 새 슬라이스를 할당하고, 스택-할당 슬라이스를 힙 사본으로 복사한 다음 그 힙 사본을 반환합니다.
이로써 원래의 extract 코드에서, 항목 수가 작은 스택-할당 버퍼에 들어맞는다면 우리는 정확히 1번, 그리고 정확히 필요한 크기만큼만 할당하게 됩니다. 항목 수가 작은 스택-할당 버퍼의 용량을 넘어서면, 스택-할당 버퍼가 넘치는 순간부터는 평소처럼 두 배씩 늘려가며 할당합니다.
Go 1.26의 최적화는 수동 최적화 코드보다 실제로 더 낫습니다. 수동 최적화 코드는 마지막에 항상 추가 할당+복사를 요구하지만, Go 1.26의 방식은 반환 시점까지 스택 기반 슬라이스로만 동작한 경우에만 할당+복사를 요구합니다.
복사 비용을 치르긴 하지만, 그 비용은 시작 단계에서 더 이상 수행하지 않아도 되는 복사들에 의해 거의 상쇄됩니다. (사실 새 방식은 최악의 경우에도 기존 방식보다 원소를 한 개 더 복사하면 됩니다.)
수동 최적화는 여전히 유익할 수 있습니다. 특히 슬라이스 크기를 미리 꽤 정확히 추정할 수 있다면 그렇습니다. 하지만 이제 컴파일러가 단순한 경우들의 상당수를 잡아주고, 여러분은 정말 중요한 나머지 경우에 집중할 수 있기를 바랍니다.
이 모든 최적화를 올바르게 수행하려면 컴파일러가 확인해야 할 세부 사항이 많습니다. 만약 이런 최적화 중 하나가 여러분에게 정확성 문제나 (부정적인) 성능 문제를 일으킨다고 생각된다면, -gcflags=all=-d=variablemakehash=n으로 끌 수 있습니다. 이 최적화를 껐을 때 도움이 된다면, 조사를 위해 이슈를 등록해 주세요.
1 Go 스택에는 동적으로 크기가 변하는 스택 프레임을 위한 alloca 스타일 메커니즘이 없습니다. 모든 Go 스택 프레임은 상수 크기입니다.