본문 바로가기
Study 공부할레나

[스터디/밑시딥3] 제 2고지 자연스러운 코드로_17. 메모리 관리와 순환 참조

by LENA-cfg 2023. 8. 26.

지금까지의 구현은 처리 속도와 메모리 사용량에 신경 쓰지 않았기 때문에 성능을 개선할 수 있는 기술을 사용합니다.

 

파이썬이라고 하면 보통은 프로그램 언어를 가리키지만 파이썬 코드를 실행하는 '프로그램'을 지칭할 때도 사용합니다.

이 프로그램을 일반적으로 '파이썬 인터프리터'라고 부르며 표준으로 사용되는 인터프리터는 C언어로 구현된 CPython입니다.

 

그래서, 이번 단계에서 사용하는 메모리 관리 설명은 CPython을 기준으로 합니다.

 

17.1 메모리 관리

파이썬은 필요 없어진 객체를 메모리에서 자동으로 삭제합니다.

불필요한 객체는 파이썬 인터프리터가 제거해주기 때문에 더 중요한 작업에 집중할 수 있었습니다.  

하지만, 코드를 제대로 작성하지 않으면 메모리 누수 및 메모리 부족등의 문제가 발생합니다. 

 

특히, 신경망에서는 큰 데이터를 다루는 경우가 많아서 메모리 관리를 하지않으면 실행 시간이 오래걸리거나 GPU의 경우 메모리 에러가 떠 실행할 수 조차 없게 됩니다.

 

실제로 학습 할때 배치 사이즈 조금만 크게 가져가도 바로 메모리 에러 떠서 적절한 배치 사이즈를 다시 찾아야 했던 적이 몇번 있던 것 같습니다.

 

파이썬의 메모리 관리 방식

 

1. 참조 수를 세는 방식 => 참조 카운트

- 파이썬에서 객체는 참조 카운팅을 통해 관리됩니다.

- 객체에 새로운 참조가 생성될 때마다 참조 카운트가 증가하고, 참조가 해제될 때마다 참조 카운트가 감소합니다.

- 참조 카운트가 0이 되면 해당 객체는 메모리에서 해제됩니다.

 

아래는 참조 카운트를 조회하는 방법입니다.

import sys
a = []
print(sys.getrefcount(a))  # 'a'의 참조 카운트를 조회

 

2. 세대를 기준으로 쓸모 없어진 객체를 회수 하는 방식=>GC(garbage collection)

- 참조 카운팅만으로 순환 참조 문제를 해결할 수 없습니다. 

- 두 객체가 서로를 참조할 경우 참조 카운트가 절대 0이 되지 않아 메모리 누수가 발생 할 수 있습니다.

- 가비지 컬렉터는 이러한 문제를 해결하기 위해 주기적으로 메모리를 스캔하여 도달할 수 없는 객체를 회수합니다.

 

17.2 참조 카운트 방식의 메모리 관리

참조 카운트는 구조가 간단하고 속도도 빠릅니다.

모든 객체는 참조 카운트가 0인 상태로 생성되고, 다른 객체가 참조할 때마다 1씩 증가합니다.

반대로 객체에대한 참조가 끊길 때마다 1만큼 감소하다가 0이 되면 파이썬 인터프리터가 회수해갑니다.

이런 방식ㅇ로 객체가 더 이상 필요 없어지면 즉시 메모리에서 삭제됩니다.

 

아래의 경우 참조 카운트가 증가합니다.

- 대입 연산자를 사용할 때

- 함수에 인수로 전달할 때

- 컨테이너 타입 객체(리스트, 튜플, 클래스 등)에 추가할 때

class obj:
    pass

def f(x):
    print(x)

a = obj() # 변수에 대입: 참조 카운트 1

f(a) # 함수에 전달: 함수 안에서는 참조 카운트 2

# 함수 완료: 빠져 나오면 참조 카운트 1
a = None # 대입 해제: 참조 카운트 0

- obj()에 의해 생성된 객체를 a에 대입합니다. 이때 참조 카운트는 1입니다.

- f(a)를 호출하는데, 이때 a가 인수로 전달되기 때문에 f의 범위 안에서는 참조 카운트가 1증가해 총 참조 카운트는 2가 됩니다.

- 함수의 범위를 벗어나면 참조 카운트가 다시 1 감소합니다. 

- 마지막으로 a = None으로 참조를 끊으면 결국 0이 됩니다. 이렇게 0이 되면 해당 객체는 메모리에서 삭제됩니다.

a = obj()
b = obj()
c = obj()

a.b = b
b.c = c

a = b = c = None

a, b, c라는 세 개의 객체를 생성했고 a가 b를 참조하고, b가 c를 참조합니다.

이제 객체 관계는 아래 이미지 처럼 됩니다.

a = b = c = None 줄을 실행 시키면 객체의 관계는 아래 이미지처럼 변합니다.

a의 참조 카운트는 0이고 a는 삭제됩니다. 그 여파로 b의 차조 카운트가 1에서 0으로 감소하여 b 역시 삭제되고 같은 원리로 c 역시 0으로 감소하게 됩니다. 

이렇게 사용되지 않는 객체들이 마치 도미노처럼 한꺼번에 삭제됩니다.

 

참조 카운트 방식은 메모리 메모리 관리를 많이 해결해주지만 순환 참조에서는 참조 카운트 방식이 해결할 수 없기도 합니다.

 

17.3 순환 참조

a = obj()
b = obj()
c = obj()

a.b = b
b.c = c
c.a = a

a = b = c = None

이번에는 c에서 a로 참조가 추가됐습니다. 그래서 a, b, c 세개가 원모양을 이루며 서로가 서로를 참조하게 되는데 이 상태가 바로 순환 참조 입니다.

 

a = b = c = None을 해도 순환 참조의 참조 카운트가 0이 되지 않고 메모리에서 삭제가 되지 않습니다.

 

GC는 참조 카운트와 달리 메모리가 부족해지는 시점에 파이썬 인터프리터에 의해 자동으로 호출됩니다. 

*명시저 호출도 가능(gc 모듈을 임포트 해서 gc.collect()를 실행)

 

일반적인 파이썬 프로그래밍에서는 순환 참조를 의식할 필요가 없으나 메모리 해제를 GC에 미루다보면 프로그램의 전체 메모리 사용량이 커지게 됩니다.

 

Function클래스는 2개의 Variable 인스턴스(입출력)를 참조합니다. 

그리고 출력 Variable(맨 오른쪽)은 창조자인 Function 인스턴스를 참조합니다. 

이때, Function 인스턴스와 Variable 인스턴스가 순환 참조 관계를 만듭니다. 

이 순환 참조는 표준 파이썬 모듈인 weakref로 해결할 수 있습니다.

 

17.4 weakref 모듈

weakref.ref를 사용하여 약한 참조를 만들 수 있습니다.

약한 참조는 다른 객체를 참조하되 참조 카운트는 증가시키지 않는 기능입니다.

 

예시 이미지

a는 일반적인 방법으로 참조하고 b는 약한 참조를 갖게 했습니다.

b를 출력해보면 weakref at ~으로 ndarray를 가리키는 약한 참조임을 확인 할 수 있습니다.

참조된 데이터에 접근하는 방법은 b()입니다.

a = None을 넣으면 ndarray 인스턴스는 메모리에서 삭제됩니다.

즉, b가 참조를 가지고 있지만 약한 참조여서 참조 카운트에 영향을 주지 못합니다.

 

Function 클래스에 추가

class Function:
    def __call__(self, *inputs):
        xs = [x.data for x in inputs]
        ys = self.forward(*xs)
        if not isinstance(ys, tuple):
            ys = (ys,)
        outputs = [Variable(as_array(y)) for y in ys]

        self.generation = max([x.generation for x in inputs])
        for output in outputs:
            output.set_creator(self)
        self.inputs = inputs
        self.outputs = [weakref.ref(output) for output in outputs] # 약한 참조 추가
        return outputs if len(outputs) > 1 else outputs[0]

self.outputs = [weakref.ref(output) for output in outputs]

각각의 output이 약한 참조를 가리키도록 weakref.ref(output)으로 변경해줍니다.

함수는 출력변수를 약하게 참조합니다.

class Variable:

        while funcs:
            f = funcs.pop()
            gys = [output().grad for output in f.outputs]
            gxs = f.backward(*gys)

 gys = [output().grad for output in f.outputs] 부분에서 output()으로 변경해줌으로써 약하게 참조된 output의 값에 접근할 수 있게 됩니다.

17.5 동작 확인

for i in range(10):
    x = Variable(np.random.randn(10000))  # big data
    y = square(square(square(x)))

x와 y를 사용자가 참조하고 있을 때의 관계도

for문이 두 번째 반복될 때 x, y가 덮어 써지고 사용자는 이전의 계산 그래프를 참조하지 않게 되고

참조 카운트가 0이 되므로 이 시점에 계산 그래프에 사용된 메모리가 바로 삭제됩니다.

 

* 메모리 사용량을 측정하려면 memory profiler를 사용하면 됩니다.

실제 memory profiler 사용한 결과 아래 그래프에서 사용량이 1.0초부터 대략 1.8초까지 사용량이 증가하지 않는 것을 알 수 있습니다.

memory profile 사용방법

# 해당 모듈 설치
pip install memory-profiler
pip install matplotlib

 

if main 부분 추가

import weakref
import numpy as np
from matplotlib import pylab

class Variable:
    def __init__(self, data):
        if data is not None:
            if not isinstance(data, np.ndarray):
                raise TypeError('{} is not supported'.format(type(data)))

        self.data = data
        self.grad = None
        self.creator = None
        self.generation = 0

    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1

    def cleargrad(self):
        self.grad = None

    def backward(self):
        if self.grad is None:
            self.grad = np.ones_like(self.data)

        funcs = []
        seen_set = set()

        def add_func(f):
            if f not in seen_set:
                funcs.append(f)
                seen_set.add(f)
                funcs.sort(key=lambda x: x.generation)

        add_func(self.creator)

        while funcs:
            f = funcs.pop()
            gys = [output().grad for output in f.outputs]  # output is weakref
            gxs = f.backward(*gys)
            if not isinstance(gxs, tuple):
                gxs = (gxs,)

            for x, gx in zip(f.inputs, gxs):
                if x.grad is None:
                    x.grad = gx
                else:
                    x.grad = x.grad + gx

                if x.creator is not None:
                    add_func(x.creator)


def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x


class Function:
    def __call__(self, *inputs):
        xs = [x.data for x in inputs]
        ys = self.forward(*xs)
        if not isinstance(ys, tuple):
            ys = (ys,)
        outputs = [Variable(as_array(y)) for y in ys]

        self.generation = max([x.generation for x in inputs])
        for output in outputs:
            output.set_creator(self)
        self.inputs = inputs
        self.outputs = [weakref.ref(output) for output in outputs]
        return outputs if len(outputs) > 1 else outputs[0]

    def forward(self, xs):
        raise NotImplementedError()

    def backward(self, gys):
        raise NotImplementedError()


class Square(Function):
    def forward(self, x):
        y = x ** 2
        return y

    def backward(self, gy):
        x = self.inputs[0].data
        gx = 2 * x * gy
        return gx


def square(x):
    return Square()(x)


if __name__ == '__main__':
    for i in range(10):
        x = Variable(np.random.randn(10000))  # big data
        y = square(square(square(x)))

cmd로 들어가서 폴더 경로 들어간 다음 아래 명령어 쳐주면됩니다.

mprof run 폴더명(예시:step17.py)

mprof plot