Python Garbage Collector에 대한 고찰

·2023년 8월 3일
0

이번 포스트에서는 Python 메모리 관리의 핵심인 Garbage Collector에 대한 고찰에 대해 포스팅합니다.

Background

Python은 현재 사용자 친화적인 고수준 언어중 가장 인기가 많은 언어 중 하나이다. 사용자 친화적임에 더불어 Python은 default로 메모리 관리를 지원한다. 하지만 default로 적용되어지는 것은 최적화가 부족하여 직접 관리에 손을 대야하는 경우가 많다. 이번 포스트에서는 Garbage Collector에 대한 설명과 최적화를 진행 하면서 느꼈던 고찰에 대해 서술한다.

Python 메모리 관리와 Garbage Collection

Python은 기본적으로 Reference Counting 방법을 사용하여 메모리를 관리한다. 하지만 해당 방법은 reference cycle의 경우에는 메모리 누수가 발생하게 되므로 추가적으로 Garbage Collection을 사용하여 메모리를 관리한다.

Reference Counting

Python의 가장 기본적인 메모리 관리 전략으로, 모든 Python 객체는 참조 횟수를 Tracking한다. 객체 참조 수가 0이 되면, Python은 해당 객체를 메모리에서 해제한다.

import sys

a = []
print(sys.getrefcount(a))  # 출력: 2

위의 코드에서, a라는 빈 리스트를 생성하고, 이 리스트를 참조하는 횟수를 계산한다. sys.getrefcount() 함수는 객체에 대한 참조 수를 반환하는데, 이 때 a를 인수로 전달하면 2가 출력된다. 왜 1이 아니고 2가 나오냐고 생각할 수도 있지만 sys.getrefcount 함수 파라미터 인자로 넘겨주는 부분까지도 참조 count에 포함되기 때문에 2가 출력이 되는 것이다.

Reference Counting의 한계 - Reference Cycle

Reference counting는 매우 효과적인 메커니즘이지만, reference cycle(순환 참조)가 발생되는 경우에는 효과적이지 않다. reference cycle이란 두 개 이상의 객체가 서로를 참조하는 것을 의미한다. 이러한 reference cycle가 발생하면, 객체들은 서로를 참조하게 되므로 reference count가 절대 0이 될 수 없기 때문이다.

import sys

# 순환 참조 생성
list1 = []
list2 = [list1]
list1.append(list2)

위의 예제는 reference cycle의 예제이다. 서로 cycle하게 참조하는 list1과 list2는 reference count가 0에 수렴할 수 없다. del을 이용해 제거를 해도, 객체 변수에 접근을 할수 없도록 제거되지만 메모리에서까지 제거되진 않는다. 정상적인 객체라면 del을 해서 접근 참조를 제거한 후 메모리에 남게되어도 reference count가 0이 되기때문에 메모리에서 삭제된다. 이런 경우를 위해 Python은 Garbage Collector를 이용해 reference cycle를 감지하고 제거한다.

Generational Garbage Collector

Python의 Garbage Collector는 Generational Garbage Collector이라는 방법을 이용해 메모리를 관리한다. Generational Garbage Collector는 Python의 모든 객체를 tracking하여 객체의 수가 일정 임계값을 넘으면 자동으로 Garbage Collector가 작동하여 메모리를 정리하는 구조이다. 이때 세대(generation)의 개념이 등장하게 되는데 세대는 각 객체의 생성된 시기 정도라고만 생각하면 된다. Garbage Collector는 세대를 0, 1, 2로 구분하여 관리하는데, 최근에 생성된 객체는 0세대(young)에 들어가고 오래된 객체일수록 2세대(old)에 존재하며 한 객체는 하나의 세대에만 속한다. Garbage Collector는 0세대일수록 더 자주 Garbage Collection을 하도록 설계되었는데 이는 generational hypothesis에 근거한다. JVM에서의 weak generational hypothesis와도 매우 유사하다.

해당 내용은 대부분의 객체는 빠르게 사용되었다가 사라지며(young), 오래된 객체(old)는 전역 변수처럼 존재하여 메모리에서 해제 해야할 필요성이 적다는 것이다. 해당 내용을 토대로 Garbage Collector는 세대별로 객체를 관리하여 young 객체에 대해서는 자주 Garbage Collection하여 메모리를 해제하고, old 객체에 대해서는 비교적 적게 Garbage Collection하여 메모리를 해제한다.

import gc

print(gc.get_threshold())
# 출력 예: (700, 10, 10)

해당 코드에서는 0, 1, 2 세대의 대한 threshold 값을 나타내는데, n세대에 객체를 할당한 횟수가 threshold 값을 초과하면 Garbage Collection이 수행된다. 0세대의 경우 메모리에 객체가 할당된 횟수에서 해제된 횟수를 뺀 값, 즉 객체 수가 threshold 0을 초과하면 실행된다. 다만 그 이후 세대부터는 조금 다른데 0세대 Garbage Collection이 일어난 후 0세대 객체를 1세대로 이동시킨 후 카운터를 1 증가시킨다. 이 1세대 count가 threshold 1을 초과하면 그때 1세대 가비지 컬렉션이 일어난다. 0세대 Garbage Collection이 객체 생성 700번만에 일어난다면 1세대는 7000번만에, 2세대는 7만번만에 일어난다는 뜻이다. 조금 더 쉽게 말하자면 한 번도 Garbage Collection가 scan 하지 않은 객체는 세대 0, Garbage Collection가 scan을 진행하였지만 생존한 객체는 세대 1, 두 번 이상 Garbage Collection가 scan하였지만 생존한 객체는 세대 2에 속하게 된다.

Garbage Collector에 대한 고찰

위에서 설명했듯이 Garbage Collector는 메모리를 자동으로 관리해주는 매우 좋은 방법이다. 하지만 "자동"이기 때문에 의도한바와 달리 시스템의 성능을 떨어뜨릴 수 있다. 필자는 머신러닝을 서빙하는 ML 백엔드를 구현하면서 겪었던 고충에 대해서 얘기해보고자한다.

1. gc.collect()를 굳이 사용할 필요가 없다.

필자는 ML 백엔드를 최적화 하면서 메모리 누수 문제를 겪었는데, 무작정 누수를 해결해보고자 gc.collect() 함수를 남발하여 사용했었다. 하지만 명령어를 사용하지 않고도 Python은 Generational Garbage Collector로 인해 자동으로 gc.collect()를 수행한다. 즉 메모리 누수를 잡기 위해 누수가 의심되는 코드 주위에 gc.collect() 함수를 직접 삽입하지 않아도 일정 객체 수가 할당될 시 자동으로 실행되기때문에 무분별한 gc.collect() 사용은 시스템 성능을 악화시킨다.

import time
import gc


def create_objects():
    """ 많은 수의 객체를 생성하고 가비지 컬렉션 실행 """
    for _ in range(10000):
        data = ["dummy"]
        data.append(data)  # 순환 참조 생성
        gc.collect()  # 가비지 컬렉션 실행


def create_objects_without_gc():
    """ 많은 수의 객체를 생성하지만 가비지 컬렉션을 실행하지 않음 """
    for _ in range(10000):
        data = ["dummy"]
        data.append(data)  # 순환 참조 생성


start = time.time()
create_objects()
end = time.time()
print(f"With gc.collect(): {end - start} seconds")
# 출력 결과 : 1.912431240081787 seconds

start = time.time()
create_objects_without_gc()
end = time.time()
print(f"Without gc.collect(): {end - start} seconds")
# 출력 결과 : 0.001001596450805664 seconds

위의 예제에서는 많은 수의 순환 참조 객체를 생성하면서 gc.collect()를 수행하는 함수 create_objects()와 gc.collect()를 수행하지 않는 함수 create_objects_without_gc()에 대한 예제이다. 결과부터 보자면, gc.collect()를 수행하는 함수가 수행하지 않는 함수보다 현저하게 느린걸 볼 수 있다. 약 2초와 0.001초의 차이는 어마어마한 차이이다. 그렇다면 gc.collect()를 수행하지 않는 함수는 메모리 누수가 있을까? 정답은 없다. 일정 수의 객체가 생성될때 마다 Python에서 자동으로 gc.collect()를 수행하기때문에 메모리 걱정을 하지 않아도 된다. 즉 일정 수의 객체가 할당되면 자동으로 gc.collect()를 수행하기 때문에 매번 gc.collect()를 수행하게 된다면 오히려 시스템 성능을 악화시킬 수 있다.

2. Garbage Collector의 Threshold 값은 생각보다 작다.

필자가 최적화를 진행했던 ML 백엔드는 수초안에 수천, 수만개의 객체가 할당되어진다. 이는 꼭 ML 시스템뿐만 아니라 대부분의 큰 규모의 시스템에서도 해당되는 사항일것이다. Python의 Garbage Collector는 세대라는 개념을 사용한다고 하였는데 0, 1, 2 세대로 나뉘어서 각각의 threshold를 넘게되면 Garbage Collector가 실행된다. Python의 Garbage Collector는 Default로 (700, 10, 10)의 threshold parameter가 설정되어있는데 수초안에 수만개의 객체가 생성될 수 도 있는 환경이라면 해당 threshold는 적절하게 조절해야 한다.

import time
import gc


class DummyClass:
    def __init__(self):
        self.data = [[] for _ in range(10)]


def create_objects() -> None:
    dummyClass = [DummyClass() for _ in range(1000)]


# 기본 임계치로 실행
times = []
for _ in range(500):
    start = time.time()
    create_objects()
    times.append(time.time() - start)

avg_time = sum(times) / len(times) * 1000
max_time = max(times) * 1000

print(f"With default threshold - avg time: {avg_time:.2f}ms, max time: {max_time:.2f}ms")
# 출력 결과 : avg time: 0.98ms, max time: 2.00ms

# 가비지 컬렉션 임계치를 아주 높게 설정
gc.set_threshold(10000, 1000, 1000)

# 높은 임계치로 실행
times = []
for _ in range(500):
    start = time.time()
    create_objects()
    times.append(time.time() - start)

avg_time = sum(times) / len(times) * 1000
max_time = max(times) * 1000

print(f"With high threshold - avg time: {avg_time:.2f}ms, max time: {max_time:.2f}ms")
# 출력 결과 : avg time: 0.81ms, max time: 1.00ms

해당 예제는 기본 deafult 셋팅(700, 10, 10)과 더 높은 threshold 값(10000, 1000, 1000)을 설정 한 뒤, 객체를 무수히 많드는 예제에 대해 시간 비교를 한다. 결과부터 보자면 더 높은 threshold 값을 설정했을때 avg time과 max time 둘 다 낮게 나오는 경향을 볼 수 있다. avg time은 약 80% 수준으로 낮아졌고 max time은 절반 수준으로 낮아졌다. 이는 수많은 객체를 생성할 때 높은 threshold 일수록 Garbage Collector가 발생되는 빈도 수가 적기 때문이다. 하지만 해당 방법을 사용할 수록 메모리가 정리되는 빈도 수가 적기 때문에 시스템이 실행되고 있는 도중에 메모리 사용률이 높아질 수 있다는 단점이 있다. 상황에 맞게 사용하게된다면 시스템의 성능이 더욱 개선될 것이다. Instagram은 gc를 아예 disable하여 시스템의 성능을 비약적으로 개선시켰었다. 해당 사례처럼 Garbage Collecto를 사용하지 않는 방법이나 빈도 수를 낮추는 방법은 시스템 성능 개선에 유용할 수 있다.

마무리

필자는 ML 백엔드를 최적화 하면서 겪었던 메모리 문제에 대해서 여러가지 블로그를 참고하면서 해당 포스트를 작성하였다. 나와 같은 문제를 겪었던 분들이 굉장히 많았었고 여러가지 흥미로운 접근법을 통해 시스템 성능을 개선 할 수 있었다. 하지만 모든 방법에는 정답이 없듯이 나의 고찰이 정답이 아닐 수 있다. 하지만 본인이 구현하는 시스템의 리소스 상황을 고려하면서 해당 방법들을 사용해보면 또 다른 배움을 얻을 수 있을것이다.

Reference

https://medium.com/dmsfordsm/garbage-collection-in-python-777916fd3189
https://hyperconnect.github.io/2023/05/30/Python-Performance-Tips.html

profile
Machine Learning Engineer

1개의 댓글

comment-user-thumbnail
2023년 8월 3일

큰 도움이 되었습니다, 감사합니다.

답글 달기