본문 바로가기
컴퓨터

[Go] 메모리 할당 위치

by dbadoy 2023. 4. 11.
해당 글의 대부분은 https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast 글을 번역한 내용입니다. 링크의 글은 'Go를 빠르게 만드는 5가지'라는 주제로 여러 예시와 함께 잘 정리되어 있습니다.
Most of this article is a translation of an article from 'https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast'. The article at the link is called "5 things that make Go fast" and is well organized with lots of examples.

This post is not monetized and will be removed upon request by the author.
email: dbadoy4874@gmail.com

 

스택과 힙은 실행 시 동적으로 메모리를 확보하는 영역이다. 스택 메모리는 함수 호출 스택을 저장하고 로컬 변수, 인수, 반환 값을 주로 저장한다. 스택의 Push와 Pop은 고속이며 객체를 스택 메모리에 저장하는 비용은 작다(GC를 위한 메타 데이터도 저장할 필요도 없기 때문에). 단, 함수를 나오면 스택이 Pop 되어 해제되므로 함수의 수명을 넘는 객체는 살 수 없다. 힙 메모리는 콜스택과는 관계 없으므로 함수 범위에 얽매이지 않고 값을 저장해 둔다. 다만 빈 영역을 찾고, GC로 쓸모 없게된 값을 회수 하기도 하므로 처리 비용이 든다. Go 언어는 컴파일러가 객체를 스택에 확보할지 힙에 확보할지 결정하므로 프로그래머가 의식할 필요는 보통 없다. 이 컴파일러의 기능을 이를 탈출 검사(escape analysis)라고 한다.

func New(name string, age int) *user {
	u := user{name, age} // 스택 메모리에 할당해야 하나?
	return &u // 외부로 값이 나가기 때문에 힙 메모리에 할당.
}

 

컴파일러 구현은 시간이 지남에 따라 변경되기 때문에 Go 코드를 읽는 것만으로는 어떤 변수가 힙에 할당되는지 알 수 있는 방법이 없다. 따라서 컴파일러의 gcflags 옵션을 이용하여 흐름을 확인하는 것이 좋다.

go build -gcflags '-m -l'

 

주의해야 할 점은, noinline pragma를 입력해주어야 한다는 것이다.

//go:noinline
func tempFn() int {
	y := 2
	return y * 2
}

 

인라인?

함수 호출은 무료가 아니다. 함수가 호출되면 세 가지 일이 발생한다.

  • 새 스택 프레임이 생성되고 호출자의 세부 정보가 기록
  • 함수 호출 중에 덮어쓸 수 있는 모든 레지스터는 스택에 저장
  • 프로세서는 함수의 주소를 계산하고 해당 새 주소로 분기를 실행

함수 호출은 매우 일반적인 작업이기 때문에 CPU 설계자는 이 절차를 최적화하기 위해 연구를 하고 여러 패턴을 적용시키지만 오버헤드를 완전히 제거할 수는 없었다고 한다. 함수가 수행하는 작업에 따라 이 오버헤드는 사소하거나 중요할 수 있다. 인라인은 함수 호출 오버헤드를 줄이는 최적화 기술이다.

Go 컴파일러는 함수 본문을 호출자의 일부인 것처럼 처리하여 함수를 인라인 처리한다. 인라인에는 비용이 든다. 바로 바이너리 크기 증가이다. 함수 호출의 오버헤드가 함수가 수행하는 작업에 비해 클 때만 인라인하는 것이 의미가 있으므로 간단한 함수만 인라인 대상이 된다. 복잡한 함수는 일반적으로 호출 오버헤드에 의해 지배되지 않으므로 인라인되지 않는다. 인라인 대상에 대한 처리도 컴파일러가 담당하기 때문에 우리가 신경써야 할 것은 없다.

 

[인라인 예시]

package util

func Max(a, b int) int {
	if a > b {
    	return a
    }
    return b
}

---

package main

func Double(a, b int) int {
	return 2 * util.Max(a, b)
}

 

컴파일러는 위 코드를 아래와 같이 최적화(인라인)를 수행한다.

func Double(a, b) int {
    temp := b
    if a > b {
    	temp = a
    }
    return 2 * temp
}

 

또 다른 인라인 예시를 보자.

func Test() bool { return false }

func Heavy() {
	if Test() {
    	// 굉장히 무거운 작업 수행.
    }
}

--> compile

func Heavy() {
	if false {
    	// 컴파일러는 해당 컨텍스트에 도달하지 않음을
        // 인지한다.
    }
}

컴파일러는 비용이 많이 드는 코드에 도달할 수 없음을 알고 있다. 이렇게 하면 호출 비용이 절약될 뿐만 아니라 지금은 도달할 수 없는 값비싼 코드를 컴파일하거나 실행하는 비용도 절약된다. Go 컴파일러는 파일과 패키지 간에 함수를 자동으로 인라인할 수 있다. 여기에는 표준 라이브러리에서 인라인 가능한 함수를 호출하는 코드가 포함된다.

이와 같이, Go는 컴파일러가 인라인을 수행하기 때문에, 메모리 흐름을 보기 위해서 인라인을 수행하지 말라는 pragma를 입력해주어야 하는 것이다.

인라인은 Go에만 국한되지 않고, 거의 모든 컴파일 또는 JIT 언어가 이 최적화를 수행한다.

 

이전 이야기로 돌아가, 메모리 할당에 대한 플로우를 볼 수 있는 gcflags를 사용해보자.

func main() {
	_ = stackIt()
}

//go:noinline
func stackIt() int {
	y := 2
	return y * 2
}

$ go build -gcflags '-m -l'

위 결과값은 아무것도 출력되지 않는다. 왜냐하면 힙 메모리 할당이 존재하지 않기 때문이다. 우리는 여기서 값을 할당하면, 변수를 스택에 유지하고 힙에 할당하지 않는다는 것을 알게 되었다.

 

func  main () {
   _ = stackIt2()
}
 //go:noinline 
func  stackIt2 () * int {
   y := 2
   res := y * 2 
   return &res
}

$ go build -gcflags '-m -l'
-> ./main.go:10:2: moved to heap: res

힙 메모리에 할당되었다. 이 결과에서 포인터는 힙 메모리에 할당된다라는 것을 도출할 수 있을까? 아래의 예도 테스트해보자.

 

func  main () {
   y := 2
    _ = stackIt3(&y) // y를 포인터로 스택 아래로 전달
 }

 //go:noinline 
func  stackIt3 (y *int )  int {
   res := *y * 2 
   return res
}

$ go build -gcflags '-m -l'

그러나 위의 예시는 힙에 할당되지 않고 있다.

왜 이런 불일치가 발생할까? stackIt2는 스택 위로 y의 주소를 메인으로 전달하는데, 여기서 y는 이미 stackIt2의 스택 프레임이 해제된 후에 참조된다. 만약 그렇게 하지 않으면 y를 참조하려고 시도할 때 메인에서 nil 포인터를 얻게 되기 때문이다.

반면에 stackIt3는 스택 아래로 y를 전달하고, 메인3 외부에서는 y가 참조되지 않는다. 그러므로 컴파일러는 y가 스택 내에만 존재할 수 있고 힙에 할당할 필요가 없다고 판단할 수 있다. 어떤 상황에서도 y를 참조하여 nil 포인터를 생성할 수 없다.

여기서 유추할 수 있는 일반적인 규칙은 스택 위로 포인터를 공유하면 할당이 발생하지만 스택 아래로 포인터를 공유하면 할당이 발생하지 않는다는 것이다. 그러나 이것이 보장되는 것은 아니기 때문에 확실하게 확인하려면 gcflags나 벤치마크를 통해 확인해야 한다. 확실한 것은 allocs/op를 줄이려는 모든 시도에는 잘못된 포인터를 찾아내는 것이 포함된다는 것이다.

 

힙 메모리 할당이 더 느리다는 것을 알겠으나, 최적화를 통해 얼마나 성능을 향상시킬 수 있을까?

BenchmarkStackIt-8   680439016  1.52 ns/op  0 B/op  0 allocs/op
BenchmarkStackIt2-8  70922517   16.0 ns/op  8 B/op  1 allocs/op
BenchmarkStackIt3-8  705347884  1.62 ns/op  0 B/op  0 allocs/op

위 예시 세 가지의 메서드에 대한 벤치마크를 한 결과다. 관련된 변수의 메모리 요구 사항이 거의 동일함에도 불구하고 BenchmarkStackIt2의 상대적인 CPU 오버헤드가 두드러진다. stackIt과 stackIt2 구현의 CPU 프로파일에 대한 플레임 그래프를 생성하면 좀 더 많은 인사이트를 얻을 수 있다.

stackIt CPU profile

 

stackIt2 CPU profile

 

이는 힙에 할당하는 작업에 관련된 복잡성을 보여준다.

때문에, Go 컴파일러는 데이터를 최대한 스택 영역에 저장하려고 시도한다.

 

Go 컴파일러는 escape 분석 성능이 우수하고 필요 시 힙에 할당되지 않도록 프로그래머가 제어할 수 있기 때문에, 세대 별 가설에서 나오는 수명이 짧은 객체는 heap이 아닌 stack에 할당되는 경우가 많다(GC를 수행할 필요가 없음). 따라서 세대별 GC로 얻을 수 있는 이점이 일반적인 runtime GC에 비해 적고, 이는 Go가 GC로 세대별 GC를 사용하지 않는 근거가 된다.

세대별 가설: 많은 애플리케이션에서 새로 할당된 객체는 대부분 수명이 짧다는 가설.

 

마지막으로 Go 컴파일러가 스택, 힙 저장하는 기준에 대해서 몇 가지 적고 글을 마무리 한다.

스택 -> 힙 이동하는 경우

  • 함수 외부에서 값을 사용해야 하는 경우
  • 스택에 있는 값을 인터페이스로 변환하는 경우 (함수 내부에서만 사용 돼도 힙에 할당된다)
  • (!) new 해도 스택인 경우가 있다. 함수 내에서만 사용되는 값인 경우가 그렇다. (위의 예시에서 보았듯이 '포인터 선언 == 힙 메모리 할당' 가 반드시 성립되지는 않는다)

 

참고

https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast

https://jacking75.github.io/go_stackheap/

https://engineering.linecorp.com/ko/blog/go-gc