최근 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로 감싸는 부분을 아래와 같이 바꿔 주면 된다. 

mymodel = DDP(mymodel, find_unused_parameters=True, device_ids=[local_rank], output_device=[local_rank])

문제가 발생한 원인은 torch 1.7 부터는 좀더 명시적인 정보를 DDP 모듈에 제공해 줘야 하기 떄문인 것으로 보인다. 

내가 원하는 건 multi process multi gpu 학습인데  DDP 호출 시 apex에서는 visible gpu 에 알아서 모델을 옮겼다면

torch DDP는 어디에 model을 복사 할건지 명시적으로 지정하도록 했다. 이 부분이 devices_ids=[local_rank] 부분이다.

 

find_unused_parameters=True 는 model backward pass에 연관 되지 않는 parameter 들을 mark해서 DDP가 해당 파라미터들의 gradient들을 영원히 기다리는 것을 방지 한다. 

이에 대한 설명은 [여기]를 참조면 된다. 

 

오늘은 deep learning 모델을 학습 시킬때 GPU 사용율이 저조한 경우 이 현상의 원이이 뭐고

어떻게 개선 할수 있는지에 대해 포스팅을 해보고자 한다. 어디 까지나 필자의 접근 법이고 완벽한 답은 아니다.. 

 

개발 환경:

frame work: pytorch 1.7

os: ubuntu 20.04

training env: multi gpu(distributed module 사용)

 

목차:

1. 현상

2. 원인 분석 및 profile

3. 개선

 

현상

학습 하는 네트워크는 CNN 기반의 segmentation 네트워크 였고 학습 데이터는 이미지 약 7만장 이상이었다. 

초기 input pipe line은 image-annotation file pair 를 읽어 들이는 원시적인 형태로 되어있었다.

annotation file에는 polygon 형태의 segmentation label 정보가 저장되어 있어서 이 정보를 mask image로 바꾸어 NAS에 저장해두었다.

디렉토리 구조는 아래와 같다.

Fig 1. 초기 디렉토리 구조

 

v100 tensor core gpu 로 위 상태 그대로 학습을 진행 했을때 배치 사이즈 48 기준 약 25분이 걸리는 신기한 현상이 발생했다.

 gpu 사용율은 아래 그림 Fig 2. 처럼 100% -> 9% -> 90% -> 10%->0% -> 100% 과 사용율의 등락율이 굉장히 커보였다. 

아래 그림은 위 상황에 대한 캡쳐 본으로 아래 명령을 입력해 확인할 수 있다.

watch -n 1 nvidia-smi

Fig 2. gpu 사용율(Volatile GPU-Util 항목)을 보면 위 그림에서 100% 사용하다가 아래 그림에서는 13%,0%,0%,100%로 사용율의 등락률이 큰 것을 확인할 수 있다.

 

원인 분석 및 Profile

위와 같은 현상이 나타나면 대부분 원인은 둘 중 하나다. 

  1. 파일이 저장된 위치가 NAS 니까 사용자가 많아 대역폭 확보가 안되어 File I/O가 병목의 원인이거나 
  2. I/O 가 문제가 아니라면 CPU 연산이 많아 병목의 원이이 되는 경우다.

위 현상을 진단할 수 있는 방법은 크게 2가지이다. 

  • iftop 을 이용해 File I/O speed를 체크해 보는 방법이 있고(엄밀히 말하면 network를 통한 file transfer speed)
  • htop 을 이용해 CPU 사용량을 체크해 보는 방법이 있다.

나는 sudo 권한이 없어서 1번 방법을 쓸 수 없어서 htop을 이용해 진단을 했다. 

방법은 간단하고 나름 합리적이고 크게 두 가지 케이스로 나눠 진다. 

  1. htop을 사용해 CPU 사용량을 모니터링했을 때 CPU 코어의 사용율이 저조 하고 데이터 로더 프로세스 상태가 D(uninterruptible sleep-usually I/O) 인 경우가 많다면 File I/O가 병목인 경우가 대부분이다. 
  2. CPU 코어의 사용율이 거의 100%에 가깝고 데이 로더 프로세스 상태가 D인 경우가 거의 없다면 CPU 연산이 병목의 원인인 경우가 많다.

본인의 경우가 케이스 2. 에 해당 한다면 GPU 프로파일을 한번쯤은 해보는게 좋다. 혹시 라도 네트워크의 구조 또는 loss의 구현이 비효율적으로 되어있어 GPU 사용율이 떨어질 가능 성이 있기 때문이다. 

 

본인의 경우에는 케이스 2에 해당되어 GPU 프로파일 까지 진행했었다. 

gpu프로파일에 nvidia NSIGHT SYSTEMS PROFILE을 이용했다. 

사용방법은 매우 간하며 아래와 같은 명령어를 사용하면 된다. 

nsys profile –t cuda,osrt,nvtx,cudnn,cublas -o baseline.qdstrm -w true python your_train_script.py

사용시 주의할 점은 반드시 <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. GPU 프로파일 결과

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 4. 개선 버전의 GPU profile 결과

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 사용률의 등락 폭은 줄어 들지 않았다. 

아래 그림을 보면 이 분석을 시작한 현상이 그대로 남아 있음을 알 수 있다. 

Fig 5. data loader 코드 개선 버전의 gpu 사용률 

그래서 다시 htop로 cpu 사용률 모리터링을 해보니 아래 그림 Fig 6. 과 같이 cpu가 100%일을 하고 있지는 않다. 

Fig 5. CPU 바운드 원인 코드 개선 버전의 cpu 사용율

 

이전 섹션인 '원인 분석 및 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 을 이용해 학습

에러 발생 순서:

  1. DistributedDataParallel 을 이용해 학습 도중 _model.state_dict()_을 저장
  2. 학습 하던 모델에서 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()

 

학습된 파라미터 로드 중 표제와 같은 에러가 발생하는 경우가 있다. 이런 경우에 대한 해결책을 정리해본다.

에러 발생 작업 순서:

1. apex의 DistributedDataParallel를 이용해 multi gpu로 모델 학습 중 아래 코드를 이용해 모델 state 저장

torch.save('state_dict':model.state_dict())

 

2. 저장된 state_dict을 단일 gpu를 사용해 테스트 하기 위해 torch.load()로 복원 하던 중 다음과 같은 에러가 발생했다.

'''
Error(s) in loading state_dict for model: 
	Missing key(s) in state_dict: "backbone.block0.0.0.weight", ~, ~
	Unexpected key(s) in state_dict: "module.backbone.0.0.weight", ~, ~
'''

3. 위 에러 메시지에서 확인 가능 하듯 저장된 state_dict에는 모든 weight 이름 앞에 "module" 이라는 prefix가 추가되어 있다.

 

발생 사유:

아래와 같이 DistributedDataParallel 사용 시

DDPmodel = DistributedDataParallel(model)

리턴된 _DDPmodel_은 _model_을 _module_로 감싼 형태 이다.

즉, 기존 model_의 *attribute_은 *model.module.attribute 과 같이 접근 해야 하는데 그걸 빼먹은 것이다.

 

 

해결 방법 1:

학습 중 모델 저장 시 아래 코드를 이용해 모델 상태를 저장한다.

torch.save('state_dict':model.module.state_dict())

 

 

해결 방법 2:

본래 목적이 기 학습된 모델의 구조를 유지하고 파라미터의 극히 일부분만 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)

비슷 한 이슈를 격은 사람들이 이 링크에 있는거 같다.

link:참조

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만으로 구성된 새로운 텐서를 만들려면 

Fig 1. gather 메서드의 동작 방식

 

# 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 번째 원소를 선택해 출력한 결과 이다. 

 

t.shape == [4,2,3] 이므로 dimension 0의 원소들은 shape 이 [2,3]인 행렬이다.

즉, t[1].shape==[2,3], t[0].shape == [2,3], t[3].shape==[2,3]

 

장황해 지만 다시 정리하면 torch.gather 메서드는 input tensor의 타겟 dimension으로 부터 원하는 원소를 추출해

새로운 텐서를 만들때 사용 하며 index tensor는 다음을 만족해야 한다.

 

1. inputTensor.dim()==indexTensor.dim()

2. inputTensor.size() == [x,y,z] 이고 indexTensor.size()==[x',y',z'] 일 때

   타겟 dimension=0 이면 y==y' and z==z' 이어야 한다.

   타겟 dimension=1 이면 x==x' and z==z' 이어야 한다. 

   타겟 dimension=2 이면 x==x' and y==y' 이어야 한다. 

 타겟 dimension 이란 torch.gather(dim=x, indexTensor) 에서 dim 파라미터에 할당되는 값을 의미한다.

 

+ Recent posts