19 분 소요

실습 5 — 재현 가능한 학습(Reproducible Training): 설정 플래그, 성능 비용, 실행 산출물

GPU에서는 동일한 코드와 동일한 난수 시드(seed)를 사용하더라도 기본 설정만으로 항상 동일한 결과가 보장되지는 않는다. 이 문제는 지속적 통합(CI, Continuous Integration) 통과 기준을 불안정하게 만들고, 학습 회귀(training regression)를 식별하기 어렵게 하며 모델 품질 문제를 추적하는 작업을 복잡하게 만든다.

원인은 단순히 torch.manual_seed(42)를 설정하지 않았기 때문만은 아니고 다음 요소가 함께 영향을 준다.

  1. cuDNN 자동 튜너(auto-tuner)가 최근 메모리 상태와 입력 형태에 따라 실행할 커널을 선택할 수 있다.
  2. 원자적 덧셈(atomic add)을 사용하는 scatter_add, bincount, index_add, grid_sample 역전파, 임베딩 기울기 연산은 스트리밍 멀티프로세서(SM, Streaming Multiprocessor) 사이의 누적 순서가 고정되지 않을 수 있다.
  3. cuBLAS 작업 공간(workspace)이 여러 CUDA 스트림(stream)에서 공유되면 행렬 곱셈 누적 순서가 달라질 수 있다.
  4. 데이터 로더 작업자(DataLoader worker)가 개별 난수 생성기(RNG, Random Number Generator)를 사용하면 작업자 수와 실행 순서에 따라 샘플별 데이터 증강 결과가 달라질 수 있다.

이 실습에서는 이러한 변동을 제어하기 위한 운영 환경 수준의 방법을 구성한다. 결정론적 실행(deterministic execution)을 활성화하고, 그에 따른 성능 비용을 측정하며 각 학습 실행을 추적할 수 있도록 내용 주소 기반 학습 실행 해시(content-addressable training-run hash)를 생성한다.

재현성이 필요한 이유

  • 지속적 통합(CI) — 100단계를 학습한 뒤 loss < 2.0을 검사하는 테스트는 재현성이 없으면 실행할 때마다 결과가 달라질 수 있다. 결정론적 실행을 적용하면 실제 품질 통과 기준으로 사용할 수 있다.
  • 디버깅(debugging) — 데이터 스키마나 전처리 코드를 변경한 뒤 모델 품질이 하락했다면, 이전 조건과 새로운 조건에서 정확히 동일한 학습을 재실행할 수 있어야 원인을 비교할 수 있다.
  • 규정 준수와 감사(compliance and audit) — 모델 산출물이 어떤 코드 커밋, 데이터, 하이퍼파라미터, 소프트웨어 환경에서 생성되었는지 증명할 수 있어야 한다.

참고 자료 - PyTorch Reproducibility Notes - PyTorch torch.use_deterministic_algorithms - NVIDIA Deep Learning Performance Documentation - Are Labels Necessary? Sample-Efficient Learning for Multi-Domain Image Classification

1단계 — 제어해야 할 변동 기준선(noise floor) 확인

결정론적 실행이 효과가 있는지 확인하기 전에, 재현성을 설정하지 않았을 때 어느 정도의 결과 변동이 발생하는지 측정한다.

이 단계에서는 서로 다른 난수 시드 4243을 사용해 두 번 학습한다. 이는 누군가 시드를 설정하지 않았거나, 라이브러리 변경으로 내부 난수 생성기 호출 순서가 달라진 상황을 모의한다.

다음 항목이 달라질 수 있다.

  • 최종 학습 손실(training loss)
  • 모델 가중치(weight)의 L2 거리

시드 변화로 발생하는 변동이 코드 변경으로 얻은 성능 개선보다 크다면, 해당 변경이 실제로 효과가 있었다고 판단하기 어렵다. 지속적 통합 테스트에서 허용 오차를 지나치게 작게 설정하면 무작위 실패가 발생하고 지나치게 크게 설정하면 실제 회귀를 놓칠 수 있다. 결정론적 실행은 이러한 기준 변동을 최소화해 작은 변화도 비교할 수 있게 한다.

재현성 측정에 사용할 모델

CUDA 비결정성(nondeterminism)을 관찰하기 위해 대규모 모델을 사용할 필요는 없다. 소규모 임베딩(embedding)과 다층 퍼셉트론(MLP, Multi-Layer Perceptron) 분류기만으로도 부동소수점 누적 순서 차이를 확인할 수 있다.

train_one_epoch 함수는 다음 흐름을 하나의 함수 안에서 수행한다.

  1. 난수 시드를 설정한다.
  2. 모델을 생성한다.
  3. 합성 학습 데이터를 생성한다.
  4. 지정한 단계 수만큼 학습한다.
  5. 최종 손실과 모든 모델 가중치를 하나의 1차원 텐서로 펼쳐 반환한다.

두 실행이 비트 단위로 동일하다면 최종 가중치 텐서도 완전히 동일해야 한다. 차이가 있다면 (wa - wb).norm() 값이 0보다 크게 나타난다.

import random

import numpy as np
import torch
import torch.nn as nn

# 모델과 학습 데이터를 실행할 CUDA 장치를 지정한다.
device = 'cuda'


def seed_all(seed=42):
    """Python, NumPy, PyTorch CPU와 모든 CUDA 장치의 난수 시드를 동일하게 설정한다."""

    # Python 표준 라이브러리의 난수 생성기 시드를 설정한다.
    random.seed(seed)

    # NumPy 난수 생성기 시드를 설정한다.
    np.random.seed(seed)

    # PyTorch CPU 난수 생성기 시드를 설정한다.
    torch.manual_seed(seed)

    # 현재 프로세스가 사용하는 모든 CUDA 장치의 난수 시드를 설정한다.
    torch.cuda.manual_seed_all(seed)


class EmbedMLP(nn.Module):
    """임베딩과 다층 퍼셉트론으로 구성된 소규모 분류 모델이다."""

    def __init__(self):
        # nn.Module의 초기화 로직을 실행한다.
        super().__init__()

        # 512개의 토큰 ID를 128차원 임베딩 벡터로 변환한다.
        self.emb = nn.Embedding(
            num_embeddings=512,
            embedding_dim=128,
        )

        # 임베딩 합계 벡터를 10개 클래스의 로짓으로 변환하는 MLP를 구성한다.
        self.net = nn.Sequential(
            nn.Linear(128, 256),
            nn.LayerNorm(256),
            nn.GELU(),
            nn.Linear(256, 256),
            nn.LayerNorm(256),
            nn.GELU(),
            nn.Linear(256, 10),
        )

    def forward(self, idx):
        # 토큰 ID를 임베딩 벡터로 변환한다.
        embedded = self.emb(idx)

        # 시퀀스 차원의 임베딩을 합산해 샘플별 고정 길이 벡터를 만든다.
        pooled = embedded.sum(dim=1)

        # 합산한 벡터를 MLP에 전달해 클래스별 로짓을 계산한다.
        return self.net(pooled)


def train_one_epoch(seed=42, n_steps=200):
    """지정한 시드와 단계 수로 모델을 학습하고 최종 손실과 가중치 벡터를 반환한다."""

    # 모델 초기화, 데이터 생성, 미니배치 선택에 사용할 모든 난수 시드를 설정한다.
    seed_all(seed)

    # 새로운 모델을 생성하고 CUDA 장치로 이동한다.
    model = EmbedMLP().to(device)

    # AdamW 옵티마이저를 생성하고 학습률을 1e-3으로 설정한다.
    optimizer = torch.optim.AdamW(
        model.parameters(),
        lr=1e-3,
    )

    # 다중 클래스 분류를 위한 교차 엔트로피 손실 함수를 생성한다.
    loss_fn = nn.CrossEntropyLoss()

    # 합성 데이터 생성을 시작하기 전에 PyTorch 난수 시드를 다시 고정한다.
    torch.manual_seed(seed)

    # 전체 합성 학습 샘플 수를 정의한다.
    n_samples = 2048

    # 각 샘플이 32개의 토큰 ID를 가지는 입력 데이터를 GPU에 생성한다.
    data_idx = torch.randint(
        low=0,
        high=512,
        size=(n_samples, 32),
        device=device,
    )

    # 각 샘플에 대응하는 0~9 범위의 클래스 레이블을 GPU에 생성한다.
    data_y = torch.randint(
        low=0,
        high=10,
        size=(n_samples,),
        device=device,
    )

    # 마지막 학습 단계의 손실을 저장할 변수를 초기화한다.
    final_loss = None

    # 지정한 단계 수만큼 미니배치 학습을 반복한다.
    for step in range(n_steps):
        # 전체 데이터에서 64개 샘플의 인덱스를 무작위로 선택한다.
        picked_indices = torch.randint(
            low=0,
            high=n_samples,
            size=(64,),
            device=device,
        )

        # 선택한 인덱스로 입력과 레이블 미니배치를 구성한다.
        x = data_idx[picked_indices]
        y = data_y[picked_indices]

        # 이전 단계의 기울기를 초기화한다.
        optimizer.zero_grad()

        # 순전파를 수행하고 현재 미니배치의 손실을 계산한다.
        logits = model(x)
        loss = loss_fn(logits, y)

        # 손실을 기준으로 모델 파라미터의 기울기를 계산한다.
        loss.backward()

        # 계산된 기울기로 모델 파라미터를 갱신한다.
        optimizer.step()

        # 마지막 손실 값을 Python 실수로 변환해 저장한다.
        final_loss = loss.item()

    # 모든 모델 파라미터를 1차원으로 펼친 뒤 하나의 텐서로 연결한다.
    weights = torch.cat([
        parameter.detach().flatten()
        for parameter in model.parameters()
    ])

    # 최종 손실과 전체 가중치 벡터를 반환한다.
    return final_loss, weights
/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]

결정론적 실행을 끈 상태에서 변동 기준선 측정

먼저 PyTorch 기본 실행 방식에 가까운 설정으로 두 번 학습한다. cudnn.benchmark=True를 사용하고 결정론적 알고리즘은 비활성화한다.

두 실행에는 서로 다른 난수 시드 4243을 사용한다. 이는 상위 코드에서 시드 전달이 잘못되었거나, 데이터 로더 작업자가 서로 다른 시드를 사용한 상황을 모의한다.

두 실행의 가중치 L2 차이는 실험에서 고려해야 하는 변동 기준선(noise floor)이다. 코드 변경으로 손실이 5% 개선되었더라도 시드 변화에 따른 결과 차이가 더 크면, 코드 변경의 효과와 무작위 변동을 구분하기 어렵다.

# 결정론적 알고리즘 사용을 비활성화한다.
# 이 설정에서는 모든 실행이 동일한 결과를 낸다고 보장하지 않는다.
torch.use_deterministic_algorithms(False)

# cuDNN이 결정론적 알고리즘만 선택하도록 제한하지 않는다.
torch.backends.cudnn.deterministic = False

# cuDNN 자동 튜너가 입력 형태에 적합한 빠른 알고리즘을 탐색하도록 허용한다.
torch.backends.cudnn.benchmark = True

# 서로 다른 시드로 첫 번째 학습을 실행한다.
nondet_run_a_final_loss, weights_a = train_one_epoch(
    seed=42,
)

# 서로 다른 시드로 두 번째 학습을 실행한다.
nondet_run_b_final_loss, weights_b = train_one_epoch(
    seed=43,
)

# 두 실행의 전체 가중치 벡터 사이의 L2 거리를 계산한다.
nondet_weight_diff = (
    weights_a - weights_b
).norm().item()

# 두 실행의 최종 손실과 가중치 차이를 출력한다.
print('서로 다른 시드 42와 43을 사용한 두 번의 실행 결과다.')
print(
    f'  실행 A(seed=42) 최종 손실: '
    f'{nondet_run_a_final_loss:.6f}'
)
print(
    f'  실행 B(seed=43) 최종 손실: '
    f'{nondet_run_b_final_loss:.6f}'
)
print(
    f'  |loss_a - loss_b|: '
    f'{abs(nondet_run_a_final_loss - nondet_run_b_final_loss):.3e}'
)
print(
    f'  가중치 L2 차이: '
    f'{nondet_weight_diff:.3e}'
)
print()
print('이 차이는 코드 변경 효과와 구분해야 하는 변동 기준선이다.')
print('다음 단계에서는 동일한 시드와 결정론적 설정으로 결과를 다시 비교한다.')
Two runs, seeds 42 vs 43 (simulating a stray seed change):
  run A (seed=42) final loss: 0.592542
  run B (seed=43) final loss: 0.469983
  |loss_a - loss_b| = 1.226e-01
  weight L2 diff:     3.621e+02

That gap is the noise floor you'd be fighting on every PR.
A PR that improves loss by 5% is indistinguishable from this seed variance.
Step 2 shows what happens when you pin seed + turn on determinism flags.

2단계 — 결정론적 실행 활성화와 재검증

재현 가능한 학습을 위해 다음 설정을 함께 적용한다.

os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8'
os.environ['PYTHONHASHSEED'] = '42'

torch.use_deterministic_algorithms(True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

torch.manual_seed(42)
torch.cuda.manual_seed_all(42)
random.seed(42)
numpy.random.seed(42)

각 설정의 역할은 다음과 같다.

  • CUBLAS_WORKSPACE_CONFIG — cuBLAS가 결정론적 방식으로 작업 공간을 사용하도록 설정한다. cuBLAS가 초기화되기 전에 설정해야 한다.
  • PYTHONHASHSEED — Python 해시 기반 자료구조의 순서를 고정한다.
  • torch.use_deterministic_algorithms(True) — 결정론적 구현이 있는 연산만 사용하도록 설정하고 결정론적 구현이 없는 연산을 만나면 오류를 발생시킨다.
  • cudnn.deterministic=True — cuDNN에서 결정론적 알고리즘을 선택한다.
  • cudnn.benchmark=False — 입력 형태별로 가장 빠른 알고리즘을 탐색하는 자동 튜너를 비활성화한다.
  • 난수 시드 설정 — Python, NumPy, PyTorch CPU, 모든 CUDA 장치의 난수 생성기를 고정한다.

설정을 적용한 뒤 동일한 시드로 두 번 학습하고 최종 손실과 가중치가 동일한지 확인한다.

import os

# cuBLAS가 결정론적 작업 공간 구성을 사용하도록 환경 변수를 설정한다.
# 이 값은 cuBLAS가 처음 초기화되기 전에 설정하는 것이 가장 안전하다.
# 현재 노트북에서 이미 cuBLAS를 사용했다면 커널을 재시작한 뒤 처음부터 실행해야 할 수 있다.
os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8'

# Python 해시 기반 자료구조의 순서를 고정하기 위한 시드를 설정한다.
# 이 환경 변수도 Python 프로세스 시작 전에 설정하는 것이 가장 확실하다.
os.environ['PYTHONHASHSEED'] = '42'

# 결정론적 구현이 있는 연산만 사용하도록 전역 설정을 활성화한다.
# 결정론적 구현이 없는 연산을 만나면 경고가 아니라 RuntimeError를 발생시킨다.
torch.use_deterministic_algorithms(
    True,
    warn_only=False,
)

# cuDNN이 결정론적 알고리즘만 선택하도록 설정한다.
torch.backends.cudnn.deterministic = True

# 입력 형태별 최적 알고리즘을 탐색하는 cuDNN 자동 튜너를 비활성화한다.
torch.backends.cudnn.benchmark = False

# 현재 적용한 결정론적 설정을 추적 가능한 딕셔너리로 저장한다.
determinism_settings = {
    'torch_use_deterministic_algorithms': True,
    'cudnn_deterministic': True,
    'cudnn_benchmark': False,
    'cublas_workspace_config': os.environ[
        'CUBLAS_WORKSPACE_CONFIG'
    ],
    'manual_seed_set': True,
    'python_hash_seed_set': True,
}

# 동일한 시드로 첫 번째 결정론적 학습을 실행한다.
det_run_a_final_loss, weights_a = train_one_epoch(
    seed=42,
)

# 동일한 시드로 두 번째 결정론적 학습을 실행한다.
det_run_b_final_loss, weights_b = train_one_epoch(
    seed=42,
)

# 두 실행의 가중치 벡터 사이의 L2 거리를 계산한다.
det_weight_diff = (
    weights_a - weights_b
).norm().item()

# 두 실행의 손실과 가중치가 동일한지 확인할 수 있도록 결과를 출력한다.
print('결정론적 모드에서 동일한 시드로 두 번 실행한 결과다.')
print(
    f'  실행 A 최종 손실: '
    f'{det_run_a_final_loss:.10f}'
)
print(
    f'  실행 B 최종 손실: '
    f'{det_run_b_final_loss:.10f}'
)
print(
    f'  |loss_a - loss_b|: '
    f'{abs(det_run_a_final_loss - det_run_b_final_loss):.3e}'
)
print(
    f'  가중치 L2 차이: '
    f'{det_weight_diff:.3e}'
)
print()
print('현재 적용한 결정론적 설정은 다음과 같다.')

# 설정 이름과 값을 한 줄씩 출력한다.
for setting_name, setting_value in determinism_settings.items():
    print(
        f'  {setting_name:>38s}: '
        f'{setting_value}'
    )
Deterministic mode, same seed, run twice:
  run A final loss: 0.5925423503
  run B final loss: 0.5925423503
  |loss_a - loss_b| = 0.000e+00
  weight L2 diff:     0.000e+00

Active determinism settings:
      torch_use_deterministic_algorithms: True
                     cudnn_deterministic: True
                         cudnn_benchmark: False
                 cublas_workspace_config: :4096:8
                         manual_seed_set: True
                    python_hash_seed_set: True

3단계 — 결정론적 실행의 성능 비용과 제한 연산 확인

결정론적 실행에는 다음 두 가지 비용이 있다.

  1. 성능 비용(performance cost)빠른 CUDA 커널 중 일부는 원자적 연산(atomic operation)이나 실행 순서가 고정되지 않은 병렬 축소(reduction)를 사용한다. 결정론적 모드에서는 이러한 커널 대신 실행 순서를 보장하는 대체 구현을 사용하므로 처리량이 낮아질 수 있다.
  2. 결정론적 구현이 없는 연산 일부 PyTorch 연산은 CUDA에서 결정론적 구현을 제공하지 않을 수 있다. torch.use_deterministic_algorithms(True, warn_only=False)를 활성화한 상태에서 이러한 연산을 호출하면 RuntimeError가 발생한다.

실제 성능 저하는 모델 구조, GPU 아키텍처, PyTorch와 CUDA 버전, 연산 구성에 따라 달라진다. 따라서 일반적인 수치만 따르기보다 실제 작업 부하(workload)에서 직접 측정해야 한다.

결정론적 실행의 성능 비용 측정

결정론적 알고리즘을 활성화하면 PyTorch는 비트 단위 재현성을 보장할 수 있는 커널을 우선적으로 선택한다. 이러한 커널은 원자적 축소를 사용하는 빠른 커널보다 느릴 수 있다.

다음 코드에서는 결정론적 실행을 끈 상태와 켠 상태에서 각각 200단계 학습 시간을 측정한다.

정확한 CUDA 시간을 측정하기 위해 다음 절차를 따른다.

  • 실제 측정 전에 워밍업(warmup)을 수행한다.
  • 측정 시작 전과 종료 후에 torch.cuda.synchronize()를 호출한다.
  • 동일한 모델 구조와 단계 수를 사용한다.
import time


def time_epoch(deterministic, n_steps=200):
    """결정론적 실행 여부에 따라 지정한 학습 단계의 경과 시간을 밀리초로 반환한다."""

    # 결정론적 실행을 요청한 경우 관련 PyTorch와 cuDNN 설정을 활성화한다.
    if deterministic:
        torch.use_deterministic_algorithms(
            True,
            warn_only=False,
        )
        torch.backends.cudnn.deterministic = True
        torch.backends.cudnn.benchmark = False

    # 비결정적 실행을 요청한 경우 빠른 알고리즘 선택을 허용한다.
    else:
        torch.use_deterministic_algorithms(False)
        torch.backends.cudnn.deterministic = False
        torch.backends.cudnn.benchmark = True

    # 초기 CUDA 컨텍스트 생성과 내부 버퍼 할당 비용을 제외하기 위해 짧게 워밍업한다.
    train_one_epoch(
        seed=0,
        n_steps=20,
    )

    # 워밍업에서 제출한 모든 비동기 CUDA 연산이 끝날 때까지 기다린다.
    torch.cuda.synchronize()

    # 실제 학습 시간 측정 시작 시각을 기록한다.
    start_time = time.perf_counter()

    # 지정한 단계 수만큼 학습을 실행한다.
    train_one_epoch(
        seed=1,
        n_steps=n_steps,
    )

    # 모든 CUDA 연산이 완료될 때까지 기다린다.
    torch.cuda.synchronize()

    # 전체 경과 시간을 밀리초 단위로 변환해 반환한다.
    return (
        time.perf_counter() - start_time
    ) * 1000


# 결정론적 알고리즘을 끈 상태의 학습 시간을 측정한다.
perf_nondet_ms = time_epoch(
    deterministic=False,
)

# 결정론적 알고리즘을 켠 상태의 학습 시간을 측정한다.
perf_det_ms = time_epoch(
    deterministic=True,
)

# 비결정적 실행 대비 결정론적 실행의 상대적 성능 비용을 백분율로 계산한다.
determinism_cost_pct = (
    100.0
    * (perf_det_ms - perf_nondet_ms)
    / perf_nondet_ms
)

# 두 실행 방식의 경과 시간과 성능 차이를 출력한다.
print(
    f'비결정적 학습 실행 시간: '
    f'{perf_nondet_ms:7.1f} ms'
)
print(
    f'결정론적 학습 실행 시간: '
    f'{perf_det_ms:7.1f} ms'
)
print(
    f'결정론적 실행의 성능 비용: '
    f'{determinism_cost_pct:+5.1f}%'
)
Non-deterministic epoch:   147.6 ms
Deterministic epoch:       161.1 ms
Cost of determinism:      +9.2%

CUDA에서 자주 문제가 되는 비결정적 연산

torch.use_deterministic_algorithms(True, warn_only=False)를 활성화한 뒤 다음과 같은 오류가 발생할 수 있다.

does not have a deterministic implementation

주요 원인은 여러 CUDA 스레드가 동일한 출력 위치에 값을 누적하는 원자적 축소(atomic reduction)다. 여러 스트리밍 멀티프로세서가 같은 메모리 위치에 값을 더할 때, 하드웨어는 연산 완료 순서를 항상 동일하게 보장하지 않는다. 부동소수점 덧셈은 결합 법칙이 정확하게 성립하지 않으므로 누적 순서가 달라지면 마지막 비트가 달라질 수 있다.

다음 목록은 CUDA 환경에서 비결정적 경로가 발생할 수 있는 대표 연산을 정리한 예시다. 실제 지원 여부는 PyTorch 버전에 따라 달라질 수 있으므로 공식 문서를 함께 확인해야 한다.

# CUDA에서 비결정적 실행 경로가 발생할 수 있는 대표 연산을 정리한다.
# 실제 결정론적 지원 여부는 PyTorch 버전에 따라 달라질 수 있다.
operations_with_nondet_paths = [
    {
        # 동일한 출력 인덱스에 여러 값을 누적하는 산포 덧셈 연산이다.
        'op': 'scatter_add',

        # 여러 SM의 원자적 덧셈 순서가 고정되지 않을 수 있다.
        'why_nondet': (
            '여러 스트리밍 멀티프로세서가 hardware atomicAdd를 사용하며 '
            '누적 순서가 고정되지 않을 수 있다.'
        ),

        # 정렬과 구간 합산 방식으로 연산 구조를 변경하는 대안을 설명한다.
        'fix': (
            '사용 중인 PyTorch 버전의 결정론적 지원 여부를 확인하고, '
            '필요하면 정렬(sort)과 구간 합(segmented sum) 기반으로 재구성한다.'
        ),

        # 임베딩 기울기 계산에서 자주 나타날 수 있음을 기록한다.
        'note': (
            '임베딩 기울기 갱신과 희소 인덱스 누적 연산에서 나타날 수 있다.'
        ),
    },
    {
        # 정수 값의 발생 횟수를 계산하는 히스토그램 연산이다.
        'op': 'bincount',

        # 동일한 구간에 여러 스레드가 원자적으로 값을 더할 수 있다.
        'why_nondet': (
            '히스토그램 구간에 원자적 덧셈을 수행하므로 누적 순서가 달라질 수 있다.'
        ),

        # 고유값과 인덱스 연산을 조합한 대체 방식을 설명한다.
        'fix': (
            '필요하면 torch.unique와 인덱스 기반 집계 연산으로 대체한다.'
        ),

        # 레이블 빈도를 세는 일부 손실 또는 통계 코드에서 나타날 수 있다.
        'note': (
            '레이블 개수나 빈도 분포를 계산하는 코드에서 사용될 수 있다.'
        ),
    },
    {
        # 지정한 인덱스 위치에 값을 누적하는 인플레이스 연산이다.
        'op': 'index_add_',

        # 동일한 인덱스에 대한 원자적 덧셈 순서가 달라질 수 있다.
        'why_nondet': (
            '인덱스 차원에서 atomicAdd를 사용하면 누적 순서가 고정되지 않을 수 있다.'
        ),

        # 최신 PyTorch의 결정론적 대체 경로 사용 여부를 확인하도록 안내한다.
        'fix': (
            'torch.use_deterministic_algorithms(True)에서 제공하는 '
            '결정론적 대체 경로가 현재 버전에 있는지 확인한다.'
        ),

        # gather 역전파 등 다양한 내부 연산에서 사용될 수 있다.
        'note': (
            'gather 연산의 역전파와 인덱스 기반 누적 처리에서 널리 나타날 수 있다.'
        ),
    },
    {
        # 격자 좌표를 사용해 입력을 보간하는 연산의 역전파다.
        'op': 'grid_sample backward',

        # 여러 출력 위치가 같은 입력 기울기 위치에 값을 더할 수 있다.
        'why_nondet': (
            '쌍선형 보간 위치에서 입력 기울기에 원자적 쓰기를 수행할 수 있다.'
        ),

        # PyTorch 버전별 결정론적 구현과 성능 비용을 확인하도록 안내한다.
        'fix': (
            '현재 PyTorch 버전의 결정론적 경로를 확인하고, '
            '지원되는 경우 추가 성능 비용을 측정한다.'
        ),

        # 공간 변환과 광학 흐름 모델에서 자주 사용된다.
        'note': (
            '공간 변환기(spatial transformer)와 광학 흐름(optical flow) 모델에서 사용된다.'
        ),
    },
    {
        # 쌍선형 또는 쌍입방 보간의 역전파 연산이다.
        'op': 'interpolate backward (bilinear/bicubic)',

        # 보간된 여러 출력이 동일한 입력 기울기 위치에 누적될 수 있다.
        'why_nondet': (
            '보간 역전파에서 동일한 입력 위치에 원자적 덧셈이 발생할 수 있다.'
        ),

        # 최근접 보간이나 결정론적 대체 경로를 검토하도록 안내한다.
        'fix': (
            '최근접 이웃(nearest-neighbor) 보간으로 변경하거나 '
            '결정론적 경로의 성능 비용을 수용한다.'
        ),

        # 이미지 분할과 초해상도 작업에서 영향을 받을 수 있다.
        'note': (
            '이미지 분할과 초해상도(super-resolution) 파이프라인에서 사용된다.'
        ),
    },
]

print()
print('CUDA에서 비결정적 경로가 발생할 수 있는 대표 연산은 다음과 같다.')

# 각 연산의 원인, 대안, 사용 위치를 순차적으로 출력한다.
for operation in operations_with_nondet_paths:
    print(
        f"  • {operation['op']:36s}: "
        f"{operation['why_nondet']}"
    )
    print(
        f"    대안: {operation['fix']}"
    )
    print(
        f"    참고: {operation['note']}"
    )

Common non-deterministic ops on CUDA:
  • scatter_add                 : uses hardware atomicAdd across SMs; ordering of accumulation is undefined
    fix: no det implementation as of PyTorch 2.x — consider reformulating with sort + segmented sum
    note: shows up in every embedding gradient update, so pervasive
  • bincount                    : atomicAdd-based histogram
    fix: use torch.unique + index operations instead
    note: rare but catches you in loss functions that count labels
  • index_add_                  : atomicAdd along the indexed dim
    fix: use_deterministic_algorithms(True) provides a sort-based fallback since PyTorch 2.1
    note: gradient of gather is index_add — used widely
  • grid_sample backward        : atomic writes to the input gradient at bilinear-interpolated pixel locations
    fix: in PyTorch 2.2+ a deterministic path exists but is much slower
    note: every spatial-transformer / optical-flow model uses this
  • interpolate backward (bilinear/bicubic): same atomic-add issue
    fix: switch to nearest-neighbor or accept the det path penalty
    note: image-segmentation / super-resolution pipelines are affected

4단계 — 내용 주소 기반 학습 실행(content-addressable training run)

비트 단위로 동일한 학습 결과를 만들 수 있더라도 나중에 해당 산출물이 어떤 설정에서 생성되었는지 확인할 수 있어야 한다.

이를 위해 다음 정보를 기록한다.

  1. 전체 학습 구성(full configuration)난수 시드, 하이퍼파라미터, 모델 버전, CUDA와 드라이버 버전, Git 커밋 해시, 데이터셋 체크섬을 포함한다.
  2. 안정적인 구성 해시(stable configuration hash)동일한 구성은 항상 동일한 해시를 생성해야 하며 정 하나라도 변경되면 다른 해시가 생성되어야 한다.
  3. 재현성 체크리스트(reproducibility checklist) 모든 학습 작업을 시작할 때 난수 시드, 결정론적 설정, 소프트웨어 버전, 하드웨어, 데이터, 코드 상태를 검사한다.

이는 Git 객체 해시나 Docker 이미지 다이제스트(digest)와 같은 내용 주소 기반 저장(content-addressable storage) 개념과 유사하다. 학습 실행을 job-abc123def와 같은 식별자로 관리하면 해당 식별자가 정확한 학습 조건을 나타내게 된다.

구성 해시 생성 — 설정 변경 여부 증명

결정론적 학습을 사용하더라도 두 실행이 동일한 설정을 사용했는지 증명할 수 있어야 한다. 정규화된 구성(canonicalized configuration)에 안정적인 해시를 적용하면 한 줄의 식별자로 학습 조건을 검증할 수 있다.

안정적인 해시를 만들기 위해 다음 두 가지 방법을 사용한다.

  1. json.dumps(..., sort_keys=True)딕셔너리 키 순서와 관계없이 항상 동일한 JSON 문자열을 생성한다.
  2. default=str 경로(path), 열거형(enum), 버전 객체처럼 JSON으로 직접 직렬화할 수 없는 값은 문자열로 변환한다.

구성 해시는 체크포인트(checkpoint), 학습 로그, 모델 레지스트리(model registry)에 함께 저장하는 것이 좋다. 그러면 동일한 실행인지 확인하는 작업을 해시 비교로 단순화할 수 있다.

import hashlib
import json
import platform

# 실제 운영 환경에서 기록할 수 있는 학습 구성을 딕셔너리로 정의한다.
training_config = {
    # Python과 PyTorch CPU 난수 생성기에 사용할 기본 시드를 기록한다.
    'seed': 42,

    # 모든 CUDA 장치에 사용할 난수 시드를 기록한다.
    'cuda_seed': 42,

    # 학습에 사용할 수치 정밀도를 기록한다.
    'dtype': 'bfloat16',

    # 한 학습 단계에서 처리할 샘플 수를 기록한다.
    'batch_size': 32,

    # 옵티마이저의 학습률을 기록한다.
    'learning_rate': 1e-3,

    # 사용할 옵티마이저 이름을 기록한다.
    'optimizer': 'AdamW',

    # 전체 학습 단계 수를 기록한다.
    'n_steps': 10_000,

    # 현재 실행 중인 PyTorch 버전을 기록한다.
    'torch_version': torch.__version__,

    # PyTorch가 빌드된 CUDA 버전을 기록한다.
    'cuda_version': torch.version.cuda,

    # 현재 운영체제 커널 버전을 기록한다.
    # 실제 운영 환경에서는 NVIDIA 드라이버 버전을 별도로 수집하는 것이 더 정확하다.
    'driver_version': platform.release(),

    # 실제 작업에서는 `git rev-parse HEAD` 결과를 기록한다.
    'git_sha': 'abc123def456',

    # 실제 작업에서는 학습 데이터 파일의 SHA-256 체크섬을 기록한다.
    'dataset_checksum': 'sha256:9f8e7d6c...',

    # 앞 단계에서 구성한 결정론적 설정 전체를 함께 기록한다.
    'determinism_flags': determinism_settings,
}


def config_hash(config: dict) -> str:
    """학습 구성을 정규화한 뒤 안정적인 SHA-256 해시 문자열을 반환한다."""

    # 딕셔너리 키를 정렬해 생성 순서와 관계없이 동일한 JSON 문자열을 만든다.
    # JSON으로 직접 직렬화할 수 없는 객체는 문자열로 변환한다.
    canonical_json = json.dumps(
        config,
        sort_keys=True,
        default=str,
    )

    # 정규화된 JSON 문자열을 UTF-8 바이트 배열로 변환한다.
    config_bytes = canonical_json.encode('utf-8')

    # SHA-256 해시를 계산하고 16진수 문자열로 반환한다.
    return hashlib.sha256(
        config_bytes
    ).hexdigest()


# 원본 학습 구성의 해시를 계산한다.
config_hash_a = config_hash(
    training_config
)

# 동일한 구성을 다시 해시해 결과가 안정적으로 같은지 확인한다.
config_hash_b = config_hash(
    training_config
)

# 원본 구성을 얕게 복사한 뒤 학습률 하나만 변경한다.
modified_config = dict(
    training_config
)
modified_config['learning_rate'] = 2e-3

# 변경된 구성의 해시를 계산한다.
config_hash_different = config_hash(
    modified_config
)

# 동일한 구성의 해시가 같고 변경된 구성의 해시가 다른지 출력한다.
print(
    f'hash(config):       '
    f'{config_hash_a}'
)
print(
    f'hash(config) 재실행: '
    f'{config_hash_b} '
    f'(안정적: {config_hash_a == config_hash_b})'
)
print(
    f'hash(lr=2e-3):      '
    f'{config_hash_different} '
    f'(변경됨: {config_hash_a != config_hash_different})'
)

재현성 체크리스트 — 반드시 기록해야 할 항목

구성 해시는 딕셔너리에 포함된 값만 검증한다. 실제 재현성을 보장하려면 난수 생성기 상태, 결정론적 설정, 소프트웨어 버전, 하드웨어, 데이터, 코드, 하이퍼파라미터를 모두 기록해야 한다. 한 범주라도 빠지면 동일한 학습을 재현하기 어려울 수 있다.

가장 흔한 문제 중 하나는 데이터 드리프트(data drift)다. 학습 파일을 같은 경로에 덮어쓰면 이전 실행에서 사용한 데이터와 현재 데이터가 달라질 수 있다. 데이터셋은 파일 이름만으로 구분하지 말고 내용 해시(content hash)로 버전을 관리해야 한다.

# 학습 실행마다 반드시 기록해야 할 재현성 항목을 범주별로 정의한다.
reproducibility_checklist = [
    {
        # 모든 난수 생성기의 상태를 고정하기 위한 항목이다.
        'category': '난수 시드(seeds)',

        # Python, NumPy, PyTorch, CUDA, 데이터 로더 작업자 시드를 기록한다.
        'what_to_record': (
            'torch.manual_seed, torch.cuda.manual_seed_all, '
            'numpy.random.seed, Python random.seed, '
            'DataLoader worker_init_fn과 generator 시드'
        ),

        # 하나의 난수 생성기라도 고정되지 않으면 실행 결과가 달라질 수 있다.
        'why': (
            '학습 파이프라인의 모든 난수 생성기를 고정해야 한다. '
            '외부 데이터 증강 라이브러리 하나만 시드가 누락되어도 재현성이 깨질 수 있다.'
        ),
    },
    {
        # 결정론적 커널 선택과 자동 튜너 설정을 기록하는 항목이다.
        'category': '결정론적 설정(determinism flags)',

        # PyTorch, cuDNN, cuBLAS 관련 설정을 모두 기록한다.
        'what_to_record': (
            'torch.use_deterministic_algorithms, '
            'cudnn.deterministic, cudnn.benchmark, '
            'CUBLAS_WORKSPACE_CONFIG'
        ),

        # 설정이 다르면 cuDNN과 cuBLAS가 다른 커널을 선택할 수 있다.
        'why': (
            '이 설정이 없으면 실행마다 다른 커널이나 누적 경로가 선택될 수 있다.'
        ),
    },
    {
        # 실행 환경의 정확한 소프트웨어 버전을 기록하는 항목이다.
        'category': '소프트웨어 버전(software versions)',

        # Python부터 드라이버까지 빌드 접미사를 포함한 정확한 버전을 기록한다.
        'what_to_record': (
            'Python, PyTorch, CUDA, cuDNN, NCCL, '
            'NVIDIA 드라이버의 정확한 버전과 빌드 정보'
        ),

        # 소프트웨어 업데이트는 기본 연산 경로나 커널 선택을 변경할 수 있다.
        'why': (
            'PyTorch 또는 드라이버의 작은 버전 변경도 기본 커널과 수치 결과를 바꿀 수 있다.'
        ),
    },
    {
        # 실행에 사용한 하드웨어 구성을 기록하는 항목이다.
        'category': '하드웨어(hardware)',

        # GPU 제품, 드라이버, 노드 토폴로지, CPU 제품군을 기록한다.
        'what_to_record': (
            'GPU SKU, 드라이버 버전, 다중 GPU 노드 토폴로지, CPU 제품군'
        ),

        # 서로 다른 GPU 아키텍처는 다른 커널과 부동소수점 경로를 사용할 수 있다.
        'why': (
            'A100과 H100처럼 GPU 아키텍처가 다르면 결정론적 설정이 같아도 '
            '동일한 비트 패턴을 보장하지 못할 수 있다.'
        ),
    },
    {
        # 학습 데이터와 전처리 상태를 기록하는 항목이다.
        'category': '데이터(data)',

        # 데이터 파일, 데이터 분할, 전처리 코드의 식별자를 기록한다.
        'what_to_record': (
            '데이터셋 압축 파일의 SHA-256, '
            '학습·검증 분할 인덱스, 전처리 스크립트 커밋'
        ),

        # 같은 파일 이름이라도 내용이 변경되면 학습 결과가 달라진다.
        'why': (
            '파일이 조용히 덮어써지면 기존 실행을 재현할 수 없다. '
            '로드하는 모든 데이터는 내용 해시로 식별해야 한다.'
        ),
    },
    {
        # 학습에 사용한 코드 상태를 기록하는 항목이다.
        'category': '코드(code)',

        # Git 커밋, 작업 트리 변경 여부, 서브모듈 버전을 기록한다.
        'what_to_record': (
            '학습 저장소의 Git SHA, is_dirty 상태, 서브모듈 커밋'
        ),

        # 커밋되지 않은 변경으로 실행하면 동일한 코드를 다시 확보하기 어렵다.
        'why': (
            '커밋되지 않은 코드로 학습하면 산출물의 정확한 소스 상태를 복원하기 어렵다. '
            '운영 작업에서는 깨끗한 Git 상태를 요구하는 것이 좋다.'
        ),
    },
    {
        # 학습 동작을 결정하는 모든 설정값을 기록하는 항목이다.
        'category': '하이퍼파라미터(hyperparameters)',

        # 사용자가 지정한 값뿐 아니라 기본값이 적용된 최종 구성을 기록한다.
        'what_to_record': (
            '전체 argparse 결과, 전체 YAML 구성, 해석된 기본값'
        ),

        # 소프트웨어 버전이 바뀌면 기본값도 달라질 수 있다.
        'why': (
            '기본값은 버전 변경에 따라 조용히 달라질 수 있다. '
            '사용자 입력값만 기록하지 말고 최종 해석된 전체 구성을 기록해야 한다.'
        ),
    },
]

print()
print('=== 재현성 체크리스트 ===')

# 범주별로 기록 대상과 기록 이유를 출력한다.
for item in reproducibility_checklist:
    print(
        f"\n  [{item['category']}]"
    )
    print(
        f"    기록 대상: {item['what_to_record']}"
    )
    print(
        f"    기록 이유: {item['why']}"
    )

실습 결과

이 실습에서는 다음 구성 요소를 구현했다.

  • 서로 다른 시드에서 발생하는 결과 차이를 확인하는 비결정성 시연(nondeterminism demonstration)
  • 동일한 시드와 결정론적 설정으로 실행 결과를 고정하는 결정론적 구성(determinism configuration)
  • 결정론적 실행 전후의 학습 시간을 비교하는 성능 비용 측정(performance cost measurement)
  • 각 학습 실행을 추적할 수 있는 내용 주소 기반 구성 해시(content-addressable configuration hash)
  • 운영 환경에서 기록해야 할 항목을 정리한 재현성 체크리스트(reproducibility checklist)

활용 위치

  • 지속적 통합 회귀 테스트(CI regression test)final_loss < expected + ε와 같은 통과 기준을 안정적으로 사용할 수 있다.
  • 모델 레지스트리(model registry)각 모델 산출물과 함께 config_hash를 저장하면 동일한 구성을 사용한 실행을 식별할 수 있다.
  • 장애 분석(incident response) 모델 품질 저하가 코드, 데이터, 환경, 난수 변동 중 어디에서 발생했는지 비교할 수 있다.

댓글남기기