golang - go로 쓴 코드의 힙 할당 여부 확인하는 방법

출처

서두

Allocation Efficiency in High-Performance Go Services · Segment Blog 라는 기사를 읽었다. 좋기 때문에 꼭 일독을 권장한다.

이 글은 나의 이해와 실제로 시험해 본 결과의 메모이다.
가장 중요한 포인트는 go build -gcflags ‘-m’ 같이 옵션을 지정하여 빌드하면 코드의 어떤 부분에서 힙 할당이 발생했는지를 확인할 수 있다는 것이다.

pprof 및 go test -benchmem 에서도 힙 할당의 발생 횟수는 확인할 수 있지만, 위의 방법으로 코드의 어디에 (몇 행의 어떤 열에서) 힙 할당이 발생했는지 왜 발생했는지 이유를 확인할 수 있다.

원래 글의 내용 참고

처음에 올린 글을 읽고 내가 이해 한 내용의 메모이다. 원 글의 모든 내용을 쓰고 있는 것은 아니기 때문에, 원 글도 꼭 보자. 한편, 원 기사에 없지만 읽고 내가 생각했던 내용도 추기해서 잘못된 것을 쓰고 있을 가능성도 있다. 이 경우 twitter 등으로 지적 주시면 감사하겠다.

대전제

  • 이른 최적화는 피한다.
  • 최적화 할 때는 도구로 측정하고 병목 현상을 찾아 낸다. Go 공식 블로그 Profiling Go Programs - The Go Blog 기사가 좋기 때문에 그 쪽을 참조.

Go 메모리 할당

  • 스택 할당과 힙 할당의 2 종류.
  • 스택 할당은 싸고(가벼운 처리) 힙 할당은 비싸다(무거운 처리).
  • 스택의 할당과 해제는 CPU의 명령어 2개로 끝난다(할당과 해제에 하나씩) 때문에 가볍다.
  • Go 컴파일러는 코드를 분석하여 가능하면 스택 할당하지만, 그렇지 않으면 힙 할당된다.
  • 스택 할당 할 수 있는 것은 변수의 수명과 메모리 사용량이 컴파일 시에 확정 할 수 있는 경우에만.
  • 힙 할당은 실행 시에 malloc 호출로 힙에 동적으로 할당 해야하는 것과 할당 후 가비지 컬렉터가 할당 된 개체를 다시 참조되지 않게 되었는지를 주기적으로 검사 할 필요가있다. 따라서 힙 할당은 스택 할당에 비하면 상당히 무거운 처리.

이스케이프 분석 (escape analysis)

  • Go 컴파일러는 이스케이프 분석이라는 기술을 사용하여 스택 할당과 힙 할당 중 어느 것을 사용할지를 선택한다.
  • 기본적인 아이디어는 가비지 컬렉션 작업을 컴파일 시 할 수있는 부분은 하는 것.
  • 컴파일러가 코드 영역에 걸쳐 변수의 범위를 추적하여 수명이 특정 범위로 한정 될 수 있거나 메모리 크기가 컴파일 시에 확정 할 경우 스택 할당된다.
  • 확정 할 수 없는 경우는 탈출 했다라고(위의 추적을 피했다는 이미지 하나) 힙 할당을 할 필요가있다.
  • 탈출 분석 규칙은 Go 언어 사양에서 규정되지 않는다(Go 버전이 올라 컴파일러가 진화하면 스택 할당 할 수있는 케이스가 증가 하기 때문이라고 생각된다).
  • go build -gcflags ‘-m’ 같이 옵션을 지정하여 빌드하면 탈출 분석 결과가 출력된다.
  • go build -gcflags ‘-m -m’ 처럼 -gcflags의 -m 옵션을 여러 번 지정하여 빌드하면 더 자세한 결과가 출력된다.

포인터는 스택 할당의 저해 요인이므로 가능하면 피한다.

  • 포인터를 사용하면 대부분의 경우 힙 할당되어 버린다.

포인터를 피하는 것이 좋은 이유.

  • 함수의 인수 나 메소드 리시버도 포인터 하지 않고 값을 복사하는 것이 많은 경우 가벼운 처리가 된다.
  • 포인터의 디레퍼런스 때는 실행시 nil 체킹 분만큼 처리가 늘어난다.
  • 포인터를 사용하지 않고 값을 복사하는 것이 메모리에서 국소화하여 CPU 캐시 적중률도 오른다.
  • 캐시 라인에 포함 된 개체의 복사본은 단일 포인터의 복사와 거의 같이 가볍다.
    • x86 이라면 64 바이트 이하의 객체 라면 이용 할 수 있다.
    • Go는 Duff’s devices 라는 기법을 사용하여 메모리 복사 등의 일반적인 작업에 대해 매우 효율적인 어셈블러 코드를 생성한다.
  • 포인터의 사용처는 소유권을 나타내는 경우와 mutlable(값을 변경 가능하게 하는) 경우.
  • 기본 값으로 전달하고 필요할 때만 포인터 전달하는 것이 좋다.
  • 값 전달 한다면 nil 체크가 필요 하지 않는 이점도있다.
  • 포인터를 포함하지 않는 메모리 영역은 가비지 컬렉터가 검사를 생략 할 수있다(예 : []byte의 백스토어 메모리 영역은 검사 필요).
  • 반대로 말하면, 포인터가 있으면 가비지 컬렉터는 포인터의 참조를 스캔 할 필요가있다. 참조 포인터를 포함하는 구조체 등이면 더욱 포인터의 참조처도 검사가 필요하다. 그러면 메모리 상에 흩어진 영역을 계속 로드 되므로 처리로서도 무겁고, 로드하는 것에 의해 CPU 캐시에서 다른 데이터를 쫓아내서 CPU 캐시 적중률도 나 빠진다.

슬라이스와 문자열에도 주의

  • 슬라이스는 크기가 동적으로 컴파일 시에는 미 결정이므로 백스토어(슬라이스 내의 포인터가 참조하는 곳) 배열이 힙 할당된다.
  • 문자열도 바이트 슬라이스 이므로 마찬가지.
  • 슬라이스가 아닌 배열을 사용할 경우, 배열은 크기 고정되므로 스택 할당 할 수 있다. 필요한 크기의 최대 값을 사전에 알아서 스택에서도 문제 없는 정도의 크기이면 백스토어 배열을 지역 변수로 선언하여 사용하면 된다.
  • append를 사용하여 원래의 백스토어의 용량이 부족해서 크기 확장하는 경우 확장 후의 백스토어는 힙 할당된다.
  • 슬라이스를 받는 함수에 배열 a을 전달할 때는 a[:] 등 Slice expressions를 사용하면 된다.

time.Time 주의

  • 시간대 정보를 포인터로 가지고있다.
  • 힙 유지하면 time.Time으로 유지하는 것보다 Unix time 정수를 가지는 것이 가비지 컬렉터에 상냥하다.
  • 원 글에서는 Unix time 초를 int64과 나노초 부분을 uint32으로 가지고 있었지만, 1678년부터 2262년까지의 날짜를 취급한다면 Unix time을 나노 초로 int64로 가질 수 있다.
    • time.UnixNano ()
    • Go 언어의 os.Chtimes에서 설정 가능한 최대 시간은 2262-04-11 23:47:16.854775807 +0000 UTC

반환 값에서 문자열이나 슬라이스를 반환하는 함수에 주의.

  • 예를들면 func (t Time) Format (layout string) string 의 반환 값 string 값(정확하게는 백스토어 배열)은 힙 할당된다.
  • 만약 반환 문자열의 쓰임새가 다른 바이트 슬라이스에 추가하고 싶은 경우 func (t Time) AppendFormat (b [] byte, layout string) [] byte 를 사용하는 것이 좋다. 인수 b의 백스토어 배열의 용량이 크면 거기에 직접 기록하면 되므로 여분의 heap 할당이 발생하지 않는다. 용량 부족의 경우 확장된 백스토어가 힙 할당으로 된다. 하지만 반환 값으로 돌려주고 나서 추가로 두 번 힙 할당 되기 때문에 한 번에 끝나는 이쪽이 좋다.
  • 뿐만 아니라 strconv의 Itoa와 FormatFloat 등은 용도로서 가능하다면 AppendInt 및 AppendFloat을 사용하는 것이 좋다.

인터페이스의 메소드 호출은 구조체의 그것보다 무거운 처리

  • 인터페이스의 메소드 호출은 다이나믹 디스패치에서 실행된다.
  • 원래 기사는 적지 않았지만, 인터페이스를 유지하는 변수에 보존 되는 값은 구현의 구조체에 대한 포인터가 되기 때문에 위의 포인터 말과 통하는 것입니다.
  • 반복해서 실행되어 병목이 되는 처리라면 인터페이스를 사용하지 않는 코드로 고쳐 써서 heap 할당이 발생하지 않도록하는 것도 하나의 방법. 그러나 인터페이스를 통해 확장성을 잃게 되므로 트레이드 오프는 있다.

테스트 환경

테스트 환경은 Ubuntu 16.04에서 go 버전은 다음과 같다.

$ go version
 go version go1.10rc1 linux / amd64

실제로 시험해 보았다

예 1

package  main

import  "fmt"

func  main ()  { 
        x  : =  42 
        fmt . Println ( x ) 
}

-gcflags ‘-m’ 붙임으로 빌드 해 보았다. 7 번째 줄의 x는 스택 할당 이라고 생각했는데 힙 할당 되었다.

  
$ go build -gcflags '-m' main.go
 # command-line-arguments
 ./main.go:7:13 : x escapes to heap 
./main.go:7:13 : main ... argument does not escape

-gcflags ‘-m -m’ 붙임으로 빌드하면 보다 상세한 출력이 나온다.

$ go build -gcflags '-m -m' main.go
 # command-line-arguments
 ./main.go:5:6 : can not inline main : non-leaf function 
./main.go:7:13 : x escapes to heap 
./main.go:7:13 : from ... argument (arg to ...) at ./main.go:7:13 
./main.go:7:13 : from * (... argument) (indirection) at ./main.go:7:13 
./main.go:7:13 : from ... argument (passed to call [argument content escapes]) at ./main.go:7:13 
./main.go:7:13 : main ... argument does not escape

x는 fmt.Println 라는 함수의 인수로 전달되고, 그 인수가 탈출하기 때문에 x도 탈출한다는 것을 알 수 있다.
다른 예도 시도했지만,이 글에서는 생략한다. 궁금한 사람은 원 문서를 참조하자.

조금 주의

덧붙여서 -gcflags 지정을 바꾸지 않고 두 번 실행하면 아무것도 출력 되지 않았다. 컴파일 된 바이너리 파일(이 경우 ./main)를 삭제 한 후 다시 실행하면 출력되었다. 파일을 지우지 않고 touch main.go 빌드도 출력되지 않았다.

덧붙여 main.go를 변경해서 다시 빌드 할 때 탈출 분석 결과가 출력되었다. 보통은 코드 변경없이 2번 빌드하거나 하지 않고, 변경하고 빌드하므로 평소에는 의식 할 필요는 없을 것이다.

결론

원 글의 마지막에 있던 정리를 번역 해 두었다.

  1. 조기 최적화는 하지 않는다! 최적화 할 때는 측정한 데이터를 기반으로 할 것.
  2. 스택 할당은 싸고(가벼운 처리), 힙에 할당은 비싸다(무거운 처리).
  3. 탈출 분석 규칙을 이해함으로써 보다 효율적인 코드를 작성할 수있다.
  4. 포인터가 있으면 대부분의 경우 스택 할당하지 못하고 힙 할당된다.
  5. 성능이 중요한 코드 섹션에서는 메모리 할당을 제어 할 수있는 API를 제공하는 것을 고려한다.
  6. 핫패스(반복 실행되는 처리)는 인터페이스 타입의 사용은 신중하게(많이 하지 않음).

보충하면 4는 위의 func (t Time) AppendFormat (b [] byte, layout string) [] byte 처럼 API를 이용자가 미리 필요한 메모리 할당을하는 것을 가능하게하는 API 의미 이다. func (t Time) Format (layout string) string 이 더 쉽게 사용할 수 있지만 반환 값이 힙 할당되어 버린다.
성능이 중요한 국면에서는 AppendFormat 쪽이 제어 할 여지가 있다.

그리고 원 기사에 나와 있지 않았지만 임시 객체를 반복 사용하는 경우 sync.Pool 도 성능 향상에 도움이 된다.
눈에 띄는 예로 valyala / fasthttp : Fast HTTP package for Go. Tuned for high performance. Zero memory allocations in hot paths. Up to 10x faster than net / http에서 HTTP 요청 및 응답 등의 개체를 sync.Pool 로 관리하고, 요청 처리 끝나면 회수하여 다음 요청 처리에서 재사용하는 것으로 고속화를 실현하고 있다.

그냥 sync.Pool 로는 객체를 사용한 시점에서 func (p * Pool) Put (x interface {}) 를 명시 적으로 호출 할 필요가 있는 것이 귀찮은 곳이다.
사용이 끝난 것을 전하지 않으면 pool로 회수 할 수 없기 때문에 당연하지만, 메모리 관리를 가비지 컬렉터에 맡겨서 신경 쓰지 않아도 잘 된다는 이상에서 멀어지는 것이 조금 유감이다. 즉 자동이 아닌 수동으로 관리하는 것이다.
하지만 성능에 중요한 부분이 빨라져서 기쁘기 때문에 장단점이 있다.


이 글은 2019-01-06에 작성되었습니다.