최근 pytorch version을 1.4에서 1.7로 업그래이드 하면서 개발 환경이 삐그덕 거리기 시작해
발생하는 문제와 해결 했던 방법을 정리 하고자 한다.
기존 개발 환경:
docker + ngc(apex, torch.1.4, cuda 10.1) + single node -multigpu
new 개발 환경:
docker + ngc(torch.distributed, torch.1.7, cuda 10.1) + single node - multi gpu
발생 문제:
DDP module 을 기존 apex에서 torch.nn.parallel.DistributedDataParallel로 변경 후 아래와 같이
실행 하면 전에 볼수 없었던 에러 메시지 발생
python -m torch.distributed.launch 00nproc_per_node 4 train.py
"아래가 발생 에러"
"Single-Process Multi-GPU is not the recommended mode for "
/opt/conda/lib/python3.6/site-packages/torch/nn/parallel/distributed.py:448:
UserWarning: Single-Process Multi-GPU is not the recommended mode for DDP.
In this mode, each DDP instance operates on multiple devices and creates multiple
module replicas within one process.
The overhead of scatter/gather and GIL contention in every forward pass can slow down
training. Please consider using one DDP instance per device or per module replica by
explicitly setting device_ids or CUDA_VISIBLE_DEVICES.
Traceback (Most recent call last):
~
~
RuntimeError: All tensors must be on devices[0]: 0
에러 발생 원인 코드:
위 에러가 발생한 순간은 아래와 같이 apex.parallel 의 ddp 모듈을 torch의 ddp 모듈로 바꾼뒤이고
#from apex.parallel import DistributedDataParallel as DDP
from torch.nn.parallel import DistributedDAtaParallel as DDP
DDP로 모델을 감싸는 부분에서 위 에러가 발생했다.
#기존 에러 발생 코드 (torch.1.4 및 apex에서는 정상동작)
mymodel = DDP(mymodel)
해결방법:
문제를 해결 하기 위해서는 위의 model을 DDP로 감싸는 부분을 아래와 같이 바꿔 주면 된다.
사용시 주의할 점은 반드시 <your_train_script.py> 가 의미 있는 유한 시간안에 정상 종료 해야 한다는 것이다.
무슨 말이냐면 아래 코드 처럼 train iteration횟 수를 유한 시간안에 끝날 수 있는 횟수로 제한 하는 것이 필요 하다는 것이다.
for i in range(10):
nvtx.range_push('data loading')
batch = next(dataloader)
nvtx.range_pop()
for key, data in batch.items():
batch[key]=data.cuda()
nvtx.range_push('Forward pass')
nvtx.range_push('Batch')
out = model.forward(image.cuda())
loss = getLoss(out)
nvtx.range_pop()
nvtx.range_pop()
nvtx.range_push('backward pass')
loss.backward()
optimizer.step()
optimizer.zero_grad()
nvtx.range_pop()
nvtx.range_push(), nvtx.range_pop() 등은 프로파일 결과 파일을 보기 편하게 해주는 역할을 해준다.
NSIGHT (GPU driver 설치시 대부분 같이 설치 된다.)을 이용해 프로파일 결과 파일을 열면 아래와 같은 그래프를 볼 수 있다.
Fig 3. 의 붉은 박스 부분을 보면 NVTX 항목에 data loding, Batch, backward, data loading, batch, backward 라는 부분을 볼 수 있다. 각 항목은 위 예제 코드에서 직관 적으로 알수 있듯이 각각 data loading에 걸리는 시간, batch의 forward pass 연산에 걸리는 시간, backward pass에 걸리는 시간의 포션을 나타낸다.
그래프에서 확인 가능 하듯, data loading이 차지 하는 시간이 절대적으로 많다.
data loading에서 augmentation 을 하는데 이 연산은 cpu를 활용하고 gpu 연산에 비에 이 cpu 연산이 오래 걸려 gpu가 처리할 데이터를 꾸준히 공급하지 못하는 것이 낮은 gpu 사용율의 원인이었다. 이때 GPU 사용율을 높이고자 한다면 이 GPU starvation 의 간격을 최대한 줄이거나 더 나아가 GPU 연산부와 CPU 연산부가 서로 겹치도록 연산 파이프라인을 구축하는 것이 최선이다.
이렇게 GPU 프로파일을 하고 보니 현재 문제가 되는 부분은 확실히 data loading 이다.
FILE I/O는 거의 문제가 안되고 있으니
CPU 연산이 절대적으로 많거나 비효율 적으로 연산을 하고 있는 것으로 해석된다.
다음으로 data loader 내부에서 시간을 많이 잡아 먹는 부분을 특정 정하기 위해 python profile을 수행했다.
cProfile이라는 python 모듈을 이용했는데 결과 캡쳐하는걸 깜박해... 사진은 생략한다.
가슴아프게도 결과는 data augmentation이 약 40%를 차지 했고
사용한 augmentation 은 flip, translation, rotation, gaussian noise, hue transform, shadow adding 등이다.
augmentation 후 약 3개의 함수에서 loss 계산에 필요한 특정 heat map, offset map을 만들기 위한 작업을 진행하는데
이 부분이 약 40%의 시간을 차지 했다. 나열하고 CPU연산이 많긴 하다.
개선
augmentation 은 nvidia DALI를 사용하고자 했지만 아직 구현을 완료 하지 못했다. (40%의 성능 향상을 아직은 이루지 못했다는 소리다- 이 부분은 향후 과제다. DALI 버전을 완료 하면 내용을 보완해야겠다.)
나머지 40%의 개선을 이루기 위해 loss 계산에 필요핸 map 들을 만드는 부분을 수정했는데
결과는 이미지 1장당 약 4ms 걸리던 작업을 0.4ms 정도 걸리게 끔 구현을 다시 했다.
사실 엄청난 걸한거 같지만 기존 코드가 말도 안되게 비효율 적인 구조를 가지고 있었다.
numpy array를 처리 하는데 형태가 (n,1,2) 인 포인트의 좌표 를 담고 있는 배열을 (n 개의 (x,y) 좌표 값을 가진 배열)
python for문을 이용해 순회 하며 offset map 과 heat map 등을 만들고 있었다.
for 문을 없에고 numpy indexing 과 matrix 계산으로 바꾸니 약 1/10 로 속도가 개선되었다.
아래 그림은 수정한 코드에서 CPU 사용율을 캡쳐 한것이다.
Fig 5 를 Fig 3과 비교해보면 data loading 시간이 분명 줄었다. 27.xx 초 에서 10.372로 줄었으니 줄긴 많이 줄었다. 실제로
한 epoch 학습 시키는데 걸리는 시간도 50%가량 줄었다. (data loader가 10초 씩이나 걸리는게 말이 안된다고 생각 하겠지만 프로파일러가 샘플링을 하기 때문에 저렇게 말도 안되는 시간이 나온거다. 실제 몇초 인지 수치 보다 gpu 연산 시간을 나타내는 Batch 의 길이와 data loading의 길이 비율 차에 집중해서 보길 바란다.)
여기까진 좋았다 원하던 결과다 다만 위와 같이 개선한 후에도 여전히 data loading 시간이 gpu의 forward + backward pass 연산 시간에 비해 더 많은 시간을 소모 한다. 그 결과 당연히 gpu 사용률의 등락 폭은 줄어 들지 않았다.
아래 그림을 보면 이 분석을 시작한 현상이 그대로 남아 있음을 알 수 있다.
그래서 다시 htop로 cpu 사용률 모리터링을 해보니 아래 그림 Fig 6. 과 같이 cpu가 100%일을 하고 있지는 않다.
이전 섹션인 '원인 분석 및 profile'에서 CPU 가 거의 항상 100%사용 중임에도 불구하고
GPU 사용율이 낮았기 때문에 FILE i/o가 병목 현상의 원인은 아닐거라고 생각했다.
그런데 코드를 개선 하고 나니 CPU 사용율은 절반 수준으로 낮아졌으나 data loading 시간은 여전히 오래 걸린다.
여기서 의문이 들었다.
cpu가 저렇게 50~60%정도의 사용율을 보인다는 것을 File I/o의 문제라고 볼 수 있을까?
File I/o가 문제라면 cpu core의 사용율이 낮은게 아니라 말그대로 놀고 있는 0~1%의 사용율이 나와야 하는거 아닐까?
그럼 저렇게 cpu 사용율이 낮아졌는데 역전히 data loader가 batch를 생산하는데 시간이 오래 걸리는 원인이 뭘까?
기 학습된 모델과 파라미터를 로드해 특정 레이어를 제외한 나머지 레이어의 _weight_을 freeze하고 finetuning하고자 하다가 또 어이없는 실수를 하고 말아 다시 정리한다.
환경:
apex.parallel DistributedDataParallel 을 이용해 학습
에러 발생 순서:
DistributedDataParallel 을 이용해 학습 도중 _model.state_dict()_을 저장
학습 하던 모델에서 backbone 및 특정 _head_의 _weight_만 학습 시키기 위해 학습을 원하지 않는 _tensor_의 _requires_grad_를 _False_로 세팅 한다. 대략 적인 코드는 다음과 같다.
model = modelFactory(model)
#wrap model using distributedDataParallel
if torch.cuda.is_available():
model.cuda()
if torch.distributed.is_initialized():
model = DistributedDataParallel(model)
#load trained parameters
checkpoint = torch.load(path, map_location = lambda storage, loc: storage.cuda(torch.distributed.get_rank()))
model.load_state_dict(checkpoint['state_dict'])
model.freezePartOfModel() #<- 에러 발생 부분
위와 같은 작업 흐름을 따라 갈 경우 아래와 같은 에러가 발생할 수 있다.
AttributeError: 'DistributedDataParallel' has bo attribute 'freezePartOfModel'
발생 원인:
아래와 같이 DistributedDataParallel 로 모델을 wrap up 할 경우 기존 _model_은 _module_로 감싸여 진다.
modelDDP= DistributedDaraParallel(model)
즉, 감싸여져 있다는 의미는 model.a_라는 *attribute_에 접근 하기 위해서는 *modelDDP.module.a 로 접근 해야 한다는 것이다.
해결 방법:
아래 처럼 코드를 고치면 해결이 가능 하다.
if torch.distributed.is_distributed():
model.module.freezePartOfModel()
else: # for single gpu usage
model.freezePartOfModel()
본래 목적이 기 학습된 모델의 구조를 유지하고 파라미터의 극히 일부분만 finetuning_하는 것이 었기 때문에 *_해결 방법 1** 은 좋은 해결 방법은 아니다. 본래의 목적을 위해서는 로드된 모델 _state_dict_의 파라미터 이름에서 *module. (또는 .module)을 제거 하면 정상적인 로드가 가능하다.다음은 예제 코드이다.
loaded_state_dict = torch.load(state_dict_path)
new_state_dict = OrderedDict()
for n, v in loaded_state_dict.items():
name = n.replace("module.","") # .module이 중간에 포함된 형태라면 (".module","")로 치환
new_state_dict[name] = v
model.load_state_dict(new_state_dict)
pytorch 코드를 분석하다 torch.gather() 메서드의 동작이 헷갈려 정리해 본다.
torch.gather() 메서드는 input tensor로 부터 원하는 차원에서 주어진 index에 해당하는 원소만을 골라 새로운 텐서를 만들때 사용하며
만들어진 새로운 텐서는 주어진 index 텐서와 shape(또는 size)가 같다.
일단 예제를 보자.
아래와 같은 3차원 텐서를 t 생각해보자
import torch
import numpy as np
t = torch.tensor([i for i in range(4*2*3)]).reshape(4,2,3)
print(t)
여기서 아래 그림과 같이 axis =0 에서 원소 1,0,3만으로 구성된 새로운 텐서를 만들려면
# 1,0,3 은 추출하고 싶은 원소의 타겟 dimension의 원소 index 이다.
ind_A = torch.tensor([1,0,3])
# torch.gather()에서 index tensor의 차원수는 input tensor의 차수원수와 같아야 한다.
# 즉 이 예제에서 t.dim() == ind_A.dim() 이어야 torch.gather()를 사용 할 수 있다.
# 이를 위해 ind_A의 차원을 t와 맞춰 주면
ind_A = ind_A.unsqueeze(1).unsqueeze(2)
# 여기 까지는 차원의 수만 맞춘것이다. gather가 정상적으로 동작하기 위해서는 타겟으로 하는 dimension를 제외한
# t와 ind_A의 나머지 dimension의 값이 같아야 한다.
# 즉 내가 추출하고자 하는 원소가 dim 0의 원소라면 t.size(), ind_A.size() 에서
# t.size(1)==ind_A.size(1) and t.size(2)==ind_A.size(2)의 조건을 만족해야 한다.
ind_A = ind_A.expand(ind_A.size(0), t.size(1), t.size(2))
# 여기 까지 코드를 실행 시킨면 ind_A.size() = [3,2,3] 이고 t.size()=[4,2,3] 이다.
# 앞서 설명했듯 target dimension 인 ind_A.size(0)!=t.size(0) 을 제외한 1,2 차원의 값이 2,3으로 같다.
# 최종적으로 위 그림 같이 dim=0에서 1,0,3 번째 원소를 추출하여 새로운 텐서를 구성하기 위해 아래 구문을 실면행하면된다.
res = res.gather(0,ind_A)
결과는 다음과 같다.
위 Fig 1.에서 처럼 t(input tensor)로 부터 dimension 0 의 1,0,3 번째 원소를 선택해 출력한 결과 이다.