본문 바로가기
AI/기술

GPU Util 99% 달성하기

by ai.forme 2021. 1. 30.
반응형

 

 

딥러닝 공부를 하다 보면 반드시 보게 되는 하나의 창이 있는데.. 바로 nvidia-smi 했을 때 나오는 GPU의 상태를 보여주는 창이다. 오른쪽에 보면 GPU-Util이라는 수치가 있는데, 이는 GPU가 얼마나 가용되고 있는지를 나타낸다. 높으면 높을수록 우리 충실한 일꾼이 계산을 열심히 하고 있다는 뜻이다. 학교에서 공부를 하다 보면 이 GPU-Util 수치를 중요하지 않게 생각하게 되는데 (학교에서는 그 누구도 알려주지 않기 때문에), 사실 학습 파이프라인의 기본이자 시작은 GPU를 최대한 활용하는 것이다.

 

 

 

 

혹시 이런 생각을 가지고 있진 않는가? 불과 한 달 전의 필자도 이런 생각을 가지고 있었기에, 오늘은 학습 파이프라인에서, GPU Util을 높여야 하는 이유와 방법들에 대해 포스팅을 하고자 한다. 학교에서는 얻지 못할 소중한 배움을 주신 제 사수님께 무한한 감사의 말씀을 드린다.

 


GPU-Util을 높여야 하는 이유

 

 

 

 

그러면 왜 GPU Util을 높여야 할까? 그냥 좋은 모델이 학습되면 된 거 아닌가? 결과론적으로 보면 맞다. 어쨌든 모델을 잘 학습시키면 된다. 그러면 한 가지 의문점이 생긴다. 학습이 잘 된 모델은 뭐지?

 

모델의 성능이 좋느냐를 판별하기 위한 다양한 방법들이 존재하는데 아래의 예시들이 있다.

 

- Image Classification : Accuracy

- Natural Language Processing : SQuAD, GLUE, ..

- GAN : Frechet Inception Distance (FID)

 

 

하지만 여기서 한 가지 맹점이 존재하는데, 위의 평가 방법들은 현존하는 모델들을 비교하기 위한 방법이지 최고의 모델을 찾는 방법은 아니다. 다시 말해, 위의 방법들은 모델 A가 낫냐, 모델 B가 낫냐를 가려내기 위한 방법이라는 것이다. 그러면 왜 최고의 모델인지 아닌지를 판별하는 방법은 없을까? 이는 해집합이 인간은 평생 이해할 수 없을 고차원이기 때문이다.

 

 

 

 

 

이처럼 우리의 해집합은 위보다 (사실 위는 인간이 이해할 수 있는 한계인 3차원이다) 훨씬 복잡한 공간이다. 따라서 수많은 Local Minimum이 존재할 것이고, 현존하는 수많은 Optimize 방법들을 사용한다고 하더라도, 최적의 해를 찾는 것은 보장할 수 있는 일이 아니다. 따라서 우선 한 가지를 인정하고 들어가야 되는데, 우리가 만드는 모델은 최고의 모델이 아니라는 것이다.

 

 

 

 

 

그러면 우리가 할 수 있는 것은 최고의 모델을 찾는 확률을 높이는 것이다. 이때 필요한 것은 Analyze & Trial & Error이다. 이것이 무엇이냐면, 결과에서 에러를 찾아 분석하고, 다시 시도한다는 뜻이다. 여기서 에러는 Syntax & Semantic 에러가 아닌 결과가 좋지 않은 이유를 말하는 것이다. 그렇기 때문에 계속 시도해야 된다. 이 때문에 Hyperparameter Tuning이 Human power이 아니냐는 소리가 나오는 것 아닐까? 또한, 이 때문에 실제 최적의 모델을 찾는 작업은 사람보다 인공지능이 더 잘한다고도 한다. 그럼 대체 사람은 뭘 해야 되지..?

 

기계가 하든, 사람이 하든, 어쨌든 계속 시도한다는 것은 공통분모기에, 시도 횟수를 높이는 것은 최적의 모델을 찾는데 가장 중요하다. 그러면 시도 횟수를 높이려면? 한 번 모델을 학습하는데 들어가는 시간이 짧아야 한다. 이제 슬슬 왜 GPU Util이 높아야 하는지 감이 오는가? 모델을 한 번 학습하는데 시간이 짧다는 것은, 학습 파이프라인이 최적화되었다는 뜻이고 이는 곧 GPU가 한시도 놀지 않고 계속 일하고 있다는 것을 말한다.

 

 

 

 

 

이 차이는 모델이 커질수록, 데이터가 많아질수록 극심해진다. 위 사진은 언어 모델의 크기가 어떻게 증가하고 있는지를 보여주는 표인데, 현재 가장 큰 모델이 175B (1750억) 개의 Parameter을 가진 GPT-3이다. (최근에 구글에서 1T (1조) 개의 Parameter을 가진 Google-Switch 모델을 발표하긴 했지만 좀 논외로 두고) 아무튼 이렇게 모델과 데이터의 양이 커질 때 학습이 최적화되지 않으면 절망스러운 결과를 불러일으킬지도 모른다. 단적인 예시로, GPU Util이 100% 일 때 1달이 걸린다면, 50% 일 때는 두 달이 걸릴 것이고, 이는 극심한 비효율성을 야기한다.

 

 

 

 

마지막으로, 이런 거 보고 있으면 그냥 기분이 좋다.. 행복하다.. 이런 게 공대생 감성일까?


학습 파이프라인

 

 

 

딥러닝의 학습 파이프라인은 크게 두 가지로 구성된다. 

 

1. 데이터 (CPU)

- 디스크에서 데이터 읽기

- 전처리하기

- Batch 만들기

 

2. 학습 (GPU)

- Forward

- Loss

- Back Propagation

 

 

일을 하는 주체가 CPU인지 GPU인지를 기준으로 구분한 것이다. 딥러닝 학습 파이프라인을 최적화한다 하면, 혹은 GPU-Util을 99% 찍는다고 하면 두 가지 모두를 최적화해야 된다. 그래야지 비로소 GPU가 최적의 상태로, 온 힘을 다해 일을 하게 된다. 이번 포스팅에서는 이 중 CPU가 하는 일을 최적화 함으로써 GPU-Util을 높이는 방법에 대해 알아보고자 한다. GPU가 하는 일, 즉 학습을 최적화하는 것은 아래 포스팅에 잘 설명되어 있다. 참고하길 바란다.

 

medium.com/daangn/pytorch-multi-gpu-%ED%95%99%EC%8A%B5-%EC%A0%9C%EB%8C%80%EB%A1%9C-%ED%95%98%EA%B8%B0-27270617936b

 

🔥PyTorch Multi-GPU 학습 제대로 하기

PyTorch를 사용해서 Multi-GPU 학습을 하는 과정을 정리했습니다. 이 포스트는 다음과 같이 진행합니다.

medium.com


 

데이터가 GPU에 올라가는 과정

 

결국 오늘 할 것은, 데이터가 GPU에 올라가기 전까지의 과정을 최적화하는 것인데, 그러면 데이터는 GPU에 올라가기까지 어떤 과정을 거칠까? 갑자기 뿅 올라가는 것은 아닐 테니 말이다.

 

 

 

 

위와 같은 과정을 거쳐서 데이터는 GPU에 올라가게 되는데, 여기서 User Mode와 Kernel Mode는 CPU의 상태를 의미한다. CPU는 디스크에서 데이터를 읽는 것이 아닌, File System I/O Device가 디스크에 있는 데이터를 읽어 CPU에게 전달하는데 이는 CPU가 Kernel Mode일 때만 동작 가능하다. 따라서 디스크에서 데이터를 읽을 때는 User Mode와 Kernel Mode의 Context Switching이 일어나게 된다. 위 그림을 순차적으로 설명하면 아래와 같다.

 

1. 데이터를 읽으라는 명령을 받는다. (코드 / User Mode)

 

2. User Mode -> Kernel Mode (Context Switching)

 

3. Kernel Mode에서 File System I/O에 디스크에 있는 해당 데이터를 Return 해달라고 명령

 

4. Return 받은 데이터를 메모리에 올림 (Kernel Mode -> User Mode)

 

5. 메모리에 있는 데이터를 GPU에 올림

 

 

 

 

여기서 두 가지로 경우의 수가 나뉘는데, 아래와 같다.

 

1. 사용하고자 하는 데이터 전부가 메모리에 올라가는 경우 (작은 경우)

2. 사용하고자 하는 데이터 전부가 메모리에 올라가지 못하는 경우 (큰 경우)

 

이렇게 경우의 수가 나뉘는 이유는 디스크에서 메모리로 데이터를 올리는 작업이 상대적으로 매우 느리기 때문이다. CPU에서 데이터를 전처리하고 Batch를 만들어 GPU에 올리는 것은 상대적으로 빠르다. 즉, 이는 데이터를 GPU에 올리는 과정에서 디스크 -> 메모리에서 병목이 일어나 작업이 느려질 확률이 높다는 것을 의미한다.

 

 

 

 

실제로 각 상황에서 CPU의 Stat을 관찰해보면 위와 같은데, us는 CPU가 User Mode에서 일을 하는 비율, sy는 CPU가 Kernel Mode에서 일을 하는 비율을 나타낸다. 데이터가 모두 메모리에 올라가지 않는 경우에는 디스크에서 계속 메모리에 데이터의 일부분씩 올려야 되므로 CPU가 Kernel Mode에서 대부분의 시간을 보내게 된다. 이렇게 되면 데이터가 GPU에 빠르게 올라가지 못해 GPU Util이 떨어지게 되는데 그 이유는 차차 아래서 설명하도록 하겠다. 


모델이 학습되는 과정

 

 

 

한 Batch는 1, 2, 3의 과정을 거쳐 학습하게 된다. (각 부분의 길이는 상황에 따라 천차만별이다) 물론 메모리에 있는 Batch를 GPU에 올리는 시간도 있지만, PCIe 통신이 매우 빠르기에 (15G/s) 본 포스팅에서는 이를 무시하도록 한다. 각 단계를 설명하면 아래와 같다.

 

1. Disk -> Memory로 데이터를 올리는 과정

2. CPU가 Memory에 올라간 데이터를 전처리, Batch를 만드는 과정

3. GPU가 한 Batch를 학습하는 과정 (Weight Update)

 

 

 

 

본 파이프라인대로 학습을 하게 되면 무조건 GPU Util이 99%가 되지 않는다. 그 이유는 GPU가 한 Batch 학습을 끝내고 다음 Batch 학습하기 전까지 1+2의 시간 동안 지연이 되기 때문이다. 따라서 GPU Util을 높이기 위해서는 한 가지만을 명심하면 된다.

 

 


GPU에서 한 Batch 학습이 끝나기 전에 다음 Batch 메모리에 준비하기


 

 

문제는 단순화되었다! 이제는 본 목적을 달성하기 위해서 할 수 있는 방법들에 대해 알아보도록 하자.


데이터 파이프라인을 최적화하는 방법들

 

1. Multi Process Data Loading (Prefetch)

 

 

 

 

단일 프로세스이기에 생기는 1+2의 시간 지연을 없애자는 아이디어다. 즉, CPU 0이 한 Batch를 준비하여 GPU에 올려 학습하는 동안, 다른 CPU 1 (프로세스)가 다음 Batch를 준비하는 것이다. 이렇게 되면 GPU는 시간의 지연 없이 바로 다음 Batch를 학습할 수 있기 때문에 지연이 일어나지 않는다. 이는 다음 Batch를 미리 가져오는 것이기 때문에 Prefetch라고 한다. 실제로는 다중의 프로세스들이 준비한 Batch를 공유하는 Queue에 넣고, 하나씩 빼서 GPU에 올리는 방법이다.

 

 

from torch.utils.data.dataloader import DataLoader

train_loader = DataLoader(dataset=train_set,
                              num_workers=4,  # 사용할 Process의 수
                              batch_size=512,
                              persistent_workers=True)

 

딥러닝 프레임워크 Pytorch를 사용하면 위와 같이 쉽게 구현할 수 있다. torch.utils.data.dataloader의 DataLoader 객체 중 num_workers라는 인자가 있는데, 이를 1보다 크게 설정하면 Multi Process Data Loading이 구현된다. 참 쉽다. 각자의 컴퓨터 상황에 맞춰 num_workers의 수를 잘 조절하면 된다. 필자의 컴퓨터의 경우, 총 12개의 코어를 사용할 수 있어 최대 num_workers를 12까지 늘릴 수 있다.

 

 

2. 크기가 작은 Datatype 사용하기

 

 

 

 

Pytorch의 경우 모델의 Parameter은 Float32 Datatype을 가지고 있기에 Input 또한 Float32로 들어가야 한다. 하지만 Float32는 UINT8 (Unsigned Int 8로 0~255까지의 숫자를 나타낼 수 있다)보다 4배나 그 크기가 크다. 즉, 단적으로 숫자만 보자면 이는 데이터를 전송할 때 4배만큼의 시간이 더 걸린다는 뜻이다. 따라서 실제로 모델에 Input으로 넣기 전에는 크기가 작은 Datatype으로 가지고 있는 것이 전송 속도에 도움이 된다. 이미지의 경우 0~255 사이의 UINT8로 가지고 있다가 모델에 넣어주기 직전에 Normalize 하여 Float32로 바꿔주는 것이 더 빠르다는 것이다.

 

 

 

위와 같은 방법들을 사용하면, 사용할 데이터가 모두 메모리에 올라가는 경우 GPU Util을 99% 찍을 수 있다. 필자는 Cifar10 데이터로 실험해 보았는데, 성공하였다. 하지만 두 번째 경우, 즉 사용할 데이터가 모두 메모리에 올라가 지 않는 경우에는 위와 같은 방법들로만은 GPU Util을 99%을 달성할 수 없다. 그 이유는 앞서 말했듯, 디스크에서 메모리로 데이터를 올리는 것이 너무 느리기 때문이다.

 

 

 

 

데이터가 모두 메모리에 올라가지 않을 때 관찰한 CPU 스탯이다. 위 사진의 빨간색과 아래 사진의 sy 값은 모두 CPU가 Kernel Mode로 돌아가는 비율을 의미한다. 이 수치가 높은 이유는 디스크에서 메모리로 데이터를 올리는 것이 너무 느리기 때문이다. 즉, 디스크에서 메모리로 데이터를 올리는 게 느려 계속 Request가 쌓이는 중이다. 이러면 아직 디스크에서 메모리에 데이터가 올라오지 않아 Batch를 만들지 못하기에 우리의 중요한 목표 "GPU에서 한 Batch 학습이 끝나기 전에 다음 Batch 메모리에 준비하기"가 실패하게 된다.

 

 

 

위와 같은 경우에는 다른 방법들을 추가로 사용하여 GPU Util을 올릴 수 있다. 문제는 디스크에서 메모리로 데이터를 올리는 것이 너무 느리다는 것이었다. 그러면 디스크에서부터 데이터를 요청하는 횟수를 줄이면 된다. 그 방법은 데이터의 일부분을 메모리에 올려놓는 것이다. 이때 사용할 수 있는 것이 HDF5의 Chunk이다.

 

 

3. Chunk Hit

 

 

 

 

HDF5 (Hierarchical Data Format 5) 이란 HDF 그룹에 의해 관리되고 있는 대용량의 데이터를 저장하기 위한 파일 형식이다. 이름 그대로 계층적으로 구조화된 배열 데이터를 저장하기에 용이하다. Linux의 디렉터리 구조와 유사해 보인다.

 

 

 

 

Layout이란 다차원의 Dataset을 연속적인 File에 Mapping 시키는 방법을 말하는데, HDF5에는 Contiguous Layout과 Chunk Layout이 있다. 

 

1. Contiguous Layout

- Dataset을 일자로 편다.

- 배열의 메모리에 저장되는 방식과 유사하다.

- 한 개의 통으로 디스크에 저장된다.

 

2. Chunk Layout

- Dataset을 여러 개의 Chunk (블록)으로 나누어서 저장한다.

- 파일 안에 한 블록이 무작위로 저장된다.

- Chunk 별로 읽고 쓸 수 있다.

 

 

 

import h5py

celebA = h5py.File(DATA_DIR, 'w', rdcc_nslots=11213, rdcc_nbytes=1024**3, rdcc_w0=1)

celebA.create_dataset('images',
                       data=batch_images,
                       dtype=np.uint8,
                       chunks=(100, 3, 217, 178),  # 11 MB : Chunk Size
                       maxshape=(None, 3, 218, 178))
                                      
celebA.create_dataset('labels',
                       data=labels_h5[:size],
                       dtype=np.uint8,
                       chunks=(20000,))

 

 

파이썬에서는 h5py 라이브러리로 HDF5 파일을 쉽게 다룰 수 있다. 더욱 자세한 내용은 아래 링크에서 확인할 수 있다.

 

docs.h5py.org/en/stable/high/file.html

 

File Objects — h5py 3.1.0 documentation

Parameters: name – Name of file (bytes or str), or an instance of h5f.FileID to bind to an existing file identifier, or a file-like object (see Python file-like objects). mode – Mode in which to open file; one of (“w”, “r”, “r+”, “a”,

docs.h5py.org

 

 

 

 

Chunk를 사용하는 것이 GPU Util을 올리는데 중요한 이유는 위 사진이 그대로 설명해준다.

 

1. Chunk에 있는 데이터 하나를 참조하면, 해당 Chunk 전체가 메모리에 올라간다.

2. 이후 임의의 데이터를 참조했을 때, 해당 데이터가 메모리에 올라가 있는 Chunk에 있으면 메모리에서 바로 참조한다.

 

 

따라서 필자가 Chunk Hit이라고 한 이유는, 이 모양새가 Cache Hit과 유사하다고 생각했기 때문이다. Chunk가 메모리에 올라가 있는 것을 Chunk Cache라고 하는데,  Chunk Cache의 크기와 개수를 잘 조절하면 눈부신 Util 상승을 볼 수 있다.

 

 

4. Batch Echoing

 

 

 

 

Batch Echoing이란 GPU에 올라온 한 Batch를 여러 번 사용하는 것을 의미한다. "어 이렇게 되면 학습의 Randomness가 저해되는 것 아닌가요?" 정확하다. 그렇기에 본 방법은 명백한 득과 실이 존재한다. 득은 학습의 속도를 증가시킬 수 있다는 것과, 실은 Randomness가 감소한다는 것이다.

 

 

따라서 필자는 본 방법을 적용할 때 (그나마) Randomness를 유지하기 위하여 아래와 같은 트릭을 적용하였다.

 

1. 512개짜리 Batch를 GPU에 올림

2. 512개짜리 Batch를 256개짜리 Batch 2개로 나눔 (이를 A, B라고 함)

3. 모델이 A와 B 각각 학습

4. 512개짜리 Batch의 순서를 섞음

5. 섞인 512개짜리 Batch를 256개짜리 Batch 2개로 나눔 (이를 C. D라고 함)

6. 모델이 C와 D를 각각 학습.

 

 

위의 과정을 거쳐 한 Batch을 2번 사용하였다. 필자의 방법보다 훨씬 창의적이고 좋은 방법들이 많을 것이기에 각자의 상상력을 십분 발휘해보길 바란다. 필자가 위와 같이 Batch Echoing을 적용한 이유는, 이렇게 되면 A, B, C, D 가 모두 다른 이미지들로 구성된 Batch가 되어 어느 정도 Randomness가 유지될 것이라고 생각했기 때문이다.


본 글의 배움

 

사실 이번 포스팅에서 가져가야 할 것은 방법론적인 것들이 아니다. GPU Util이 떨어졌을 때 (최적화가 덜 되었을 때) 그 원인이 무엇인지 파악과 해결하는 능력과 방법이 중요하다. 그 프로세스는 아래와 같이 이루어진다.

 

1. 하드웨어의 속도에 대한 감을 가지고, 프로세스가 느림을 인지

 

2. 속도 저하의 원인을 파악

이때는 여러가 도구를 사용할 수 있다. 예를 들어 CPU의 스탯을 확인할 수 있는 top, htop, atop 등을 사용해 도움을 받을 수 있다. 중요한 것은 무작정 원인을 파악하는 것이 아니라 가설을 세워야 한다는 것이다. 그 이유는 원인의 범위를 좁혀 효율적으로 파악할 수 있기 때문이다.

 

3. 원인을 해결할 수 있는 방법 고안

 

4. 실행

 

위와 같은 문제 해결 파이프라인의 이번 포스팅의 교훈이다. 다들 이것을 명심하고 즐거운 개발을 하길 바란다! 요즘 회사에서 인턴을 하고 있는데, 이것이 너무 바빠 글을 쓰지 못하였다. 반성하며, 앞으로는 좀 더 자주 글을 올리고자 한다.

반응형