페이지

2022년 8월 20일 토요일

17.4 weakref 모듈

 파이썬에서는 weakref.ref 함수를 사용하여 약한 참조(weak reference)를 만들 수 있습니다. 약한 참조란 다른 객체를 참조하되 참조 카운트는 증가시키지 않는 기능입니다. 다음은 weakref.ref함수를 사용하는 예입니다.

import weakref
import numpy as np

a = np.array([123])
b = weakref.ref(a)

b

<weakref at 0x7f640483f410; to 'numpy.ndarray' at 0x7f640483f210>

b()
array([1, 2, 3])


a = None
b
<weakref at 0x7f640483f410; to 'numpy.ndarray' at 0x7f640483f210>

nbarray 인스턴스를 대상으로 실험을 해봤습니다. 먼저 a는 일반적인 방식으로 참조하고, 다음으로 b는 약한 참졸르 갖게 했습니다. 이 상태로 b를 출력해보면 ndarray를 가리키는 약함참조(weakref)임을 확인할 수 있습니다. 참고로, 참조된 데이터에 접근하려면 b()라고 쓰면 됩니다.

그럼 앞의 코드에 바로 이어서 a = None을 실행하면 어떻게 될까요? 결과는 다음과 같습니다.


이와 같이 ndarray 인스턴스는 참조 카운트 방식에 따라 메모리에서 삭제됩니다. b도 참조를 가지고 있지만 약한 참조이기 때문에 참조 카운트에 영향을 주지 못하는 것이죠. 그래서 b를 출력하면 dead라는 문자가 나오고, 이것은 ndarray 인스턴스가 삭제됐음을 알 수 있습니다.


지금까지의 약한 참조 실험 코드는 파이썬 인터프리터에서 실행한다고 가정했습니다. IPython과 주피터 노트북(Jupyter Notebook)등의 인터프리터는 인터프리터 자체가 사용자가 모르는 참조를 추가로 유지하기 때문에 앞의 코드에서 b가 여전히 유효한 참조를 유지할 것입니다(dead가 되지 않습니다)


이 weakref 구조를 DeZero에서 도입하려 합니다. 먼저 Function에 다음 음영부분을 추가합니다.


import weakref    #
class Function(object):
  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가 대상을 약한 참조로 가리키게 변경합니다. 그 결과 함수는 출력 변수를 약하게 참조합니다. 또한 이 변경의 여파로 다른 클래스에서 Function클래스의 outputs를 참조하는 코드로 수정해야 합니다. DeZero에서는 Variable클래스의 backward메서드를 다음처럼 수정하면 됩니다.


class Variable:

  def __init__(selfdata):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{}은(는) 지원하지 않습니다.' .format(type(data)))
    self.data = data
    self.grad = None
    self.creator = None
    self.generation = 0     

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


  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]  
      gys = [output().grad for output in f.outputs]  #
      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 cleargrad(self):
    self.grad = None


이와 같이 [output.grad for ,...] 부분을 [output().grad for ...]로 수정합니다. 이상으로 DeZero의 순환 참조 문제가 해결되었습니다.

17.3 순환 참조

 다음은 순환 참조(circular reference)를 설명하기 위해 준비한 코드입니다.


앞서 보여드린 코드와 거의 같지만, 이번에는 c에서 a로의 참조가 추가됐습니다. 그래서 세 개의 객체가 원 모양을 이루며 서로가 서로를 참조하게 되는데, 이 상태가 바로 순환 참조입니다. 현재의 a, b, c관계는 [그림 17-2]와 같습니다.


[그림 17-2]의 오른쪽에서 a, b, c의 참조 카운트는 모두 1입니다. 하지만 사용자는 이들 세 객체중 어느 것에도 접근할 수 없습니다(즉, 모두 불필요한 객체입니다). 그러나 a = b = c = None을 실행하는 것으로 순환 참조의 참조 카운트가 0이 되지않고, 결과적으로 메모리에서 삭제되지 않습니다. 그래서 또 다른 메모리 관리 방식이 등장합니다. 이 주인공이 GC입니다(정확하게 '세대별 가비지 컬렉션(generational garbage collection).

GC는 참조 카운트보다 영리한 방법으로 불필요한 객체를 찾아냅니다(GC의 구조는 복잡하기 때문에 이 책에서는 설명을 생략합니다). GC는 참조 카운트와 달리 메모리가 부족해지는 시점에 파이썬 인터프리터에 의해 자동으로 호출됩니다. 물론 명시적으로 호출할 수도 있습니다(gc 모듈을 임포트해서 gc.collect()를 실행).

GC는 순환 참조를 올바르게 처리합니다. 따라서 일반적인 파이썬 프로그래밍에서는 순환 참조를 의식할 필요가 특별히 없습니다. 하지만 메모리 해제를 GC에 미루다 보면 프로그램의 전체 메모리의 사용량이(순환 참조가 없을때와 비교해) 커지는 원인이 됩니다(자세한 내용은 문헌[10]참고). 그런데 마침 머신러닝, 특히 신경망에서 메모리는 중요한 자원입니다. 따라서 DeZero를 개발할 때는 순환 참조를 만들지 않는 것이 좋겠지요.

이 정도면 파이썬 메모리 관리에 관한 지식은 충분한 것 같습니다. 그럼 DeZero로 눈을 돌려볼까요? 사실 현재의 DeZero에는 순환 참조가 존재합니다. 바로 [그림 17-3]과 같이 '변수'와 '함수'를 연결하는 방식에 순환 참조가 숨어 있습니다.


[그림 17-3]에서 보듯Function인스턴스는 두 개의 Variable 인스턴스(입력과 출력)를 참조합니다. 그리고 출력 Variable인스턴스는 창조자인 Function인스턴스를 참조합니다. 이때 Function인스턴스와 Variable 인스턴스가 순환 참조 관계를 만듭니다. 다행이 이 순환 참조는 표준 파이썬 모듈인 weakref로 해결할 수 있습니다.

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

 파이썬 메모리 관리의 기본은 참조 참조카운트입니다. 참조 카운트는 구조가 간단하고 속도도 빠릅니다. 모든 객체는 참조 카운트가 0인 상태로 생성되고, 다른 객체가 참조할 때마다 1씩 증가합니다. 반대로 객체에 대한 참조가 끊길 때마다 1만금 감소하다가 0이 되면 파이썬 인터프리터가 회수해갑니다. 이런 방식으로 객체가 더 이상 필요 없어지면 즉시 메모리에서 삭제됩니다. 이상이 참조 카운트 방식의 매모리 관리입니다.

참고로 가령 다음과 같은 경우에 참조 카운트가 증가합니다.

1) 대입 연산자를 사용할 때

2) 함수에 인수로 전달할때

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

코드로도 예를 준비했습니다(개념을 설명하기 위한 의사코드라서 동작하지 않습니다).

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를 참조합니다. 자. 이제 객채의 관곈느 [그림 17-1]의 왼쪽처럼 되었습니다.


그런 다음 a = b = c = None줄을 실행하면 객체의 관계는 [그림 17-1]의 오른쪽처럼 변함니다. 이때 a의 참조 카운트는 0이 됩니다(b와 c의 참조 카운트는 1입니다). 따라서 a는 즉시 삭제됩니다. 그 여파로 b의 참조 카운트가 1에서 0으로 감소하여 b역시 삭제됩니다. 똑같은 원리로 c의 참조 카운트로 0이 되어 삭제됩니다. 이렇게 사용자로부터 참조되지 않는 책체들이 마치 도미노처럼 한꺼번에 삭제되는 것입니다.

이상이 파이썬의 참조 카운트 방식 메모리 관리입니다. 이 기능이 수많은 메모리 관리 문제를 해결해 줍니다. 하지만 참조 카운트로는 해결할 수 없는 문제가 있으니, 바로 순환 참조입니다.

17.1 메모리 관리

 파이썬은 필요 없어진 객체를 메모리에서 자동으로 삭제합니다. 이 고마운 기능 덕에 우리는 메모리 관리를 의힉할 일이 크게 줄어듭니다. 불필요한 객체는 파이썬 인터프리터가(우리 모르게) 제거해주기 때문에 우리는 더 중요한 작업에 집중할 수 있는 것이죠. 그렇더라도 코드를 제대로 작성하지 않으면 때때로 메모리 누수(memory leak)또는 메모리 부족(out of memory)등의 문제가 발생합니다. 특히 신경망에서는 큰 데이터를 다루는 경우가 많아서 메모리 관리를 제대로 하지 않으면 실행 시간이 오래 걸리는(CPU의 경우 실행할 수조차 없는)일이 자주 발생합니다. 그렇다면 파이썬은 메모리를 어떤 식으로 관리하고 있을까요? 파이썬(정확하게 CPython)의 메모리 관리는 두 가지 방식으로 진행됩니다. 하나는 참조(reference)수를 세는 방식이고, 다른 하나는 세대(generation)를 기준으로 쓸모없어진 객체(garbage)를 회수(collection)하는 방식입니다. 이 책에서 전자를 '참조 카운트'로, 후자를 'GC(Gabage Collection)라고 부르겠습니다. 우선 참조 카운트에 대해 설명합니다.

문헌에 따라 참조 카운트 방식의 메모리 관리도 GC로 보기도 합니다. 이 책에서는 둘을 구분해서 부르겠습니다.


2022년 8월 19일 금요일

STEP 17 메모리 관리와 순환 참조

 DeZero는 교육적인 면을 중시하여 가능한 이해하기 쉽도록 만들었습니다. 그래서 성능은 다소 희생한 감이 있습니다. 실제로도 지금까지의 구현에서는 처리 속도와 메모리 사용량에 전혀 신경 쓰지 않았습니다. 하지만 너무 한쪽으로 치우치는 것도 교육적으로 좋지는 않으므로 이번 단계와 다음 단계에 걸쳐 성능을 개선할 수 있는 대책(기술)을 DeZero에 도입할 꼐획입니다. 그럼 본격적인 시작에 앞서 파이썬에서의 메모리 관리에 대해 살짝 알아보겠습니다.


'파이썬'이라고 하면 보통은 '프로그래밍 언어'를 가리키지만 때로는 파이썬 코드를 실행하는 '프로그램'을 지칭할 때도 씁니다. 이 프로그램을 일반적으로 '파이썬 인터프리터'라고 부릅니다. 또한 표준으로 사용되는 파이썬 이터프리터는 C 언어로 구현된 CPython입니다. 그래서 이번 단계에서 설명하는 파이썬 메모리 관리 설명은 CPython을 기준으로 합니다.

16.4 동작 확인

 이상으로 세대가 큰 함수부터 꺼낼 수 있게 되었습니다. 아무리 복잡한 계산 그래프의 역전파도 올바른 순서로 진행할 수 있게 된 것이죠. 그럼 시험 삼아 [그림 16-4]의 계산을 미분해 봅시다.


코드로는 다음과 같습니다.


결과를 보면 x의 미분은 64.0입니다. 수식으로 확인하면 [그림 16-4]의 계산 그래프는 y = (x **2)**2 + (x**2)**2이므로 간단히 y=2x**4을 미분하는 문제입니다. 이때 y**t = 8x**3 이므로 x = 2.0일때 미분은 64.0입니다. 물론 코드를 실행한 결과와 일치합니다.

축하합니다! 여러분은 드리어 복잡한 계산 그래프도 다룰 수 있게 되었습니다. [그림 16-4]는 여전히 간단한 편이지만, 사실 지금의 DeZero는 아무리 복잡한 '연결'도 제대로 미분할 수 있습니다. 가렬 다음 페이지의 [그림 16-5]와 같은 꼐산 그래프도 문제없습니다!

이상으로 또하나의 단계를 끝마쳤습니다. 이번 단계는 이 책에서 특별히 어려운 부분에 속합니다. 여기까지 잘 쫓아왔다면 곧 DeZero의 제대로 된 실력을 확인할 수 있으니 조금만 더 견뎌 주세요. 다음 단계에서는 DeZero 성능, 특히 메모리 사용량에 대해 살펴보겠습니다.

16.3 Variable 클래스의 backward

 본론으로 돌아와서 Variable 클래스의 backward 메서드를 구현하겠습니다. 이전과 달라진 부분(음영)에 주목해서 살펴보죠.

class Variable:

  def __init__(selfdata):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{}은(는) 지원하지 않습니다.' .format(type(data)))
    self.data = data
    self.grad = None
    self.creator = None
    self.generation = 0     # 세대 수를 기록하는 변수

  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     #  세대를 기록한다(부모 세대 + 1).


  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]  
      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) #수정 전 funcs.append(x.creator)   

  def cleargrad(self):
    self.grad = None

가장 큰 변화는 새로 추가된 add_func 함수입니다. 그 동안 'DeZero 함수'를 리스트에 추가할 때 funcs.append(f)를 호출했는데, 대신 add_func 함수를 호출하도록 변경했습니다. 이 add_func함수가 DeZero함수 리스트를 세대 순으로 정렬하는 역할을 합니다. 그 결과 funcs.pop()은 자동으로 세대가 가장 큰 DeZero함수를 꺼내게 됩니다.

참고로 add_func 함수를 backward메서드 안에 중첩 함수로 정의했습니다. 중첩 함수는 주로 다음 두 조건을 충족할 때 적합합니다.

1) 감싸는 메서드(backward 메서드)안에서만 이용한다.

2) 감싸는 메서드(backward 메서드)에 정의된 변수(funcs과 seen_set)를 사용해야 한다.


add_func 함수는 이 조건들을 모두 충족하기 때문에 메서드 안에 정의했습니다.


앞의 구현에서는 seen_set이라는 '집합(set)'을 이용하고 있습니다. funcs리스트에 같은 함수를 중복 추가하는 일을 막기위해서 입니다. 덕분에 함수의 backward메서드가 잘못되어 여러번 불리는 일은 발생하지 않습니다.