10 분 소요

실습 5 — PyTorch 프로파일러(PyTorch Profiler)와 텐서보드(TensorBoard)

Nsight Systems Profiling 실습은 CUDA API 호출, CPU 스레드, NVTX(NVIDIA Tools Extension, 코드 실행 구간에 사용자 정의 레이블을 추가해 CPU·GPU 작업을 프로파일링 타임라인에서 쉽게 구분하도록 하는 도구) 범위, 커널 실행과 같은 시스템 수준 관점(system view)을 제공한다. 분석 범위가 넓고 상세하지만 도구가 비교적 무거우며, 셸(shell) 환경에서 작업하고 CSV 파일을 분석해야 한다.

PyTorch 프로파일러(PyTorch Profiler)는 더 가벼운 프로파일링 도구다. Python 코드 몇 줄만으로 연산자별 실행 시간, 메모리 할당량, 크롬 트레이스(Chrome trace)를 확인할 수 있다. 특히 “어떤 PyTorch 연산자가 가장 많은 실행 시간을 사용하는가?”와 같은 모델 수준 최적화(model-level optimization)에 적합하다.

두 도구는 서로 대체하는 관계가 아니라 상호 보완적인 관계다.


분석 질문 적합한 도구


“현재 GPU가 유휴 상태인가, 작업 Nsight Systems 와 중인가?” 타임라인(timeline)

“모델에서 가장 느린 연산자는 PyTorch 프로파일러(PyTorch 무엇인가?” Profiler)와 key_averages

“각 계층(layer)이 얼마나 많은 profile_memory=True를 적용한 메모리를 할당하는가?” PyTorch 프로파일러

“마이크로초 단위에서 GPU가 어떤 Nsight Systems 또는 Nsight Compute 작업을 수행하는가?”

“지속적 통합(CI) 환경에서 사용할 PyTorch 프로파일러(PyTorch 수 있는가?” Profiler): 스크립트 실행과 결과 분석이 가능함 —————————————————

  • 여기서 타임라인(timeline)은 CPU 스레드, CUDA API 호출, GPU 커널 실행, 메모리 전송 등의 동작을 시간 순서대로 시각화하여 GPU가 작업 중인지 대기 중인지 보여주는 분석 화면이다.

참고 자료

실습 목표

  1. 학습 단계(training step)를 torch.profiler.profile(...)로 감싸고 실행 시간이 긴 주요 연산자를 추출한다.
  2. key_averages() 결과표를 출력하고 각 열의 의미를 이해한다.
  3. 메모리 프로파일링(memory profiling)을 적용해 메모리 할당량이 큰 연산자를 찾는다.
  4. 크롬 트레이스(Chrome trace)를 내보내 스케줄러의 유휴 구간을 분석한다.

1단계 — 학습 단계 프로파일링 및 주요 연산자 추출

최소한으로 사용할 수 있는 프로파일링 코드는 다음과 같다.

with torch.profiler.profile(activities=[CPU, CUDA]) as prof:
    for _ in range(5):
        training_step()
prof.key_averages().table(sort_by='cuda_time_total', row_limit=20)

다음 두 가지 사항에 주의해야 한다.

  • 프로파일러 외부에서 워밍업 단계(warmup step)를 먼저 실행한다. 최초 CUDA 실행에서는 커널 컴파일과 초기화가 발생하므로 측정값이 왜곡될 수 있다.
  • 측정 마지막에 torch.cuda.synchronize()를 호출한다. CUDA 커널은 비동기식(asynchronous)으로 실행되므로, 동기화하지 않으면 일부 커널의 실행 시간이 집계되지 않을 수 있다.

프로파일러의 주요 시간 열은 다음과 같다.


열 측정 내용


CPU 시간(CPU time) CPU 스레드가 연산자 내부에서 디스패치(dispatch), 인자 바인딩(argument binding) 등을 수행한 시간

CUDA 시간(CUDA time) 해당 연산자가 실행한 GPU 커널의 전체 실행 시간

자체 CUDA 시간(Self CUDA time) 하위 연산자(child op)의 실행 시간을 제외한 해당 연산자 자체의 CUDA 실행 시간 ——————————————

개별 연산자 수준 최적화에는 자체 CUDA 시간(Self CUDA time)을 사용하고, 전체 모듈 수준 최적화에는 CUDA 시간(CUDA time)을 사용하는 것이 적절하다.

import torch
import torch.nn as nn
from torch.profiler import profile, ProfilerActivity, record_function

# CUDA GPU를 실행 장치로 설정한다.
device = torch.device('cuda')

# 재현 가능한 결과를 위해 난수 시드를 고정한다.
torch.manual_seed(0)

# 소규모 합성곱 신경망(CNN) 학습 환경을 구성한다.
model = nn.Sequential(
    nn.Conv2d(3, 64, 7, stride=2, padding=3), nn.BatchNorm2d(64), nn.ReLU(),
    nn.Conv2d(64, 128, 3, stride=2, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
    nn.Conv2d(128, 256, 3, stride=2, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
    nn.AdaptiveAvgPool2d(1), nn.Flatten(),
    nn.Linear(256, 1000),
).to(device)

# 옵티마이저(optimizer)와 손실 함수(loss function)를 정의한다.
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

def training_step():
    # 임의의 입력 배치와 클래스 레이블을 GPU에 생성한다.
    x = torch.randn(64, 3, 224, 224, device=device)
    y = torch.randint(0, 1000, (64,), device=device)

    # 순전파(forward pass)를 수행하고 손실을 계산한다.
    out = model(x)
    loss = loss_fn(out, y)

    # 기울기를 초기화한 뒤 역전파와 파라미터 갱신을 수행한다.
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

# 최초 CUDA 초기화 비용을 제외하기 위해 프로파일링 전에 워밍업한다.
for _ in range(3):
    training_step()

# 비동기 CUDA 연산이 완료될 때까지 대기한다.
torch.cuda.synchronize()
print('워밍업 완료')
/usr/local/lib/python3.11/dist-packages/torch/cuda/__init__.py:65: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]

warmed up
# 다섯 번의 학습 단계를 CPU와 CUDA 기준으로 프로파일링한다.
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
    for _ in range(5):
        # Trace에서 학습 단계 구간을 식별할 수 있도록 레이블을 지정한다.
        with record_function('training_step'):
            training_step()

    # 모든 비동기 CUDA 커널 실행이 끝날 때까지 대기한다.
    torch.cuda.synchronize()

# 연산자별 누적 실행 시간과 호출 횟수를 추출한다.
top_ops = []
for evt in prof.key_averages():
    top_ops.append({
        'name': evt.key,
        'cpu_time_us': evt.cpu_time_total,
        'cuda_time_us': (
            evt.device_time_total
            if hasattr(evt, 'device_time_total')
            else evt.cuda_time_total
        ),
        'self_cuda_time_us': (
            evt.self_device_time_total
            if hasattr(evt, 'self_device_time_total')
            else evt.self_cuda_time_total
        ),
        'count': evt.count,
    })

# 전체 CUDA 실행 시간이 긴 순서로 상위 15개 연산자를 선택한다.
top_ops.sort(key=lambda op: -op['cuda_time_us'])
top_ops = top_ops[:15]

print(f'CUDA 실행 시간 기준 상위 연산자 {len(top_ops)}개를 수집했다.\n')
print(f'{"연산자":<40s} {"CPU(ms)":>10s} {"CUDA(ms)":>10s} {"호출 수":>8s}')

for op in top_ops[:12]:
    print(
        f'{op["name"][:40]:<40s} '
        f'{op["cpu_time_us"]/1000:>10.2f} '
        f'{op["cuda_time_us"]/1000:>10.2f} '
        f'{op["count"]:>8d}'
    )
/usr/local/lib/python3.11/dist-packages/torch/profiler/profiler.py:217: UserWarning: Warning: Profiler clears events at the end of each cycle.Only events from the current cycle will be reported.To keep events across cycles, set acc_events=True.
  _warn_once(

Captured 15 top ops (by CUDA time):

op                                         CPU (ms)  CUDA (ms)  count
autograd::engine::evaluate_function: Con       3.98      28.51     15
ConvolutionBackward0                           3.70      28.51     15
aten::convolution_backward                     3.62      28.51     15
training_step                                  0.00      22.72      5
training_step                                 39.81      22.70      5
aten::conv2d                                   3.73      12.38     15
aten::convolution                              3.67      12.38     15
aten::_convolution                             3.39      12.38     15
autograd::engine::evaluate_function: Cud       1.82      10.45     15
CudnnBatchNormBackward0                        1.43      10.45     15
aten::cudnn_batch_norm_backward                1.31      10.45     15
aten::cudnn_convolution                        2.53       8.95     15

2단계 — 표준 key_averages().table() 요약표

prof.key_averages().table(...)은 프로파일링 결과를 사람이 읽을 수 있는 표 형식으로 출력한다. 성능 문제를 깃허브 이슈(GitHub issue)나 팀 채팅에서 공유할 때 사용할 수 있다.

주요 인자는 다음과 같다.

  • sort_bycpu_time_total, cuda_time_total, self_cpu_time_total, self_cuda_time_total, count 중 하나를 기준으로 정렬한다.
  • row_limit — 출력할 최대 행 수를 지정한다. 기본값은 25다.
  • group_by_input_shape=True입력 텐서 형태(input shape)별로 연산자를 구분한다. 동일한 연산자(예: aten::mm)라도 입력 형태가 다르면 별도의 행으로 표시되므로, 어떤 텐서 형태가 실행 시간을 가장 많이 차지하는지 확인할 수 있다.

🐛 일반적인 실수: 호출 횟수 기준 정렬

count를 기준으로 정렬하면 호출 횟수가 가장 많은 연산자가 표시된다. 일부 분석에는 유용하지만, 호출 횟수가 많다고 해서 전체 실행 시간을 가장 많이 차지하는 것은 아니다. 초기 분석에서는 일반적으로 cuda_time_total을 기준으로 정렬한다.

# CUDA 실행 시간을 기준으로 정렬한 연산자 요약표를 생성한다.
# PyTorch 버전 간 호환성을 위해 cuda_time_total을 사용한다.
table_str = prof.key_averages(
    group_by_input_shape=False
).table(
    sort_by='cuda_time_total',
    row_limit=15,
)

print(table_str)
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  
                                                   Name    Self CPU %      Self CPU   CPU total %     CPU total  CPU time avg     Self CUDA   Self CUDA %    CUDA total  CUDA time avg    # of Calls  
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  
autograd::engine::evaluate_function: ConvolutionBack...         0.33%     276.715us         4.75%       3.979ms     265.258us       0.000us         0.00%      28.514ms       1.901ms            15  
                                   ConvolutionBackward0         0.09%      78.921us         4.42%       3.702ms     246.811us       0.000us         0.00%      28.514ms       1.901ms            15  
                             aten::convolution_backward         1.82%       1.524ms         4.32%       3.623ms     241.549us      26.521ms        39.49%      28.514ms       1.901ms            15  
                                          training_step         0.00%       0.000us         0.00%       0.000us       0.000us      22.718ms        33.83%      22.718ms       4.544ms             5  
                                          training_step        20.76%      17.404ms        47.49%      39.811ms       7.962ms       0.000us         0.00%      22.696ms       4.539ms             5  
                                           aten::conv2d         0.08%      63.111us         4.45%       3.729ms     248.632us       0.000us         0.00%      12.381ms     825.392us            15  
                                      aten::convolution         0.33%     274.614us         4.37%       3.666ms     244.425us       0.000us         0.00%      12.381ms     825.392us            15  
                                     aten::_convolution         0.35%     289.608us         4.05%       3.392ms     226.117us       0.000us         0.00%      12.381ms     825.392us            15  
autograd::engine::evaluate_function: CudnnBatchNormB...         0.46%     388.718us         2.17%       1.819ms     121.256us       0.000us         0.00%      10.449ms     696.618us            15  
                                CudnnBatchNormBackward0         0.14%     117.601us         1.71%       1.430ms      95.342us       0.000us         0.00%      10.449ms     696.618us            15  
                        aten::cudnn_batch_norm_backward         0.75%     625.724us         1.57%       1.313ms      87.502us      10.449ms        15.56%      10.449ms     696.618us            15  
                                aten::cudnn_convolution         1.68%       1.407ms         3.01%       2.525ms     168.358us       8.953ms        13.33%       8.953ms     596.836us            15  
_5x_cudnn_ampere_scudnn_128x128_stridedB_splitK_medi...         0.00%       0.000us         0.00%       0.000us       0.000us       6.937ms        10.33%       6.937ms       1.387ms             5  
void cudnn::bn_bw_1C11_kernel_new<float, float, floa...         0.00%       0.000us         0.00%       0.000us       0.000us       6.308ms         9.39%       6.308ms       1.262ms             5  
                                       aten::batch_norm         0.08%      63.860us         2.46%       2.059ms     137.291us       0.000us         0.00%       5.871ms     391.380us            15  
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  
Self CPU time total: 83.825ms
Self CUDA time total: 67.152ms

3단계 — 메모리 프로파일링: 어떤 연산자가 가장 많은 GPU 메모리를 할당하는가?

메모리 단편화(memory fragmentation)와 최대 메모리 사용량 문제는 메모리 부족(OOM, Out of Memory)과 함께 AI 모델 학습 실패의 주요 원인이다. profile_memory=True를 설정하면 학습 과정에서 발생하는 cudaMalloccudaFree 이벤트를 기록하고, 메모리 할당을 발생시킨 연산자와 연결한다.

일반적으로 모델 가중치(weight)보다 활성값(activation)이 더 많은 메모리를 차지한다. 예를 들어, ResNet의 한 번의 순전파(forward pass)는 모델 파라미터 용량보다 5~10배 많은 메모리를 할당할 수 있다. 이러한 활성값은 역전파(backward pass) 과정에서 해제된다. 최대 메모리 할당량이 예상보다 크다면 캐시된 중간 출력이나 누락된 .detach() 호출로 인해 텐서가 계속 유지되는지 확인해야 한다.

구분해야 할 두 가지 지표

  • 자체 CUDA 메모리 사용량(Self CUDA memory usage) — 해당 연산자가 순수하게 할당한 메모리다. 연산자 내부의 할당량에서 해제량을 뺀 값이다.
  • 최대 메모리 사용량(Peak memory usage) — 해당 연산자의 실행 범위에서 기록된 최고 메모리 사용량이다.

메모리 부족(OOM)을 방지하려면 일반적으로 최대 메모리 사용량을 줄이는 것이 중요하다.

메모리 추적을 포함한 프로파일링

profile_memory=True를 설정하면 프로파일러가 연산자별 메모리 할당자 이벤트(allocator event)도 기록한다. 이를 통해 GPU 메모리(VRAM)를 많이 사용하는 연산자를 찾고, 활성값 체크포인팅(activation checkpointing, 순전파 중 일부 활성값만 저장하고 나머지는 역전파 시 다시 계산해 GPU 메모리 사용량을 줄이는 기법)이나 연산자 융합(operator fusion, 여러 연산을 하나의 커널로 결합해 메모리 접근과 커널 실행 오버헤드를 줄이는 최적화 기법)의 적용 대상을 검토할 수 있다.

record_shapes=True는 입력 텐서 형태 정보를 함께 저장한다. 따라서 보고서에서 linear-[32×1024]linear-[8×768]처럼 동일한 연산자라도 입력 형태가 다른 실행을 구분할 수 있다.

# 메모리 사용량과 입력 텐서 형태를 포함해 프로파일링한다.
with profile(
    activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
    profile_memory=True,
    record_shapes=True,
) as prof_mem:
    for _ in range(3):
        training_step()

    # 비동기 CUDA 연산을 완료한 후 측정을 종료한다.
    torch.cuda.synchronize()

CUDA 메모리 사용량을 기준으로 연산자 순위 지정

key_averages()를 순회하면서 전체 메모리 할당량이 1 MiB를 초과하는 연산자만 수집한다. PyTorch 버전에 따라 메모리 속성 이름이 다를 수 있다. 일부 버전은 cuda_memory_usage를 사용하고, 최신 버전은 device_memory_usage를 사용하므로 코드는 두 속성을 순서대로 확인한다.

결과를 내림차순으로 정렬하면 가장 위에 표시되는 항목이 GPU 메모리를 가장 많이 사용하는 연산자다.

# 연산자별 CUDA 메모리 사용량을 수집한다.
mem_ops = []

for evt in prof_mem.key_averages():
    # PyTorch 버전에 따라 메모리 속성 이름이 다를 수 있다.
    cuda_mem = getattr(evt, 'device_memory_usage', None)
    if cuda_mem is None:
        cuda_mem = getattr(evt, 'cuda_memory_usage', 0)

    self_cuda_mem = getattr(evt, 'self_device_memory_usage', None)
    if self_cuda_mem is None:
        self_cuda_mem = getattr(evt, 'self_cuda_memory_usage', 0)

    # 메모리 사용량이 1 MiB를 초과하는 연산자만 유지한다.
    if abs(cuda_mem) > 1024 * 1024:
        mem_ops.append({
            'name': evt.key,
            'cuda_mem_mib': abs(cuda_mem) / (1024 * 1024),
            'self_cuda_mem_mib': abs(self_cuda_mem) / (1024 * 1024),
            'count': evt.count,
        })

# 전체 CUDA 메모리 사용량이 큰 순서로 상위 10개를 선택한다.
mem_ops.sort(key=lambda m: -m['cuda_mem_mib'])
mem_ops = mem_ops[:10]

print('CUDA 메모리 할당량 기준 상위 연산자\n')
print(f'{"연산자":<40s} {"전체(MiB)":>12s} {"자체(MiB)":>12s} {"호출 수":>8s}')

for op in mem_ops:
    print(
        f'{op["name"][:40]:<40s} '
        f'{op["cuda_mem_mib"]:>12.1f} '
        f'{op["self_cuda_mem_mib"]:>12.1f} '
        f'{op["count"]:>8d}'
    )
Top memory-using ops (total CUDA memory allocated):

op                                        total (MiB)   self (MiB)  count
aten::empty                                    2173.0       2173.0     99
[memory]                                       1140.0       1140.0     42
aten::batch_norm                               1029.0          0.0      9
aten::_batch_norm_impl_index                   1029.0          0.0      9
aten::cudnn_batch_norm                         1029.0          0.0      9
CudnnBatchNormBackward0                        1029.0          0.0      9
aten::cudnn_batch_norm_backward                1029.0          0.0      9
aten::empty_like                               1029.0          0.0     12
aten::conv2d                                   1029.0          0.0      9
aten::convolution                              1029.0          0.0      9

4단계 — 시각적 분석을 위한 크롬 트레이스(Chrome trace) 내보내기

표는 전체 결과를 요약하는 데 유용하지만, 병목 구간은 타임라인(timeline)에서 더 명확하게 확인할 수 있다. 잘 구성된 프로파일링 결과에서는 CUDA 스트림(stream)이 레인(lane)으로 표시되고, GPU 커널은 색상 막대로 표시되며, CPU 스레드 작업도 함께 나타난다.

타임라인에서는 다음 항목을 확인할 수 있다.

  • 유휴 구간(idle gap) — CPU가 작업을 충분히 빠르게 전달하지 못해 GPU가 대기하는 시간이다.
  • 커널 직렬화(kernel serialization) — 암시적 동기화 장벽(sync barrier)으로 인해 여러 커널이 동시에 실행되지 못하는 현상이다.
  • 호스트-디바이스 전송(host-to-device transfer) — 데이터 전송과 연산이 중첩되는지 확인한다. 중첩되면 효율적이고, 중첩되지 않으면 최적화가 필요하다.

PyTorch의 크롬 트레이스는 표준 JSON 형식이다. 크롬 기반 브라우저의 chrome://tracing에서 열거나 ui.perfetto.dev에 업로드해 분석할 수 있다. 퍼페토(Perfetto)는 다양한 브라우저에서 사용할 수 있으며 분석 인터페이스가 더 편리하다.

추가 기능: torch.profiler 스케줄 API(schedule API)

장시간 학습에서는 모든 단계를 프로파일링하면 실행 속도가 느려지고 트레이스 파일이 지나치게 커질 수 있다. schedule 인자를 사용하면 특정 구간만 선택적으로 프로파일링할 수 있다. 다음 예시는 대기 5단계, 워밍업 5단계, 실제 측정 5단계를 수행한다.

prof = profile(
    activities=[...],
    schedule=torch.profiler.schedule(wait=5, warmup=5, active=5, repeat=1),
    on_trace_ready=torch.profiler.tensorboard_trace_handler('./tb_logs'),
)

이 구성과 텐서보드(TensorBoard)를 함께 사용하는 방식은 대표적인 운영 환경 프로파일링 패턴이다. 텐서보드 플러그인을 사용하려면 torch_tb_profiler를 설치한다.

# 프로파일링 결과를 크롬 트레이스 JSON 파일로 저장한다.
chrome_trace_path = '/tmp/pytorch_trace.json'
prof.export_chrome_trace(chrome_trace_path)

import os
import json

# 트레이스 파일 크기와 이벤트 수를 확인한다.
size_kb = os.path.getsize(chrome_trace_path) / 1024

with open(chrome_trace_path, encoding='utf-8') as f:
    trace = json.load(f)

events = trace.get('traceEvents') if isinstance(trace, dict) else trace

# 이벤트 범주(category)별 발생 횟수를 집계한다.
event_types = {}
for event in events:
    category = event.get('cat', 'unknown')
    event_types[category] = event_types.get(category, 0) + 1

print(
    f'크롬 트레이스 저장 완료: {chrome_trace_path} '
    f'({size_kb:.1f} KB, 이벤트 {len(events)}개)'
)
print('\n트레이스에 포함된 이벤트 범주:')

for category, count in sorted(
    event_types.items(),
    key=lambda item: -item[1],
)[:8]:
    print(f'  {category:<25s} {count:>6d}개')

print('\nchrome://tracing 또는 ui.perfetto.dev에서 타임라인을 분석할 수 있다.')
Chrome trace written: /tmp/pytorch_trace.json (994.9 KB, 4118 events)

Event categories (what's in the trace):
  cpu_op                      2085 events
  ac2g                         897 events
  cuda_runtime                 512 events
  kernel                       340 events
  fwdbwd                       150 events
  unknown                       62 events
  gpu_memset                    40 events
  user_annotation               15 events

Open at chrome://tracing or ui.perfetto.dev to explore the timeline.

실습 결과

Python 코드만으로 실행 가능한 프로파일링 흐름을 구성했다. 여기에는 실행 시간 요약표, 연산자별 메모리 할당 순위, 크롬 트레이스 내보내기가 포함된다.

Nsight Systems Profiling 결과와 결합하면 시스템 전체 타임라인과 연산자 수준 세부 분석을 모두 수행할 수 있다. 이후의 성능 최적화 작업은 이 두 관점 중 하나 또는 두 관점을 함께 사용해 진행할 수 있다.

참고 자료

댓글남기기