Python

Python gc 튜닝을 통한 성능 개선 (Flask 기반 예시 코드)

검정비니 2023. 10. 14. 21:50
728x90
반응형

일반적으로 파이썬 환경은 CPython을 의미하기 때문에, 이 문서에서 다루는 내용 역시 CPython 인터프리터에 대한 실험을 포함하고 있습니다. PyPy나 RustPython과 같은 환경에서는 또 다른 상황이 발생할 수 있다는 점을 미리 고지드립니다.

 

CPython에서 gc의 동작 원리

기본적으로 파이썬의 gc는 reference count를 기반으로 작동을 하게 된다. CPython에서 class 등을 통해 만들어진 인스턴스나 기본형 데이터 등이 생성되게 되면 내부적으로 PyObject 타입의 struct에 대해 memory allocation이 발생하게 된다. 그리고 CPython은 할당된 메모리의 관리를 위해 reference list에 생성된 PyObject에 대한 reference를 추가함으로써 메모리 관리를 하게 된다. 또한, 이렇게 생성된 객체에 대해 참조를 세고, 참조 카운트가 0이 된 PyObject에 대해서는 unreachable object로 판단한 뒤 메모리에서 해제시키는 작업을 수행하게 된다.

 

파이썬 gc에서 세대 구분

생성된 객체들은 총 3단계의 세대(gen0, gen1, gen2)로 구분이 되어지게 된다. 한번도 gc가 불리지 않은 객체는 gen0, 한번 불렸지만 생존한 객체는 gen1, 두 번 이상 gc가 불렸지만 생존한 객체는 gen2에 속하게 된다.

 

파이썬에서 각 세대마다 gc가 호출되는 빈도 수도 따로 조절된다. 이에 대한 값은 Python의 내장 함수인 gc.get_threshold()를 통해서 확인할 수가 있다:

import gc

gc.get_threshold()

보통 gc.get_threshold()의 기본 값은 (700, 10, 10)이다. 위의 세대별 threshold가 의미하는 내용이 각 세대별로 조금씩 다르다. 0세대의 경우에는 현재 생성된 후 해제되지 않은 객체의 수가 threshold 값보다 클 때에 자동으로 gc를 활성화시킨다. 1세대의 경우에는 gen0의 gc가 몇번 불렸을 때 gen1 gc를 호출할 지에 대해서 결정하는 값으로 사용된다. 2세대의 경우에는 gen1의 gc가 몇번 불렸을 때 gen2 gc를 호출할 지에 대해서 결정하는 값으로 사용되는데, gen1과 차이점이 gen2 gc의 경우, (long_lived_pending / long_lived_total)의 값이 25%를 넘는 경우에만 gc를 호출한다. long_lived_total이란 gen2의 총 객체 수이며, long_lived_pending은 gen1에서 gen2로 이동된 후 한번도 gc 검사를 수행하지 않은 객체의 수이다.

 

즉, young generation 객체는 old generation 객체에 비해 자주 scan을 진행해주는 것이 타당하다는 가설하에 디자인된 시스템이다.

 

gc의 오버헤드

그렇다면, 각 세대의 gc는 해당 세대 내의 객체들 중 어떤 것이 참조되고 있고 어떤 것이 참조가 해제되었는지 어떻게 파악을 할 수가 있을까? CPython에서는 기본적으로 해당 세대의 "모든 객체를 scan"하는 방식으로 gc를 작동시키게 된다. 즉, O(n)의 시간 복잡도를 가지는 작업을 gc 함수가 불릴 때마다 실행하게 되는 것이다.

 

또한, gen0의 gc가 불릴 때마다 해제되지 않은 객체들은 gen1으로 이동되기 때문에, gen0가 너무 자주 불리게 되면 전체 시스템의 성능이 크게 저하될 수 있게 된다.

그리고 gen2의 경우에는 기본적으로 오랜시간 살아남은 객체들이기 때문에 gen2 gc에서 객체가 해제되는 상황은 일반적으로는 거의 일어나지 않는다. 오히려 장기적으로 계속 살아있어야 하는 객체에 대해서 불필요한 scan 작업을 진행하기 때문에 장기적으로는 CPU 낭비가 증가하게 되는 것이다.

 

gc의 최중요 목표는 순환참조의 해제

만약 아래와 같이 순환 참조를 사용하는 코드가 있다면 del을 사용해도 reference count가 0이 안될 수 있다:

a = SomeObject() # a의 ref count는 1이 됨
b = SomeObject() # b의 ref count는 1이 됨

a.ref = b # b의 ref count는 2가 됨
b.ref = a # a의 ref count는 2가 됨

del a # a의 ref count는 1이 됨
del b # b의 ref count는 1이 됨

위와 같은 경우에서는 순환 참조로 인해 논리적으로는 존재하지 않는 객체가 실질적으로는 순환 참조로 인해 reference count가 0이 되지 않아서 즉각적으로 메모리에서 해제되지 않게 된다. 파이썬 gc의 최대 목표는 이러한 경우가 발생하더라도 파이썬 인터프리터가 알아서 메모리를 해제해 주는 것을 목표로 한다.

 

실제로 https://docs.python.org/3/library/gc.html를 보면, 자신의 코드 내에 순환 참조(reference cycle)가 발생하지 않는다고 확신할 수 있으면 gc.disable()을 통해 가비지 컬렉션을 비활성화 시켜도 된다고 나온다.

 

이제 파이썬 gc의 동작 원리에 대해서 어느정도 알아봤으니, 실질적인 성능 튜닝으로 들어가보도록 하자.

 

Flask 서버의 gc 튜닝

gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_LEAK)를 사용하면 프로그램이 실행되는 중에 gc가 언제 어떻게 호출되는지에 대해서 자세히 확인을 할 수가 있다.

Start-up 시의 수만개의 객체 생성

위의 코드를 통해 gc 디버깅을 활성화시킨 뒤 flask 서버를 실행시켜본 결과, 앱 실행과 동시에 gc가 수차례 호출되면서 순식간에 gen2에 60,000개 이상의 참조가 쌓이게 된다. 이 60,000개의 참조 중 대부분이 numpy에 의해서 생성된 객체들이다. numpy나 pytorch의 경우, 내부적으로 C/C++ 기반의 참조 객체들을 많이 생성하기 때문에 import torch나 import numpy만으로도 순식간에 많은 수의 객체들이 생성되게 된다. 또한, 이 객체들은 기본적으로 라이브러리 내부에서 계속 사용되기 때문에 참조가 절대 0 이하가 되지 않게 되고, 영원히 gen2에 남아있게 된다.

flask 시작 시 gc

애초에 프로그램이 종료되기 전까지 계속 메모리 상에 남아있어야 하는 객체들이라면 일정 기간마다 gen2 gc에 의해 scan 되어지는 것은 매우 비합리적이라고 할 수가 있다. 이러한 문제를 해결하기 위해 gc.freeze()를 호출하는 코드를 서버 코드 내에 추가하게 되었다.

gc.freeze()는 현재 gc 내에 있는 참조 변수들을 모두 "permanent" 메모리 영역으로 옮겨서 해당 객체들이 더 이상 gc의 scan의 영향을 받지 않도록 설정해주게 된다.

즉, numpy 등의 라이브러리의 내부적으로 사용되는 핵심 객체들에 대해서 gc scan의 영향을 받지 않도록 함으로써 cpu가 gc에 사용하는 비효율적인 연산을 최소화시키도록 도와주게 되는 것이다.

 

FastAPI에서는 startup 이벤트가 발생할 때 gc.freeze()를 호출하는 방식으로 gc.freeze() 호출 타이밍을 결정할 수가 있으나, flask는 이러한 기능이 없는 것으로 판단이 되었기 때문에 app과 router들이 초기화되는 시점에 해당 함수를 호출하도록 설정하였다.

flask gc freeze

Request마다 gc가 호출되는 비율 파악 및 성능 최적화

기본적으로 Flask 서버와 같은 WAS는 사용자로부터 요청이 들어올 때마다 상태가 변경되게 된다. 이는 다시 말해, 요청이 들어올 때마다 객체가 생성되게 된다는 의미가 된다. 기본적인 시나리오 상, 사용자는 웹을 통해 시스템을 사용하기 때문에 각 웹페이지 별로 어느 정도의 객체가 생성이 될 것이다. 따라서, 웹페이지를 통해 다양한 작업들을 해보면서, gc 호출 비율은 어떻게 되는지 등에 대해서 알아보는 방식으로 작업을 진행하였다.

 

실험 결과 다음 사항들에 대해서 확인을 할 수가 있었다:

1) Werkzeug가 내부적으로 PyObject를 많이 생성해서 gc가 예상보다 더 자주 호출되게 된다
2) gen2로 이동한 객체들은 실질적으로 해제가 안되고 반영구적으로 메모리 공간에 남아있게 된다
3) 단일 API 요청을 통해 생성되는 객체의 수가 생각보다 많으며, 데이터소스로부터 가져오는 데이터의 크기가 클 수록 그 수가 더 많아지게 된다
4) gen0 gc는 API마다 다르지만 사실상 요청 1회당 gc 1회라고 봐도 될 정도로 자주 불리게 된다

 

결국 생각보다 gen0 gc가 너무 자주 불려서 gen1 gc 역시 너무 자주 불리게 되고, 그로인해 gen2에 쌓이는 객체의 수가 너무 빠르게 증가하는 상황이 발생하게 된다.

flask gc logs

한번의 요청을 보냈을 때 평균적으로 약 1270개의 객체가 garbage collection의 대상이 되며, 약 5개의 gen2 객체가 garbage collection의 대상이 되는 것을 확인하였다.

즉, python의 기본 gc threshold인 (700, 10, 10)으로는 매 요청마다 최소 1회, 운이 나쁘면 2회 gen0 gc가 불리게 되는 것이다. 또한, gen0의 호출 빈도가 높기 때문에 gen1과 gen2 gc 역시 자주 호출되게 되면서 서버 전체의 성능이 크게 저하되는 상황이 발생하게 된다.

 

Flask 자체에 의한 문제도 꽤 있지만, 어찌되었든 gen0의 호출이 너무 잦기 때문에 서버의 성능 자체가 낮아지게 되는 것이다. gen0와 gen1의 호출 빈도를 낮추게 되면 전체 시스템의 성능을 크게 개선할 수 있을 것이라는 가설을 세운 뒤, 다음과 같이 gc threshold 값들을 튜닝했다:

import gc

def gc_optimization_on_startup(debug=False):
    # numpy나 torch는 import를 통해 초기화 시, 내부적으로 많은 object를 생성한다.
    # 이러한 object들이 reference count에 영향을 주고, gc가 더 자주 동작하도록 만든다.
    gc.freeze()

    # gc가 너무 자주 불리는 것도 문제가 될 수 있음.
    gc.set_threshold(80_000, 20, 20)

    if debug:
        gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_LEAK | gc.DEBUG_UNCOLLECTABLE)

위에서 볼 수 있듯이, gen0의 호출 threshold를 700에서 80,000으로 약 11배정도 늘렸으며, gen1 gc의 호출 빈도를 2배 상승시켰다. 이를 통해 기존에는 매 요청마다 gc가 발생하던 것을 약 19개의 요청마다 gc가 발생하도록 변경할 수 있었다.

 

실제 성능에 어느정도 영향을 미치는지 테스트해보기 위해서 jMeter를 통해 EC2에 배포되어 있는 서버로 부하테스트를 진행해보았다. 약 100명의 vUser들을 만든 뒤 약 30분 정도 부하테스트를 진행해본 결과, 기존에는 약 200TPS 정도 나오던 API가 약 500TPS까지 성능이 향상된 것을 확인할 수 있었다. 그러나, gen2 gc가 활성화되기 전에 gen2 gc에 쌓이는 객체의 양이 많아지다보니 실제로 gen2 gc가 호출될 때 lookup을 진행하는 참조의 수가 크게 증가하게 되면서 gen2 gc에 의한 부담이 크게 증가하게 되었다.

gc.disable을 통한 성능 튜닝

만약 gc 자체에 의한 영향이 지속적으로 이루어지게 된다면, gc 자체를 없애는 것 역시 고려해 보는 것이 좋을 것 같다는 생각이 들게 되었다. 즉, GIL처럼 gc 역시 문제가 해결 불가능하다면 gc의 영향 자체를 없애버리는 것이 맞지 않을까하는 생각을 하게 되었다.

import gc

gc.disable()

위와 같이 gc.disable() 함수를 호출하게 되면 gc가 자동으로 작동하는 것을 방지할 수 있다. 위에서 언급했듯이, 파이썬 인터프리터는 reference count가 0이 되면 알아서 리소스를 메모리로부터 해제하며, gc는 reference counting이 불가능하거나 제대로 동작 안하는 (i.e. 순환 참조) 시나리오를 위한 부가적인 메모리 관리 툴이다. 따라서, gc의 비활성화가 장기적인 실행에 문제가 없다면 이 방법 역시 좋은 옵션이 될 것이라고 생각을 하게 되었다.

 

따라서, 기존의 gc_optimization_on_startup 함수를 아래와 같이 수정함으로써, 서버 운영자가 원하는 옵션에 맞게 gc 설정을 변경할 수 있도록 만들었다:

import gc


def get_current_gc_threshold():
    return gc.get_threshold()


def gc_optimization_on_startup(debug:bool=False, disable_gc:bool=False):
    if debug:
        # gc.DEBUG_STATS: print statistics
        # gc.DEBUG_LEAK: print objects that are likely to be leaked
        # gc.DEBUG_UNCOLLECTABLE: print objects that cannot be collected
        gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_LEAK | gc.DEBUG_UNCOLLECTABLE)

    if disable_gc:
        gc.disable()
        return

    # numpy나 torch는 import를 통해 초기화 시, 내부적으로 많은 object를 생성한다.
    # 이러한 object들이 reference count에 영향을 주고, gc가 더 자주 동작하도록 만든다.
    gc.freeze()

    # gc가 너무 자주 불리는 것도 문제가 될 수 있음.
    gc.set_threshold(80_000, 20, 20)

 

반응형