Backend 에서 Tensorflow, Keras 로 머신러닝, 딥러닝 작업 시 GPU 메모리 반환하지 않는 문제 해결 방법
+ 저는 Pytorch 도 테스트 해보니 GPU 메모리 반환이 잘 이뤄졌습니다.
How to clear GPU memory when using tensorflow or pytorch?
인트로
해당 문제는 프로젝트의 특이성으로 인해 “지정된 GPU를 사용해야 하는” 그리고 “백엔드에서 추론(inference)을 위해 GPU를 사용하는 것”과 “다른 특정한 프로그램에서도 같은 GPU를 사용”함으로써 “GPU를 공유해서 사용”해야 하는 경우 하나의 프로세스가 GPU 메모리를 반환하지 않고 잡고 있어 문제가 된 상황입니다.
GPU를 사용하면 nvidia-smi 상에서 GPU 메모리가 꽉 차있는 듯이 표현되어 있고
이 상태에서 다른 프로그램(프로세스)에서 해당 GPU를 사용하면 CUDA MEMORY 에러가 발생하게 됩니다.
그렇기 때문에 하나의 프로세스가 끝날 때마다 메모리의 반환이 반드시 필요한 상황입니다.
해당 문제가 발생한 이유
Tensorflow의 GPU 활용 방법의 정책으로 인한 문제로 확인
https://www.tensorflow.org/guide/gpu
해석해보자면 ”메모리 단편화를 줄여 GPU를 효율적으로 사용하기 위해 [모든 힘을 쏟아 붓는다]” 입니다.
- 여기서 잠깐! 넣어보자 상식!
여기서 의문을 가져봐야 합니다.
그렇다면 지금까지 GPU를 통해 학습했던 것들에 대해 nvidia-smi를 통해 확인했을 때는
어떻게 GPU메모리 반환이 모두 되어 있었을까?
현재 발생한 문제와 위 의문을 기반으로 추론
- 보통 머신러닝 연구와 같은 경우는 백엔드와 같이 계속 프로세스를 유지하고 있는 방식이 아닌 한번 학습 또는 추론을 하고 끝나면 죽는다.
- 백엔드는 언제 들어올지 모를 유저 - 요청에 대비해 항시 프로세스를 유지하고 있어야 한다
- 테스트한 결과 프로세스 Kill을 하면 메모리 반환이 이뤄진다.
결과적으로 “GPU를 사용하도록 명령한 프로세스가 죽어야 메모리 반환이 이뤄진다”가 도출됐습니다.
그렇다면 위와 같은 여러 프로세스가 공유되는 GPU를 사용할 때 메모리 문제 현상을 줄이거나 없앨 수 있을까?
우선 줄이는 방법에 대해 알아보겠습니다.
텐서플로우에서 권장하는 방식은 다음과 같습니다.
1. GPU 지정하기
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
# 텐서플로가 첫 번째 GPU만 사용하도록 제한
try:
tf.config.experimental.set_visible_devices(gpus[0], 'GPU')
except RuntimeError as e:
# 프로그램 시작시에 접근 가능한 장치가 설정되어야만 합니다
print(e)
or
try:
# 유효하지 않은 GPU 장치를 명시
with tf.device('/device:GPU:2'):
a = tf.constant([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
b = tf.constant([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
c = tf.matmul(a, b)
except RuntimeError as e:
print(e)
텐서플로우는 모든 자원을 끌어다 쓴다고 했습니다.
다만 위와 같이 하면 현재 보유하고 있는 GPU 중 몇 번을 사용할 것인지 명시한 장치만 사용하게 됩니다.
ex) GPU 0 ~ 9번을 보유 중, GPU 0번만 사용
gpus에는 현재 보유중인 gpu 목록이 들어있게 됩니다.
2. GPU 메모리 증가를 허용
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
try:
tf.config.experimental.set_memory_growth(gpus[0], True)
except RuntimeError as e:
# 프로그램 시작시에 메모리 증가가 설정되어야만 합니다
print(e)
텐서플로우는 메모리를 전부 할당하게 되는데요, 이 방법을 사용하면 처음에는 메모리를 조금만 할당하고, 프로그램이 실행되어 점점 더 많은 GPU 메모리가 필요하면 메모리 영역을 점차 확장해 나갑니다.
ex) 해당 기능을 사용하기 이전에는 GPU를 사용하기만 하면 48,000 MiB 메모리 중 → 47,800 MiB 언저리로 거의 풀로 사용했는데, 해당 코드를 적용한 이후에는 학습에 따라 다음과 같이 48,000 MiB 메모리 중 → 7,000 MiB 만에 추론이 끝나 해당 모델이 딱! 필요한 만큼만 메모리가 증가해서 현저히 적게 사용
3. 할당될 GPU 메모리를 제한
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
# 텐서플로가 첫 번째 GPU에 1GB 메모리만 할당하도록 제한
try:
tf.config.experimental.set_virtual_device_configuration(
gpus[0],
[tf.config.experimental.VirtualDeviceConfiguration(memory_limit=1024)])
except RuntimeError as e:
# 프로그램 시작시에 가상 장치가 설정되어야만 합니다
print(e)
전체 메모리 제한을 부여하는 방식으로 GPU가 공유되는 환경에서 보통 사용되는 방법입니다.
ex) 해당 기능을 사용하면 memory_limit에 따라 48,000 MiB 메모리 중 → 1,600MiB or 2,400MiB
등 메모리 제한 값을 바꾸지 않는 이상 거의 고정된 MiB 사용 값만 사용하게 됩니다.
다만 테스트 한 결과 학습과 추론을 하기 위한 메모리보다 너무 낮은 메모리로 제한을 할 경우, 에러가 발생하는 현상이 확인됩니다.
그렇기 때문에 학습, 추론에 따라 어느 정도의 메모리가 필요할지 정확한 산출 하에 적용이 필요합니다.
위 예시들은 효율적인 메모리 사용법에 대한 예시이고, 백엔드와 같이 계속 프로세스가 살아있는 경우 효율적인 메모리 사용이 되긴 하겠지만, 프로세스가 종료되지 않기 때문에 우리가 원하는 메모리 반환은 이뤄지지 않습니다.
다음은 없애는 방법을 알아보겠습니다.
위에 있던 테스트 결과를 봤을 때 알다시피 프로세스가 메모리를 잡고 있는 것이 문제였습니다.
그렇다면 우리가 원하는 바를 이루고자 프로세스를 죽여서 메모리 유지하는 방법을 봅시다
첫번째 찾아본 해결 방법, 갓 구글에 numba
를 통한 CUDA GPU메모리 초기화 방법도 있었지만 적용해본 결과는 다음과 같습니다.
- 1차, GPU 사용으로 메모리 가득 참 ( 47,853 / 48,000 )
- numba를 통한 메모리 초기화 ( 5 / 48,000 ) 됨을 확인
- 2차, GPU 사용하려 했으나 작동하지 않음을 확인
생각해본 두 번째 해결 방법, 다른 프로세스를 만들어 두고 재시작 할 수 있도록 API 기능을 실행한다
처음에는 알아서 죽고 살릴 명령을 해 놓은 파일 실행하게 하면 될 것만 같았는데, 부모 프로세스가 죽으니 자식 프로세스가 도저히 살아남을 방법이 없더군요...
그래서 위와 같은 방법으로 살려줄 프로세스를 만들어 놓고 [ 죽고 + 살리는 ] 명령을 작성해 놓았습니다.
실험 결과 새로운 PID를 부여 받은 백엔드가 바로 살아나는 것까지 확인!
다만 위 방법은 기존에 존재하던 백엔드를 죽이기 때문에 백엔드로서의 가치가 현저히 떨어지게 됐다고 생각합니다.
세 번째 해결 방법, multiprocessing
이렇게 고민하던 와중... 회사 동료분께서 “이런 방법도 있다!”는 것을 찾아봐 주셨습니다. (감사합니다 ㅊㅇ님)
이전에 파이썬에서 프로세스를 새로 생성해서 하면 되지 않을까? 해서 열심히 찾아보다 보긴 했었던 것인데, multiprocessing 에 대해서 처음에는 “병렬 처리? 이건 뭐 텐서플로우 돌릴 때 프로세스 멀티로 돌려서 학습의 효율을 상승 시킨다는 건가? 나는 죽이는게 필요한데... 별 필요 없을 것 같네...” 하고 넘기고 이리저리 찾아도 해결 방법이 없어서 계속 찾고 있었는데
해당 글을 동료분께서 추천해주셔서 한번 유심히 보게 되니 “새로운 프로세스를 생성해서 실행하고 나면 죽으니까 GPU 메모리도 같이 지옥에 끌고 가는...” 제가 원하는 개념과 일맥상통했습니다.
바쁘더라도 대충 넘기지 말고 좀 더 제대로 파악해보는 것이 문제해결에 더 낫다는 것을 다시 한번 느끼게 되었습니다.
하여튼 다음과 같이 사용하면 됩니다.
from multiprocessing import Process
import os
def info(title):
print(title)
print('module name:', __name__)
print('parent process:', os.getppid())
print('process id:', os.getpid())
def f(name):
info('function f')
print('hello', name)
if __name__ == '__main__':
info('main line')
p = Process(target=f, args=('bob',)) # target = function 입니다.
p.start()
p.join()
------------결과----------------
main line
module name: __main__
parent process: 30844
process id: 32256
function f
module name: __mp_main__
parent process: 32256
process id: 32308
hello bob
사용 방법은 https://docs.python.org/3/library/multiprocessing.html 파이썬 문서에 있습니다.
문제는 해당 방법을 그냥 사용할 경우 반환 값을 받지도 못했고 multiprocessing으로 실행한 메소드 안에서는 GPU 인식을 하지 못해 GPU 사용이 되지 않는 문제가 있었습니다.
cuda_error_not_initialized: initialization error
일반적인 multiprocessing 기능 만으로는 불가능 했습니다.
다음과 같은 방법을 사용해야 했습니다.
set_start_method(’forkserver’, force=True)를 해줘야 합니다.
forkserver란?
force=True를 사용하지 않을 경우
두 번째 실행 시 다음과 같이 에러가 발생하니 반드시 포함 해줘야 합니다.
예시
import multiprocessing as mp
import tensorflow as tf
def predict_test(image, path, img_height=256, img_weight=256):
# 사용 가능한 GPU 체크
gpus = tf.config.experimental.list_physical_devices('GPU')
# 사용 가능한 GPU가 존재 할 경우
if gpus:
try:
# 사용할 GPU number 부여
tf.config.experimental.set_visible_devices(gpus[0], 'GPU')
# GPU 메모리를 전부 사용하지 않고 천천히 사용할 만큼만 상승 시킨다.
tf.config.experimental.set_memory_growth(gpus[0], True)
except Exception:
raise {Exception}
result = { predict }
return result
def multiprocessing_test():
# 멀티 프로세싱 설정을 한다. *반드시 force를 사용
mp.set_start_method('forkserver', force=True)
# Process Pool생성
p = mp.Pool()
# predict_test를 실행 후 데이터를 반환
prediction = p.starmap(predict_test, [(image, path, 256, 256)])
# machine learning으로 발생한 다량의 데이터를 garbage collect로 정리를 한다.
# 정리하지 않을 경우 메모리 부족으로 에러가 발생한다.
gc.collect()
return prediction
multiprocessing_test()
GPU 메모리 변화 과정
- 실행 전
- 실행 중
- 실행 후 초기화
데이터를 반환 받는 방법은 starmap이 아닌 call by reference를 이용하는 방법도 있습니다
그 외 참고 해볼 사항 (주관적인 테스트 결과)
- GPU를 사용하는 경우 nvidia-smi에 메모리가 꽉 차 있다고 나오는데
그렇다면 이 상태에서 다른 모델 테스트가 안되는 것인가???? 생각 할 수 있는데요.
다른 프로세스가 메모리를 잡고 있지 않고 백엔드가 메모리를 계속 잡고 있기 때문에 잘 작동했습니다.
오히려 메모리가 잡혀있지 않은 상태에서 추론을 할 경우 6초 걸릴 시간이
메모리가 잡혀있는 상태에서 추론을 할 경우 1초도 걸리지 않고 결과를 받을 수 있었습니다.
- 참고사항
- 첫번째 : 다른 프로세스에서 GPU 전체 메모리를 잡고 있다면
백엔드에서 GPU를 이용한 학습, 추론시 out of memory가 발생합니다.
- 두번째 : 다만, 백엔드 프로세스에서 GPU 메모리를 잡고 있다면
한번 쓰고, 두번 쓰고, 세번 사용해서 다른 모델을 돌려도 문제는 없었습니다.
- 세번째 : growh 또는 limit를 사용해 메모리 사용량을 나눌 경우 여러 프로세스에서 나눠 사용이 가능합니다. 다만 그 메모리 사용량의 동적으로 나눠 사용하도록 제어하려면 좀 더 연구해야 할 것 같습니다.
- 결론은 현재 GPU를 이용해 학습을 하려는 프로세스가 아닌
다른 프로세스에서 GPU 전체 메모리를 잡고 있다면 죽여야 한다.
또는 가장 효율적 또는 안정적으로 사용하기 위해서는 전체 메모리를 잡고 있지 않더라도 다른 프로세스를 죽이는 것이 낫다
2. 백엔드에서 만약 추론 한 번 만에 Out of Memory가 발생하거나 여러 번 추론을 했을 때 오류가 발생한다면 오히려 다음 사항을 의심해봐야 했습니다.
- 메모리 누수
- 추론하는 코드의 부정확성 또는 효율
- 해당 학습의 Batch Size 크기
- 해당 학습에서 사용하는 총 GPU Memory 크기
- 위 실험 결과들로 유추해 보았을 때, 텐서플로우는 GPU 메모리가 단편화 되지 않기 위해 그리고 최대의 성능을 내기 위해 GPU 전체 메모리를 “메모리 풀” 상태로 유지하는 것이 아닌가 생각됩니다.
- https://ko.wikipedia.org/wiki/메모리_풀#:~:text=메모리 풀(memory pool)은,할당을 가능하게 해준다.&text=아파치 웹 서버와 같은,것을 메모리 풀이라고 한다
메모리 풀 방식으로 퍼포먼스를 유지하는 것이죠!
어떻게 보면 무식하고 어떻게 보면 참 효율적인 방식이라고 생각됩니다.
마무리
파이썬의 멀티프로세싱을 위와 같이 사용하는 개념을 가지고 접근하는 경우는 많지 않을 것이라 생각되는데요.
특수한 상황으로 인해 해당 방식을 사용해봤고 정리, 작성해봤습니다.
이 경험이 다른 사람이 무언가 문제를 풀어나갈 때 시행착오를 줄일 수 있도록 도움이 되길 바랍니다.
이상입니다.