텐서의 연산 그래프와 메모리 관리
연산 그래프 (Computation Graph)
파이토치에서 텐서는 모든 연산의 기초가 되며, 연산의 기록과 추적을 가능하게 하는 중요한 개념이 연산 그래프다. 연산 그래프는 텐서 간의 수학적 연산을 노드(node)와 엣지(edge)로 표현한 구조로, **자동 미분(autograd)**을 가능하게 해준다.
연산 그래프에서 노드는 텐서를 의미하며, 엣지는 이 텐서들 사이의 연산을 의미한다. 예를 들어, 두 텐서 (\mathbf{x})와 (\mathbf{y})의 합 (\mathbf{z} = \mathbf{x} + \mathbf{y})는 연산 그래프에서 다음과 같이 나타낼 수 있다:
[ \mathbf{x} \xrightarrow{+} \mathbf{z} \xleftarrow{+} \mathbf{y} ]
이때, 파이토치는 각 텐서에 대해 .grad_fn 속성을 통해 해당 텐서가 어떤 연산으로부터 유도되었는지 정보를 저장한다. 이를 통해 backward() 함수가 호출되면 그래프를 거슬러 올라가면서 각 텐서에 대한 미분값(gradient)을 계산하게 된다.
연산 그래프는 파이토치의 핵심으로, 역전파(backpropagation) 알고리즘의 기반이 된다. 역전파는 손실 함수(loss function)의 미분을 통해 각 텐서의 **기울기(gradient)**를 계산하여, 모델 파라미터를 업데이트할 수 있게 한다. 파이토치에서는 이러한 미분의 자동화가 매우 효율적이어서, 별도의 수식을 유도할 필요 없이 손실 함수의 기울기를 쉽게 얻을 수 있다.
미분 그래프의 동적 생성
파이토치의 연산 그래프는 **동적 그래프(dynamic graph)**라고 불리는데, 이는 연산이 수행될 때마다 즉시 그래프가 생성되고 수정되는 방식이다. 기존의 정적 그래프(static graph)와 달리, 동적 그래프는 매 연산 시점에 새로운 연산을 추가할 수 있어 매우 유연하다. 예를 들어, 다음과 같은 코드가 있다고 하자:
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x * 2
z = y.sum()
z.backward()위 코드에서 (\mathbf{y})와 (\mathbf{z})는 각각 (\mathbf{x})에 대한 연산을 통해 생성된 텐서들로, (\mathbf{x})의 .grad_fn 속성을 통해 어떤 연산으로부터 파생되었는지 알 수 있다. z.backward()를 호출하면, 파이토치는 z에서 시작하여 (\mathbf{x})로 역방향으로 이동하며 각 연산의 기울기를 계산하게 된다.
메모리 관리
연산 그래프의 사용은 강력하지만, 모든 연산의 기울기를 저장해야 하는 특성 때문에 메모리 사용량이 증가할 수 있다. 특히, 대규모 모델의 경우 학습 중 메모리 관리가 매우 중요한데, 파이토치에서는 이를 제어할 수 있는 몇 가지 방법을 제공한다.
torch.no_grad()
torch.no_grad()연산 그래프를 생성하지 않고 텐서 연산을 수행하려면 torch.no_grad() 블록을 사용할 수 있다. 이 블록 내에서 수행된 연산은 그래프에 기록되지 않으며, 메모리를 절약할 수 있다. 주로 모델의 평가(evaluation) 시에 사용된다:
위 코드는 그래프 생성을 생략하여 메모리를 절약하면서 모델의 출력을 계산한다.
detach()
detach()텐서의 그래디언트 흐름을 차단하려면 .detach() 메서드를 사용할 수 있다. 이 메서드는 특정 텐서에서 연산 그래프를 분리하여, 더 이상 이 텐서를 통해 역전파가 일어나지 않게 한다:
위 코드에서 y_detached는 그래프에서 분리된 텐서로, y.backward()를 호출하더라도 y_detached를 통해 미분 계산이 되지 않는다.
retain_graph 옵션
retain_graph 옵션일반적으로 .backward() 메서드를 호출하면 연산 그래프는 사용 후 자동으로 삭제된다. 이는 메모리를 절약하기 위해서인데, 일부 경우에는 동일한 그래프에서 여러 번 역전파를 해야 하는 상황이 있을 수 있다. 예를 들어, 특정 변수에 대한 기울기를 여러 번 계산해야 하는 경우가 있다.
이때 retain_graph=True 옵션을 사용하면 그래프가 유지되어 이후에도 다시 역전파를 수행할 수 있다:
위 코드는 z에 대한 그래프를 삭제하지 않고 유지시켜, 이후에도 .backward()를 다시 호출할 수 있도록 한다. 단, 이 옵션을 사용할 경우 메모리 사용량이 증가할 수 있으므로 필요한 경우에만 사용해야 한다.
미니배치와 메모리 관리
딥러닝 모델을 학습할 때, 메모리 효율성을 높이기 위해 미니배치(mini-batch) 학습을 주로 사용한다. 미니배치는 데이터셋 전체를 한 번에 학습하지 않고, 데이터의 일부씩 나누어 처리하는 방식이다. 이렇게 하면 메모리 사용량을 줄일 수 있으며, 모델의 기울기 계산과 매개변수 업데이트가 더욱 효율적이 된다.
예를 들어, 모델이 입력 데이터 (\mathbf{X})를 한 번에 모두 처리하는 대신, (\mathbf{X}_1, \mathbf{X}_2, \ldots, \mathbf{X}_N)의 미니배치로 나누어 학습을 진행하게 된다. 각 미니배치에 대해 그래디언트를 계산한 뒤, 그래디언트의 평균 또는 합을 사용하여 모델의 가중치를 업데이트한다.
그래프의 순환 구조와 메모리 누수
파이토치의 연산 그래프는 동적 그래프이므로, 코드 실행 중에 생성된다. 이때 순환 구조(circular reference)가 발생할 수 있는데, 이는 파이썬의 기본 가비지 컬렉터(GC)가 모든 메모리를 올바르게 해제하지 못하게 할 수 있다. 이러한 문제가 생기면 **메모리 누수(memory leak)**가 발생할 수 있다.
예를 들어, 다음과 같은 코드를 생각해보자:
위의 코드는 1000번의 루프가 반복되면서 매번 새로운 텐서가 생성되지만, 그래프가 자동으로 해제되지 않는다면 메모리 누수가 발생할 수 있다. 이런 경우, del 명령어를 사용하여 메모리를 수동으로 해제하거나 가비지 컬렉션을 강제로 호출할 수 있다.
이러한 방법으로 순환 참조로 인해 발생할 수 있는 메모리 문제를 예방할 수 있다.
torch.cuda.empty_cache()
torch.cuda.empty_cache()GPU 메모리 관리가 필요할 때는 **torch.cuda.empty_cache()**를 사용하여 사용하지 않는 메모리를 해제할 수 있다. 파이토치의 기본 동작은 메모리를 최대한 확보하고 유지하는 방식으로, 모델 학습 중 필요하지 않게 된 메모리도 잠시 동안 계속 유지한다. 그러나, torch.cuda.empty_cache()를 호출하면 즉시 GPU 메모리에서 비어 있는 캐시 메모리를 반환하여, 더 많은 메모리 자원이 필요할 때 도움이 된다:
이 함수는 실제로 메모리 할당을 해제하지는 않지만, 파이토치가 관리하는 캐시 메모리를 비워 메모리 사용량을 낮추어준다. 이를 통해 메모리 사용 패턴을 최적화하고, 더 큰 모델이나 더 많은 데이터를 처리할 수 있게 한다.
메모리 핀 연산 (Pinned Memory)
파이토치는 CPU와 GPU 간의 데이터 전송을 최적화하기 위해 **"메모리 핀 연산(pinned memory)"**을 지원한다. 기본적으로 CPU에서 GPU로 데이터를 전송할 때, 파이토치는 데이터를 일반적인 CPU 메모리에서 GPU 메모리로 복사한다. 그러나 이 과정은 다소 시간이 걸릴 수 있으며, 특히 대규모 데이터셋을 다룰 때 성능에 영향을 줄 수 있다.
이를 해결하기 위해 **고정 메모리(pinned memory)**를 활용할 수 있다. 고정 메모리는 CPU 메모리의 특정 영역을 고정시켜 빠르게 GPU로 데이터를 전송할 수 있게 해준다. 파이토치에서는 DataLoader에 pin_memory=True 옵션을 설정함으로써 이 기능을 사용할 수 있다:
이렇게 하면 CPU 메모리에서 GPU로 데이터를 전송할 때, 데이터가 고정 메모리 영역에 저장되어 전송 속도가 빨라진다. 특히 대규모 데이터를 사용하는 모델의 경우 학습 속도를 상당히 개선할 수 있다.
torch.set_grad_enabled()
torch.set_grad_enabled()학습 모드와 평가 모드를 명확히 구분할 때, **torch.set_grad_enabled()**를 사용하여 효율적인 메모리 관리를 할 수 있다. 이 함수는 컨텍스트 매니저의 역할을 하며, 연산 그래프의 생성을 제어할 수 있다. 보통 학습 모드에서는 자동 미분을 활성화해야 하지만, 평가 모드에서는 불필요하므로 이를 비활성화하여 메모리 사용을 줄일 수 있다:
이처럼 torch.no_grad()와 비슷한 기능을 하지만, torch.set_grad_enabled()는 컨텍스트 내에서 여러 번 호출되거나 조건에 따라 활성화 여부를 동적으로 설정할 때 유용하다.
기울기 축적 방지 (zero_grad)
zero_grad)파이토치에서 모델의 기울기(gradient)는 역전파가 수행될 때마다 기존의 값에 더해지므로, 새로운 역전파를 수행하기 전에 항상 기울기를 0으로 초기화해주어야 한다. 이는 매 학습 루프에서 아래와 같은 방식으로 이루어진다:
기울기를 초기화하지 않으면, 이전의 역전파 값이 계속 축적되기 때문에 잘못된 기울기로 학습이 진행될 수 있다. 특히, 메모리 관리 측면에서도 기울기 값을 명확히 초기화하는 것은 중요하다. 불필요한 메모리 사용을 방지하고, 기울기 계산이 반복되어도 일정한 메모리 사용량을 유지할 수 있기 때문이다.
retain_graph=True 사용 시 주의점
retain_graph=True 사용 시 주의점앞서 언급한 retain_graph=True 옵션을 사용하여 그래프를 유지할 때, 메모리 누수 문제가 발생할 수 있다. 이 옵션을 자주 사용하게 되면, 그래프가 계속해서 메모리에 남아 있게 되어 필요 이상으로 메모리를 점유할 수 있다. 따라서, 필요할 때만 선택적으로 사용해야 하며, 불필요한 경우에는 반드시 피해야 한다.
예를 들어, 두 번의 역전파를 수행해야 할 때, 첫 번째 backward 호출에서 retain_graph=True 옵션을 사용했다면 두 번째 backward 호출 후에는 메모리를 해제할 수 있도록 해야 한다:
이처럼 효율적인 메모리 관리는 파이토치에서 큰 모델을 학습할 때 매우 중요하다. 메모리 사용량을 최적화하여, 한정된 자원 내에서도 최상의 성능을 발휘할 수 있도록 주의해야 한다.
텐서 메모리 관리 예시
다음은 여러 메모리 관리 기법을 종합적으로 적용한 예제 코드다:
위의 코드는 기울기 초기화, 텐서 전송, 혼합 정밀도(Mixed Precision) 학습, 메모리 캐시 해제를 모두 포함하여, 메모리 사용을 최소화하면서 효율적인 학습을 가능하게 한다.
Last updated