seed를 다 고정했는데 매번 결과가 다른 이유 — PyTorch 완전 재현성 체크리스트

seed를 다 고정했는데 매번 결과가 다른 이유 — PyTorch 완전 재현성 체크리스트

torch.manual_seed(42)까지 넣었는데도 똑같은 코드, 똑같은 데이터로 돌릴 때마다 loss 값이 미묘하게 달라진 경험이 있으실 겁니다. seed를 다 고정했는데 매번 결과가 다른 이유는 사실 대부분 seed 하나만으로는 절대 막을 수 없는 GPU 연산 특성과 데이터 로딩 구조에 있습니다. 이 글에서는 seed 고정 이후에도 남아있는 재현성 구멍을 하나씩 짚고, 실제로 동작하는 코드로 막는 방법을 정리합니다.

torch.manual_seed()가 못 막는 것들

torch.manual_seed(42)는 PyTorch의 CPU/GPU 난수 생성기(RNG) 초기값만 고정합니다. 문제는 GPU 연산 자체가 내부적으로 비결정적(non-deterministic)인 알고리즘을 기본값으로 쓴다는 점입니다. 대표적인 예가 cuDNN의 컨볼루션 연산입니다. cuDNN은 같은 연산이라도 하드웨어 스레드 스케줄링에 따라 덧셈 순서가 달라질 수 있는 알고리즘을 자동 선택하는데, 부동소수점 덧셈은 결합법칙이 성립하지 않기 때문에(a+b+c의 계산 순서가 바뀌면 마지막 자리 오차가 달라짐) 같은 입력이라도 결과가 미세하게 어긋납니다.

또 하나 놓치기 쉬운 부분은 DataLoader의 num_workers입니다. num_workers를 1 이상으로 주면 PyTorch는 워커마다 별도의 난수 시드를 자동 생성하는데, 이 시드는 메인 프로세스의 seed와 별개로 동작해서 데이터 증강(augmentation)에 쓰이는 random.random()이나 np.random 호출 결과가 실행할 때마다 달라집니다. seed 하나로 numpy, random, torch 세 가지 RNG를 전부 고정했다고 착각하는 경우가 실무에서 가장 흔한 실수입니다.

완전 재현성 체크리스트 (실제 코드)

아래는 학습 스크립트 최상단에 넣어야 하는 재현성 고정 코드입니다. 순서와 위치가 중요합니다. import 직후, 모델을 생성하기 전에 실행해야 cuDNN 알고리즘 선택 캐시가 오염되지 않습니다.

import os
import random
import numpy as np
import torch

def seed_everything(seed: int = 42):
    os.environ["PYTHONHASHSEED"] = str(seed)
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)

    # cuDNN이 입력 크기에 따라 최적 알고리즘을 자동 탐색하는 기능을 끔
    torch.backends.cudnn.benchmark = False
    # cuDNN 컨볼루션 연산을 결정론적 알고리즘만 쓰도록 강제
    torch.backends.cudnn.deterministic = True

    # PyTorch 전역 결정론 모드 (버전에 따라 지원 연산이 다름)
    torch.use_deterministic_algorithms(True, warn_only=True)

seed_everything(42)

CUDA 10.2 이상 환경에서 torch.use_deterministic_algorithms(True)를 완전히 적용하려면 CUBLAS 연산의 워크스페이스 설정도 필요합니다. 이건 파이썬 코드가 아니라 스크립트를 실행하기 전에 셸 환경변수로 지정해야 적용됩니다.

GPU 연산과 재현성을 상징하는 반도체 회로 이미지

# Linux/macOS
export CUBLAS_WORKSPACE_CONFIG=:4096:8

# Windows PowerShell
$env:CUBLAS_WORKSPACE_CONFIG=":4096:8"

이 값이 없으면 torch.use_deterministic_algorithms(True)를 켜도 일부 행렬곱 연산에서 RuntimeError가 나거나, warn_only=True로 두면 경고만 뜨고 여전히 비결정적으로 동작합니다.

DataLoader와 멀티프로세스 재현성

DataLoader에서 num_workers를 쓰는 경우, worker_init_fn과 generator를 명시적으로 지정하지 않으면 워커별 시드가 매 실행마다 달라집니다. 아래처럼 두 인자를 함께 넘겨야 합니다.

def worker_init_fn(worker_id):
    worker_seed = torch.initial_seed() % 2**32
    np.random.seed(worker_seed)
    random.seed(worker_seed)

g = torch.Generator()
g.manual_seed(42)

loader = torch.utils.data.DataLoader(
    dataset,
    batch_size=32,
    shuffle=True,
    num_workers=4,
    worker_init_fn=worker_init_fn,
    generator=g,
)

generator를 지정하지 않으면 shuffle=True일 때 매 에폭(epoch)마다 뽑히는 셔플 순서 자체가 실행 세션마다 달라집니다. 학습 곡선을 비교하려고 여러 번 재실행했을 때 초반 몇 스텝부터 loss가 갈라진다면 십중팔구 이 부분이 원인입니다.

그래도 안 맞는다면 — 원자적 GPU 연산과 하드웨어 차이

체크리스트를 전부 적용해도 소수점 마지막 몇 자리가 계속 흔들린다면, atomicAdd 기반 연산이 원인일 수 있습니다. scatter_add, index_add, 일부 backward 연산은 여러 GPU 스레드가 같은 메모리 위치에 동시에 값을 더하는 방식으로 구현되어 있는데, 이 덧셈이 도착하는 순서는 스레드 스케줄링에 좌우되어 deterministic 모드로도 완전히 통제되지 않는 경우가 있습니다. PyTorch 공식 문서는 이런 연산 목록과 예외 상황을 별도로 정리해두고 있으므로, 정확한 지원 범위는 PyTorch 공식 재현성 가이드에서 실행 중인 버전 기준으로 확인하는 것이 가장 정확합니다.

코드를 작성하는 개발자의 모니터 화면

또한 재현성은 같은 하드웨어, 같은 CUDA/cuDNN 버전, 같은 PyTorch 버전 조합 안에서만 보장됩니다. 부동소수점 연산의 결합법칙 미성립 문제는 IEEE 754 표준 자체의 특성이라 IEEE 754 부동소수점 표준 문서를 참고하면 왜 연산 순서가 바뀌면 값이 달라지는지 원리 차원에서 이해할 수 있습니다. GPU 모델이 다르거나(A100 vs 3090) CUDA 버전이 다르면 seed와 deterministic 설정을 동일하게 맞춰도 값이 달라질 수 있다는 점은 감안해야 합니다.

체크리스트 요약

항목 설정 위치 미설정 시 증상
torch.manual_seed / cuda.manual_seed_all 스크립트 최상단 모델 초기 가중치가 매번 달라짐
random.seed / np.random.seed / PYTHONHASHSEED 스크립트 최상단 데이터 증강 결과가 매번 달라짐
cudnn.benchmark = False 모델 생성 전 입력 크기별 알고리즘 자동 탐색으로 연산 순서 변동
cudnn.deterministic = True 모델 생성 전 컨볼루션 연산에서 오차 누적
torch.use_deterministic_algorithms(True) 모델 생성 전 전역적으로 비결정적 커널 허용
CUBLAS_WORKSPACE_CONFIG 셸 환경변수 행렬곱 연산 재현성 미보장
DataLoader worker_init_fn / generator DataLoader 생성 시 워커별 셔플·증강 시드가 매 실행마다 달라짐

이 표의 7가지를 모두 적용하면 같은 하드웨어·같은 라이브러리 버전 조합에서는 bit 단위까지 동일한 결과를 재현할 수 있습니다. 반대로 이 중 하나라도 빠지면 “seed는 고정했는데 왜 다르지”라는 질문으로 다시 돌아오게 됩니다.

마무리

seed 고정은 재현성의 시작일 뿐, GPU 비결정적 알고리즘 차단(cudnn.deterministic, use_deterministic_algorithms), 환경변수 설정(CUBLAS_WORKSPACE_CONFIG), DataLoader 워커 시드 지정까지 4단계를 함께 적용해야 완전한 재현성이 확보됩니다. 지금 쓰고 있는 학습 스크립트에 위 seed_everything() 함수와 DataLoader 설정을 그대로 옮겨 넣고, 같은 조건으로 두 번 학습시켜 첫 배치의 loss 값이 소수점까지 일치하는지 직접 확인해보시길 권합니다.

자주 묻는 질문 (FAQ)

Q1. cudnn.deterministic = True로 설정하면 학습 속도가 느려지나요?
네, cuDNN이 여러 알고리즘 중 가장 빠른 것을 자동 선택하지 못하고 결정론적 알고리즘 하나만 쓰도록 제한되기 때문에 컨볼루션 연산 속도가 느려질 수 있습니다. 속도 손실 정도는 모델 구조와 GPU에 따라 다르므로, 최종 검증이나 논문용 실험처럼 재현성이 꼭 필요한 경우에만 켜고 일반 학습에서는 끄는 방식으로 운용하는 경우가 많습니다.

Q2. 멀티 GPU(DDP) 환경에서도 이 체크리스트가 그대로 적용되나요?
기본 항목은 동일하게 적용되지만, DDP는 프로세스마다 별도의 시드를 넣어줘야 하는 경우가 있고 all-reduce 통신 시점의 부동소수점 합산 순서도 재현성에 영향을 줄 수 있습니다. 완전한 멀티 GPU 재현성은 단일 GPU보다 조건이 훨씬 까다로워, 실무에서는 완벽한 재현성보다 “허용 오차 범위 내 재현”을 목표로 삼는 경우가 많습니다.

Q3. use_deterministic_algorithms(True)를 켰는데 RuntimeError가 발생합니다.
사용 중인 연산 중 결정론적 구현이 아직 지원되지 않는 연산이 있을 때 발생합니다. warn_only=True 옵션을 주면 에러 대신 경고만 출력하고 실행은 계속되지만, 그 연산 구간은 재현성이 보장되지 않는다는 뜻이므로 어떤 연산이 문제인지 경고 메시지를 확인해 해당 부분을 다른 구현으로 대체하는 것이 정확한 해결 방법입니다.

Leave a Comment