Go GC의 Memory overhead와 비용
CPU overhead와 Memory overhead의 관계와 비용에 관해 정리된 포스팅이 있어서 정리해 보고자 한다. 예시코드는 linux기반에서만 동작함을 숙지해주길 바란다.
Samsara라는 cloud기업에서 작성되었으며 원제는 "Reducing Costs for Large Caches in Go: Eliminating the Memory Overhead of the Garbage Collector"이며 2022.2월에 작성된 포스팅이다.
Reducing Costs for Large Caches in Go
Go heap incurs a 100% memory overhead. Samsara shares how to manage the increasing cost of your Go application.
www.samsara.com
Samsara는 100만개의 IOT기기에서 50억개의 Data point를 가지고 있으며, 대용량 데이터를 Memory-bound하는 경우 이를 핸들링하여 약 33%의 비용절감을 이루어 냈다고 한다.
메모리 오버헤드가 문제되는 시기
When the memory overhead is an issue
앞선 dlog에서 Go 어플리케이션은 메모리를 희생함으로서 CPU오버헤드를 10% 내외로 유지하게 된다고 밝혔는데, 이는 어플리케이션이 Memory-bound보다 CPU-bound를 사용하는 경향이 있기 때문이다.
그리고 이 방식은 꽤 효과적이여서 Samsara에서 사용하는 대부분의 시스템에서는 메모리 오버헤드로 인해 큰 비용이 발생하지 않음을 발견했다. 구체적인 예로, Samsara에서 사용하는 대부분의 Go 어플리케이션은 하나의 CPU에 2 Gb의 메모리를 가지며, 어플리케이션이 실행되는 인스턴스(예. C6i)가 제공하는 비율과 이와 동일하다.
즉, 대부분의 시스템에서 GC의 메모리 오버헤드를 제거하더라도 비용을 아낄 순 없다.
For most of our systems, if we could remove the garbage collector’s memory overhead, it wouldn’t reduce the number of computers we use and so wouldn’t reduce costs.
- 이미 제공할 메모리 크기가 정해져 있기에 이 한도 내에서는 Go GC의 메모리 오버헤드를 줄여서 추가적인 메모리를 확보해도 비용 감소로 이어지지 않는다는 의미로 생각된다.
하지만, 당연히 예외는 있는 법. Samsara에서는 TSDB(Time Series DataBase)와 같이 거대한 데이터를 꺼내서 캐싱하는 경우 문제를 발견했다. 이 캐싱 컴포넌트는 CPU-bound대신 Memory-bound를 사용하면서 큰 메모리 오버헤드를 발생시켰다.
만약 2개의 RPC서버에 1Gb의 캐시를 붙이는 경우, live heap은 1,024Mb (cache) + 64Mb (requests) = 1,088Mb가 될 것이다. 따라서 이 프로그램이 사용하는 메모리의 총량은 이의 2배인 2,176Mb가 될 것임을 예측할 수 있다. 아래의 코드를 통해 예측과 거의 비슷한 2,248Mb 메모리가 프로세스에 할당됨을 확인할 수 있다.
GcTrace를 통해 Go Heap 레벨에서 live heap의 크기는 1,100Mb, GC는 Heap goal을 2,200Mb로 설정했음을 알 수 있다. 결론적으로 대용량 캐시는 많은 비용을 발생시킨다. Samsara에서는 이러한 문제를 해결하기 위해 2가지 방법을 시도했다.
메모리 오버헤드 감소
Reducing the memory overhead
혹시 앞서서 GC가 끝나면 디폴트로 live heap의 100%의 추가 메모리를 확보한다는 것을 기억하는가? 이를 GOGC 환경변수나 SetGCPercent통해 설정할 수 있는데, 이를 이용하여 메모리 오버헤드를 줄일 수 있다.
이 방법을 통해 GC의 메모리 오버헤드를 감소시킬 수 있다. GC를 더 빈번히 발생시킴으로 결과적으로 GC 사이클마다 더 적은 Garbage가 발생할 것이고, 자연스레 heap에는 더 적은 메모리가 할당 될 것이다.
그러나 이 방식 또한 CPU 오버헤드가 증가한다는 단점이 있다. 더 빈번한 GC는 더욱 빈번한 CPU사용을 야기한다. 아래 그래프를 보자.
메모리 오버헤드를 절반으로 줄이려면 GC을 두 배 더 자주 실행해야 하기 때문에 x축의 GOGC 값이 감소함에 따라 메모리 오버헤드가 선형적으로 감소하는 반면 y축의 GC빈도가 기하급수적으로 증가하는 것을 확인할 수 있다. 여기서 우리는 궁극적으로 관심을 두어야 할 것은 CPU 사용량이다.
- 메모리 오버헤드를 100%에서 10%로 줄이게 되면 GC빈도가 초당 0.77사이클에서 6.0사이클로 5배 이상 증가한다.
- 일반적인 Go프로그램은 GC에 CPU의 1~10%를 사용한다. GC가 5배 증가하면 CPU를 최대 50% 사용하게 된다.
- 메모리 오버헤드를 적당하게 절반으로 줄이면 GC빈도가 초당 1.3사이클로 CPU를 최대 20% 사용하게 된다.
즉, GOGC 환경변수를 조정하면 특정 프로그램의 메모리 오버헤드를 약간 줄일 수 있지만, CPU 오버헤드로 인해 비용은 거의 비슷하다.
메모리 오버헤드 제거
Eliminating the memory overhead
두번째 방법은 메모리 오버헤드를 제거해 버리는 것이다. GC은 자신이 관리하는 메모리(Go 힙을 구성하는 메모리)에 대해서만 메모리 오버헤드를 발생시킨다. 그리고 Go 코드는 Go 힙뿐만 아니라 주소 공간의 모든 메모리에 액세스할 수 있다. 따라서 Go Heap 외부에서 메모리를 관리하여 특정 개체에 대한 GC의 메모리 오버헤드를 제거할 수 있다.
즉, 메모리 오버헤드 문제를 야기한 캐시를 명시적으로 Heap이 아닌 Kernal에 할당하고 사용함으로 Heap에 할당되는 메모리 크기를 줄이고, 비용의 절감이 가능해진다. 아래의 코드를 통해 프로세스에서 사용하는 메모리 양이 2,248MiB에서 1,162MiB로 48% 감소함을 확인할 수 있다.
하지만 대규모 시스템에서는 명시적으로 할당하는 것이 더 복잡해진다. Samsara에서는 이러한 문제를 GC에 넘겨버리는 방식으로 해결했다. 외부에 할당된 캐시의 참조객체를 Heap에 할당하면서, GC가 이를 해제하도록 유도했다.
Go 소멸자(finalizers)를 사용하여 메모리를 해제했으며, 소멸자는 Heap의 동작에만 기반해서 GC가 해당 객체를 해제하는 경우에만 실행된다. 따라서 견고하고 추론가능한 메모리 관리가 가능하기 때문에 이런 방식을 사용했다(목록). Samsara 에서는 이 기능을 구현하며 다음 두 포스팅이 큰 도움이 되었음을 밝혔다. 참고하기 바란다.
Go 애플리케이션을 작성할 때 가장 먼저 걱정하는 것은 GC이 아닐 수도 있지만, 어떻게 작동하는지 아는 것은 도움이 될 수 있다. 특히 애플리케이션이 기본 하드웨어가 제공하는 것보다 훨씬 더 높은 메모리를 사용하는 경우 발생할 가능성이 높다.
그리고 Samsara는 시계열 데이터(time series data)의 캐시 오버헤드를 제거함으로서 33%의 비용 절감을 이루어 낼 수 있었다. 본 포스트를 통해 Go 런타임이 애플리케이션과 어떻게 작동하는지 이해하는 데 도움이 되기를 바라며 여기에 나온 일부 기술이 시스템 비용을 줄이는 데 도움이 되기를 희망한다.
내 결론
메모리 오버헤드가 곧바로 비용감소로 이어지지 않는다는 내용이 굉장히 흥미로웠다.
- 극단적으로 큰 객체 하나를 캐싱하는 대신 이를 쪼개어 캐싱하면 원론적으로 차단할 수 있었을거란 생각이 드는데 왜 그렇게 해결하지 않았는지 다루지 않아 아쉬웠다.
- 또한 서비스의 성능적인 부분도 조금은 다루어 줬으면 하는 부분도 있었다. 본 포스팅은 너무 비용적인 측면에만 초첨이 맞추어져있다.