반응형

Introduction

  • 딥러닝 학습을 잘(?)한다는 것을 정의하기는 어렵지만, 더 빠른 시간 안에 많은 양을 학습하는 것은 매우 중요하다.
  • 딥러닝의 모델은 다수의 layer로 구성되어 있기 때문에, 각 layer의 결과가 데이터가 어느 형태로 존재하는지, 어느 layer가 병목 현상인지를 파악하는 것이 까다롭다.
  • 특히, 최근에는 모델을 직접 코드로 구현하기보다는,  pre-trained model을 사용하는 경우가 많은데, 사전에 사용하는 모델의 구조를 알지 못하면, 내부 동작을 제대로 파악할 수 없다.
  • Pytorch에서는 이러한 모델의 구조와 각 layer에서의 cost를 profiling 할 수 있는 torch profiler를 지원한다.

 

Code Sample

  • torch profiler 테스트를 위한 resnet18을 이용한 CIFAR-10 classification code이다. 
  • 모델을 직접 코드로 구현한 것이 아닌, torchvision에서 load 하였다.
  • 사용 상황을 가정하자면, load 한 모델의 구조를 모르거나, 모델에 부하가 존재하는 부분을 tuning 해야 하는데, 어느 layer를 바꿔야 할지 모르는 상황이다. 
import torch
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.optim as optim

TRAIN_EPOCH = 10
TRAIN_PRINT_FREQUENCY = 200

if __name__ == '__main__':
    transform = transforms.Compose([
        transforms.RandomHorizontalFlip(),
        transforms.RandomCrop(32, padding=4),
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
    ])

    trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
    trainloader = torch.utils.data.DataLoader(trainset, batch_size=128, shuffle=True, num_workers=2)

    testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
    testloader = torch.utils.data.DataLoader(testset, batch_size=100, shuffle=False, num_workers=2)

    model = torchvision.models.resnet18(pretrained=False)
    num_features = model.fc.in_features
    model.fc = nn.Linear(num_features, 10)

    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model = model.to(device)

    for epoch in range(TRAIN_EPOCH):
        running_loss = 0.0
        for i, data in enumerate(trainloader, 0):
            inputs, labels = data[0].to(device), data[1].to(device)
            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()

            running_loss += loss.item()
            if i % TRAIN_PRINT_FREQUENCY == TRAIN_PRINT_FREQUENCY - 1:
                print(f'Epoch: {epoch + 1}, Batch: {i + 1}, Loss: {running_loss / 200:.3f}')
                running_loss = 0.0
    print("Training finished.")

    correct = 0
    total = 0
    with torch.no_grad():
        for data in testloader:
            images, labels = data[0].to(device), data[1].to(device)
            outputs = model(images)
            _, predicted = torch.max(outputs.data, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    accuracy = correct / total
    print(f'Test Accuracy: {accuracy:.2%}')

 

Torch Profiler

[Setup]

  • Pytorch에서는 1.8 버전 이상부터 torch의 profiling을 위한 torch.profiler를 제공한다. 따라서, Torch 버전이 1.8 이상인 경우에는 별도의 설치가 필요 없다. 
  • 현재 설치된 Torch 버전을 잘 모른다면, Python에서 아래 명령어를 통해 확인해 보자.
import torch
print(torch.__version__)
  • 만약, torch 버전이 1.8 미만에 torch 버전을 바꿔도 문제가 없는 상황이라면, 아래 명령어를 통해 torch 버전을 업그레이드해 준다. 
pip install --upgrade torch torchvision

 

[사용법]

  • 사용방법은 매우 간단하다. 우선 torch.profiler를 import 하고, 콘텍스트 관리자(with 절)를 이용하여, profiling을 위한 부분을 감싸주면 된다. (함수 전체에 대한 profiling은 profile with 절을 @profile(acitivities~)와 같은 decorator로 처리할 수 있다.)
  • 아래는 sample code 중, train에 대한 profiling을 위한 소스이다. 주의할 점은, profiling에 memory가 많이 소모되기 때문에, train epoch을 1로 낮춰놓고 profiling을 하는 것이 좋다. (어차피, 같은 동작이 반복되기 때문에, input 하나만을 측정해도 별 문제는 없다.)
  • 모델을 GPU에서 돌려서  "ProfilerActivity.CUDA "를 포함했지만, CPU로 돌리는 환경에서는 해당 인자를 생략해도 된다. (다만, GPU 환경에서는 CPU, CUDA 인자 모두 필요함)
...
from torch.profiler import profile, record_function, ProfilerActivity
...

    with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True) as prof:
        for epoch in range(TRAIN_EPOCH):
            running_loss = 0.0
            for i, data in enumerate(trainloader, 0):
                inputs, labels = data[0].to(device), data[1].to(device)
                optimizer.zero_grad()
                with record_function("model_train"):
                    outputs = model(inputs)
                loss = criterion(outputs, labels)
                loss.backward()
                optimizer.step()

                running_loss += loss.item()
                if i % TRAIN_PRINT_FREQUENCY == TRAIN_PRINT_FREQUENCY - 1:
                    print(f'Epoch: {epoch + 1}, Batch: {i + 1}, Loss: {running_loss / 200:.3f}')
                    running_loss = 0.0
    print(prof.key_averages().table(sort_by="self_cpu_time_total"))
    print("Training finished.")

[인자 설명]

  • activities : list 형태로 입력받는다. 어떤 활동을 profiling 할 것인지를 지정한다. 가능한 활동은 다음과 같다.
    • ProfilerActivity.CPU : CPU 작업(연산, 함수 호출)에 대한 프로파일링, CPU 시간, 메모리 사용량등을 제공
    • ProfilerActivity.CUDA  : CUDA 작업(GPU 연산, 호출)에 대한 프로파일링, GPU 시간, 메모리 사용량등을 제공
  • record_shapes : bool 형태, 각 layer의 입력(input)을 기록할지 여부
  • profile_memory : bool 형태, memory를 profiling 할지 여부, False로 설정하면 time에 대한 profiling만 진행한다.
  • on_trace_ready : Profiling 결과가 준비되었을 때, 호출될 callback 함수를 지정할 수 있음. on_trace_ready 옵션을 통해, 함수를 사전 정의해, profiling 결과 등을 file 형태로 떨굴 수 있다.
  ...
    with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True,
                 profile_memory=True, on_trace_ready=finish_profiler) as prof:
  ...
  • with_stack : bool 형태, 함수 호출 stack 정보를 표기할지에 대한 여
  • with_flops : bool 형태, 실제로 계산 비용을 FLOPs로 측정한 결과 
  • with_modules : bool 형태, profiling 결과에 연산의 호출 stack에 대한 module의 계층 구조를 기록해 줌. (어떤 연산이 어떤 연산의 내부에서 호출되었는지를 나타내줌)

 

 

[결과 출력]

  • 결과는 다음과 같은 명령어로 호출할 수 있다.
print(prof.key_averages().table(sort_by="self_cpu_time_total"))
  • 결과는 table 형태로 보이는데, 아래와 같이 다양한 옵션들을 사용할 수 있다. (key_averages() 내의 인자 형태로 들어감)
    • group_by_input_shape : True로 설정하면, 동일한 입력 모양을 가진 연산 또는, 함수 호출을 grouping 할 수 있다. (모델의 input 사이즈를 보려면, 해당 옵션을 True 설정해야 한다.)
    • group_by_stack_n : 연산 또는 함수의 stack의 상위 n 단계만을 기준으로 grouping 할지 지정하는 인자
  • table의 인자도 지정할 수 있는데, table의 출력에 대한 옵션을 지정한다.
    • sort_by : table을 어떤 기준으로 order by 할지 (default : None)
    • row_limit : 몇 개까지 표시할지
    • header : header를 표시할지 (default : None)
    • top_level_events_only : 해당 옵션을 True 설정하면, 최상위 호출 단계까지만 표시
  • 아래는 sample code에 대해 profiling을 수행한 결과이다. (CPU 시간이 큰 10개만 추출)

  • 결과에서 보이는 각 칼럼은 다음과 같다.  
  • CPU Time 관련 
    • Self CPU % : 연산 or 함수 호출이 소비한 CPU 시간의 백분율 (전체 실행 시간에서 해당 연산이 소요한 CPU 시간)
    • Self CPU : 해당 연산 or 함수 호출이 소비한 총 CPU 시간
    • CPU  total % : 해당 연산 or  함수 호출과 그 하위 호출에서 소요된 총 CPU 시간의 백분율
    • CPU total : 해당 연산 or 함수 호출과 그 하위 호출에 의해 사용된 총 CPU 시간
    • CPU time avg : 해당 연산 or 함수 호출의 평균 CPU 시간 (평균적으로 해당 연산이 소요되는 시간)
  • CUDA Time 관련  
    • Self CUDA : 해당 연산 or 함수 호출이 소비한 총 CUDA 시간
    • Self CUDA % : 해당 연산 or 함수 호출이 소비한 총 CUDA 시간의 백분율
    • CUDA total : 해당 연산 or 함수호출과 그 하위 호출에서 소요된 총 CUDA 시간
    • CUDA time avg : 해당 연산 or 함수호출과 그 하위 호출에서 소요된 평균 CUDA 시간
    • # of Calls : 해당 연산 또는 함수 호출의 호출 횟수
  • Model Input 관련 
    • Input Shapes : record shapes를 True로 하고, key_averages에 group_by_input_shape를 true로 지정한 경우에만 보인다. 각 연산의 input shape이 보인다.
  • CPU memory 관련 (snapshot 형태기 때문에 사용 전과 후의 memory 사용 delta값이 나온다. 즉, 음수가 될 수 있다.) 
    • CPU Mem : 연산 or 함수 호출이 소비한 CPU의 메모리 총 용량 
    • Self CPU Mem : 연산 or 함수 호출이 직접적으로 사용한 CPU 메모리 용량
  • CUDA memory 관련 (snapshot 형태기 때문에 사용전과 후의 memory 사용 delta값이 나온다. 즉, 음수가 될 수 있다.)
    • CUDAMem : 연산 or 함수 호출이 소비한 CUDA의 메모리 총 용량 
    • Self CUDAMem : 연산 or 함수 호출이 직접적으로 사용한 CPU 메모리 용량
  • 연산량 관련
    • Total MFLOPs : 연산 or 함수 호출이 실행될 때, 총 수행된 MFLOPs 수 

 

Torch 모델에서 torch Profiling을 통해, 부하가 되는 부분이나, Layer의 input size 등을 확인할 수 있다. 해당 profiling은 모델에서 부하가 되는 부분을 개선하거나, 하드웨어 확장에 대한 의사결정, Batch size 조절 등 다양한 model 개선에 사용될 수 있다.

반응형

Introduction

  • Python으로 짜인 Code를 서비스하다 보면, CPU 100%나 Memory Fault, 실행시간이 길어지는 등 다양한 문제를 만나게 된다. 
  • 자신이 개발한 코드에서는 직감적으로 어느 부분이 문제가 될지를 간파할 수 있지만, 다른 사람이 짠 코드에서 문제에 원인이 되는 부분을 찾아내기는 매우 어렵다. 
  • 일반적으로 가장 쉽게 떠올릴수 있는 방법은 실행시간은 time 모듈을 이용한 print 디버깅이나 unittest, CPU나 memory는 작업 관리자를 통해 확인하는 방법이다. 하지만, 이 방법들은 대략적인 정도만 알아낼 수 있고, 어느 부분이 문제가 있는지 진단하기 매우 어렵다.
  • Python에서는 Profiling을 위한 다양한 도구들을 가지고 있어, code 분석이 매우 용이하다. 어떤 것들이 있는지 확인해보자!

 

Code Sample

  • 각 도구들을 Test 하기 위한 sample code이다. 
  • code는 각각 validation_check, data_preprocessing, outlier_remove, data_sort, data_cal_half_avg 함수를 거쳐 최종 결과를 내도록 되어있다. 
import numpy as np

def data_validation_check(sensor_value):
    try:
        for i in sensor_value.split("|"):
            float(i)
        return True
    except:
        print("Error")
        return False


def data_preprocessing(sensor_value):
    sensor_value = sensor_value.split("|")
    sensor_value = list(map(float, sensor_value))

    return sensor_value


def outlier_remove(sensor_value):
    data_mean = np.mean(sensor_value)
    data_std = np.std(sensor_value)

    lower_bound = data_mean - 3 * data_std
    upper_bound = data_mean + 3 * data_std

    sensor_value = [i for i in sensor_value if lower_bound < i and upper_bound > i]
    return sensor_value


def data_sort(sensor_value):
    return np.sort(sensor_value)


def data_cal_half_avg(sensor_value):
    return np.mean(sensor_value[int(len(sensor_value) * 0.5):])


def run(sensor_value):
    if data_validation_check(sensor_value):
        sensor_value = data_preprocessing(sensor_value)
        sensor_value = outlier_remove(sensor_value)
        sensor_value = data_sort(sensor_value)
        sol = data_cal_half_avg(sensor_value)
        return sol
    else:
        return "Error!"


if __name__ == '__main__':
    sensor_value = "|".join([str(i) for i in range(10000000)])
    print(run(sensor_value))

 

memory_profiler : Memory Profiling 

  • Python은 머신러닝 같은 데이터 처리를 위한 언어로 자주 사용되기 때문에, 메모리 관련된 이슈에 자주 직면하게 된다. 
  • 따라서, 어느 부분이 memory를 많이 소모하는지 확인이 필요한 경우가 많다.
  • Python에서는 "memory_profiler"를 통해 memory 사용량을 측정할 수 있다.

 

[설치 방법]

  • 설치 방법은 매우 간단하다. pip을 이용하여 설치한다. 
pip install memory_profiler

[사용 방법]

  • 사용 방법도 매우 간단하다. memory_profiler의 profiler을 import 하고, memory profiling을 하고자 하는 함수에 "@profiler" 데코레이터를 사용하고, 프로그램을 실행하면 끝난다.

 

[사용 예시]

from memory_profiler import profile
import numpy as np

@profile
def data_validation_check(sensor_value):
    try:
        for i in sensor_value.split("|"):
            float(i)
        return True
    except:
        print("Error")
        return False
...

 

[결과]

  • 결과는 다음과 같이, 테이블 형태로 터미널에 출력된다. 

  • 각 칼럼은 다음을 의미한다.
    • Line # : code 내 몇 번째 줄인 지 
    • Mem Usage : 해당 라인이 실행되기 전의 메모리 사용량
    • Increment : 해당 라인의 실행으로 추가적으로 사용된 메모리의 양
    • Occurrences : 각 라인이 실행된 횟수
    • Line Contents : 라인 코드 내용
  • 즉, memory profiler는 각 라인이 수행되기 전과 후를 스냅숏으로 메모리의 증분값을 보여주어, memory 사용량을 나타낸다. (따라서, memory를 해제하는 경우 등에는 음수값이 나올 수 있다.)
  • memory_profiler의 결과를 file 형태로 저장하기 위해서는, logger를 사용하거나, 아래와 같이 프로그램 수행 결과를 txt 형태로 내리도록 하면 된다.
python -m memory_profiler main.py > log.txt
  • memory_profiler를 run 한 후, 아래 명령어로 그래프를 그릴 수 있는데, 사실 이 그래프로 뭘 알 수 있는지는 의문이다. (그냥 시간에 따른 메모리 사용량만 표시된다.) 
mprof plot -o memory_profiler_result.png

 

[Sample 수행 결과]

...

Filename: main.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    14    118.7 MiB    118.7 MiB           1   @profile
    15                                         def data_preprocessing(sensor_value):
    16    807.1 MiB    688.3 MiB           1       sensor_value = sensor_value.split("|")
    17    501.2 MiB   -305.8 MiB           1       sensor_value = list(map(float, sensor_value))
    18                                         
    19    501.2 MiB      0.0 MiB           1       return sensor_value


Filename: main.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    21    501.2 MiB    501.2 MiB           1   @profile
    22                                         def outlier_remove(sensor_value):
    23    501.3 MiB      0.1 MiB           1       data_mean = np.mean(sensor_value)
    24    501.3 MiB      0.0 MiB           1       data_std = np.std(sensor_value)
    25                                         
    26    501.3 MiB      0.0 MiB           1       lower_bound = data_mean - 3 * data_std
    27    501.3 MiB      0.0 MiB           1       upper_bound = data_mean + 3 * data_std
    28                                         
    29    579.9 MiB     78.5 MiB    10000003       sensor_value = [i for i in sensor_value if lower_bound < i and upper_bound > i]
    30    579.9 MiB      0.0 MiB           1       return sensor_value


Filename: main.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    32    503.5 MiB    503.5 MiB           1   @profile
    33                                         def data_sort(sensor_value):
    34    579.9 MiB     76.3 MiB           1       return np.sort(sensor_value)


Filename: main.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    36    197.5 MiB    197.5 MiB           1   @profile
    37                                         def data_cal_half_avg(sensor_value):
    38    197.5 MiB      0.0 MiB           1       return np.mean(sensor_value[int(len(sensor_value) * 0.5):])


7499999.5
mprof: Sampling memory every 0.1s
running new process
running as a Python program...
  • 사실, 언뜻 생각하기엔 sort에서 가장 많은 memory가 사용될 것이라고 생각했지만, 의외로 outlier 제거를 위한 순회나, split등에서 많이 사용된다는 것을 알 수 있다.

[주의점]

  • memory의 profile은 memory의 snapshot과 기록에 많은 추가 시간이 소요되기 때문에, memory profile과 실행시간 측정은 동시에 진행하면 안 된다.
  • memory가 snapshot 형태로 기록되기 때문에, memory 소요값이 절대적이지 않고, 실행 환경 등에 따라 다르다는 점을 꼭 기억하자!

 

Execution Time Profiling  : line_profiler

  • Execution Time은 Python에서 가장 민감한 부분이기도 하다. 
  • 보통 time 모듈을 이용하여 디버깅을 진행하기도 하는데, 매구 간마다 디버깅을 위해 시간을 찍는 것도 매우 비효율적이다.
  • 이런 비효율을 덜어줄 수 있는 Execution Time profiling 도구 line_profiler이다. 

[설치 방법]

  • 설치 방법은 memory_profiler처럼 pip을 이용하여 설치한다. 
pip install line_profiler

[사용 방법]

  • 사용 방법은 더 간단하다. 실행 시간을 측정하고 싶은 함수에 "@profile" 데코레이터를 넣어주고, 터미널에서 아래 명령어를 실행해 주면 된다. 
kernprof -l -v main.py

 

[사용 예시]

# memory_profiler가 import 안되도록 한번 더 확인!
import numpy as np

@profile
def data_validation_check(sensor_value):
    try:
        for i in sensor_value.split("|"):
            float(i)
        return True
    except:
        print("Error")
        return False
...

 

[결과]

  • 결과는 다음과 같이, 테이블 형태로 터미널에 출력된다. 

  • 우선 맨 위에, 시간 unit과 각 함수 total 수행 시간이 표시된다. (전체 total 수행 시간이 아니다.)
  • 아래 각 칼럼은 다음을 의미한다.
    • Line # : code 내 몇 번째 줄인 지 
    • Hits: 각 라인이 실행된 횟수
    • Time : 수행 시간
    • Per Hit: 각 실행당 걸린 시간
    • % Time : 함수 내 실행 시간에서 차지하는 퍼센트
    • Line Contents : 라인 코드 내용
  • line_profiler의 결과를 file 형태로 저장하기 위해서는, 아래 명령어를 사용하면 된다. line_profiler를 실행하면, 실행 파일에 대한 lprof의 파일 결과가 떨어지는데, 이를 text 파일로 떨구면 된다.
python -m line_profiler main.py.lprof > log.txt

 

[Sample 수행 결과]

  • 첫 생각과는 다르게, validation check가 가장 많은 시간이 소요되는 것을 확인할 수 있다. 
Timer unit: 1e-06 s

Total time: 4.83922 s
File: main.py
Function: data_validation_check at line 3

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     3                                           @profile
     4                                           def data_validation_check(sensor_value):
     5         1          0.5      0.5      0.0      try:
     6  10000000    1970775.1      0.2     40.7          for i in sensor_value.split("|"):
     7  10000000    2868439.0      0.3     59.3              float(i)
     8         1          1.5      1.5      0.0          return True
     9                                               except:
    10                                                   print("Error")
    11                                                   return False

Total time: 1.48381 s
File: main.py
Function: data_preprocessing at line 13

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    13                                           @profile
    14                                           def data_preprocessing(sensor_value):
    15         1     369882.5 369882.5     24.9      sensor_value = sensor_value.split("|")
    16         1    1113930.3 1113930.3     75.1      sensor_value = list(map(float, sensor_value))
    17                                           
    18         1          1.3      1.3      0.0      return sensor_value

Total time: 2.66128 s
File: main.py
Function: outlier_remove at line 20

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    20                                           @profile
    21                                           def outlier_remove(sensor_value):
    22         1     337871.3 337871.3     12.7      data_mean = np.mean(sensor_value)
    23         1     366792.1 366792.1     13.8      data_std = np.std(sensor_value)
    24                                           
    25         1          5.7      5.7      0.0      lower_bound = data_mean - 3 * data_std
    26         1         10.6     10.6      0.0      upper_bound = data_mean + 3 * data_std
    27                                           
    28         1    1956595.8 1956595.8     73.5      sensor_value = [i for i in sensor_value if lower_bound < i and upper_bound > i]
    29         1          0.7      0.7      0.0      return sensor_value

Total time: 0.415683 s
File: main.py
Function: data_sort at line 31

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    31                                           @profile
    32                                           def data_sort(sensor_value):
    33         1     415683.2 415683.2    100.0      return np.sort(sensor_value)

Total time: 0.003097 s
File: main.py
Function: data_cal_half_avg at line 35

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    35                                           @profile
    36                                           def data_cal_half_avg(sensor_value):
    37         1       3097.0   3097.0    100.0      return np.mean(sensor_value[int(len(sensor_value) * 0.5):])

 

[주의점]

  • memory profiler와 마찬가지로, line_profiler로 수행시간을 차분하여, 라인 별 수행시간을 구하는 것이다. 따라서, 절대적이지 않고 수행 환경에 따라 달라진다. 

 

Process Profiling  : py-spy

  • CPU는 운영체제의 스케줄링이나 프로세스 등에 따라 동적으로 변하기 때문에, 함수마다의 수행시간을 정확히 측정하는 것은 매우 어렵다.
  • 따라서, CPU는 리눅스 명령어나 윈도 작업관리자를 통해, 프로그램 수행 후 observation 형태로 간접적으로 파악하는 방법 밖에 없다.
  • 또한, CPU는 사용량이 많더라도, 조치하기가 매우 어렵다. 따라서, 너무 CPU 사용량이 많은 부분만 확인하는 정도의 이상감지용 지표로 활용하는 것이 좋다.
  • CPU 사용률을 직접 측정하기는 어렵지만, 각 Process에 걸리는 부하를 간접적으로 알 수 있는 도구가 있는데, 바로 py-spy이다. 

[설치 방법]

  • 설치 방법은 앞선 profiler들처럼 pip을 이용하여 설치한다. 
pip install py-spi

[사용 방법]

  • 사용 방법은 단순히 아래 명령어를 터미널에 입력해 주면 된다.
py-spy record -o profile.svg -- python main.py

 

[결과]

  • 결과는 위에서 지정한 profile.svg(이름은 바꿔도 된다.) 파일의 스택 플레임 그래프 형태로 떨어진다.

  • 결과는 다음의 형태를 가진다. 
    • 함수 호출 스택이 위에서부터 바깥쪽의 함수를 의미한다. 예를 들어, 예제의 run 함수 →  data_preprocessing 함수 → split 함수 형태로 위부터 아래로 표시된다.
    • Box 표시 : 각 함수가 Box로 표시된다. Box의 크기가 해당 함수의 소비 시간을 나타낸다. 따라서, 상위 함수는 하위 여러 함수들의 박스들로 구성된다. 
    • 색상 : 어두운 색상에 있는 함수일수록 깊은 호출 스택을 의미한다. 
  • 일반적으로, 다음과 같은 결과 해석이 가능하다.
    • 우선 Box가 큰 함수의 부분이 부하의 원인이 되는 경우가 많기 때문에 주목해서 봐야 한다.
    • Box가 큰 함수들 중, 호출 스택이 깊은 함수들은 여러 번 중첩되는 경우가 많은데, 이 부분의 중첩을 줄여야 개선이 가능하다.
    • 다른 profiler들과 다르게, 내부의 import 된 함수 단위까지 표시가 되기 때문에, 어떤 구조로 함수가 호출되는지 이해가 쉽다.

 

[주의점]

  • 사실, 수행 시간을 통해, 간접적으로 프로세스의 중첩이나, 부하를 확인하는 것이기 때문에, CPU 사용률과 직접적인 연관이 없다. (참고용으로만 사용하는 것이 좋다.)

 

 

 

이 밖에, Python 내장 profiler인 CProfile 같은 Profiler와, Palanteer, Pyinstrument 등의 Profiler 들도 존재한다. 하지만, 프로그램의 수행결과로 논문을 쓸 것이 아니라면, 다음과 같은 툴로도 충분하다고 생각한다. 

반응형

Introduction

[문제]

  • Python은 매우 간단하고, 응용 모듈이 많아서, 많이 선호되지만, 속도가 중요한 프로그램에서는 항상 문제가 된다.(Python 코드의 속도 튜닝의 끝은 다른 언어로 다시 개발하는 것이라고 할 만큼, Python은 느리다.)
  • 하지만, Python에만 존재하는 응용 패키지들이 많아서, Python 언어를 유지하면서 속도 튜닝이 필요한 경우가 많다.
  • Python이 느린 이유는 많지만, 아래의 이유가 치명적이다.
    1. 인터프리터 언어 : 코드를 한줄 씩 읽고, 해석하는 식으로 동작한다.
    2. 동적 타이핑 : Python은 형식을 지정해주지 않아, 코드 실행 중에 Type 지정이 필요하다.

[Numba]

  • Numba는 이러한 Python의 실행 속도를 개선하기 위한 대표적인 라이브러리로, JIT(just-in-time)이라는 Compiler를 통해, Numpy 배열, 함수, loop의 속도를 개선하였다.
  • 단순히, 패키지 import와 decorator 사용만 하면 되어서, 매우 간단하다. 
  • Numba는 Array 처리 등의 무거운 Python 코드를 동적으로 compile 하여, 기계어로 변환한다. 이 과정에서 type 정보 분석 & 최적화를 하여, 속도를 최적화한다. 

 

Numba 설치 방법

  • Numba의 설치 방법은 매우 간단하다. 
pip install numba

또는

conda install numba

 

Numba 사용

  • numba는 기본적으로 함수에 @jit의 decorator를 넣어주면 된다. 
from numba import jit

@jit
def numba_func(input):
    sol = np.tanh(input)
    return sol
  • numba에는 몇 가지 옵션을 사용할 수 있는데, 각 옵션은 다음과 같다. (해당 옵션등은 함께 적용할 수 있다.)
Option 명 설명 주의점 사용 예시
nopython Python을 interpreter로 처리하지 않고, Compile을 진행 Python에서만 존재하는 라이브러리(pandas 등)를 사용하면 Error가 뜸 @jit(nopython=True)
nogil GIL(Global Interpreter Lock)을 사용하지 않음. Thread 간 안전성 문제가 있을 수 있음, 메모리 사용량이 많아질 수 있음 @jit(nogil=True)
cache Compile 결과를 디스크에 캐싱하여, 이후에 재사용 할 수 있도록 함 Numba 버전이나, 코드 변경 시, 캐시 파일이 의미가 없을 수 있음.  @jit(cache=True)
parallel 병렬 처리를 위해 사용, 반복문과 배열의 연산을 병렬화하여 cpu 코어를 활용  가능 추가적인 메모리 사용과 오버헤드 발생 가능, 병렬화로 인해 항상 성능 향상을 보장하지 못함. @jit(parallel=True)

 

  • 일반적으로 nopython 모드를 True로 하는 경우가 많다. 이것은 Python interpreter와 상호작용을 최소화하여, 함수를 빠르게 수행할 수 있기 때문이다.
  • nopython 모드는 자주 사용되기 때문에 njit decorator를 통해 사용되기도 한다. 
from numba import njit

//@jit(nopython=True)와 같음
@njit
def numba_func(input):
    sol = np.tanh(input)
    return sol

 

Numba 성능 비교

  • H/W와 코드 환경, 연산하는 함수에 따라, 컴파일 및 최적화 정도는 천차만별이다. 따라서, 절댓값이 주목하기보다는 대략적으로 이런 효과가 있구나 정도로 생각해 주길 바란다.
import numpy as np
from numba import jit
import time

@jit
def numba_func(input):
    sol = np.tanh(input)
    return sol 


def no_numba_func(input):
    sol = np.tanh(input)
    return sol 


if __name__  == '__main__':
    data_length = 1000000000
    input_data = np.arange(data_length)

    start_time = time.time()
    numba_func(input_data)
    end_time = time.time()
    print("Elapsed Time (with numba):",end_time-start_time)

    start_time = time.time()
    no_numba_func(input_data)
    end_time = time.time()
    print("Elapsed Time (without numba):",end_time-start_time)
  • 길이가 10억개의 데이터에 대해서 jit과 jit 옵션이 없는 코드를 실행해 보았다. jit을 사용한 것이 빠른 속도를 보여주는 것을 확인할 수 있다.

  • 길이가 1억개의 데이터에 대해서 jit과 jit 옵션이 없는 코드를 실행해 보았다. jit을 사용하지 않은 것이 더 빠른 속도를 보여주는 것을 확인할 수 있다. 

  • 길이가 1000개 정도의 소규모(?) 데이터에서 실행 결과, jit의 overhead가 확실히 존재한다는 것을 확인할 수 있다.

 

→ 다만, JIT의 성능 향상에 대한 연산량은 H/W 등의 실험환경에 크게 영향을 받는다. 꼭, 실제 코드를 돌릴 환경에서 테스트해 보고 적용하는 것을 추천한다.

 

Numba 주의점

  • Numba는 대용량의 연산이 아닌, 소규모의 연산에서는 오히려 느린 성능을 보여준다. 이는 Numba의 JIT 컴파일에 약간의 오버헤드가 있기 때문이다.
  • Numba에서 성능 향상을 보기 위해서는, 최대한 간단하고, 배열 위주의 작업들을 대용량 데이터에서 사용해야한다. 제어 흐름이 복잡한 코드는 최적화에 한계가 있다.
  • nopython 옵션 적용 시, JIT이 컴파일 할 수 없는 경우에는 에러가 뜬다. Input과 Output의 타입, 함수 내의 연산이 명확한 경우에만 사용하도록 한다. 
  • Numba가 효과있는 데이터 양등을 실제 프로그램이 돌아갈 환경에서 실험해 보고, 데이터 연산량을 대략적으로 계산하여,  JIT을 적용한 함수와 적용하지 않는 함수를 각각 놓고 분기를 치는 것도 좋은 방법이다. 
  • 실제로 운영 단에 있는 코드 들에서는 하나의 함수에 복잡한 내용이 섞여있는 경우가 많다. 이러한 경우, numpy나 for문만 별도의 함수로 나눠서 JIT을 적용해줘야한다. 이러한 변경은 최적화에서는 이점이 있을지 모르지만, 가독성에서는 해가될 수 있다.

Numba는 Python의 고질 병인 속도 문제를 해결하기 위해, 등장한 라이브러리다. 비록, 대용량 데이터에서만 효과를 볼 수 있다는 아쉬운 점도 있지만, 이런 옵션이 존재한다는 것이 어딘가 싶다. (사실, 대용량 데이터가 아니면, 굳이 속도 문제가 치명적이진 않을 것이다.)  만약, 운영 환경에서 간혹 존재하는 대용량 데이터에 고통받고 있다면, 예외처리용으로 사용해도 좋을 것 같다. 

+ Recent posts