HPC & GPU 인프라 핸드북

클러스터 구성부터 스케줄링, 성능 튜닝, 장애 분석까지 순서대로 읽는 실무 문서

  • 12모듈
  • 162문서
  • 2026-08-14갱신

문서 제목 옆의 고치기를 누르면 이 화면에서 바로 고칠 수 있다. 저장하면 GitHub에 변경 이력이 남고 1분쯤 뒤 사이트에 반영된다. 처음 저장할 때 한 번만 토큰을 넣으면 되고, 토큰은 이 브라우저에만 저장된다.

01

HPC 기초입문

클러스터의 구성과 작업 실행 경로

슈퍼컴퓨터는 거대한 한 대의 컴퓨터가 아니라 서버 수백 대를 빠른 네트워크로 묶은 것이다. 그 묶음이 어떤 부품으로 이루어지고 제출한 계산이 어느 경로로 실행되는지부터 본다.

클러스터의 구성

HPC 클러스터는 역할이 다른 네 덩어리로 나뉜다. 이 구분을 알아두면 장애가 났을 때 볼 곳이 빨리 좁혀진다.

구성 역할 하면 안 되는 것
로그인 노드 사용자가 SSH로 접속해 코드를 편집하고 작업을 제출한다 무거운 계산. 로그인 노드가 멈추면 전원이 클러스터에 못 들어온다
컴퓨트 노드 계산이 도는 곳. GPU 노드는 보통 가속기 4~8장을 단다 직접 SSH로 들어가 임의 실행. 스케줄러가 모르는 부하가 생긴다
공유 스토리지 모든 노드가 같은 경로로 보는 파일시스템. 데이터셋과 체크포인트를 둔다 작은 파일 수십만 개를 동시에 여는 패턴. 메타데이터 서버가 먼저 죽는다
인터커넥트 노드 사이를 잇는 저지연 네트워크. InfiniBand나 RoCE를 쓴다 일반 이더넷과 같게 취급. 대역폭보다 지연과 집합 통신 성능이 중요하다

사용자 관점의 흐름은 단순하다. 로그인 노드에서 작업 스크립트를 쓰고, 스케줄러에 제출하고, 스케줄러가 빈 컴퓨트 노드를 골라 실행하고, 결과는 공유 스토리지에 남는다.

로그인 노드에서 제출한 작업을 스케줄러가 컴퓨트 노드에 배치하고 결과가 공유 스토리지에 남는 구조

노드 한 대가 죽어도 전체가 멈추지 않게 하려고, 또 비싼 GPU 노드를 계산에만 쓰게 하려고 이렇게 나눈다.

GitHub에서 열기

개인 서버와의 차이

개인 서버는 SSH로 들어가 프로그램을 바로 실행한다. 클러스터에서는 그렇게 하지 않는다. 계산을 직접 실행하는 대신 스케줄러에 제출하고, 스케줄러가 실행할 자리를 잡아 준다. 이 차이가 처음 오는 사람에게 가장 낯선 부분이다.

이유는 자원을 나눠 쓰기 때문이다. 로그인 노드는 수십 명이 동시에 접속하는 공용 공간이다. 여기서 학습 스크립트를 직접 돌리면 CPU와 메모리를 혼자 차지하고, 같은 노드에 접속한 다른 사람의 편집기와 명령이 함께 느려진다. GPU도 로그인 노드에는 없거나 계산용으로 열려 있지 않다. 큰 데이터를 로그인 노드에서 읽으면 공유 스토리지 대역폭까지 잠식해 클러스터 전체가 굼떠진다. 관리자가 이런 프로세스를 강제로 종료하는 경우가 흔하다.

그래서 순서가 정해져 있다. 로그인 노드에서는 코드를 편집하고 작업 스크립트를 쓰는 데까지만 하고, 실제 계산은 제출해서 컴퓨트 노드에서 돌린다.

자주 나오는 용어부터 정리한다.

용어 뜻
로그인 노드 접속과 편집, 제출을 하는 공용 서버. 계산을 돌리는 곳이 아니다
컴퓨트 노드 제출한 계산이 실제로 도는 서버. 사람이 직접 들어가지 않는다
노드 서버 한 대. CPU 여러 개와 메모리, 때로는 GPU를 갖춘 단위다
대기열 제출된 작업이 자원이 빌 때까지 줄 서서 기다리는 목록
파티션 노드를 용도로 묶은 대기열. 시간 한도와 접근 권한을 파티션마다 다르게 준다
작업(job) 스케줄러에 제출하는 요청 하나. 필요한 자원과 실행할 명령을 담는다
태스크(task) 작업 안에서 실제로 실행되는 프로세스 하나. 작업 하나가 여러 태스크로 나뉜다
GRES 일반 자원(Generic Resource)의 약자. GPU처럼 코어나 메모리가 아닌 자원을 가리킨다
배열 작업 같은 스크립트를 번호만 바꿔 여러 번 제출하는 묶음

GitHub에서 열기

스케줄러가 필요한 이유

노드가 두세 대면 사용자끼리 말로 조율할 수 있다. 수십 대가 되고 사용자가 수십 명이 되면 그 방식은 무너진다. 배치 스케줄러는 이 문제를 대기열로 푼다.

사용자는 GPU 4장, 12시간, 이 스크립트라는 요청서를 내고 로그아웃한다. 스케줄러는 자원이 빌 때 그 요청을 꺼내 실행한다. 대화형 실행과는 세 군데가 다르다.

  • 제출과 실행이 분리된다. 새벽 3시에 시작해도 문제없다.
  • 실행 중인 작업은 할당받은 GPU와 메모리를 독점한다. 옆 사람 작업이 내 메모리를 잠식하지 않는다.
  • 우선순위, 사용량 한도, 공정 배분을 규칙으로 강제할 수 있다.

클러스터 운영의 상당 부분은 누가 얼마나 오래 무엇을 쓰는가라는 정책 문제로 귀결된다. 모듈 06에서 이어 본다.

스케줄러가 실제로 하는 일은 네 가지로 나뉜다.

  • 자원 매칭 각 작업이 요청한 GPU 수, 코어 수, 메모리, 시간을 보고 그것을 모두 만족하는 빈 노드를 찾는다. 조건에 맞는 노드가 없으면 작업은 대기 상태로 남는다.
  • 순서 결정 대기 중인 작업이 여럿이면 우선순위로 줄을 세운다. 우선순위는 기다린 시간, 그동안 쓴 양, 소속 그룹의 몫 같은 요소로 계산한다.
  • 빈틈 채우기 큰 작업이 자원이 모이기를 기다리는 동안, 그 사이 시간에 끝날 수 있는 작은 작업을 끼워 넣는다. 백필이라 부르며, 짧은 작업에 --time을 정확히 적으면 이 혜택을 받는다.
  • 격리 실행 중인 작업이 할당량을 넘겨 옆 작업의 GPU나 메모리를 건드리지 못하게 막는다.

이 규칙들이 있어서 사용자는 자원 상황을 몰라도 제출만 하면 된다. 언제 실행될지는 스케줄러가 정하고, 그 판단 근거는 관리자가 정한 정책이다. 정책이 곧 우선순위이므로, 내 작업이 자꾸 밀린다면 그룹의 사용량이 이미 많거나 요청이 과한 경우가 대부분이다. 요청한 자원과 시간을 줄이면 순번이 당겨질 여지가 생긴다.

GitHub에서 열기

Slurm의 구조

Slurm은 HPC에서 가장 널리 쓰는 오픈소스 스케줄러다. 데몬 세 개만 알면 그림이 잡힌다.

데몬 위치 역할
slurmctld 컨트롤러 노드 대기열을 관리하고 어떤 작업을 어느 노드에 배치할지 결정한다
slurmd 모든 컴퓨트 노드 컨트롤러의 지시를 받아 프로세스를 띄우고 자원을 격리한다
slurmdbd DB 노드(선택) 누가 언제 얼마나 썼는지 기록한다. 과금과 공정 배분의 근거가 된다

사용자의 제출을 slurmctld가 받아 각 노드의 slurmd에 배치하고 slurmdbd가 사용량을 기록한다

설정은 slurm.conf 하나가 중심이고, 모든 노드가 같은 사본을 가져야 한다. 노드마다 내용이 다르면 컨트롤러와 컴퓨트 노드의 인식이 어긋나 노드가 DOWN으로 떨어진다. 구축 초기에 가장 자주 만나는 문제다.

# 노드 정의. 실제 하드웨어와 값이 다르면 노드가 드레인된다
NodeName=gpu[01-16] CPUs=128 RealMemory=1000000 Gres=gpu:8 State=UNKNOWN

# 파티션은 대기열의 단위다. 시간 한도와 접근 권한을 여기서 나눈다
PartitionName=short Nodes=gpu[01-04] MaxTime=04:00:00 Default=YES
PartitionName=train Nodes=gpu[05-16] MaxTime=72:00:00 AllowGroups=ml

RealMemory를 실제 탑재량과 똑같이 적으면 OS가 쓰는 몫 때문에 노드가 곧바로 드레인된다. 보통 몇 GB를 빼고 적는다. 노드가 이유 없이 DOWN이 되면 scontrol show node의 Reason 필드를 먼저 본다.

GitHub에서 열기

매일 쓰는 Slurm 명령

손에 익어야 하는 명령은 예닐곱 개다.

명령 쓰임
sbatch job.sh 스크립트를 대기열에 제출한다
srun --pty bash 자원을 잡은 대화형 세션을 연다. 디버깅에 쓴다
squeue -u $USER 내 작업의 상태와 대기 사유를 본다
sinfo -N -l 노드별 상태를 본다. 유휴, 할당, 드레인이 한눈에 보인다
scancel <jobid> 작업을 취소한다
sacct -j <jobid> 끝난 작업의 종료 코드와 사용량을 조회한다
scontrol show job <id> 작업의 모든 속성을 펼쳐 본다

배치 스크립트는 주석처럼 보이는 #SBATCH 지시자로 자원을 요청한다.

#!/bin/bash
#SBATCH --job-name=train-7b
#SBATCH --partition=train
#SBATCH --nodes=4                 # 노드 4대
#SBATCH --ntasks-per-node=8       # 노드당 프로세스 8개, GPU 1장당 1개
#SBATCH --gres=gpu:8              # 노드당 GPU 8장
#SBATCH --cpus-per-task=12        # 프로세스당 CPU 12코어, 데이터 로더 몫
#SBATCH --time=24:00:00
#SBATCH --output=logs/%x-%j.out   # %x는 작업명, %j는 작업번호

srun python train.py

작업은 대기에서 실행으로 가고 정상 종료, 실패, 시간 초과, 노드 장애 중 하나로 끝난다

squeue의 NODELIST(REASON) 열이 대기 이유를 알려준다. Resources는 자원이 빌 때까지 기다리는 정상 상태고, Priority는 앞에 우선순위가 높은 작업이 있다는 뜻이다. QOSMaxJobsPerUserLimit처럼 정책 이름이 보이면 한도에 걸린 것이다.

GitHub에서 열기

sbatch 스크립트 해부

#SBATCH로 시작하는 줄은 셸에게는 주석이다. #로 시작하니 bash는 무시하고 넘어간다. 같은 줄을 sbatch 명령이 제출 시점에 읽어 자원 요청으로 해석한다. 스크립트 하나가 셸 스크립트이면서 동시에 자원 요청서인 셈이다. 그래서 #SBATCH 줄은 반드시 실행 명령보다 위, 첫 실행 줄이 나오기 전에 와야 한다. 중간에 끼우면 무시된다.

한 줄씩 뜯어본다.

#!/bin/bash
#SBATCH --job-name=demo      # squeue와 로그 파일에 붙는 이름
#SBATCH --partition=short    # 어느 파티션(대기열)에 넣을지
#SBATCH --nodes=1            # 노드 몇 대를 잡을지
#SBATCH --ntasks=1           # 태스크(프로세스) 몇 개를 띄울지
#SBATCH --cpus-per-task=4    # 태스크 하나에 붙일 CPU 코어 수
#SBATCH --mem=8G             # 노드당 요청할 메모리
#SBATCH --time=00:30:00      # 최대 실행 시간. 넘기면 강제 종료된다
#SBATCH --output=logs/%x-%j.out   # 표준 출력이 저장될 경로

각 값은 요청량이다. 실제로 쓰는 양이 아니라 예약하는 양이므로, 크게 잡으면 그만큼 빈 노드를 오래 기다린다. --time은 특히 정확할수록 유리하다. 짧게 적은 작업이 백필로 먼저 실행될 여지가 커진다. 반대로 실제 소요보다 짧게 적으면 계산 도중에 잘린다.

%x와 %j는 sbatch가 치환하는 자리표시자다. %x는 작업 이름으로, %j는 작업 번호로 바뀐다. 출력 경로에 이 둘을 넣으면 작업마다 로그가 겹치지 않는다.

GitHub에서 열기

처음 제출하는 세 가지 예제

가장 작은 것부터 올린다. 첫 제출은 자원을 거의 안 쓰고 경로만 확인하는 용도다.

#!/bin/bash
#SBATCH --job-name=hello
#SBATCH --partition=short
#SBATCH --ntasks=1
#SBATCH --time=00:05:00
#SBATCH --output=logs/%x-%j.out

echo "실행 노드: $(hostname)"
echo "작업 번호: $SLURM_JOB_ID"
sleep 10

mkdir -p logs 뒤에 sbatch hello.sh로 제출하고, squeue -u $USER로 상태를 보고, 끝나면 logs/ 밑의 파일을 열어 본다. 여기까지가 클러스터에서 무언가를 돌리는 최소 왕복이다.

두 번째는 CPU 여러 코어를 쓰는 예제다. 전처리나 데이터 변환이 여기 해당한다.

#!/bin/bash
#SBATCH --job-name=preprocess
#SBATCH --partition=short
#SBATCH --ntasks=1
#SBATCH --cpus-per-task=8
#SBATCH --mem=16G
#SBATCH --time=01:00:00
#SBATCH --output=logs/%x-%j.out

export OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK
srun python preprocess.py --workers $SLURM_CPUS_PER_TASK

세 번째는 GPU를 잡는 예제다. --gres=gpu:N으로 GPU를 요청하는 부분이 앞의 두 예제와 다른 지점이다.

#!/bin/bash
#SBATCH --job-name=train
#SBATCH --partition=train
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --gres=gpu:1
#SBATCH --cpus-per-task=12
#SBATCH --mem=64G
#SBATCH --time=12:00:00
#SBATCH --output=logs/%x-%j.out

module purge
module load cuda/12.4
nvidia-smi --query-gpu=index,name --format=csv,noheader
srun python train.py

세 예제는 요청하는 자원만 다르고 제출과 확인 절차는 똑같다. GPU 한 장으로 도는 것을 확인한 뒤에 노드 수와 GPU 수를 늘리는 순서가 안전하다.

GitHub에서 열기

대화형 작업

제출하고 기다리는 방식만 있는 것은 아니다. 개발과 디버깅에서는 자원을 잡은 채 셸에서 직접 명령을 두드리는 대화형 방식을 더 많이 쓴다. 코드를 고치고 바로 돌려 보고 오류를 확인하는 순환이 배치 제출보다 빠르기 때문이다.

두 가지 방법이 있다.

# 방법 1: 자원을 할당받고 그 안에서 셸을 연다
salloc --partition=short --gres=gpu:1 --cpus-per-task=8 --time=02:00:00
# 할당되면 프롬프트가 돌아온다. 이후 srun으로 컴퓨트 노드에서 실행한다
srun python train.py --debug

# 방법 2: 컴퓨트 노드의 대화형 셸로 바로 들어간다
srun --partition=short --gres=gpu:1 --cpus-per-task=8 --time=02:00:00 --pty bash
# 프롬프트가 컴퓨트 노드로 바뀐다. nvidia-smi로 GPU가 붙었는지 확인한다

--pty bash는 컴퓨트 노드 위에서 대화형 셸을 띄운다. 이 셸 안은 로그인 노드가 아니라 할당받은 컴퓨트 노드이므로, 여기서는 GPU를 써도 된다. 짧은 실험과 환경 점검, 오류 재현에 적합하다.

주의할 것이 있다. 대화형 세션은 사람이 붙어 있는 동안 자원을 계속 점유한다. 생각하는 사이에도 GPU가 놀며 잡혀 있으므로, 실험이 끝나면 셸을 빠져나와 할당을 반납한다. 긴 학습은 대화형으로 돌리지 않고 배치로 제출하는 편이 자원 활용에 낫다.

GitHub에서 열기

대기 사유 읽기

squeue의 NODELIST(REASON) 열은 작업이 왜 아직 안 도는지 알려준다. 값마다 뜻과 사용자가 할 수 있는 일이 다르다.

REASON 뜻 할 수 있는 일
Priority 앞에 우선순위가 높은 작업이 있다 기다린다. 요청 자원이나 시간을 줄이면 순번이 당겨진다
Resources 조건에 맞는 노드가 빌 때까지 대기 중이다 정상 상태다. 요청을 줄이면 더 빨리 잡힌다
QOSMaxJobsPerUserLimit 사용자당 동시 실행 한도에 걸렸다 실행 중인 내 작업이 끝나야 다음이 시작된다
ReqNodeNotAvail 요청한 노드가 예약, 점검, 다운 상태다 sinfo로 노드 상태를 보고 조건을 넓힌다
Dependency 선행 작업이 아직 안 끝났다 의존 대상 작업의 상태를 확인한다
AssocMaxJobsLimit 그룹 단위 한도에 걸렸다 그룹의 다른 작업이 끝나기를 기다린다
PartitionTimeLimit 요청 시간이 파티션 한도를 넘었다 --time을 줄이거나 한도가 긴 파티션으로 옮긴다

Resources와 Priority는 기다리면 풀리는 정상 상태다. 정책 이름이 붙은 값은 한도에 걸린 것이므로 기다림만으로는 안 풀리고, 내 다른 작업이 끝나거나 요청을 조정해야 한다.

GitHub에서 열기

작업이 끝난 뒤 확인할 것

작업은 끝나는 방식이 여러 가지이고, 그때마다 볼 곳이 다르다.

상태 뜻 확인할 것
COMPLETED 정상 종료 없음
FAILED 종료 코드가 0이 아니다 애플리케이션 로그
TIMEOUT 요청 시간을 넘겼다 --time 설정, 실제 소요 시간
OUT_OF_MEMORY 메모리 한도 초과 MaxRSS와 요청량
NODE_FAIL 노드가 빠졌다 그 노드의 상태와 로그
CANCELLED 취소됐다 누가 취소했는지, 관리자 조치인지
sacct -j 12345 --format=JobID,JobName,State,ExitCode,MaxRSS,ReqMem,Elapsed,Timelimit,NodeList

ExitCode는 두 값이 콜론으로 이어진다. 앞은 프로세스 종료 코드, 뒤는 받은 신호 번호다. 0:9면 SIGKILL을 받은 것이고 0:15면 SIGTERM이다. 시간 초과로 스케줄러가 죽인 경우가 흔하다.

MaxRSS가 요청 메모리에 근접했다면 다음 제출에서 늘린다. 반대로 요청량의 10퍼센트만 썼다면 줄이는 편이 낫다. 과하게 요청하면 자기 작업이 대기열에서 밀린다.

GitHub에서 열기

재현 가능한 작업 만들기

같은 스크립트가 어제는 됐는데 오늘은 안 되는 상황은 대부분 환경 차이에서 온다. 작업 스크립트가 사용자 셸 설정에 기대지 않게 만들면 이 문제가 크게 준다.

#!/bin/bash
#SBATCH ...

set -euo pipefail            # 실패를 조용히 넘기지 않는다
module purge                 # 셸에 남아 있던 모듈을 내린다
module load cuda/12.4 nccl/2.21

echo "host=$(hostname) job=$SLURM_JOB_ID"    # 나중에 조사할 근거를 남긴다
nvidia-smi --query-gpu=index,name,driver_version --format=csv
python -c "import torch; print(torch.__version__, torch.version.cuda)"

srun python train.py

앞부분의 몇 줄이 로그에 남아 있으면 나중에 문제를 조사할 때 시간을 크게 줄인다. 어떤 노드에서 어떤 드라이버와 어떤 라이브러리 버전으로 돌았는지가 곧 단서다.

환경을 고정하는 방법은 네 군데를 잡는 것이다. 모듈은 cuda가 아니라 cuda/12.4처럼 버전을 박아 부른다. 버전을 빼면 클러스터의 기본값이 바뀔 때 결과가 조용히 달라진다. 컨테이너를 쓰면 태그를 latest가 아니라 다이제스트까지 고정한다. 시드는 실행 시작에서 한 번에 묶어 고정한다.

module load cuda/12.4 nccl/2.21                     # 버전을 박아 부른다
export PYTHONHASHSEED=0
export CUBLAS_WORKSPACE_CONFIG=:4096:8              # cuBLAS 결정성 확보

# 컨테이너는 태그가 아니라 다이제스트로 고정한다
CImg=nvcr.io/nvidia/pytorch@sha256:9f1c...          # 다이제스트 전체를 적는다
srun --container-image=$CImg python train.py
import random, numpy as np, torch
SEED = 42
random.seed(SEED); np.random.seed(SEED)
torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)
torch.use_deterministic_algorithms(True)           # 비결정 연산을 막는다

무엇으로 돌렸는지를 로그에 남겨야 나중에 되짚는다. 코드의 커밋 해시와 미커밋 변경 여부를 함께 찍는다.

echo "commit=$(git rev-parse HEAD) dirty=$(git status --porcelain | wc -l)"
pip freeze > logs/pip-$SLURM_JOB_ID.txt             # 실제 설치본을 통째로 남긴다

같은 결과가 안 나오면 넓은 쪽부터 좁힌다. 커밋 해시가 같은지, pip freeze 결과가 같은지, 모듈과 드라이버 버전이 같은지, 시드가 실제로 걸렸는지 순서로 본다. 여기까지 같은데도 값이 흔들리면 원자적 덧셈이나 비결정 커널이 원인인 경우가 많고, torch.use_deterministic_algorithms가 그 지점을 예외로 알려준다.

작업 스크립트는 코드와 같은 저장소에서 함께 버전 관리한다. 스크립트만 홈 디렉터리에 따로 두면 코드가 바뀔 때 스크립트가 뒤처져 재현이 깨진다. 저장소에 slurm/ 디렉터리를 두고 제출 파일을 넣은 뒤, 어떤 커밋으로 제출했는지 태그로 남긴다.

git add slurm/train.sh && git commit -m "train: 노드 4대 설정"
git tag run-$(date +%Y%m%d)-$SLURM_JOB_ID           # 이 제출을 커밋에 고정한다

GitHub에서 열기

작업 의존성으로 단계 잇기

전처리가 끝난 뒤에 학습을 시작해야 하는 것처럼, 작업 사이에 순서가 있는 경우가 있다. 손으로 결과를 확인하고 다음을 제출하는 대신 의존성을 걸어 두면 스케줄러가 순서를 지킨다.

# 전처리를 제출하고 작업 번호를 받는다
pre=$(sbatch --parsable preprocess.sh)

# 전처리가 정상 종료(afterok)한 뒤에만 학습을 시작한다
sbatch --dependency=afterok:$pre train.sh

--parsable은 작업 번호만 출력해 변수에 담기 좋게 한다. afterok:은 선행 작업이 종료 코드 0으로 끝났을 때만 다음을 실행한다. 전처리가 실패하면 학습은 시작되지 않고 DependencyNeverSatisfied 상태로 남아, 잘못된 데이터로 학습하는 사고를 막는다.

조건은 몇 가지가 있다.

조건 실행 시점
afterok:ID 선행 작업이 정상 종료한 뒤
afterany:ID 선행 작업이 성공이든 실패든 끝난 뒤
afternotok:ID 선행 작업이 실패로 끝난 뒤. 복구용
after:ID 선행 작업이 시작된 뒤

여러 단계를 이어 학습, 평가, 리포트 생성을 하나의 사슬로 묶으면 사람이 중간에 개입하지 않아도 순서대로 돈다.

GitHub에서 열기

배열 작업으로 스윕 돌리기

같은 코드를 학습률만 바꿔 열 번 돌리는 상황에서, 스크립트를 열 개 만들 필요는 없다. 배열 작업은 스크립트 하나를 번호만 바꿔 여러 번 제출한다.

#!/bin/bash
#SBATCH --job-name=sweep
#SBATCH --partition=train
#SBATCH --gres=gpu:1
#SBATCH --cpus-per-task=8
#SBATCH --time=06:00:00
#SBATCH --array=0-4                     # 5개 작업. 인덱스는 0부터 4까지
#SBATCH --output=logs/%x-%A-%a.out      # %A는 배열 전체 번호, %a는 개별 인덱스

lrs=(0.1 0.05 0.01 0.005 0.001)
lr=${lrs[$SLURM_ARRAY_TASK_ID]}         # 인덱스로 자기 몫의 값을 고른다

srun python train.py --lr $lr

--array=0-4는 인덱스 0부터 4까지 다섯 개의 하위 작업을 만든다. 각 하위 작업은 $SLURM_ARRAY_TASK_ID로 자기 번호를 알고, 그 번호로 배열에서 학습률을 골라 쓴다. 다섯 작업이 자원이 있는 대로 병렬로 실행된다.

동시에 도는 개수를 제한하려면 %를 붙인다. --array=0-19%4는 스무 개 중 최대 네 개만 한꺼번에 돌린다. 클러스터를 혼자 점유하지 않으면서 큰 스윕을 걸 때 쓴다.

GitHub에서 열기

자주 하는 제출 실수

증상 원인 고치는 법
계속 PENDING 요청이 어떤 노드보다도 크다 sinfo -o "%n %c %m %G" 로 실제 사양 확인
GPU가 안 보인다 --gres 를 빼먹었다 --gres=gpu:N 추가
프로세스가 하나만 뜬다 python 을 직접 실행했다 srun python 으로 실행
데이터 로더가 느리다 --cpus-per-task 가 작다 GPU당 8~16코어를 잡는다
로그가 안 남는다 출력 경로의 디렉터리가 없다 제출 전에 mkdir -p logs

표의 첫 줄이 가장 흔하다. 요청한 자원이 어떤 노드보다도 크면 작업은 영원히 PENDING으로 남는다. GPU 12장을 요청했는데 노드마다 8장뿐이면 조건을 만족하는 노드가 없기 때문이다. 제출 전에 실제 노드 사양을 확인하는 습관이 이 문제를 없앤다.

sinfo -o "%n %c %m %G"     # 노드 이름, 코어 수, 메모리, GRES(gpu 수)

srun 없이 python을 직접 부르는 실수도 잦다. 배치 스크립트에서 srun을 붙이면 스케줄러가 할당한 자원 배치대로 프로세스를 띄운다. 붙이지 않으면 지시자로 노드 여러 대를 잡아 놓고도 프로세스는 첫 노드에서 하나만 돌아, 나머지 자원이 통째로 논다. 멀티노드 작업에서 특히 치명적이다.

메모리 요청도 자주 어긋난다. --mem을 너무 작게 잡으면 작업이 OUT_OF_MEMORY로 죽고, 너무 크게 잡으면 빈 노드를 오래 기다린다. 첫 제출에서는 넉넉히 잡아 완주시킨 뒤, sacct의 MaxRSS를 보고 실제 사용량에 맞춰 줄이는 순서가 안전하다.

GitHub에서 열기

손에 클러스터가 없을 때

Slurm을 익히려고 클러스터를 기다릴 필요는 없다. 컨테이너로 노트북 위에 작은 클러스터를 세워 명령과 스크립트를 그대로 연습할 수 있다. GPU는 없지만 제출, 대기열, 의존성, 배열 작업 같은 동작은 실제와 같다.

# 컨트롤러 한 대와 컴퓨트 두 대를 띄운다
git clone https://github.com/giovtorres/slurm-docker-cluster
cd slurm-docker-cluster
docker compose up -d
docker compose exec slurmctld bash        # 로그인 노드에 들어간 셈이다
# 안에서 평소처럼 쓴다
sinfo
srun -N2 hostname
sbatch --wrap 'sleep 30; echo done'
squeue
sacct

macOS에서 Docker Desktop을 쓰지 않는다면 colima로 런타임만 띄워도 된다. 리눅스 가상 머신 하나를 올려 그 위에서 컨테이너를 돌리는 방식이라 동작은 같다.

brew install colima docker docker-compose
colima start --cpu 4 --memory 8
docker compose up -d

연습용 클러스터에서 확인할 수 있는 것과 없는 것을 구분해 둔다.

확인되는 것 확인되지 않는 것
제출과 대기열 동작 GPU 자원 요청과 배치
스크립트 문법과 환경 변수 노드 간 고속 통신
의존성과 배열 작업 공유 파일시스템 성능
파티션과 시간 제한 정책 실제 대기 시간과 경합
sacct 기록 읽기 하드웨어 장애 상황

스크립트를 여기서 다듬고 실제 클러스터에서는 자원 요청만 바꿔 제출하면 실수가 준다. 특히 처음 쓰는 사람이 #SBATCH 지시어를 잘못 적어 겪는 실패는 대부분 여기서 미리 걸러진다.

GitHub에서 열기

MPI로 노드를 넘어 계산하기

한 노드 안에서는 스레드로 병렬화하면 되지만 노드를 넘어가면 메모리가 공유되지 않는다. MPI는 이 상황에서 프로세스들이 메시지를 주고받아 협력하게 하는 표준이다.

rank는 프로세스마다 붙는 0부터의 번호이고 size는 전체 프로세스 수다. 이 둘이면 충분하다. 모든 프로세스가 같은 프로그램을 실행하되 자기 rank를 보고 다른 일을 한다.

from mpi4py import MPI

comm = MPI.COMM_WORLD
rank, size = comm.Get_rank(), comm.Get_size()

local = sum(range(rank, 1_000_000, size))   # 각자 자기 몫만 계산한다
total = comm.allreduce(local, op=MPI.SUM)   # 하나로 합친다

if rank == 0:
    print(f"{size}개 프로세스로 계산한 합계: {total}")

점대점 통신은 send와 recv로 특정 상대와 주고받고, 집합 통신은 broadcast, allreduce, allgather처럼 전체가 함께 참여한다. 분산 학습에서 그래디언트를 평균 내는 연산이 allreduce이고, 학습 속도는 이 집합 통신 성능에 크게 좌우된다.

PyTorch의 DistributedDataParallel이 내부에서 하는 일도 allreduce다. NVIDIA 환경에서는 MPI 대신 NCCL이 그 역할을 맡아 NVLink와 InfiniBand를 활용한다. 개념은 같고 구현이 다르다.

GitHub에서 열기

02

멀티코어 병렬 프로세싱핵심

코어를 늘려도 빨라지지 않는 이유

노드 한 대에 코어가 128개 있어도 프로그램이 저절로 128배 빨라지지는 않는다. 어디까지 나눌 수 있고 어디서 막히는지, 스레드를 어느 코어에 붙여야 하는지를 본다.

병렬화의 상한

프로그램에서 병렬로 돌릴 수 없는 구간이 조금이라도 있으면 전체 속도는 그 구간에 묶인다. 95퍼센트를 완벽히 병렬화해도 나머지 5퍼센트 때문에 최대 20배에서 멈춘다. 코어를 1000개 주더라도 마찬가지다.

병렬 가능 비율 코어 16개 코어 64개 코어 무한
50% 1.8배 2.0배 2배
90% 6.4배 8.8배 10배
95% 9.1배 15.4배 20배
99% 13.9배 39.3배 100배

병렬 가능 비율이 99퍼센트여도 코어 32개에서 24배에 그친다

여기서 나오는 결론은 두 가지다. 코어를 늘리기 전에 순차 구간을 먼저 줄여야 하고, 코어 수를 두 배로 늘렸을 때 성능이 두 배가 되지 않는 것은 정상이라는 점이다.

문제 크기를 함께 키우면 사정이 달라진다. 코어를 늘린 만큼 데이터도 늘리는 경우를 약한 확장이라 하고, 이쪽은 훨씬 잘 늘어난다. 대규모 학습에서 GPU를 늘리며 배치 크기를 함께 키우는 방식이 여기 해당한다.

코어를 두 배로 늘려도 두 배가 안 되는 이유는 병렬로 못 나누는 구간이 그대로 남기 때문이다. 이 직렬 구간은 눈에 잘 안 띄는 곳에 숨어 있다. 파일을 읽고 쓰는 입출력, 스레드를 띄우기 전의 메모리 할당, 각 스레드가 계산한 값을 하나로 합치는 취합, 공유 자료를 지키는 잠금이 대표적이다. 코어를 늘리면 병렬 구간의 시간만 줄고 이 직렬 구간의 시간은 그대로여서, 전체에서 직렬이 차지하는 비중이 점점 커진다.

확장이 어디서 꺾이는지는 재봐야 안다. 코어 수를 1, 2, 4, 8로 두 배씩 올리며 같은 입력으로 시간을 재고, 코어를 두 배로 했을 때 시간이 절반 가까이 주는 구간까지가 잘 늘어나는 범위다. 어느 지점부터 시간이 덜 줄거나 오히려 늘면 거기가 상한이고, 그 너머로 코어를 더 요청하면 클러스터 점유만 늘고 이득이 없다.

강한 확장과 약한 확장은 재는 목적이 다르다. 강한 확장은 문제 크기를 고정한 채 코어만 늘려 같은 일을 얼마나 빨리 끝내는지 본다. 정해진 데이터셋의 전처리를 앞당기려는 경우가 여기 해당하고, 암달의 법칙이 상한을 정한다. 약한 확장은 코어당 일감을 고정한 채 코어와 문제를 함께 늘려, 더 큰 문제를 같은 시간에 푸는지 본다. 노드를 늘리며 배치와 모델을 함께 키우는 대규모 학습이 약한 확장이고, 이쪽은 직렬 비중이 상대적으로 작아져 훨씬 멀리 늘어난다. 무엇을 재는지 정하지 않고 배속만 보면 같은 코드가 잘 늘어나는지 아닌지를 반대로 읽는다.

GitHub에서 열기

암달의 법칙

앞의 배속은 암달의 법칙에서 나온다. 전체 작업에서 병렬로 돌릴 수 있는 비율을 p라 하면, 코어 N개로 얻는 배속은 아래 식으로 정해진다.

speedup(N) = 1 / ( (1 - p) + p / N )

1 - p가 순차 구간이다. N을 아무리 키워도 p / N은 0으로 갈 뿐이라, 배속은 1 / (1 - p)를 넘지 못한다. p가 0.95면 상한이 20배, 0.99면 100배다.

병렬 비율 p 코어 16개 코어 64개 코어 256개 코어 1024개 상한
75% 3.4배 3.8배 4.0배 4.0배 4배
90% 6.4배 8.8배 9.7배 9.9배 10배
95% 9.1배 15.4배 18.6배 19.6배 20배
99% 13.9배 39.3배 72.1배 91.2배 100배

같은 코드라도 p가 0.9인지 0.99인지에 따라 코어 256개에서 결과가 9.7배와 72.1배로 갈린다. 코어를 늘리기 전에 순차 구간을 줄이는 편이 이득이 큰 이유가 여기 있다. 순차 구간이 1퍼센트만 있어도 코어 256개의 4분의 3이 노는 셈이다.

구스타프슨의 법칙은 반대편을 본다. 암달은 문제 크기를 고정하지만, 코어가 늘면 더 큰 문제를 푸는 경우가 많다. 문제를 키우면 병렬 구간은 데이터에 비례해 늘고 직렬 구간인 초기화와 취합은 거의 그대로여서, 전체에서 병렬 비율 p가 올라간다. 그래서 코어당 일감을 유지하며 문제를 키우면 배속이 코어 수에 가깝게 붙는다. 대규모 학습에서 노드를 늘리며 배치를 키우는 방식이 이 곡선을 탄다.

# 문제를 코어에 맞춰 키울 때 (구스타프슨)
scaled_speedup(N) = N - s * (N - 1)      # s는 직렬 비율 (1 - p)

s가 0.02면 코어 64개에서 암달은 약 28배에 그치지만, 문제를 함께 키우는 구스타프슨 관점에서는 약 63배에 이른다. 같은 코드가 강한 확장에서는 막히고 약한 확장에서는 뻗는 이유가 여기 있다.

실제 코드의 p는 재서 역산한다. 코어 2개와 N개에서 같은 입력으로 시간을 재고 T(n) = a + b/n 꼴에 맞춘다. a는 직렬 시간, b는 1코어 기준 병렬 시간이다.

b = (T2 - TN) * 2N / (N - 2)
a = T2 - b / 2
p = b / (a + b)

코어 2개에서 100초, 64개에서 8초가 나오면 b는 약 190, a는 약 5로 나오고 p는 약 0.97이다. p가 이 정도면 코어 256개의 상한이 약 34배여서, 그 이상 코어를 요청해도 시간이 줄지 않는다. 측정 두 점만으로 어디까지 늘려야 할지를 미리 가늠할 수 있다.

GitHub에서 열기

코어와 소켓과 하드웨어 스레드

스레드 수를 정하려면 노드의 물리 구조를 알아야 한다. 용어가 겹쳐 헷갈리기 쉬우므로 하나씩 짚는다.

  • 코어 명령을 실제로 실행하는 계산 단위다. 코어 하나가 한 번에 한 흐름의 연산을 처리한다.
  • 소켓 물리 CPU 칩 하나가 꽂히는 자리다. 노드 한 대에 소켓이 둘이면 CPU 칩이 두 개 있고, 각 칩이 코어 수십 개를 담는다.
  • 하드웨어 스레드 코어 하나가 실행 흐름 두 개를 번갈아 처리하는 기능이다. 인텔은 하이퍼스레딩이라 부른다. 한 흐름이 메모리를 기다리는 동안 다른 흐름을 돌려 빈 시간을 메운다.
  • 물리 코어와 논리 코어 하드웨어 스레드가 켜져 있으면 코어 하나가 논리 코어 둘로 나뉜다. 물리 코어 64개짜리 노드가 OS에는 논리 코어 128개로 나타난다. 연산이 빽빽한 코드는 두 흐름이 같은 계산 자원을 다투므로 물리 코어 수만큼만 스레드를 쓰는 편이 빠르다.

lscpu 출력에서 이 값들을 읽는다.

lscpu
# CPU(s):                128        논리 코어 총수 (OS가 보는 수)
# Thread(s) per core:    2          코어당 하드웨어 스레드. 2면 하이퍼스레딩 켜짐
# Core(s) per socket:    32         소켓당 물리 코어
# Socket(s):             2          소켓 수
# NUMA node(s):          2          메모리가 묶인 구역 수

물리 코어 수는 Core(s) per socket × Socket(s)로 계산한다. 이 예시는 32 곱하기 2로 물리 코어 64개, 논리 코어 128개다. 연산 위주 작업의 출발점은 논리 코어 128이 아니라 물리 코어 64다.

NUMA node(s)는 메모리 접근 거리가 갈리는 구역 수다. 소켓마다 붙은 메모리가 있어, 다른 소켓의 메모리를 읽으면 자기 소켓의 메모리보다 느리다. 스레드를 코어에 붙이는 이유가 이 거리 때문이다.

GitHub에서 열기

GPU 클러스터에 CPU 병렬이 필요한 이유

GPU가 계산을 맡는데도 CPU 병렬이 필요한 이유는 GPU를 굶기지 않기 위해서다. 학습 한 스텝은 데이터를 읽고, 디코딩하고, 증강한 뒤 GPU에 올려 계산하는 순서로 돈다. 앞의 세 단계는 CPU가 한다. CPU가 배치를 제때 못 만들면 GPU가 다음 배치를 기다리며 놀고, 비싼 가속기의 활용률이 떨어진다.

그래서 데이터 로더에 CPU 코어를 충분히 배정해야 한다. PyTorch의 DataLoader는 num_workers 수만큼 프로세스를 띄워 배치를 미리 준비하는데, 이 워커가 CPU 코어를 쓴다. 코어가 모자라면 GPU가 기다리고, 코어를 과하게 잡으면 다른 작업이 밀린다.

--cpus-per-task 산정은 노드의 코어를 GPU 수로 나누는 데서 출발한다. GPU 8장 노드에 물리 코어가 64개라면 GPU 한 장당 8코어가 배정된다.

#SBATCH --nodes=1
#SBATCH --ntasks-per-node=8     # GPU 1장당 태스크(프로세스) 1개
#SBATCH --gres=gpu:8
#SBATCH --cpus-per-task=8       # 물리 코어 64 나누기 GPU 8 = 8

이 배정 안에서 데이터 로더 워커 수를 정한다. 태스크당 8코어라면 num_workers를 6에서 8 사이로 잡고, 남는 여유는 메인 프로세스와 시스템 몫으로 둔다. 워커를 코어 수보다 많이 띄우면 프로세스들이 서로 코어를 다투어 오히려 느려진다.

논리 코어 128개를 기준으로 나누고 싶을 수 있지만, 데이터 디코딩은 연산이 빽빽해 하이퍼스레딩 이득이 작다. 물리 코어 64를 기준으로 나누는 편이 안정적이다.

GitHub에서 열기

프로세스와 스레드

병렬화 수단은 크게 둘이다. 무엇을 고르느냐에 따라 메모리와 통신 비용이 달라진다.

방식 메모리 통신 적합한 곳
멀티프로세스 (MPI) 각자 따로 명시적 메시지 노드를 넘는 병렬
멀티스레드 (OpenMP) 공유 공유 변수 노드 안의 병렬

노드 안에서는 스레드가 유리하다. 메모리를 복제하지 않아 데이터셋이 큰 작업에서 차이가 크다. 노드를 넘어가면 메모리가 공유되지 않으므로 프로세스와 메시지가 필요하다.

파이썬은 사정이 다르다. GIL 때문에 스레드로는 CPU 병렬이 되지 않는다. multiprocessing으로 프로세스를 띄우거나, NumPy처럼 GIL을 놓는 라이브러리 안에서 병렬이 일어나게 해야 한다.

스레드는 같은 메모리를 공유하므로 데이터를 복제하지 않는다. 데이터셋 하나를 여러 스레드가 함께 읽는 작업에서 메모리가 절약된다. 대신 여러 스레드가 같은 변수를 동시에 고치면 결과가 깨지므로 동기화가 필요하고, 그 동기화가 비용이 된다.

프로세스는 각자 독립된 메모리를 가진다. 서로의 변수를 건드릴 수 없어 안전하지만, 데이터를 주고받으려면 명시적으로 메시지를 보내거나 복사해야 한다. 노드를 넘어가면 애초에 메모리를 공유할 방법이 없으므로 프로세스와 메시지가 유일한 선택지다.

고르는 기준은 두 가지다.

  • 경계 한 노드 안이면 스레드, 노드를 넘으면 프로세스다.
  • 데이터 크기 데이터가 커서 복제가 부담이면 스레드가 유리하다.

실전에서는 둘을 겹쳐 쓴다. 노드 사이는 프로세스로, 노드 안은 스레드로 나누는 하이브리드 구성이 대표적이다. 뒤의 하이브리드 절에서 이어 본다.

GitHub에서 열기

OpenMP로 스레드 병렬

반복문 앞에 지시자 한 줄을 붙이면 컴파일러가 스레드로 나눈다.

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    y[i] = a * x[i] + y[i];
}

반복 사이에 의존성이 없어야 한다. 앞 반복의 결과를 뒤에서 쓰면 결과가 실행할 때마다 달라진다. 합계처럼 하나의 변수에 모으는 경우는 축약을 명시한다.

double sum = 0.0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < n; i++) sum += x[i] * y[i];

반복마다 걸리는 시간이 들쭉날쭉하면 정적 분배로는 일부 스레드가 먼저 끝나고 논다. 이럴 때 동적 분배로 바꾼다.

#pragma omp parallel for schedule(dynamic, 64)

청크를 너무 작게 잡으면 분배 자체의 비용이 커진다. 수십에서 수백 사이에서 재보고 정한다.

각 절은 변수를 스레드에 어떻게 나눌지 정한다. 잘못 쓰면 컴파일은 되지만 결과가 조용히 틀린다.

절 뜻 쓸 때 잘못 쓰면
shared(x) 모든 스레드가 같은 변수를 본다 읽기만 하는 입력 배열 여러 스레드가 동시에 쓰면 값이 깨진다
private(t) 스레드마다 별도 사본을 둔다 반복 안의 임시 변수 초기값이 없어 쓰레기값으로 시작한다
firstprivate(c) 사본을 두되 진입 전 값으로 채운다 루프 밖에서 정한 상수를 각자 쓸 때 private로 두면 그 값이 사라진다
reduction(+:s) 스레드별로 모았다가 마지막에 합친다 합계, 최댓값 같은 누적 공유 변수로 두면 경쟁으로 값이 줄어든다

가장 흔한 사고는 누적 변수를 그냥 shared로 두는 것이다. 두 스레드가 같은 sum에 동시에 더하면 한쪽 갱신이 묻혀 매번 다른 값이 나온다. 반복 안에서만 쓰는 임시 변수를 private로 선언하지 않으면 스레드끼리 그 변수를 덮어써 결과가 어긋난다.

schedule은 반복을 스레드에 나누는 방식이다. static은 처음에 균등하게 잘라 분배 비용이 거의 없다. 반복마다 걸리는 시간이 고르면 이쪽이 가장 빠르다. dynamic은 스레드가 끝나는 대로 다음 청크를 집어 부하가 들쭉날쭉한 경우에 맞지만, 청크를 집을 때마다 조율 비용이 든다. guided는 큰 청크로 시작해 점점 줄여 둘의 절충이다. 반복 시간이 균등한데 dynamic을 쓰면 조율 비용만 늘어 느려지고, 반대로 들쭉날쭉한데 static을 쓰면 무거운 몫을 받은 스레드가 끝날 때까지 나머지가 논다.

컴파일과 실행은 컴파일러에 병렬 플래그를 켜는 데서 시작한다.

gcc -O2 -fopenmp axpy.c -o axpy      # GCC는 -fopenmp, Intel은 -qopenmp
OMP_NUM_THREADS=8 ./axpy             # 스레드 수는 환경 변수로 준다

플래그를 빼먹으면 #pragma가 주석으로 무시되어 한 스레드로 돈다. 병렬화한 코드가 안 빨라지면 먼저 이 플래그가 켜졌는지 확인한다.

GitHub에서 열기

스레드를 코어에 붙이기

기본 상태에서는 OS가 스레드를 옮겨 다니게 둔다. 캐시가 매번 식고 NUMA 노드를 넘나들면서 성능이 흔들린다. 붙여 두면 재현성도 함께 올라간다.

export OMP_NUM_THREADS=16
export OMP_PROC_BIND=close     # 스레드를 서로 가까이 붙인다
export OMP_PLACES=cores        # 배치 단위는 코어

close는 한 소켓에 몰아 붙여 캐시 공유가 유리하고, spread는 흩어 놓아 메모리 대역폭을 더 쓴다. 대역폭에 묶인 연산은 spread가 나은 경우가 있다.

close는 스레드를 한 소켓에 모으고 spread는 소켓에 흩어 배치한다

메모리도 함께 묶어야 효과가 난다.

numactl --cpunodebind=0 --membind=0 ./app

리눅스는 메모리를 처음 만지는 스레드가 있는 노드에 페이지를 붙인다. 초기화를 한 스레드가 몰아서 하면 데이터 전체가 한 노드에 몰리고, 이후 다른 노드의 스레드는 계속 원격 접근을 한다. 초기화도 실제 계산과 같은 방식으로 병렬화해야 한다.

#pragma omp parallel for
for (int i = 0; i < n; i++) x[i] = 0.0;   // 계산과 같은 분배로 초기화한다

GitHub에서 열기

하이브리드 MPI + OpenMP

노드 안은 스레드로, 노드 사이는 프로세스로 나누는 구성이다. 소켓마다 프로세스 하나를 두고 그 소켓의 코어 수만큼 스레드를 붙이는 배치가 무난한 출발점이다.

#SBATCH --nodes=4
#SBATCH --ntasks-per-node=2      # 소켓당 프로세스 1개
#SBATCH --cpus-per-task=64       # 소켓의 코어 수

export OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK
export OMP_PROC_BIND=close
srun --cpu-bind=cores ./app

프로세스를 늘릴수록 통신량이 늘고, 스레드를 늘릴수록 동기화 비용과 NUMA 원격 접근이 는다. 둘 사이 균형은 코드마다 다르므로 몇 가지 조합을 재보고 고른다.

프로세스와 스레드의 곱이 노드의 물리 코어 수를 넘지 않아야 한다. 소켓이 둘이고 소켓당 코어가 64개인 노드라면, 소켓마다 프로세스 하나에 스레드 64개가 코어를 정확히 채운다. 프로세스를 소켓 경계에 맞추면 각 프로세스의 스레드가 한 소켓 안에 머물러 NUMA 원격 접근이 사라진다.

배치가 어긋나면 두 프로세스의 스레드가 같은 코어에 겹치거나, 한 프로세스가 두 소켓에 걸쳐 절반의 스레드가 원격 메모리를 읽는다. --cpu-bind와 OMP_PLACES로 경계를 맞춰야 하는 이유다.

구성 프로세스 배치 장점 단점
순수 MPI 코어마다 프로세스 1개 단순하다 프로세스 수만큼 통신량과 메모리가 는다
순수 OpenMP 노드에 프로세스 1개 통신이 없다 노드를 넘지 못한다
하이브리드 소켓마다 프로세스 1개 통신량과 NUMA 접근을 함께 줄인다 배치를 맞추기 까다롭다

어느 쪽이 빠른지는 코드가 통신에 묶였는지 메모리 대역폭에 묶였는지에 달렸다. 통신이 많으면 프로세스를 줄이고 스레드를 늘리고, 메모리 대역폭이 부족하면 반대로 간다.

GitHub에서 열기

배치가 제대로 됐는지 확인

설정을 넣었다고 적용되는 것은 아니다. 런타임이 무시하거나 스케줄러 설정과 충돌하는 경우가 있어 실제 배치를 눈으로 확인해야 한다.

export OMP_DISPLAY_ENV=verbose      # 시작할 때 적용된 설정을 찍는다
export OMP_DISPLAY_AFFINITY=true    # 스레드가 어느 코어에 묶였는지 찍는다
export OMP_AFFINITY_FORMAT="thread %0.3n bound to core %A on host %H"

Slurm에서는 할당 자체를 확인한다.

srun --cpu-bind=verbose,cores ./app 2>&1 | head    # 어느 마스크로 묶였는지
taskset -pc $$                                      # 현재 셸의 허용 코어
numastat -p <pid>                                   # 노드별 메모리 사용량

numastat에서 다른 노드의 값이 크면 원격 접근이 일어나는 중이다. 첫 접근 규칙 때문에 초기화 코드가 원인인 경우가 많다.

도구마다 보이는 것이 다르므로, 배치가 틀렸을 때 무엇이 보이는지 알아두면 원인을 빨리 좁힌다.

도구 명령 배치가 맞을 때 배치가 틀렸을 때
hwloc hwloc-ps -t -c 스레드가 서로 다른 코어에 하나씩 여러 스레드가 같은 코어에 겹쳐 표시
taskset taskset -cp <pid> 허용 코어가 소켓 하나로 좁다 0-127처럼 전체가 열려 이동을 허용
proc grep Cpus_allowed_list /proc/<pid>/status 좁은 코어 목록 노드 전체 범위
numastat numastat -p <pid> 자기 노드 값만 크다 다른 노드 값이 함께 크다
htop htop 쓰는 코어만 100퍼센트 부하가 여러 코어를 떠돌거나 절반만 참
hwloc-ps -t -c                                   # 스레드별 코어 점유를 한눈에
taskset -cp $(pgrep -n app)                       # 그 프로세스의 허용 코어 마스크
grep Cpus_allowed_list /proc/$(pgrep -n app)/status
numastat -p $(pgrep -n app)                       # 노드별 메모리 분포

taskset과 Cpus_allowed_list가 노드 전체를 가리키면 바인딩이 안 걸린 것이다. OS가 스레드를 아무 코어에나 옮길 수 있어, htop에서 부하가 코어 사이를 떠도는 모습으로 나타난다. hwloc-ps에서 두 스레드가 같은 코어에 찍히면 오버서브스크립션이고, numastat에서 원격 노드 값이 크면 첫 접근 규칙에 걸린 것이다. 세 도구가 같은 곳을 가리키면 초기화와 바인딩을 함께 손본다.

GitHub에서 열기

동기화 비용

스레드를 늘려도 안 빨라지는 두 번째 이유가 동기화다. 임계 구역과 배리어는 스레드 수에 따라 비용이 커진다.

// 나쁜 예. 매 반복마다 락을 잡는다
#pragma omp parallel for
for (int i = 0; i < n; i++) {
    #pragma omp critical
    sum += x[i];
}

// 좋은 예. 각자 모았다가 마지막에 한 번 합친다
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < n; i++) sum += x[i];

앞의 코드는 스레드를 늘릴수록 느려진다. 락을 기다리는 시간이 계산 시간을 넘기기 때문이다. 축약은 스레드마다 지역 변수를 두고 마지막에 트리 형태로 합치므로 이 문제가 없다.

배리어도 마찬가지다. 반복문이 끝날 때마다 암묵적 배리어가 걸리는데, 다음 계산이 앞의 결과에 의존하지 않으면 없앨 수 있다.

#pragma omp parallel
{
    #pragma omp for nowait          // 배리어를 없앤다
    for (int i = 0; i < n; i++) a[i] = f(i);

    #pragma omp for                 // 여기서는 필요하다
    for (int i = 0; i < n; i++) b[i] = g(a[i]);
}

GitHub에서 열기

거짓 공유를 눈으로 확인

논리적으로 겹치지 않는 변수라도 같은 캐시 라인(보통 64바이트)에 있으면 코어끼리 그 줄을 주고받는다. 결과는 맞지만 성능이 크게 떨어진다.

// 나쁜 예. sums 원소들이 같은 캐시 라인에 몰린다
double sums[8];
#pragma omp parallel num_threads(8)
{
    int t = omp_get_thread_num();
    for (int i = t; i < n; i += 8) sums[t] += x[i];
}

// 좋은 예. 캐시 라인 크기로 띄운다
struct { double v; char pad[56]; } sums[8];

의심되면 캐시 관련 카운터로 확인한다.

perf stat -e cache-misses,cache-references,LLC-load-misses ./app

같은 계산인데 스레드를 늘렸을 때 캐시 미스가 급증하면 거짓 공유를 의심한다.

캐시는 바이트 하나가 아니라 캐시 라인 단위로 오간다. 한 라인이 보통 64바이트라, double 여덟 개가 한 라인에 들어간다. 코어는 자기가 쓰는 값을 캐시 라인째로 가져와 독점 상태로 바꾼 뒤 고친다. 다른 코어가 같은 라인의 다른 값을 고치려 하면, 값이 겹치지 않아도 그 코어가 라인을 독점으로 뺏어 온다. 두 코어가 번갈아 라인을 주고받으며 매 갱신마다 캐시가 무효화되고, 계산은 맞지만 메모리를 오가는 시간이 계산을 압도한다. 스레드별 누적 변수를 배열에 촘촘히 두면 이 현상이 그대로 재현된다.

perf c2c는 어느 캐시 라인이 코어 사이를 오가는지 짚어 준다.

perf c2c record ./app          # 캐시 라인 공유를 기록한다
perf c2c report                # HITM이 많은 라인과 그 변수를 보여준다

출력에서 HITM은 다른 코어의 변경된 캐시에서 값을 가져온 횟수다. 이 값이 큰 라인이 거짓 공유의 현장이고, 리포트가 그 라인에 걸린 변수와 소스 위치까지 짚어 준다. 고치는 방법은 각 스레드의 변수를 서로 다른 캐시 라인으로 밀어내는 것이다.

// 전: 8개 누적 변수가 한두 라인에 몰려 코어끼리 라인을 뺏는다
double sums[8];

// 후: 64바이트로 정렬하고 라인 크기만큼 띄워 각자 다른 라인에 둔다
struct alignas(64) { double v; } sums[8];

띄우기 전에는 스레드 8개에서 오히려 1개보다 느린 일이 흔하다. 라인 하나를 여덟 코어가 돌려 쓰기 때문이다. 정렬로 라인을 분리하면 perf c2c의 HITM이 거의 사라지고, 스레드 수에 비례해 시간이 준다. 근본 해법은 애초에 스레드별 지역 변수에 모았다가 마지막에 합치는 축약이다.

GitHub에서 열기

작업 단위 병렬

반복문으로 나누기 어려운 구조, 예를 들어 재귀나 그래프 순회는 작업 단위로 나눈다.

#pragma omp parallel
#pragma omp single
{
    #pragma omp task
    process(left);
    #pragma omp task
    process(right);
    #pragma omp taskwait
}

작업이 너무 잘게 쪼개지면 생성 비용이 계산을 넘는다. 일정 깊이 아래로는 순차로 처리하도록 잘라 주는 편이 낫다.

task 지시자는 만나는 순간 실행하지 않고 작업 큐에 넣는다. 노는 스레드가 큐에서 하나씩 꺼내 실행한다. 반복 횟수를 미리 알 수 없는 재귀나, 가지마다 길이가 다른 트리 순회에서 반복문 분배보다 균형이 잘 맞는다.

single은 작업을 만드는 일을 스레드 하나만 하게 막는다. 이 지시자가 없으면 모든 스레드가 같은 작업을 중복해서 만든다. taskwait은 만들어 둔 하위 작업이 모두 끝날 때까지 기다리는 지점이다.

잘게 쪼갤수록 부하 균형은 좋아지지만 작업 하나를 만들고 큐에 넣는 비용이 쌓인다. 일정 깊이 아래로는 작업을 만들지 않고 순차로 처리하도록 자르면 이 비용이 준다.

void process(node *n, int depth) {
    if (n == NULL) return;
    if (depth > CUTOFF) {          // 깊으면 순차로 처리한다
        process_seq(n);
        return;
    }
    #pragma omp task
    process(n->left, depth + 1);
    process(n->right, depth + 1);  // 오른쪽은 현재 스레드가 이어서 한다
    #pragma omp taskwait
}

CUTOFF 깊이는 코드마다 다르므로 몇 값을 재보고 정한다. 얕으면 병렬이 부족하고 깊으면 작업 생성 비용이 계산을 넘는다.

GitHub에서 열기

스레드 수를 잘못 잡았을 때 증상

스레드 수가 코어 구조와 안 맞으면 나타나는 증상이 정해져 있다. 증상으로 원인을 좁힌 뒤 확인 명령으로 굳힌다.

증상 원인 확인
스레드를 늘렸는데 급격히 느려진다 오버서브스크립션. 코어 수보다 스레드가 많다 nproc와 스레드 수 비교, htop의 과부하 로드
코어 절반만 바쁘다 스레드 수가 물리 코어의 절반에 묶였다 htop에서 유휴 코어 확인, OMP_NUM_THREADS 값
어느 지점부터 배속이 안 는다 순차 구간 한계나 메모리 대역폭 포화 코어별 시간 측정, perf stat -d의 캐시 미스
실행할 때마다 시간이 흔들린다 스레드가 코어에 안 묶여 이동한다 OMP_PROC_BIND 미설정, OMP_DISPLAY_AFFINITY로 확인
논리 코어를 다 썼는데 더 느리다 하이퍼스레딩 흐름이 연산 자원을 다툰다 물리 코어 수로 낮춰 재측정

오버서브스크립션은 라이브러리가 각자 스레드를 띄울 때 잘 생긴다. MPI 프로세스 8개가 각각 64스레드를 띄우면 512스레드가 코어 64개를 다툰다. 확인 명령은 간단하다.

nproc                                  # 이 프로세스가 쓸 수 있는 코어 수
grep -c ^processor /proc/cpuinfo       # 논리 코어 총수
ps -eLf | wc -l                        # 지금 떠 있는 스레드 총수

떠 있는 스레드 총수가 코어 수를 크게 넘으면 어딘가에서 스레드가 중복으로 생기는 중이다. OMP_NUM_THREADS와 라이브러리별 스레드 변수를 함께 확인한다.

GitHub에서 열기

런타임별 환경 변수

같은 OpenMP라도 컴파일러 런타임에 따라 변수 이름이 다르다. 혼용하면 한쪽만 적용되어 원인을 찾기 어려워진다.

목적 표준 GNU Intel
스레드 수 OMP_NUM_THREADS 동일 동일
바인딩 OMP_PROC_BIND GOMP_CPU_AFFINITY KMP_AFFINITY
대기 정책 OMP_WAIT_POLICY 동일 KMP_BLOCKTIME
스택 크기 OMP_STACKSIZE 동일 KMP_STACKSIZE

KMP_BLOCKTIME은 스레드가 일이 없을 때 얼마나 기다리다 잠들지 정한다. 기본값이 크면 유휴 스레드가 CPU를 붙잡고 있어 다른 프로세스와 경합한다. MPI와 섞어 쓸 때는 줄이는 편이 낫다.

OpenMP 자체의 동작도 여러 변수로 갈린다. 이 변수들이 실제로 배치와 스케줄을 정한다.

목적 변수 값의 예
스레드 수 OMP_NUM_THREADS 8
바인딩 방식 OMP_PROC_BIND close, spread, true
배치 단위 OMP_PLACES cores, threads, sockets
기본 스케줄 OMP_SCHEDULE static, dynamic,64, guided

문제는 파이썬 과학 스택이 저마다 자기 스레드 풀을 따로 띄운다는 점이다. NumPy 뒤의 BLAS, numexpr, PyTorch가 각각 코어 전체를 쓰려 든다. 변수를 하나만 잡고 나머지를 놓치면 그 라이브러리가 숨어서 스레드를 부풀린다.

라이브러리 스레드 제어
OpenMP OMP_NUM_THREADS
Intel MKL MKL_NUM_THREADS
OpenBLAS OPENBLAS_NUM_THREADS
NumExpr NUMEXPR_NUM_THREADS
PyTorch torch.set_num_threads(n)

이 값들은 더해지지 않고 곱해진다. 데이터 로더가 num_workers=8로 워커 프로세스 8개를 띄우고, 워커마다 BLAS가 코어 64개짜리 풀을 열면 8 곱하기 64로 512스레드가 코어 64개를 다툰다. 전처리가 GPU 학습보다 느려지는 흔한 원인이 이 숨은 곱이다. 데이터 로더 워커 안에서는 BLAS 스레드를 1로 묶어 워커 수만큼만 병렬이 되게 한다.

# 워커가 각자 스레드를 부풀리지 못하게 진입 전에 1로 고정한다
export OMP_NUM_THREADS=1
export MKL_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export NUMEXPR_NUM_THREADS=1
import torch
torch.set_num_threads(1)          # 데이터 로더 워커에서는 1로 둔다

메인 연산에서는 코어를 충분히 쓰고, 워커 안에서만 1로 누르는 식으로 층을 나눈다. 어느 쪽이든 곱이 물리 코어 수를 넘지 않게 맞추는 것이 요령이다.

GitHub에서 열기

자주 밟는 함정

스레드 초과 할당 라이브러리가 각자 스레드를 띄우면 코어 수보다 훨씬 많은 스레드가 생겨 서로를 방해한다. MPI 프로세스 8개가 각각 OpenMP 스레드 64개를 띄우면 512개가 된다. 데이터 로더나 전처리에서는 아예 1로 고정하는 편이 낫다.

export OMP_NUM_THREADS=1
export MKL_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export NUMEXPR_NUM_THREADS=1

거짓 공유 서로 다른 스레드가 같은 캐시 라인에 있는 다른 변수를 쓰면, 논리적으로는 겹치지 않는데도 캐시 라인이 계속 오간다. 스레드별 누적 변수를 배열 하나에 촘촘히 두면 이 현상이 생긴다. 각자 지역 변수에 모았다가 마지막에 합치면 사라진다.

하이퍼스레딩 논리 코어 수를 그대로 스레드 수로 쓰면 연산 위주 코드에서는 오히려 느려진다. 물리 코어 수로 시작해서 재보고 늘린다.

lscpu | grep -E "^CPU\(s\)|Thread|Core"

함정마다 증상과 확인 명령이 정해져 있다. 아래 표로 원인을 빠르게 좁힌다.

함정 증상 원인 확인
스레드 초과 할당 스레드를 늘렸는데 느려진다 라이브러리 스레드 수의 곱이 코어를 넘는다 ps -eLf의 줄 수, 각 *_NUM_THREADS
거짓 공유 스레드 늘리자 캐시 미스 급증 누적 변수가 한 캐시 라인에 몰렸다 perf c2c report의 HITM
하이퍼스레딩 과신 논리 코어를 다 쓰자 더 느리다 두 흐름이 연산 자원을 다툰다 물리 코어 수로 낮춰 재측정
바인딩 누락 실행 시간이 매번 흔들린다 스레드가 코어를 떠돈다 taskset -cp <pid>
NUMA 원격 접근 스레드를 늘려도 정체 초기화가 한 노드에 몰렸다 numastat -p <pid>
잠금 과다 코어를 늘릴수록 느려진다 매 반복 임계 구역을 잡는다 perf record에서 락 함수 비중

표의 앞 세 줄이 가장 흔하다. 스레드 초과 할당은 곱의 결과이므로 라이브러리별 스레드 변수를 모두 확인해야 잡힌다. 바인딩 누락과 NUMA 원격 접근은 배치를 눈으로 확인하는 절의 도구로 현장을 짚는다. 잠금 과다는 축약이나 스레드별 지역 변수로 바꾸면 사라진다.

GitHub에서 열기

얼마나 늘어나는지 재기

느낌으로 판단하지 말고 코어 수를 바꿔가며 같은 입력으로 재본다.

for t in 1 2 4 8 16 32 64; do
  OMP_NUM_THREADS=$t /usr/bin/time -f "$t threads: %e s" ./app
done

측정 자체가 흔들리면 결과를 믿기 어렵다. 몇 가지를 지켜야 값이 안정된다.

  • 워밍업 첫 실행은 캐시가 비어 있고 페이지가 아직 안 붙어 느리다. 처음 한두 번은 버리고 그다음부터 잰다.
  • 반복과 중앙값 같은 설정을 다섯 번 이상 재고 평균이 아니라 중앙값을 쓴다. 중앙값은 가끔 끼어드는 다른 작업의 영향을 덜 받는다.
  • 주파수 고정 터보 부스트가 켜져 있으면 클럭이 부하에 따라 오르내려 코어 수 비교가 흐트러진다. 성능 거버너로 고정하고 잰다.
  • 독점 노드 다른 사람과 노드를 나눠 쓰면 측정이 오염된다. --exclusive로 노드를 통째로 잡는다.
sudo cpupower frequency-set -g performance      # 주파수를 고정한다
# Slurm에서는 제출 시 --exclusive 로 노드를 독점한다

잰 값은 표로 남겨야 비교가 된다. 코어 수, 시간, 1코어 대비 배속, 그리고 코어당 효율을 함께 적는다. 효율은 배속을 코어 수로 나눈 값이고, 이 값이 떨어지는 지점이 확장의 상한이다.

코어 시간(s) 배속 효율
1 100.0 1.0배 100%
8 13.5 7.4배 93%
32 4.2 23.8배 74%
64 3.1 32.3배 50%

이 예시는 코어 32개까지는 효율이 70퍼센트를 넘다가 64개에서 절반으로 떨어진다. 코어 64개를 요청할 만한 이득이 없다는 뜻이므로 32개 근처가 이 코드의 실용 상한이다. 코어를 두 배로 늘렸을 때 시간이 절반 가까이 줄면 그 구간까지는 잘 늘어나는 것이고, 어느 지점부터 시간이 줄지 않거나 오히려 늘면 거기가 상한이다. 그 지점을 넘겨 자원을 요청하면 클러스터 전체의 활용률만 떨어뜨린다. 어디에서 시간을 쓰는지는 프로파일러로 본다.

perf stat -d ./app                       # 명령어 수, 캐시 미스, 분기 예측
perf record -g ./app && perf report      # 함수별 비중

캐시 미스 비율이 높으면 데이터 배치를, 명령어 수 대비 시간이 길면 메모리 대역폭을 의심한다.

GitHub에서 열기

03

클러스터 운영핵심

노드 점검, 자원 관리, 모니터링

운영자가 매일 만지는 것들이다. 노드 상태 점검에서 시작해 사용자 환경, GPU 자원, 모니터링으로 넓힌다. 장애가 났을 때 어디부터 볼지에 대한 순서를 갖는 것이 이 모듈의 쓸모다.

아침 점검 순서

밤사이 클러스터가 멀쩡했는지 확인하는 순서다. 위에서 아래로 그대로 따라 하면 된다. 여기서 클러스터(cluster)는 여러 서버를 묶어 하나의 계산 자원처럼 쓰는 묶음이고, 그 안의 서버 한 대가 노드(node)다. 노드에 작업을 나눠 주는 프로그램이 스케줄러(scheduler)이고, 이 클러스터는 스케줄러로 Slurm을 쓴다. 사용자가 제출한 작업이 자원을 기다리며 늘어서는 줄이 대기열(queue)이다.

전체 상태 요약부터 본다.

sinfo -s                    # 파티션별 idle/alloc/down 노드 수를 한 줄로 요약한다
sinfo -R                    # 빠진 노드와 그 사유. 어제 없던 줄이 생겼는지 본다
squeue --start | head       # 대기 중인 작업과 예상 시작 시각

파티션(partition)은 노드를 용도별로 나눈 묶음이다. 학습용 GPU 노드와 디버깅용 노드를 다른 파티션에 둔다. sinfo -s에서 down이나 drain 개수가 어제보다 늘었으면 그 노드부터 확인한다.

스토리지와 밤사이 오류를 본다.

df -h /home /scratch                       # 사용률이 90%를 넘으면 그날 안에 손댄다
dmesg -T | grep -i xid | tail              # 밤사이 GPU 오류가 찍혔는지
journalctl -p err -S "18 hours ago" | tail # 지난밤의 시스템 오류만

여기까지 이상이 없으면 대기열이 정상적으로 흐르는지 확인하고 끝낸다. 이상이 있으면 아래의 해당 섹션으로 넘어가 원인을 좁힌다.

GitHub에서 열기

노드 상태 점검

문제 신고가 들어오면 순서를 정해두고 본다. 부하, 프로세스, 메모리, 디스크, 네트워크 순이다.

uptime                      # 1·5·15분 부하 평균. 코어 수보다 크면 과부하다
top -b -n1 | head -20       # CPU를 먹는 프로세스 확인
ps aux --sort=-%mem | head  # 메모리를 먹는 프로세스 확인
systemctl --failed          # 실패한 서비스 목록

부하 평균은 코어 수와 비교해서 읽는다. 128코어 노드에서 부하 20은 한가한 상태고, 부하 300은 대기열이 쌓였다는 뜻이다. 부하가 높은데 CPU 사용률이 낮으면 I/O 대기를 의심한다. top의 wa 값이 그 단서다.

Slurm이 보는 상태와 실제 상태가 다를 수 있다. 노드가 DRAIN이면 이유부터 확인한다.

유휴에서 할당으로, 드레인을 거쳐 다시 유휴로 돌아오는 노드 상태 전이

sinfo -R                              # 드레인된 노드와 사유를 한 번에 본다
scontrol show node gpu07 | grep -i reason
scontrol update NodeName=gpu07 State=RESUME   # 원인을 고친 뒤 복귀시킨다

GitHub에서 열기

노드 상태값의 의미

Slurm이 노드에 붙이는 상태값(state)이 무슨 뜻인지 알아야 sinfo 출력을 읽는다.

상태 뜻 어떻게 그 상태가 되는가
idle 비어 있고 작업을 받을 수 있다 작업이 없거나 방금 끝났다
alloc 노드 전체가 작업에 배정됐다 한 작업이 노드의 모든 자원을 잡았다
mix 자원 일부만 배정됐다 GPU 8장 중 몇 장만 쓰는 작업이 있다
drain 새 작업을 받지 않는다 운영자가 뺐거나 헬스체크가 실패했다
down 스케줄러가 노드와 통신하지 못한다 slurmd가 죽었거나 노드가 꺼졌거나 링크가 끊겼다
fail 곧 죽을 노드로 표시됐다 하드웨어 경고를 받았거나 관리자가 수동 표시했다

drain과 down은 다르다. drain은 운영자가 의도적으로 뺀 상태라 돌던 작업은 끝까지 돈다. down은 스케줄러가 노드를 잃은 상태라 그 위의 작업도 함께 실패한다. down이 뜨면 노드가 켜져 있는지, slurmd가 살아 있는지, 관리 네트워크가 붙어 있는지 순서로 확인한다.

scontrol show node gpu07 | grep -iE "State|Reason"
ssh gpu07 systemctl status slurmd     # 노드에 붙는지, 데몬이 사는지 함께 본다

GitHub에서 열기

장애 신고 분류

신고를 받자마자 노드에 들어가는 대신 범위부터 가른다. 무엇을 묻느냐가 조사 시간을 좌우한다.

질문 왜 묻는가
언제부터인가 최근 변경이나 특정 시각의 사건과 맞춰 본다
작업 ID가 무엇인가 sacct로 종료 코드와 자원 사용량을 바로 본다
한 노드인가 여러 노드인가 한 대면 하드웨어, 여럿이면 공용 요소를 의심한다
다시 돌리면 또 그런가 재현되면 조사가 쉽고, 아니면 그 순간 지표를 찾는다
본인만 그런가 다른 사람도 그런가 사용자 코드 문제와 클러스터 문제를 가른다

한 노드에서만 나는 문제는 그 노드의 하드웨어나 버전을 보고, 여러 노드에서 동시에 나는 문제는 공유 스토리지, 네트워크, 스케줄러, 드라이버 배포처럼 여럿이 함께 쓰는 요소를 본다. 한 사용자에게만 재현되면 그 작업 스크립트와 환경을 본다.

되묻는 질문은 정해두고 순서대로 던진다. 즉흥으로 물으면 빠뜨린다.

  • 작업 번호. sacct -j <번호>로 종료 코드와 자원 사용량이 바로 나온다. 번호 없이 "느리다"만 으로는 조사가 길어진다.
  • 언제부터인가. 오늘부터인지 원래 그랬는지에 따라 볼 곳이 다르다. 특정 시각부터면 그 시각의 변경 이력과 맞춰 본다.
  • 이전에는 되던 것인가. 한 번이라도 됐다면 환경 차이이고, 처음부터 안 됐다면 요청이나 코드 문제다.
  • 바뀐 것이 있는가. 코드, 데이터 경로, 모듈 버전, 노드 중 무엇을 바꿨는지 물어 범위를 좁힌다.

사용자 문제와 시스템 문제를 가르는 첫 확인은 세 가지다. 같은 작업이 다른 노드에서도 실패하는지, 다른 사용자도 같은 증상인지, 최소 예제로도 재현되는지다. 셋 다 그렇다면 클러스터 쪽을 보고, 이 사용자와 이 코드에서만 나면 작업 쪽을 본다.

에스컬레이션은 기준을 정해 둔다. 애매할 때 판단이 흔들리지 않게 표로 고정한다.

상황 대응 넘기는 곳
한 작업만 실패, 재현됨 운영자가 로그로 조사 담당 운영자
한 노드 하드웨어 의심 드레인 후 진단 하드웨어 담당
여러 노드 동시 장애 공용 요소부터 차단 인프라 팀, 즉시
데이터 유실·보안 의심 작업 중단하고 상태 보존 책임자, 즉시

GitHub에서 열기

메모리와 스토리지

메모리는 free의 available 열만 보면 된다. free 열은 캐시를 제외한 값이라 낮게 보이는 것이 정상이다.

free -g                     # available 열을 본다
vmstat 1 5                  # si/so가 0이 아니면 스왑이 일어나는 중이다
df -h /home /scratch        # 사용률과 남은 용량
df -i /home                 # inode 고갈. 작은 파일이 많으면 용량보다 먼저 찬다
du -sh /scratch/* | sort -h # 누가 얼마나 쓰는지

공유 스토리지에서 자주 겪는 문제는 용량이 아니라 inode 고갈과 메타데이터 부하다. 학습 데이터를 작은 파일 수백만 개로 두면 용량은 남아도 파일시스템이 느려진다. 이런 데이터는 묶음 포맷으로 바꾸는 편이 낫다.

쿼터(quota)는 사용자나 그룹이 쓸 수 있는 용량과 파일 수의 상한이다. 상한이 없으면 한 사람이 파일시스템을 가득 채워 모두가 멈춘다. 홈은 작게, 스크래치는 크게 잡는 것이 보통이다. 예를 들어 /home은 사용자당 50GB에 파일 100만 개, /scratch는 그룹당 20TB에 파일 500만 개처럼 둔다.

lfs quota -u alice /scratch          # Lustre에서 사용자 쿼터와 현재 사용량
xfs_quota -x -c 'report -h' /home    # XFS 프로젝트 쿼터

스크래치는 백업하지 않는 임시 공간이라 오래된 파일을 자동으로 지운다. 30일 이상 손대지 않은 파일을 정리한다는 정책을 미리 공지하고, 지우기 전에 목록을 사용자에게 보낸다. 쿼터에 걸린 사용자는 작업이 조용히 실패하므로, 90퍼센트 도달 시 경고 메일을 보내면 문의가 준다.

I/O가 의심되면 장치별로 확인한다.

iostat -x 1 5               # %util이 100에 가까우면 포화 상태다
iotop -oPa                  # 실제로 I/O를 일으키는 프로세스

GitHub에서 열기

사용자와 실행 환경

계정은 보통 LDAP이나 AD로 중앙에서 관리한다. 노드마다 로컬 계정을 만들면 UID가 어긋나 공유 스토리지의 파일 소유권이 깨진다.

소프트웨어는 환경 모듈로 제공한다. 같은 라이브러리의 여러 버전을 충돌 없이 두기 위해서다.

module avail                # 설치된 모듈 목록
module load cuda/12.4 nccl/2.21
module list                 # 현재 적재된 모듈
module purge                # 전부 내린다. 작업 스크립트 첫 줄에 두면 재현성이 올라간다

작업 스크립트에서 module purge로 시작해 필요한 것만 적재하면 사용자의 셸 설정에 영향받지 않는다. 재현이 안 되는 작업을 조사할 때 이 부분을 먼저 본다.

모듈 파일은 공유 스토리지의 한 경로에 두고 모든 노드가 같은 것을 읽게 한다. 예를 들어 /apps/modulefiles를 MODULEPATH에 넣어 두면 노드마다 따로 설치할 필요가 없다. 새 버전을 올릴 때는 기존 버전을 지우지 않고 나란히 둔다. cuda/12.4를 쓰던 작업이 어느 날 갑자기 cuda/12.6으로 바뀌면 재현이 깨지기 때문이다. 최소 두 세대, 보통은 마지막 세 개 버전을 유지하고, 더 오래된 것은 공지 후 3개월 뒤에 내린다.

컨테이너(container)는 애플리케이션과 라이브러리를 한 이미지로 묶어 어느 노드에서 실행해도 같은 환경을 보장하는 방식이다. HPC에서는 루트 권한 없이 도는 Apptainer(예전 이름 Singularity)를 주로 쓴다. 이미지는 사용자마다 받게 두지 말고 공유 캐시에 미리 받아 둔다.

apptainer pull /apps/images/pytorch_24.05.sif docker://nvcr.io/nvidia/pytorch:24.05-py3
srun --gres=gpu:1 apptainer exec --nv /apps/images/pytorch_24.05.sif python train.py

--nv 옵션이 호스트의 GPU 드라이버를 컨테이너 안으로 넣어 준다. 이미지 하나가 보통 5~15GB라, 500명이 각자 받으면 스토리지와 레지스트리 대역폭이 함께 터진다. 공용 이미지 몇 개를 관리자가 받아 두고 경로를 안내하는 편이 낫다.

GitHub에서 열기

로그와 1차 분류

로그는 세 군데를 본다. 커널, 시스템 데몬, 애플리케이션이다.

dmesg -T | tail -50                    # 커널. OOM, 하드웨어 오류, Xid가 여기 남는다
journalctl -u slurmd -n 100 --no-pager # 특정 서비스
journalctl -p err -S "1 hour ago"      # 최근 한 시간의 오류만

dmesg에서 Out of memory: Killed process가 보이면 커널이 프로세스를 죽인 것이다. 작업이 아무 메시지 없이 사라졌다는 신고의 상당수가 여기에 해당한다. Xid로 시작하는 줄이 보이면 GPU 문제이고, 모듈 08에서 이어 본다.

작업 단위로는 Slurm의 기록을 본다.

sacct -j 12345 --format=JobID,State,ExitCode,MaxRSS,Elapsed

ExitCode가 0:9면 SIGKILL, 0:15면 SIGTERM이다. 시간 초과로 스케줄러가 죽인 경우 State가 TIMEOUT으로 남는다. MaxRSS가 요청한 메모리에 근접했다면 메모리 부족을 의심한다.

로그를 볼 때 시각을 맞추는 것이 중요하다. dmesg는 기본적으로 부팅 후 경과 시간을 찍으므로 -T를 붙여 실제 시각으로 바꿔야 다른 로그와 나란히 놓을 수 있다. 노드마다 시간이 어긋나 있으면 인과가 뒤집혀 보이므로 시간 동기화부터 확인한다.

timedatectl status | grep -E "synchronized|NTP"
chronyc tracking | head -3            # 기준 서버와의 오차

어느 로그에서 무엇이 나오는지 알아 두면 찾는 시간이 준다.

증상 볼 곳 찾을 문자열
작업이 조용히 사라졌다 dmesg -T Out of memory, Killed process
GPU가 사라졌다 dmesg -T NVRM: Xid, GPU has fallen off the bus
노드가 대기열에서 빠졌다 journalctl -u slurmd error, Node ... not responding
작업이 시작되지 않는다 journalctl -u slurmctld job_id, Requeue
파일 접근이 실패한다 dmesg -T Lustre, evicted, nfs: server
네트워크가 끊긴다 dmesg -T link is down, mlx5_core

여러 노드에 걸친 문제는 한 대만 봐서는 모른다. 같은 시각의 로그를 여러 노드에서 한 번에 모아 비교하면 공통 원인이 드러난다. 노드마다 다른 메시지가 나온다면 노드 개별 문제이고, 같은 시각에 같은 메시지가 나온다면 공통 설비를 의심한다.

# 문제가 난 노드들에서 같은 구간의 커널 로그를 한꺼번에 모은다
pdsh -w gpu[07-18] 'dmesg -T | grep -i -E "xid|error|down" | tail -5' | sort

# 스위치나 스토리지처럼 공통 설비가 원인이면 시각이 겹친다
pdsh -w gpu[07-18] 'journalctl -p err -S "02:00" -U "03:00" --no-pager | head -3' | dshbak -c

로그를 오래 남기는 것도 준비의 일부다. 기본 설정에서는 재부팅하면 커널 로그가 사라지므로, 장애 직후에 노드를 재부팅하면 증거가 함께 없어진다. journald를 디스크에 저장하도록 두고 보존 용량을 정해 둔다.

# /etc/systemd/journald.conf
# Storage=persistent
# SystemMaxUse=2G
journalctl --disk-usage
journalctl --list-boots | head           # 지난 부팅의 로그가 남아 있는지

GitHub에서 열기

GPU 자원 관리

GPU 상태는 nvidia-smi로 본다. 사람이 읽는 화면보다 질의 형식이 자동화에 편하다.

nvidia-smi
nvidia-smi --query-gpu=index,name,memory.used,memory.total,utilization.gpu,temperature.gpu \
           --format=csv,noheader
nvidia-smi topo -m          # GPU 사이 연결 형태. NVLink 여부를 확인한다

사용률이 낮은데 학습이 느리면 GPU가 아니라 데이터 로더나 통신이 병목이다. 사용률과 메모리를 같이 보고 판단한다.

한 장을 여러 작업이 나눠 쓰게 하려면 분할 방식을 골라야 한다.

방식 격리 수준 쓰임
MIG 하드웨어 분할. 메모리와 연산이 완전히 격리된다 추론 서비스, 다중 사용자 공유
MPS 프로세스 공유. 격리는 약하다 작은 커널을 많이 띄우는 워크로드
시분할 스케줄러가 번갈아 배정한다 개발과 실험 환경

Slurm에서는 GPU를 GRES로 다룬다. --gres=gpu:2처럼 요청하면 CUDA_VISIBLE_DEVICES가 자동으로 설정되어 다른 GPU가 보이지 않는다. 사용자가 이 변수를 직접 덮어쓰면 격리가 깨지므로 작업 스크립트에서 손대지 않게 안내한다.

GitHub에서 열기

모니터링

노드에 들어가서 보는 방식은 노드가 늘면 유지되지 않는다. 지표를 모아두고 대시보드로 본다.

수집기가 노드에서 지표를 뽑고, 시계열 DB가 저장하고, 대시보드가 보여준다.

노드의 수집기가 지표를 내보내면 시계열 DB가 저장하고 대시보드와 경보로 나뉜다 GPU 지표는 DCGM Exporter가 맡는다.

지표 무엇을 알려주는가
DCGM_FI_DEV_GPU_UTIL GPU 사용률. 낮으면 병목이 GPU 밖에 있다
DCGM_FI_DEV_FB_USED GPU 메모리 사용량. OOM 예측에 쓴다
DCGM_FI_DEV_GPU_TEMP 온도. 스로틀링 판단의 근거다
DCGM_FI_DEV_POWER_USAGE 전력. 랙 단위 용량 계획에 쓴다
DCGM_FI_DEV_XID_ERRORS Xid 발생. 장애의 조기 신호다

무엇을 하나의 단위로 볼지도 정해야 한다. 노드 단위 지표만 있으면 "이 학습이 지금 잘 돌고 있는가"에 답하지 못한다. 클러스터에는 여러 층의 단위가 있고, 질문에 따라 보는 층이 다르다.

단위 답하는 질문
GPU 이 카드가 정상인가, 스로틀에 걸렸는가
노드 이 서버를 대기열에 넣어도 되는가
작업 이 학습이 자원을 제대로 쓰고 있는가
프로젝트나 팀 이 팀이 배정받은 몫을 얼마나 쓰는가
클러스터 지금 자원을 더 사야 하는가

같은 지표를 층마다 다르게 묶는다. GPU 사용률을 작업 단위로 모으면 그 학습의 효율이 되고, 팀 단위로 모으면 배정의 근거가 된다. 수집은 한 번 하고 집계를 여러 갈래로 두는 편이 단순하다.

경보는 적게 만든다. 사람이 즉시 행동해야 하는 것만 경보로 두고, 나머지는 대시보드에 둔다. 노드 다운, Xid 발생, 스토리지 사용률 90퍼센트 정도면 시작으로 충분하다.

GitHub에서 열기

네트워크 상태 점검

노드는 살아 있는데 작업만 느린 경우, 네트워크가 원인일 때가 많다. 링크 상태와 오류 카운터를 함께 본다.

ip -s link show eth0        # RX/TX errors, dropped 가 늘고 있는지
ethtool eth0 | grep -E "Speed|Duplex|Link detected"
ethtool -S eth0 | grep -iE "error|drop|discard"

Speed가 기대보다 낮으면 케이블이나 트랜시버, 스위치 포트 설정을 본다. 10G 포트가 1G로 협상되는 일이 드물지 않고, 이 상태로도 통신 자체는 되기 때문에 알아채기까지 시간이 걸린다.

노드 사이 실제 성능은 직접 재는 편이 빠르다.

# 한쪽에서 서버로 띄우고
iperf3 -s
# 다른 쪽에서 잰다
iperf3 -c gpu02 -P 4 -t 20

InfiniBand 구간은 ib_write_bw로 따로 잰다. 이더넷 수치가 정상이어도 IB 링크가 죽어 있으면 분산 학습만 느려진다.

InfiniBand 포트는 이더넷과 명령이 다르다. 포트 상태와 오류 카운터를 함께 본다.

ibstat                              # 포트가 Active/LinkUp 인지, Rate 가 규격대로인지
perfquery                           # 포트 오류 카운터. Symbol/Link 오류가 늘고 있는지
ibdiagnet                           # 팹 전체를 훑어 링크 오류와 속도 불일치를 모은다

ibstat의 State가 Active가 아니면 링크가 안 붙은 것이고, Rate가 규격보다 낮으면 협상에 실패한 것이다. NDR 400G 포트가 HDR 200G로 붙는 식의 절반 협상이 케이블이나 트랜시버 문제의 전형적 증상이다. 트랜시버를 재장착하거나 케이블을 바꿔 다시 협상시킨다.

perfquery의 SymbolErrorCounter나 LinkErrorRecoveryCounter가 시간에 따라 늘면 물리 계층이 약해지는 신호다. 카운터를 초기화하고 일정 시간 뒤 다시 재서 증가분을 본다.

perfquery -R                        # 카운터 초기화
sleep 300
perfquery                           # 5분 동안의 증가분

ibdiagnet은 팹 전체에서 속도가 낮게 붙은 링크와 오류가 쌓이는 링크를 한 번에 표시한다. 특정 포트만 반복해서 걸리면 그 포트의 케이블과 트랜시버를 교체 후보로 둔다. 케이블을 바꿔도 같은 슬롯에서 재발하면 카드나 스위치 포트를 본다.

지금 얼마나 흐르고 있는지는 초 단위로 본다. ifstat은 인터페이스별 순간 처리량을 표로 계속 찍어 주므로 어느 노드가 실제로 보내고 받는지 눈으로 갈린다.

ifstat -i eth0,ib0 1              # 1초 간격 송수신량
sar -n DEV 1 10                   # 같은 값을 기록으로 남긴다
nstat -az | grep -iE "retrans|drop"   # TCP 재전송과 폐기 누적

재전송이 늘고 있으면 대역폭이 아니라 품질 문제다. 이더넷 구간에서 재전송이 많으면 MTU 불일치, 버퍼 부족, 스위치 혼잡을 차례로 본다.

무엇이 오가는지까지 봐야 하면 패킷을 잡는다. 계산 노드에서 상시로 돌릴 것은 아니고, 원인이 좁혀진 뒤 짧게 쓴다.

# 특정 노드와의 통신만, 헤더만, 파일로 남긴다
tcpdump -i eth0 -nn -s 96 -c 5000 host 10.0.1.42 -w /tmp/cap.pcap

# 연결이 맺어지는지만 확인한다. 학습 시작 시 랑데뷰 포트가 막혔는지 볼 때
tcpdump -i any -nn "tcp[tcpflags] & (tcp-syn|tcp-rst) != 0 and port 29500"

-s 96으로 헤더만 잘라 담으면 파일이 작고 개인정보가 섞일 여지도 준다. 필터 없이 잡으면 파일이 순식간에 수 GB가 되고 그 자체가 노드를 느리게 하므로, 호스트나 포트로 반드시 좁힌다. RDMA 트래픽은 커널을 거치지 않아 tcpdump에 잡히지 않는다는 점도 기억한다. InfiniBand 구간은 perfquery와 ibdiagnet으로 본다.

GitHub에서 열기

작업 실패의 흔한 원인

같은 신고가 반복되면 원인도 대개 몇 가지로 좁혀진다.

증상 확인할 곳 대개의 원인
아무 로그 없이 사라짐 dmesg의 OOM 메시지 메모리 부족으로 커널이 죽였다
CUDA out of memory 배치 크기, 다른 프로세스 점유 GPU 메모리 부족
시작하자마자 종료 스크립트의 경로와 모듈 공유 스토리지 미마운트, 모듈 미적재
특정 노드에서만 실패 dcgmi diag, 드라이버 버전 그 노드의 하드웨어나 버전 불일치
무한 대기 squeue의 REASON 자원 부족이거나 정책 한도
통신 초기화 실패 NCCL_DEBUG=INFO 로그 방화벽, 인터페이스 선택 오류

메모리 부족은 요청량을 늘리기 전에 실제 사용량을 먼저 본다. sacct의 MaxRSS가 요청량의 절반도 안 되는데 죽었다면 다른 원인이다.

sacct -j 12345 --format=JobID,JobName,State,ExitCode,MaxRSS,MaxVMSize,Elapsed

원인별로 로그에 남는 문자열과 확인 명령, 조치를 함께 두면 조사가 빨라진다.

원인 로그에 남는 문자열 확인 명령 조치
호스트 OOM Out of memory: Killed process dmesg -T \| grep -i oom 요청 메모리를 늘리거나 배치를 줄인다
GPU OOM CUDA out of memory 작업 로그, nvidia-smi 배치 크기·해상도를 줄인다
시간 초과 DUE TO TIME LIMIT sacct -j <번호> State=TIMEOUT --time을 늘리거나 체크포인트를 쓴다
노드 장애 NODE_FAIL sinfo -R, dmesg 노드 드레인 후 진단
파일시스템 오류 Input/output error, Stale file handle dmesg, mount 마운트와 스토리지 서버를 본다
권한 문제 Permission denied ls -l, id 경로 소유권과 그룹을 맞춘다
이미지 문제 FATAL: could not open image apptainer inspect 이미지 경로와 권한을 확인한다
포트 충돌 Address already in use ss -tlnp 통신 포트를 바꾸거나 남은 프로세스를 정리한다

호스트 OOM과 GPU OOM은 자주 헷갈린다. 호스트 OOM은 커널이 dmesg에 남기고 프로세스가 아무 메시지 없이 사라지며, GPU OOM은 프레임워크가 CUDA out of memory를 출력하고 스택 추적을 남긴다. 남는 곳이 다르므로 둘을 가른 뒤 조치한다. 노드 장애로 죽은 작업은 사용자 잘못이 아니라, 자동 재시도(--requeue)를 켜 두면 성한 노드에서 다시 돌아 문의가 준다.

GitHub에서 열기

노드를 대기열에서 빼고 넣기

점검이나 수리를 하려면 그 노드에 새 작업이 들어가지 않게 막아야 한다. 실행 중인 작업을 죽이지 않고 비우는 절차가 있다.

# 새 작업만 막는다. 돌던 작업은 끝까지 돈다
scontrol update NodeName=gpu07 State=DRAIN Reason="dcgm diag 예정 $(date +%F)"

# 비었는지 확인한다
squeue -w gpu07

# 점검이 끝나면 되돌린다
scontrol update NodeName=gpu07 State=RESUME

Reason은 반드시 적는다. 며칠 뒤 드레인된 노드 목록을 봤을 때 왜 뺐는지 모르면 되돌릴 수도 없고 방치된다. 날짜와 담당자를 함께 넣는 편이 낫다.

sinfo -R --format="%50E %12U %19H %N"    # 사유, 사용자, 시각, 노드

여러 노드를 다룰 때는 범위 표기를 쓴다. 범위를 잘못 적어 전체 노드를 드레인하는 사고가 실제로 일어나므로, 실행 전에 대상을 먼저 출력해 확인한다.

scontrol show hostnames gpu[01-04]        # 무엇이 대상인지 먼저 본다
scontrol update NodeName=gpu[01-04] State=DRAIN Reason="펌웨어 교체"

GitHub에서 열기

용량을 언제 늘릴 것인가

노드를 더 사자는 판단은 느낌이 아니라 몇 가지 수치로 한다.

신호 무엇을 뜻하는가
대기 시간 중앙값이 오르는 추세 수요가 공급을 넘기 시작했다
할당률이 지속적으로 90% 이상 여유가 없다. 장애 하나에 대기열이 밀린다
GPU 사용률은 낮은데 대기가 길다 자원이 아니라 파편화나 정책 문제다
특정 파티션만 밀린다 전체가 아니라 그 구역만 조정하면 된다

세 번째 신호가 중요하다. 할당률이 높은데 실제 GPU 사용률이 낮으면 노드를 사도 같은 일이 반복된다. 이때는 자원을 잡아만 두는 작업을 찾는 편이 빠르다. 모듈 06의 활용률 항목과 함께 본다.

sreport cluster Utilization Start=2026-07-01 End=2026-08-01
sreport user TopUsage Start=2026-07-01 End=2026-08-01 TopCount=10

증설 판단에 쓰는 지표는 임계값과 함께 둔다. 값 하나가 아니라 며칠 이상 이어지는 추세로 본다.

지표 임계값 예 뜻
대기 시간 90분위 며칠 이상 24시간 초과 짧은 작업도 오래 기다린다
할당률 2주 평균 90퍼센트 이상 여유가 없다
거절된 요청 자원 부족 제출 실패가 증가 큰 작업이 아예 못 들어온다
특정 자원만 부족 GPU는 남는데 CPU·메모리가 먼저 참 노드 종류가 안 맞는다

증설 전에 병목이 GPU인지부터 가른다. GPU는 남는데 CPU나 메모리, 스토리지가 먼저 차서 GPU를 못 잡는 경우가 있다. 이때는 GPU 노드를 더 사도 소용이 없고, CPU·메모리 비율이 다른 노드를 넣거나 요청 기본값을 조정하는 편이 맞다.

sacct -a -S 2026-07-01 --format=ReqCPUS,ReqMem,AllocGRES,Elapsed -X | head
sdiag                                # 스케줄러가 무엇 때문에 작업을 대기시키는지

증설이 답이 아닌 경우도 많다. 할당률은 높은데 사용률이 낮으면 자원을 잡아만 두는 작업이 원인이라, 우선순위·선점·최대 보유 시간 같은 정책으로 푸는 편이 빠르다. 특정 파티션만 밀리면 파티션 경계를 조정하거나 백필을 손본다. 새 노드는 도입까지 몇 주가 걸리므로, 정책으로 풀 수 있는지부터 확인한 뒤 결정한다.

GitHub에서 열기

사용자 문의를 줄이는 것들

같은 질문이 반복되면 문서나 기본값을 고칠 신호다. 문의 유형별로 대응이 다르다.

문의 대개의 원인 근본 대응
작업이 안 돈다 요청이 노드 사양보다 크다 제출 예시와 노드 사양을 문서에 명시
어제는 됐는데 오늘 안 된다 환경 차이 작업 스크립트에 module purge 를 표준으로
갑자기 느려졌다 공유 스토리지 경합이나 노드 문제 대시보드를 사용자에게 열어 준다
내 차례가 언제인가 대기열 정책을 모른다 squeue --start 안내와 정책 문서화

사용자가 스스로 확인할 수 있는 통로를 만들어 두면 문의가 줄고 조사도 빨라진다. 작업 상태와 노드 상태, 대기열 현황을 보여주는 대시보드 하나면 대부분 해결된다.

문서로 미리 막을 수 있는 것과 기본값으로 막을 수 있는 것을 구분한다. 문서는 읽지 않는 사람이 늘 있으므로, 자주 틀리는 값은 아예 기본값을 안전하게 잡아 둔다. 작업 제출 템플릿을 만들어 배포하면 사용자가 빈 스크립트를 처음부터 쓰지 않아도 된다.

#!/bin/bash
#SBATCH --job-name=train
#SBATCH --partition=gpu
#SBATCH --gres=gpu:1
#SBATCH --cpus-per-task=8
#SBATCH --mem=64G
#SBATCH --time=24:00:00
module purge
module load cuda/12.4 nccl/2.21
srun python train.py

이 템플릿 하나가 "GPU를 요청했는데 안 잡힌다", "메모리가 부족하다", "어제는 됐는데 오늘 안 된다" 같은 문의의 상당수를 없앤다. 여기에 자주 나오는 오류와 해결을 정리한 짧은 FAQ, 신규 사용자용 온보딩 문서, 상태를 스스로 보는 대시보드를 더하면 반복 문의가 눈에 띄게 준다. 문의가 들어올 때마다 그 원인을 문서나 기본값에 반영하면, 같은 질문이 두 번 오지 않는다.

GitHub에서 열기

사용자가 직접 쓰게 만들기

운영 부담의 상당 부분은 사용자가 스스로 못 하는 일에서 나온다. 터미널과 배치 스크립트에 익숙한 사람만 쓸 수 있는 클러스터는 문의가 끊이지 않고, 결국 운영자가 대신 제출해 주는 상황이 된다. 웹에서 접근하는 창구를 두면 이 부담이 크게 준다.

창구 무엇을 대신해 주나 주의할 점
웹 터미널 SSH 설정 없이 로그인 노드에 붙는다 로그인 노드 부하가 늘어난다
노트북 세션 Jupyter를 컴퓨트 노드에 띄우고 연결해 준다 자리를 잡고 노는 세션이 는다
작업 제출 양식 스크립트 대신 항목을 채워 제출한다 양식이 낡으면 잘못된 요청을 만든다
파일 관리 브라우저에서 결과를 내려받는다 대용량 전송이 로그인 노드를 막는다
작업 현황 내 작업이 왜 대기 중인지 보여 준다 문의를 가장 크게 줄이는 항목이다

HPC 쪽에서는 Open OnDemand가 이 일을 한다. 사용자를 대신해 배치 작업을 제출하고, 노트북이나 원격 데스크톱 세션을 컴퓨트 노드에 띄운 뒤 브라우저로 연결해 준다. Kubernetes 쪽이면 JupyterHub가 같은 자리를 맡는다.

# 노트북 세션을 배치 작업으로 띄우는 최소 형태. 포털이 대신 하는 일이 이것이다
sbatch --gres=gpu:1 --cpus-per-task=8 --time=04:00:00 --wrap \
  'jupyter lab --no-browser --ip=0.0.0.0 --port=$(shuf -i 20000-30000 -n1)'

포털을 두면 반드시 함께 정해야 하는 것이 있다.

  • 세션 시간 제한. 노트북은 사람이 자리를 비워도 계속 산다. 대화형 파티션에 짧은 제한을 걸고 유휴가 이어지면 알린다.
  • 기본값. 양식의 기본값이 곧 대다수의 요청이 된다. GPU 1장, 4시간처럼 작게 잡아 두고 필요한 사람만 늘리게 한다.
  • 인증 연동. 포털이 대신 제출하더라도 작업은 사용자 uid로 돌아야 한다. 공용 계정으로 돌면 누가 무엇을 썼는지 사라지고 파일 권한도 어긋난다.
  • 로그인 노드 보호. 파일 전송과 웹 터미널이 같은 노드에 몰리면 그 노드가 느려진다. 포털용 노드를 따로 두는 편이 낫다.

무엇을 자동으로 보여 주느냐가 문의 건수를 정한다. 작업이 왜 대기 중인지, 지난 실행이 자원을 얼마나 썼는지, 지금 클러스터가 얼마나 붐비는지를 사용자가 직접 볼 수 있으면 같은 질문이 반복되지 않는다. 모듈 11의 대시보드 항목에서 사용자용 화면을 따로 두라고 한 이유가 여기 있다.

GitHub에서 열기

정기 점검 항목

사람이 기억해서 돌리는 점검은 언젠가 빠진다. 주기를 정해 자동으로 돌리고 결과만 확인한다.

주기 항목
매시 노드 상태, 드레인된 노드 수, 스토리지 사용률
매일 GPU 빠른 진단, ECC 카운터 증가분, 드라이버 버전 일치
매주 대기열 통계, 계정별 사용량, 백필 효과
매월 펌웨어와 드라이버 점검, 예비 부품 재고, 용량 추이

노드가 대기열에 들어가기 전에 통과해야 할 조건을 정해두면 문제 노드가 조용히 섞여 드는 것을 막을 수 있다. GPU 개수, 드라이버 버전, 마운트 상태, 빠른 진단 통과 정도면 시작으로 충분하다.

점검을 자동으로 돌리되 결과를 사람이 볼 수 있는 곳에 남긴다. 통과하면 조용하고 실패할 때만 알리는 방식이 쌓이지 않는다. 매시·매일 점검은 스케줄러의 배치 작업이나 cron으로 돌리고, 결과를 모니터링 시스템의 지표로 내보내 대시보드에서 추세를 본다.

# 노드가 대기열에 들어가기 전 통과 조건을 한 스크립트로 묶는다
gpu_count=$(nvidia-smi -L | wc -l)
[ "$gpu_count" -eq 8 ] || { echo "GPU 수 불일치: $gpu_count"; exit 1; }
nvidia-smi >/dev/null || { echo "드라이버 응답 없음"; exit 1; }
mountpoint -q /scratch || { echo "scratch 미마운트"; exit 1; }
dcgmi diag -r 1 >/dev/null || { echo "빠른 진단 실패"; exit 1; }

이 검사를 노드 부팅 후와 대기열 복귀 전에 돌리면, 마운트가 빠졌거나 GPU 한 장이 사라진 노드가 작업을 받는 사고를 막는다. 검사 항목은 장애를 겪을 때마다 늘린다. 한 번 사고가 난 원인은 다음부터 자동으로 걸러지도록 조건을 추가하는 편이 낫다. 매월 점검에는 펌웨어와 드라이버 버전을 전 노드에서 모아 비교하는 항목을 넣어, 한두 대만 뒤처진 상태를 찾아낸다.

GitHub에서 열기

04

Linux 성능 튜닝심화

NUMA, 메모리, I/O, 네트워크

같은 하드웨어인데 성능이 다르게 나오는 일이 있다. 커널 기본값은 범용 서버를 전제로 잡혀 있어 HPC 워크로드와 어긋나는 지점이 몇 군데 있고, 그 지점을 하나씩 짚는다.

NUMA와 프로세스 배치

NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 CPU가 어느 메모리에 접근하느냐에 따라 속도가 달라지는 구조다. 소켓(socket)은 물리 CPU 하나를 꽂는 자리이고, 큰 서버는 소켓을 둘 이상 둔다.

소켓이 둘 이상인 서버는 메모리가 소켓마다 붙어 있다. 다른 소켓에 붙은 메모리를 읽으면 지연이 늘고 대역폭이 준다. GPU와 NIC도 특정 소켓에 연결되어 있어, 프로세스를 어디에 두느냐가 성능을 바꾼다.

lscpu | grep -i numa         # 노드 수와 코어 배치
numactl --hardware           # 노드별 메모리 용량과 노드 간 거리
nvidia-smi topo -m           # GPU와 NIC이 어느 노드에 붙었는지

numactl --hardware의 출력은 이렇게 읽는다.

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 ... 63
node 0 size: 515000 MB
node 1 cpus: 64 65 ... 127
node 1 size: 515000 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

node 0에 코어 0–63과 약 515GB 메모리가 붙어 있고, node 1에 나머지가 붙어 있다는 뜻이다. 거리 행렬에서 대각선(자기 자신)은 10이고 노드 0에서 노드 1로 가는 값은 21이다. 노드 0의 코어가 노드 1의 메모리를 읽으면 약 2배 느리다는 근사치다. 프로세스가 쓰는 메모리와 코어를 같은 노드에 두는 것이 목표다.

numactl --hardware의 거리 행렬에서 로컬은 보통 10, 원격은 20 이상이다. 이 값이 실제 지연 비율의 근사치다.

CPU와 메모리, GPU, NIC이 소켓마다 붙어 있고 소켓을 넘는 접근은 지연이 커진다

배치를 고정하는 방법은 둘이다.

# 프로세스를 NUMA 노드 0에 묶고 메모리도 거기서만 할당한다
numactl --cpunodebind=0 --membind=0 ./app

# Slurm에서 GPU와 가까운 코어에 자동으로 묶는다
srun --gpus-per-task=1 --cpu-bind=verbose,cores ./app

GPU 8장 노드에서 프로세스 8개를 띄울 때, 각 프로세스를 담당 GPU와 같은 소켓에 묶으면 데이터 전송 구간에서 차이가 난다. --cpu-bind=verbose를 붙이면 실제 어디에 묶였는지 로그로 확인할 수 있다.

GitHub에서 열기

리눅스가 프로세스를 고르는 방식

코어 하나에서 돌 수 있는 프로세스가 여럿이면 커널이 순서를 정한다. 이 결정을 내리는 것이 스케줄러이고, 리눅스 기본 스케줄러는 CFS(Completely Fair Scheduler, 완전 공정 스케줄러)다. 이름대로 모든 프로세스에 CPU 시간을 고르게 나누는 것을 목표로 잡는다.

CFS는 프로세스마다 vruntime(virtual runtime, 가상 실행 시간)이라는 값을 들고 있다. 프로세스가 CPU를 쓴 만큼 이 값이 올라가고, 스케줄러는 vruntime이 가장 작은 프로세스를 다음에 올린다. 적게 쓴 쪽부터 주니 시간이 고르게 퍼진다. nice 값은 이 vruntime이 올라가는 속도를 바꾼다. nice가 낮은 프로세스는 같은 시간을 써도 vruntime이 천천히 올라 더 자주 선택된다. nice는 우선순위 숫자를 직접 지정하는 값이 아니라 가중치(weight)를 정하는 값이고, 한 단계 차이가 대략 1.25배의 CPU 몫으로 이어진다. -20에서 +19까지 있고, 값이 작을수록 몫이 크다.

CFS로 관리되는 일반 정책이 SCHED_OTHER다. 이와 달리 실시간 정책은 vruntime을 따지지 않고 우선순위가 높은 쪽이 CPU를 계속 쥔다.

정책 성격 쓰는 곳
SCHED_OTHER CFS가 관리하는 일반 정책, 공정 분배 대부분의 프로세스
SCHED_BATCH 대화형이 아님을 알려 전환을 줄인다 배치 계산
SCHED_IDLE 남는 시간에만 돈다 최하 우선순위 백그라운드
SCHED_FIFO 실시간, 스스로 놓기 전까지 안 뺏긴다 지연이 치명적인 소수 스레드
SCHED_RR 실시간, 같은 우선순위끼리 타임슬라이스로 번갈아 위와 같으나 여럿일 때

정책과 우선순위는 chrt로 바꾸고, 코어 묶기는 taskset으로 한다.

chrt -f 50 ./low_latency_app        # SCHED_FIFO 우선순위 50으로 실행한다
chrt -p $(pgrep trainer)            # 프로세스의 현재 정책과 우선순위를 본다
taskset -c 8-15 ./trainer           # 코어 8~15에만 묶는다
taskset -pc $(pgrep dataloader)     # 어느 코어에 묶였는지 본다

실시간 정책은 강한 도구라 잘못 쓰면 SCHED_FIFO 프로세스가 무한 루프에 빠졌을 때 그 코어의 다른 작업이 통째로 굶는다. 커널은 이를 막으려고 실시간 대역폭 한도(kernel.sched_rt_runtime_us)를 두는데, 계산 노드에서 실시간 정책을 쓸 때는 이 한도와 대상 코어를 함께 정해야 한다.

HPC 워크로드에서 기본값이 어긋나는 지점은 타임슬라이스와 이주(migration)에 있다. CFS는 응답성을 위해 프로세스를 자주 번갈아 올리고, 부하가 한쪽으로 쏠리면 프로세스를 다른 코어로 옮긴다. 대화형 데스크톱에는 맞지만, MPI 집합 통신처럼 모든 랭크가 같은 지점에서 만나야 하는 작업에는 독이 된다. 한 랭크의 계산 스레드가 잠깐 밀려 늦게 배리어에 도착하면 나머지 전부가 그 랭크를 기다린다. 잦은 전환과 이주는 캐시도 식혀 계산 자체를 느리게 만든다.

두 값으로 이 동작을 눅인다. sched_min_granularity_ns는 한 프로세스가 최소로 쥐고 있는 시간이라, 키우면 전환이 줄어든다. sched_migration_cost_ns는 이 시간 안에 돈 작업은 캐시가 뜨겁다고 보고 다른 코어로 옮기지 않는 문턱이라, 키우면 이주가 줄어든다.

# 오래된 커널에서는 sysctl로 보이고 조정한다
sysctl kernel.sched_min_granularity_ns
sysctl kernel.sched_migration_cost_ns
sysctl -w kernel.sched_min_granularity_ns=10000000   # 10ms
sysctl -w kernel.sched_migration_cost_ns=5000000     # 5ms
# 최근 커널은 이 값을 debugfs로 옮겼다
cat /sys/kernel/debug/sched/min_granularity_ns

데이터 로더와 학습 프로세스의 우선순위를 나누는 것이 실제로 효과가 큰 예다. 학습 주 프로세스는 GPU에 커널을 밀어 넣고 통신을 담당하므로 잠깐이라도 밀리면 GPU가 논다. 로더 워커는 CPU를 많이 쓰지만 잠깐 늦어도 큐가 흡수한다. 그래서 학습 프로세스는 우선순위를 올려 통신 스레드가 밀리지 않게 하고, 로더 워커는 계산 코어와 겹치지 않는 코어에 묶는다.

# 학습 주 프로세스는 통신 스레드가 밀리지 않게 우선순위를 올린다
nice -n -5 taskset -c 0-7 python train.py &
# 로더 워커는 남은 코어에 묶어 통신 코어를 건드리지 않게 한다
taskset -c 8-31 python -m data.serve &
chrt -p $(pgrep -f train.py)        # 의도한 정책이 걸렸는지 확인한다

코어를 나눌 때는 앞의 NUMA 배치와 함께 본다. 통신 코어를 NIC이 붙은 NUMA 노드에 두고, 로더 워커를 다른 노드에 두면 인터럽트와 계산이 서로를 밀어내지 않는다. 우선순위만 바꾸고 코어 배치를 안 맞추면 효과가 반감된다.

GitHub에서 열기

메모리 설정

투명 대용량 페이지는 기본으로 켜져 있고, 대개 도움이 되지만 지연에 민감한 워크로드에서는 페이지 병합 과정이 튀는 원인이 된다.

cat /sys/kernel/mm/transparent_hugepage/enabled   # [always] madvise never
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

always는 커널이 알아서 병합하고, madvise는 프로그램이 요청한 영역에만 적용한다. 지연이 중요한 곳은 madvise가 무난하다.

스왑은 계산 노드에서 사실상 재앙이다. 학습 프로세스가 스왑에 걸리면 성능이 수십 배 떨어지므로, 차라리 OOM으로 죽는 편이 낫다.

sysctl vm.swappiness         # 계산 노드는 1 또는 0으로 둔다
sysctl vm.overcommit_memory  # 0이 기본. 2로 두면 과할당을 막지만 부작용이 있다

페이지 캐시는 비워야 할 대상이 아니다. 벤치마크 재현성을 위해 비우는 경우에만 echo 3 > /proc/sys/vm/drop_caches를 쓰고, 운영 중에는 손대지 않는다.

GitHub에서 열기

HugePages

프로세스가 쓰는 주소는 가상 주소라, 실제 물리 메모리 주소로 바꾸는 변환이 매 접근마다 일어난다. 이 대응표가 페이지 테이블(page table)이다. 메모리를 페이지라는 고정 크기 조각으로 끊어 관리하는데, 기본 페이지는 4KB다. 페이지 테이블을 매번 뒤지면 느리므로, CPU 안에 최근 변환 결과를 담아 두는 작은 캐시가 있다. 이것이 TLB(Translation Lookaside Buffer, 변환 색인 버퍼)다. 접근하려는 주소의 변환이 TLB에 있으면 바로 물리 주소를 얻고, 없으면(TLB 미스) 페이지 테이블을 뒤지는 느린 경로로 빠진다.

4KB 페이지로 큰 메모리를 다루면 TLB 미스가 늘어난다. TLB가 담을 수 있는 항목 수는 수백에서 수천 사이로 정해져 있는데, 페이지 하나가 4KB이니 항목 수천 개를 다 채워도 겨우 수십 MB만 덮는다. 수십 GB를 오가는 학습 프로세스는 접근 주소가 TLB 범위를 금방 벗어나 미스가 잦아지고, 그때마다 페이지 테이블을 뒤지는 비용이 쌓인다. 페이지를 크게 만들면 항목 하나가 덮는 범위가 넓어져 같은 TLB로 훨씬 큰 메모리를 덮는다. 2MB 대용량 페이지 하나는 4KB 페이지 512개를 대신한다.

대용량 페이지를 쓰는 방식은 둘이다.

방식 성격 특징
정적 HugePages 부팅이나 실행 중에 미리 예약한 풀 크기가 고정되고 예측 가능, 애플리케이션이 명시적으로 요청
투명 HugePages(THP) 커널이 자동으로 4KB를 2MB로 병합 코드 수정 없이 적용, 병합·분해가 뒤에서 일어남

정적 HugePages는 관리자가 풀을 잡아 두고, 애플리케이션이 그 풀에서 받아 쓴다.

grep Huge /proc/meminfo                 # 현재 대용량 페이지 상태
cat /proc/sys/vm/nr_hugepages           # 예약된 2MB 페이지 수
sysctl -w vm.nr_hugepages=8192          # 2MB 8192개 = 16GB 예약
# 1GB 페이지는 부팅 파라미터로 잡는다
# default_hugepagesz=1G hugepagesz=1G hugepages=32

THP는 커널이 알아서 병합하므로 코드를 바꿀 필요가 없지만, 병합과 분해가 뒤에서 일어난다는 점이 지연을 튀게 만든다. 큰 영역을 병합하는 khugepaged 작업이나, 조각난 메모리에서 연속 2MB를 확보하려 compaction이 도는 순간 프로세스가 수 밀리초에서 수십 밀리초씩 멈춘다. 처리량만 보는 워크로드에는 이 지연이 묻히지만, 매 스텝 시간이 고르게 나와야 하는 학습이나 지연이 목표인 추론에서는 이 순간의 튐이 문제가 된다.

GPU 워크로드에서 THP를 끄는 판단은 이 튐과 이득을 견줘 내린다. 핀 메모리처럼 이미 큰 영역을 연속으로 잡아 쓰는 경우 THP가 주는 이득이 작고, 대신 compaction 지연이 스텝 시간에 얼룩을 남긴다. 지연이 튀는 증상이 보이면 madvise로 바꿔 요청한 영역에만 적용하거나 아예 끈다.

cat /sys/kernel/mm/transparent_hugepage/enabled     # [always] madvise never
echo never > /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag       # 병합 시 compaction 정책
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag

정적 HugePages 할당이 실패하는 원인은 대개 메모리 조각화다. 부팅 직후에는 연속 메모리가 넉넉해 풀이 잡히지만, 오래 돌아 메모리가 조각나면 연속 2MB나 1GB를 못 찾아 요청한 수보다 적게 잡힌다. 확인은 예약 요청과 실제 확보를 견줘 본다.

# 요청한 수와 실제 잡힌 수를 견준다
grep -E "HugePages_Total|HugePages_Free|HugePages_Rsvd" /proc/meminfo
cat /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
cat /sys/kernel/mm/hugepages/hugepages-2048kB/free_hugepages
# 노드별로 나눠 본다
numastat -m | grep -i huge

HugePages_Total이 요청보다 작으면 그만큼 확보에 실패한 것이라, 1GB 페이지는 부팅 파라미터로 잡아 조각화 전에 확보하는 편이 안전하다. 애플리케이션이 대용량 페이지를 못 받으면 조용히 4KB로 떨어져 도는 경우가 있어, 성능이 안 나올 때 이 값을 확인해 실제로 대용량 페이지가 쓰이는지 본다.

GitHub에서 열기

I/O 스케줄러와 파일시스템

NVMe는 큐가 깊고 자체적으로 재배치를 하므로 커널 스케줄러가 개입할 여지가 적다. 대부분 none이 가장 좋다.

cat /sys/block/nvme0n1/queue/scheduler    # [none] mq-deadline kyber
echo none > /sys/block/nvme0n1/queue/scheduler
장치 권장 이유
NVMe SSD none 장치가 이미 병렬 처리한다. 커널 개입이 오히려 지연을 만든다
SATA SSD mq-deadline 가벼운 정렬로 충분하다
HDD mq-deadline 또는 bfq 탐색 시간이 커서 정렬 효과가 크다

마운트 옵션에서는 noatime이 효과가 확실하다. 읽기만 해도 접근 시각을 쓰느라 메타데이터 갱신이 발생하는데, 파일을 대량으로 읽는 워크로드에서 이 비용이 무시할 수 없다.

/dev/nvme0n1 /scratch xfs defaults,noatime,nodiratime 0 0

병렬 파일시스템은 성격이 다르다. Lustre는 파일을 여러 OST에 나눠 저장하는데, 큰 파일을 쓸 때는 스트라이프를 늘려야 대역폭이 나온다.

lfs getstripe /scratch/dataset
lfs setstripe -c 8 /scratch/bigfiles     # 스트라이프 8개로 분산한다

작은 파일에 스트라이프를 늘리면 오히려 손해다. 큰 파일이 놓일 디렉터리에만 적용한다.

GitHub에서 열기

네트워크

분산 학습에서 노드 간 통신은 대개 InfiniBand나 RoCE를 쓴다. 상태 확인이 먼저다.

ibstat                      # 포트 상태와 링크 속도
ibv_devinfo                 # 장치별 상세
ib_write_bw -d mlx5_0       # 대역폭 측정. 상대 노드에서 서버 모드로 함께 띄운다

ibstat의 Rate가 규격보다 낮으면 케이블이나 트랜시버를 의심한다. 링크가 절반 속도로 붙는 경우가 드물지 않고, 성능 저하 신고의 원인이 되곤 한다.

이더넷 구간에서는 MTU를 맞춘다. 경로 위 모든 장비가 같은 값이어야 하고, 한 곳이라도 작으면 단편화가 일어나 오히려 느려진다.

ip link show eth0 | grep mtu
ping -M do -s 8972 <상대노드>    # 9000 MTU 확인. 헤더 28바이트를 뺀 값이다

NCCL은 환경 변수로 경로를 통제한다. 디버깅에는 이 둘이 쓸모 있다.

export NCCL_DEBUG=INFO          # 어떤 경로를 골랐는지 로그로 남긴다
export NCCL_IB_HCA=mlx5_0,mlx5_1  # 쓸 HCA를 지정한다

NCCL_DEBUG=INFO 로그에서 통신 경로가 NET/Socket으로 잡혔다면 InfiniBand를 못 쓰고 TCP로 돌고 있다는 뜻이다. 성능이 기대의 몇 분의 일로 떨어진다.

GitHub에서 열기

CPU 주파수와 절전

기본 배포판은 전력을 아끼는 쪽으로 설정되어 있다. 계산 노드에서는 이 설정이 지연을 만든다. 부하가 걸릴 때까지 클럭이 낮게 유지되고, 깊은 절전 상태에서 깨어나는 데 시간이 걸린다.

cpupower frequency-info                       # 현재 거버너와 클럭 범위
cpupower frequency-set -g performance         # 모든 코어를 성능 모드로
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
turbostat --interval 5                        # 실제 클럭과 C-state 체류 시간

turbostat의 Busy%가 낮은데 성능이 안 나오면 클럭이 안 올라간 것이고, CPU%c6가 크면 깊은 절전에 자주 들어간다는 뜻이다. 지연에 민감한 워크로드는 절전 깊이를 제한한다.

# 부팅 파라미터로 깊은 절전을 막는다
intel_idle.max_cstate=1 processor.max_cstate=1

전력 비용과 맞바꾸는 결정이라 노드 성격에 따라 다르게 둔다. 상시 학습이 도는 GPU 노드는 성능 모드가 맞고, 유휴 시간이 긴 노드는 굳이 그럴 필요가 없다.

GitHub에서 열기

인터럽트 배치

네트워크 카드는 패킷을 받을 때마다 인터럽트를 건다. 이 처리가 특정 코어에 몰리면 그 코어가 포화되어 전체 처리량이 떨어진다. 계산 스레드가 도는 코어와 겹치면 계산도 함께 방해받는다.

cat /proc/interrupts | grep mlx               # 인터럽트가 어느 코어로 가는지
systemctl status irqbalance                   # 자동 분배 데몬
cat /sys/class/net/eth0/device/numa_node      # NIC 이 붙은 NUMA 노드

원칙은 두 가지다. NIC 인터럽트는 그 NIC이 붙은 NUMA 노드의 코어로 보내고, 계산에 쓰는 코어와 겹치지 않게 한다. 노드를 넘어가면 인터럽트 처리마다 원격 메모리 접근이 생긴다.

# 특정 인터럽트를 특정 코어에 고정한다
echo 2 > /proc/irq/128/smp_affinity_list

# 계산 코어를 커널 작업에서 떼어 놓는다 (부팅 파라미터)
isolcpus=8-63 nohz_full=8-63 rcu_nocbs=8-63

isolcpus는 강한 도구다. 격리한 코어에는 스케줄러가 아무것도 올리지 않으므로, 그 코어를 쓰는 프로세스를 명시적으로 배치해야 한다. 설정만 하고 배치를 안 하면 코어가 통째로 논다.

GitHub에서 열기

쓰기 버퍼와 I/O 폭발

체크포인트처럼 짧은 시간에 대량으로 쓰는 워크로드는 커널의 쓰기 버퍼 설정에 영향을 받는다. 버퍼가 가득 차면 쓰기가 동기로 바뀌면서 프로세스가 멈춘다.

sysctl vm.dirty_ratio               # 이 비율을 넘으면 쓰는 쪽이 직접 내려쓴다
sysctl vm.dirty_background_ratio    # 이 비율부터 백그라운드로 내려쓰기 시작
sysctl vm.dirty_expire_centisecs    # 얼마나 오래된 데이터부터 내려쓸지

기본값은 메모리의 20퍼센트와 10퍼센트다. 메모리가 1TB인 노드라면 200GB까지 버퍼에 쌓였다가 한꺼번에 내려간다. 이 순간 다른 I/O가 전부 밀린다. 값을 낮추면 조금씩 꾸준히 내려가 지연이 고르게 퍼진다.

vm.dirty_background_ratio는 백그라운드로 내려쓰기를 시작하는 문턱이고, vm.dirty_ratio는 쓰는 쪽을 붙잡아 직접 내려쓰게 만드는 문턱이다. 앞의 값을 넘으면 커널이 조용히 뒤에서 내려쓰기 시작하고, 뒤의 값을 넘으면 쓰던 프로세스가 멈춘 채 버퍼가 빌 때까지 기다린다. 대용량 체크포인트가 바로 이 뒤 문턱을 넘겨 학습이 몇 초씩 정지하는 원인이다.

효과는 값으로 확인한다. 체크포인트를 쓰는 동안 /proc/meminfo의 Dirty와 Writeback을 지켜본다. Dirty는 아직 안 내려간 데이터, Writeback은 지금 내려가는 중인 데이터다.

grep -E "Dirty|Writeback" /proc/meminfo   # 값이 치솟았다가 한꺼번에 빠지는지 본다
watch -n1 'grep -E "Dirty|Writeback" /proc/meminfo'
sysctl vm.dirty_ratio               # 이 비율을 넘으면 쓰는 쪽이 직접 내려쓴다
sysctl vm.dirty_background_ratio    # 이 비율부터 백그라운드로 내려쓰기 시작
sysctl -w vm.dirty_background_ratio=3
sysctl -w vm.dirty_ratio=10
# 되돌리기: sysctl -w vm.dirty_background_ratio=10; sysctl -w vm.dirty_ratio=20

메모리가 큰 노드에서는 비율 대신 절대량으로 두는 편이 예측하기 쉽다. vm.dirty_bytes와 vm.dirty_background_bytes를 쓰면 메모리 크기와 무관하게 상한이 고정된다. 권장값은 워크로드가 정하지만, 버퍼가 한 번에 내려가며 멈추는 증상이 보이면 문턱을 낮춰 조금씩 자주 내려가게 만드는 방향이 맞다.

GitHub에서 열기

GPU 노드에서 특히 문제가 되는 설정

위 항목들이 GPU 학습에서 왜 문제가 되는지는 GPU가 메모리와 네트워크를 쓰는 방식과 연결된다.

페이지 잠금 메모리(page-locked memory). GPU로 데이터를 보낼 때 CPU 메모리가 디스크로 밀려나지 않도록 고정한 영역을 쓴다. 이걸 핀 메모리(pinned memory)라고 부른다. 데이터 로더가 pin_memory=True로 이 영역을 많이 잡는데, 고정된 메모리는 스왑 대상이 아니라서 스왑을 켜 둔 노드에서도 이 부분은 물리 메모리를 계속 차지한다. 한도가 낮으면 고정에 실패한다.

ulimit -l                          # 잠글 수 있는 메모리 한도(KB). unlimited 여야 안전하다
grep memlock /etc/security/limits.conf
# 되돌리기: limits.conf 의 해당 줄을 원래대로 두고 다시 로그인한다

GPUDirect. GPU가 CPU를 거치지 않고 NIC이나 다른 GPU와 직접 데이터를 주고받는 기능이다. GPUDirect RDMA는 네트워크 카드가 GPU 메모리를 바로 읽고, GPUDirect Storage는 저장장치가 GPU 메모리로 바로 넣는다. CPU를 거치는 복사가 사라져 대역폭이 오르고 지연이 준다. 이 경로는 NIC과 GPU가 같은 PCIe 스위치 아래, 같은 NUMA 노드에 있을 때 가장 잘 나온다. 앞의 NUMA 배치가 GPU 노드에서 특히 중요한 까닭이다.

vm.max_map_count. 한 프로세스가 가질 수 있는 메모리 매핑 영역의 최대 개수다. 기본값 65530은 큰 학습 프레임워크나 여러 GPU를 쓰는 프로세스에 부족할 때가 있고, 넘으면 Cannot allocate memory나 매핑 실패로 죽는다.

sysctl vm.max_map_count             # 기본 65530
sysctl -w vm.max_map_count=1048576  # 운영 중 바로 적용, 위험 낮음
# 되돌리기: sysctl -w vm.max_map_count=65530

GitHub에서 열기

설정별 위험도

설정을 바꾸기 전에 되돌리기가 얼마나 쉬운지 알아야 한다. 운영 중에 바꿔도 되는 것과 재부팅이 필요한 것, 잘못 두면 노드가 안 뜨는 것을 구분한다.

설정 되돌리기 위험도
vm.swappiness, vm.dirty_ratio sysctl -w로 즉시 낮음. 운영 중 조정 가능
I/O 스케줄러 sysfs에 다시 써서 즉시 낮음
CPU 거버너 cpupower로 즉시 낮음
THP 설정 sysfs에 다시 써서 즉시 낮음
인터럽트 affinity 값 되돌리면 즉시 중간. 잘못 묶으면 처리량이 준다
vm.max_map_count sysctl -w로 즉시 낮음
isolcpus, nohz_full 부팅 파라미터 수정 후 재부팅 높음. 배치를 안 하면 코어가 논다
C-state 제한 부팅 파라미터 수정 후 재부팅 중간. 전력이 오른다
마운트 옵션 fstab 수정 후 재마운트 중간. 오타면 부팅이 막힐 수 있다

sysctl -w로 바꾼 값은 재부팅하면 사라진다. 재부팅 후에도 유지하려면 /etc/sysctl.d/에 파일로 남긴다. 부팅 파라미터(isolcpus 등)는 잘못 적으면 노드가 정상적으로 뜨지 않으므로, 한 대에서 재부팅까지 확인한 뒤 나머지에 배포한다.

echo 'vm.max_map_count=1048576' > /etc/sysctl.d/90-hpc.conf
sysctl --system                     # sysctl.d 파일을 다시 읽어 적용한다

GitHub에서 열기

튜닝 프로파일

항목을 하나씩 만지는 대신 묶음으로 적용하는 방법도 있다. 배포판이 제공하는 프로파일은 위에서 다룬 항목 상당수를 한 번에 설정한다.

tuned-adm list                       # 사용 가능한 프로파일
tuned-adm active                     # 현재 적용된 것
tuned-adm profile hpc-compute        # 계산 노드용
tuned-adm profile latency-performance

프로파일을 먼저 적용하고 필요한 항목만 덧붙이는 순서가 실수가 적다. 무엇이 바뀌는지는 프로파일 정의 파일에서 확인할 수 있다.

cat /usr/lib/tuned/hpc-compute/tuned.conf

프로파일이 무엇을 바꾸는지 모른 채 적용하면, 나중에 문제가 생겼을 때 원인을 가릴 수 없다. tuned.conf를 열어 어떤 sysctl 값과 거버너, 디스크 설정이 들어 있는지 확인하고 적용한다. 예를 들어 latency-performance는 CPU 거버너를 performance로 두고 C-state를 얕게 제한하며 THP를 조정한다. hpc-compute는 여기에 네트워크 버퍼와 커널 스케줄러 설정을 더한다.

tuned-adm verify                     # 적용값이 실제 설정과 일치하는지 검사한다
tuned-adm off                        # 프로파일을 걷어 기본값으로 되돌린다

tuned-adm verify는 프로파일이 지정한 값이 실제로 걸려 있는지 확인한다. 다른 스크립트나 사람이 값을 덮어썼으면 여기서 어긋남이 드러난다. 프로파일과 수동 설정을 섞으면 이 검사가 실패하므로, 프로파일로 큰 틀을 잡고 정말 필요한 몇 개만 /etc/sysctl.d/로 덧붙이는 방식이 관리하기 쉽다. 노드 종류마다 다른 프로파일이 필요하면 이름을 붙여 관리하고, 어느 노드에 무엇이 걸렸는지 목록으로 유지한다.

GitHub에서 열기

바꾸기 전과 후를 비교

설정을 바꿨으면 효과를 재야 한다. 재현 가능한 부하로 같은 조건에서 비교한다.

# 메모리 대역폭
mpirun -np 64 stream_mpi

# 지연
perf bench sched pipe

# 종합
sar -u -r -d -n DEV 1 60 > baseline.txt

값이 좋아지지 않았다면 되돌린다. 설정을 쌓아 두기만 하면 나중에 문제가 생겼을 때 무엇이 원인인지 가릴 수 없다. 바꾼 항목과 이유, 측정값을 한곳에 기록해 두는 편이 낫다.

비교에는 규칙이 있다. 한 번에 한 항목만 바꾼다. 두 개를 같이 바꾸고 좋아지면 어느 쪽이 기여했는지 알 수 없다. 같은 부하를 최소 세 번 돌려 중앙값을 쓴다. 한 번의 측정은 캐시 상태나 이웃 작업 때문에 튄다. 바꾸기 전 값을 baseline으로 저장해 두고, 바꾼 뒤 같은 명령으로 재서 나란히 놓는다.

# 바꾸기 전
sar -u -r -d -n DEV 1 60 > before.txt
# 설정 변경 후 동일 부하로
sar -u -r -d -n DEV 1 60 > after.txt
sdiff before.txt after.txt | less    # 열을 나란히 놓고 차이를 본다

측정할 지표는 워크로드에 맞춘다. 메모리 대역폭이 중요한 학습은 STREAM 수치를, 통신이 중요한 분산 학습은 ib_write_bw와 NCCL 벤치마크를 본다. 실제 학습의 스텝 시간을 재는 것이 가장 정확하다. 합성 벤치마크가 좋아져도 학습이 안 빨라지면 그 튜닝은 이 워크로드에 의미가 없다. 바꾼 항목과 날짜, 이유, 측정값을 노드 종류별로 한 표에 남기면, 몇 달 뒤 성능이 달라졌을 때 무엇을 건드렸는지 되짚을 수 있다.

GitHub에서 열기

perf와 eBPF로 커널 안을 보기

성능이 안 나오는데 원인이 코드 밖에 있을 때가 있다. 캐시 미스, 커널 대기, 디스크 지연 같은 것은 애플리케이션 로그로는 안 보인다. perf와 eBPF는 커널이 이미 세고 있는 값을 꺼내 이 안쪽을 들여다보는 도구다.

perf는 CPU가 내장한 성능 카운터(PMC)와 커널 이벤트를 읽는다. 쓰임에 따라 세 하위 명령을 갈라 쓴다.

명령 무엇을 하는가 언제 쓰는가
perf top 지금 CPU를 많이 먹는 함수를 실시간으로 어디가 뜨거운지 즉석에서 볼 때
perf record/report 일정 시간 표본을 모아 나중에 분석 프로파일을 남겨 파고들 때
perf stat 정해진 카운터의 총량을 센다 캐시 미스·분기 실패 비율을 잴 때

perf stat은 프로그램을 감싸 돌리면 그동안의 카운터를 모아 준다.

perf stat -d ./trainer                  # 캐시·분기 카운터를 함께 낸다
perf stat -e cache-misses,cache-references,branch-misses,branches ./trainer
perf top                                # 지금 뜨거운 함수를 실시간으로 본다
perf record -g -- ./trainer             # 콜 그래프까지 표본을 모은다
perf report                             # 모은 표본을 함수별로 정렬해 본다

읽는 법은 비율로 본다. cache-misses를 cache-references로 나누면 캐시 미스율이 나오고, 이 값이 높으면 메모리 접근 패턴이 캐시에 안 맞는다는 뜻이라 NUMA 원격 접근이나 자료구조 배치를 의심한다. branch-misses를 branches로 나눈 분기 예측 실패율이 높으면 조건 분기가 불규칙해 CPU가 앞질러 실행한 것을 자주 버린다는 뜻이다. 절대 개수보다 이 두 비율이 판단 기준이다.

eBPF는 커널 안에서 도는 작은 프로그램을 안전하게 붙이는 장치다. 커널을 다시 빌드하거나 모듈을 올리지 않고, 특정 이벤트가 일어날 때 값을 세거나 기록하는 코드를 커널에 끼워 넣는다. 검증기가 올리기 전에 프로그램이 무한 루프에 빠지거나 잘못된 메모리를 건드리지 않는지 확인해서, 운영 중인 커널에 붙여도 커널이 죽지 않는다. bcc와 bpftrace가 이 위에서 도는 도구 모음이다.

도구 무엇을 보여주는가
biolatency 블록 I/O 지연을 히스토그램으로, 디스크가 느린지
execsnoop 새로 뜨는 프로세스를 실시간으로, 예기치 않은 실행을 잡는다
runqlat 실행 큐 대기 시간, CPU를 기다린 시간이 긴지
tcpretrans TCP 재전송, 네트워크 구간이 패킷을 흘리는지
biolatency-bpfcc 10 1                    # 10초 동안 블록 I/O 지연 분포
execsnoop-bpfcc                          # 새 프로세스가 뜰 때마다 한 줄
runqlat-bpfcc 10 1                       # 실행 큐 대기 시간 분포
tcpretrans-bpfcc                         # 재전송이 일어날 때마다 출발지·도착지

runqlat의 대기 시간이 길면 CPU가 모자라거나 스케줄러가 프로세스를 자주 밀어낸다는 뜻이라, 앞의 스케줄러 조정과 이어진다. biolatency의 꼬리가 길면 특정 디스크나 파일시스템 경합을 본다. tcpretrans가 자주 찍히면 분산 학습 통신 구간의 손실을 의심한다.

운영 중인 클러스터에서 쓸 때는 오버헤드와 범위를 정해 둔다. perf record의 표본 주기를 높이면 부하가 늘어나므로, 기본 주기로 짧게 모으고 필요할 때만 올린다. eBPF 도구는 대개 커널 안에서 집계까지 끝내고 요약만 넘겨 오버헤드가 낮지만, execsnoop처럼 이벤트마다 한 줄을 내는 도구는 이벤트가 폭주하면 출력이 부하가 된다. 학습이 도는 노드에서는 관찰 시간을 수십 초로 끊고, 재현이 되는 노드 한 대에서 먼저 확인한 뒤 넓히는 순서가 안전하다. bcc 도구 이름은 배포판에 따라 biolatency 또는 biolatency-bpfcc로 다르니 설치된 이름을 확인해 쓴다.

GitHub에서 열기

다수 노드에 명령 실행

노드가 수십 대면 한 대씩 SSH로 들어가는 방식은 유지되지 않는다. 병렬 실행 도구를 쓴다.

pdsh -w gpu[01-16] uptime
pdsh -w gpu[01-16] 'nvidia-smi --query-gpu=index,temperature.gpu --format=csv,noheader' | dshbak -c

dshbak -c는 출력이 같은 노드를 묶어서 보여준다. 16대 중 15대가 같고 1대만 다르면 그 1대가 바로 눈에 띈다. 점검 스크립트를 돌릴 때 이 조합이 편하다.

Slurm이 있으면 할당된 노드에만 실행하는 방법도 있다.

srun --nodes=16 --ntasks-per-node=1 hostname

명령을 배포하기 전에 대상 목록을 먼저 확인하는 습관이 필요하다. 범위를 잘못 적어 전체 노드에 파괴적인 명령을 보내는 사고가 실제로 일어난다.

도구마다 성격이 조금씩 다르다. 셋을 언제 쓰는지 갈라 둔다.

도구 성격 언제 쓰는가
pdsh 가벼운 병렬 SSH 빠른 점검, 한 줄 명령을 전 노드에
clush(ClusterShell) 노드 그룹과 취합이 강함 그룹을 나눠 다루고 결과를 접어서 볼 때
srun 스케줄러가 할당한 노드에만 작업에 실제로 배정된 노드에서 실행할 때

clush는 노드 그룹을 이름으로 정의해 두고 그룹 단위로 명령을 보낸다. /etc/clustershell/groups에 gpu: gpu[01-64]처럼 정의하면 -g gpu로 부른다.

clush -g gpu 'nvidia-smi --query-gpu=driver_version --format=csv,noheader' | dshbak -c
clush -bg gpu uptime                # -b 는 같은 출력끼리 묶어 보여준다

dshbak -c나 clush -b는 출력이 같은 노드를 한 덩어리로 접는다. 64대 중 63대가 같고 1대만 드라이버 버전이 다르면, 그 1대만 따로 떨어져 나와 바로 눈에 띈다. 점검은 이렇게 "다른 것만 남기는" 방식이 빠르다.

위험한 명령은 습관으로 막는다. 배포 전에 clush -g gpu true나 pdsh -w ... hostname으로 대상이 맞는지 먼저 확인하고, 재부팅이나 삭제처럼 되돌릴 수 없는 명령은 한 노드에서 검증한 뒤 그룹으로 넓힌다. 범위 표기(gpu[01-64])를 손으로 적을 때 오타 하나가 전 노드로 퍼지므로, 실행 대상을 출력해 눈으로 확인하는 절차를 건너뛰지 않는다.

GitHub에서 열기

05

병렬 파일시스템심화

Lustre 구조, 스트라이핑, GPU 클러스터에서의 데이터 경로

GPU를 아무리 늘려도 데이터가 제때 오지 않으면 계산 유닛은 논다. 노드 수백 대가 같은 파일시스템을 동시에 두드릴 때 무엇이 먼저 무너지는지, 그 부하를 어떻게 나누는지, GPU 학습에서 스토리지가 어디에 끼어드는지를 본다.

NFS로는 왜 부족한가

노드 여러 대로 학습을 돌리려면 모든 노드가 같은 데이터를 같은 경로에서 볼 수 있어야 한다. /scratch/dataset/shard-000.tar가 1번 노드와 64번 노드에서 같은 파일을 가리켜야 학습 코드를 노드마다 고치지 않아도 된다. 이 일을 하는 것이 공유 파일시스템이다.

가장 익숙한 방식이 NFS다. 서버 한 대가 디스크를 들고 있고 나머지가 네트워크로 그 디렉터리를 마운트한다. 설치가 쉽고 어디서나 돌기 때문에 작은 클러스터의 기본 선택지다. 노드가 늘면 두 곳에서 막힌다.

첫째는 대역폭이다. 서버 한 대의 네트워크 카드와 디스크가 상한이다. 100GbE 한 장을 단 서버라면 이론상 12.5 GB/s이고, 노드 100대가 동시에 읽으면 노드당 125 MB/s로 떨어진다. GPU 8장이 달린 노드에서 이 값은 데이터 로더 하나도 채우지 못한다.

둘째는 메타데이터다. 파일을 열고 닫고 속성을 확인하는 요청이 전부 한 서버로 간다. 학습 한 스텝마다 이미지 파일 수천 개를 여는 워크로드에서는 대역폭이 남아도 여기서 먼저 포화된다.

노드 수 NFS 서버 1대 기준 노드당 몫 GPU 8장 노드에 필요한 양 결과
4 3.1 GB/s 1.9 GB/s 견딘다
16 780 MB/s 1.9 GB/s GPU가 놀기 시작한다
64 195 MB/s 1.9 GB/s 계산보다 대기가 길다
100 125 MB/s 1.9 GB/s 학습이 스토리지에 묶인다

병렬 파일시스템은 데이터와 메타데이터를 여러 서버로 나눠 이 두 상한을 걷어낸다. 클라이언트는 파일 하나를 읽을 때도 여러 서버에서 동시에 받아온다. 서버를 늘리면 총 대역폭이 함께 늘어나는 구조가 핵심이다.

GitHub에서 열기

Lustre의 구조와 접근 경로

Lustre는 역할이 다른 서버 세 종류와 그 사이를 잇는 전용 네트워크 계층으로 이루어진다.

구성 이름 하는 일 늘리면 좋아지는 것
MGS 관리 서버 파일시스템 설정을 클라이언트에 배포한다 없음. 부하가 거의 없다
MDS / MDT 메타데이터 서버와 타깃 디렉터리 구조, 이름, 권한, 그리고 각 파일이 어느 OST에 어떻게 놓였는지를 관리한다 파일 개수, 열기와 조회 성능
OSS / OST 객체 저장 서버와 타깃 파일의 실제 내용을 조각으로 담는다 용량, 읽기와 쓰기 대역폭
LNet 네트워크 계층 InfiniBand나 이더넷 위에서 위 셋을 잇는다 경로 이중화, 라우팅

클라이언트는 MDS에 파일 위치를 물은 뒤 OSS와 직접 데이터를 주고받는다

파일을 읽을 때 벌어지는 일을 순서대로 보면 구조가 왜 이런지 이해된다.

  1. 클라이언트가 open()을 부른다. 요청은 MDS로 간다.
  2. MDS는 이름을 찾아 권한을 확인하고, 그 파일의 배치 정보를 돌려준다. 배치 정보에는 조각이 몇 개이고 각각 어느 OST에 있는지가 들어 있다.
  3. 이후의 read()와 write()는 MDS를 거치지 않는다. 클라이언트가 해당 OSS들과 직접 주고받는다.

읽기와 쓰기가 MDS를 통과하지 않기 때문에 총 대역폭이 OSS 수에 비례해 늘어난다. 반대로 파일을 여는 동작은 전부 MDS를 지나므로, 작은 파일을 많이 여는 워크로드에서는 OSS를 아무리 늘려도 빨라지지 않는다. 용량과 대역폭은 OST로 늘리고, 파일 개수와 열기 성능은 MDT로 늘린다. 두 축이 따로 놀기 때문에 한쪽만 늘리면 다른 쪽이 병목으로 남는다.

일관성은 분산 락으로 지킨다. 여러 클라이언트가 같은 파일 영역을 동시에 쓰면 락 경합이 생기고, 심하면 대역폭보다 락이 먼저 병목이 된다. 프로세스마다 다른 파일에 쓰는 방식이 한 파일에 같이 쓰는 방식보다 대개 빠른 이유가 여기 있다.

GitHub에서 열기

스트라이핑

파일 하나를 여러 OST에 나눠 저장하는 것이 스트라이핑이다. 조각 크기만큼 잘라 정해진 개수의 OST에 번갈아 배치한다.

파일을 조각 크기 단위로 잘라 여러 OST에 번갈아 저장한다

lfs getstripe /scratch/dataset/train.bin      # 현재 설정과 조각이 놓인 OST
lfs setstripe -c 8 -S 4M /scratch/bigfiles    # 조각 8개, 조각 크기 4MB
lfs setstripe -c 1 /scratch/smallfiles        # 작은 파일은 한 곳에 둔다
lfs setstripe -c -1 /scratch/huge             # 모든 OST에 펼친다

설정은 파일을 만드는 시점에 정해지고 이미 있는 파일에는 적용되지 않는다. 디렉터리에 걸어 두면 그 아래 새로 만드는 파일이 물려받는다. 기존 파일의 배치를 바꾸려면 옮겨 써야 한다.

lfs migrate -c 8 /scratch/dataset/train.bin   # 배치를 바꾸며 다시 쓴다

조각 수는 파일 크기에 맞춰 정한다. 기준은 조각 하나가 최소 수백 MB는 되게 하는 것이다.

파일 크기 조각 수 이유
1 GB 미만 1 나눠도 조각이 작아 이득이 없고 접속만 늘어난다
1~10 GB 4 조각당 수백 MB로 여러 OSS를 동시에 쓴다
10~100 GB 8~16 단일 파일 대역폭이 필요한 구간이다
100 GB 이상 -1 전체 OST에 펼친다

요즘 Lustre는 크기에 따라 배치를 다르게 하는 방식을 지원한다. 한 번 걸어 두면 작은 파일과 큰 파일을 따로 관리할 필요가 없어진다.

# 처음 1MB는 조각 1개, 1GB까지는 4개, 그 이상은 16개로 늘린다
lfs setstripe -E 1M -c 1 -E 1G -c 4 -E -1 -c 16 /scratch/mixed

아주 작은 파일은 아예 MDT에 본문까지 넣을 수 있다. OST 접속을 열지 않으므로 열기와 읽기가 한 번에 끝난다.

lfs setstripe -E 64K -L mdt -E -1 -c 4 /scratch/manysmall

GitHub에서 열기

GPU 클러스터에서의 데이터 경로

학습 중 데이터는 네 구간을 지난다. 뒤로 갈수록 빨라지고, 맨 앞이 가장 느리다.

공유 스토리지에서 로컬 NVMe, 페이지 캐시, GPU 메모리로 갈수록 대역폭이 커진다

필요한 대역폭은 계산할 수 있다. GPU 한 장이 초당 처리하는 샘플 수에 샘플 하나의 크기를 곱하면 된다. 이미지 학습에서 GPU 한 장이 초당 2,000장을 처리하고 평균 파일 크기가 120 KB라면 GPU당 약 240 MB/s, 8장이 달린 노드는 약 1.9 GB/s가 필요하다. 노드 64대면 총 123 GB/s다. 이 값이 공유 스토리지가 감당해야 하는 규모다.

토큰으로 학습하는 언어 모델은 사정이 다르다. 전처리된 토큰은 샘플당 수 KB 수준이라 같은 연산량에서도 필요한 대역폭이 훨씬 작다. 반면 체크포인트 쓰기는 훨씬 무겁다. 워크로드에 따라 병목이 읽기 쪽인지 쓰기 쪽인지가 갈린다.

이 계산에서 세 가지가 따라 나온다.

  • 노드당 필요한 대역폭이 인터커넥트 한 포트로 감당되는지 확인해야 한다. 200Gb IB 한 포트는 이론상 25 GB/s이고 실효는 그보다 낮다.
  • 총 요구량을 OSS 수로 나눠 서버당 부하를 본다. 서버 한 대가 낼 수 있는 값을 넘으면 OSS를 늘려야 한다.
  • 같은 데이터를 여러 에폭 반복해 읽는다면 첫 에폭에만 공유 스토리지를 쓰고 이후에는 노드 로컬 NVMe에서 읽는 편이 낫다. 데이터셋이 로컬 디스크에 들어가는 크기라면 효과가 크다.
# 첫 에폭 전에 노드마다 자기 몫을 로컬로 내려받는다
srun --ntasks-per-node=1 bash -c '
  mkdir -p /local/nvme/data
  rsync -a --info=progress2 /scratch/dataset/shard-$SLURM_NODEID-*.tar /local/nvme/data/'

GitHub에서 열기

노드 수백 대가 한꺼번에 붙을 때

Lustre 클라이언트는 따로 세우는 서버가 아니라 컴퓨트 노드마다 올라가는 커널 모듈이다. GPU 노드 64대로 학습을 돌리면 클라이언트도 64개이고, 그 64개가 같은 MDS와 같은 OST를 동시에 두드린다. 실제로 요청을 만드는 주체는 노드보다 훨씬 많다.

층 개수 근거
노드 64 작업이 잡은 노드 수
랭크(프로세스) 512 노드당 GPU 8장, GPU 하나에 프로세스 하나
데이터 로더 워커 4,096 랭크당 8개
동시에 열려 있는 파일 4,096 이상 워커마다 최소 하나

랭크는 분산 학습에서 프로세스마다 붙는 전역 번호다. 파일을 여는 요청은 이 4,096개 주체에서 각각 나오고, 전부 MDS 한 대로 모인다.

시작하는 순간이 가장 위험하다. 512개 랭크가 같은 초에 파이썬을 켠다. import torch 한 줄이 파일 수천 개를 연다. 라이브러리가 공유 파일시스템에 설치돼 있으면 512 곱하기 수천 건의 메타데이터 요청이 몇 초 안에 MDS로 몰린다. 대역폭 그래프는 0에 붙어 있는데 작업이 몇 분째 시작되지 않는 상태가 이것이다. 원인을 대역폭에서 찾으면 아무것도 나오지 않는다.

# 작업을 띄운 직후 메타데이터 요청이 몰리는지 확인한다
lctl get_param mdc.*.stats | grep -E 'open|getattr|statfs'

대응은 파이썬 환경을 공유 파일시스템에서 빼는 것이다.

  • 컨테이너 이미지에 환경을 담는다. Enroot는 이미지를 노드 로컬에 풀어 두므로 import가 로컬 디스크에서 일어나고 MDS에 요청이 가지 않는다.
  • 컨테이너를 쓰지 않는다면 작업 시작 시 환경을 노드 로컬로 복사하고 그쪽에서 실행한다.
  • 둘 다 어려우면 랭크 시작을 몇 초씩 흩는다. 증상은 줄지만 원인은 남는다.

읽는 파일이 겹치는지가 속도를 가른다. 랭크마다 자기 샤드를 읽으면 파일이 겹치지 않고, 파일이 많으니 OST 전체에 저절로 흩어진다. 반대로 모두가 큰 파일 하나를 읽는 구성에서는 그 파일의 조각 수가 곧 동원되는 OST 수다. 조각 수가 1이면 512개 랭크가 서버 한 대에 붙는다.

랭크마다 자기 파일을 읽으면 OST 전체에 흩어지고, 조각 수 1인 큰 파일 하나를 함께 읽으면 서버 한 대로 몰린다

lfs getstripe /scratch/dataset/tokens.bin   # 이 파일이 실제로 몇 대에 걸쳐 있는지
lfs setstripe -c 16 /scratch/dataset        # 앞으로 만들 파일을 16대에 나눠 둔다

읽기 락은 서로 막지 않는다. 여러 클라이언트가 같은 영역을 읽는 것은 공유 락이라 경합이 없다. 512개 랭크가 같은 파일의 다른 구간을 읽는 구성 자체는 문제가 아니다. 문제는 쓰기다. 두 랭크가 같은 파일의 같은 영역에 쓰면 락이 오가면서 대역폭보다 먼저 병목이 된다. 체크포인트를 랭크마다 다른 파일에 쓰라는 권고가 여기서 나온다.

GitHub에서 열기

LLM 학습에서 오가는 트래픽

학습이 도는 동안 노드와 Lustre 사이에는 다섯 종류의 트래픽이 오간다. 방향도 양도 빈도도 전부 다르다.

학습 시작 시 체크포인트 읽기, 매 스텝 토큰 읽기, 주기적 체크포인트 쓰기, 장애 후 되읽기, 계속되는 로그 쓰기가 방향별로 오간다

시점 방향 무엇 양
시작할 때 한 번 스토리지에서 노드로 체크포인트, 토크나이저, 설정 980 GB
매 스텝 스토리지에서 노드로 토큰 샤드 초당 1.6 MB
30분마다 노드에서 스토리지로 체크포인트 980 GB를 49초에
노드가 빠진 뒤 스토리지에서 노드로 마지막 체크포인트 980 GB
학습 내내 노드에서 스토리지로 로그, 지표, 이벤트 파일 양은 작고 여는 횟수가 많다

토큰 읽기는 생각보다 훨씬 작다. 계산해 보면 이유가 드러난다.

  • 토큰 하나를 학습하는 데 드는 연산은 파라미터 수의 6배 안팎이다. 70B 모델이면 토큰당 4.2 곱하기 10의 11승 FLOP이다.
  • H100 한 장이 bf16으로 실효 400 TFLOP/s를 낸다면 초당 950토큰 정도를 처리한다. 활성값 재계산을 켜면 700토큰 안팎으로 내려간다.
  • GPU 512장이면 초당 40만 토큰이다.
  • 전처리된 토큰은 정수 하나로 저장한다. 어휘가 65,536개 이하면 2바이트, 넘으면 4바이트다.
  • 40만 곱하기 4바이트는 초당 1.6 MB다.

클러스터 전체가 초당 1.6 MB를 읽는다. 노트북 한 대가 감당하는 양이다. 같은 문서 앞부분에서 계산한 이미지 워크로드는 같은 규모에서 123 GB/s를 요구했다. 샘플 하나가 120 KB짜리 JPEG이냐 몇 KB짜리 정수 배열이냐의 차이가 여기서 갈린다.

워크로드 샘플 하나 클러스터 전체 읽기 먼저 막히는 곳
이미지 분류 120 KB JPEG 123 GB/s 읽기 대역폭과 메타데이터
토큰 사전학습 수 KB 정수 배열 1.6 MB/s 체크포인트 쓰기
멀티모달 둘이 섞인다 수십 GB/s 양쪽 다

LLM 사전학습만 놓고 보면 Lustre로 가는 트래픽의 거의 전부가 체크포인트다. 데이터셋 읽기를 기준으로 스토리지를 설계하면 규모를 크게 잘못 잡는다. 파인튜닝이나 멀티모달로 넘어가면 다시 읽기가 커지므로 워크로드부터 확인해야 한다.

기울기를 합치는 트래픽은 스토리지를 지나지 않는다. 매 스텝 모든 랭크가 계산한 기울기를 서로 더한다. 70B 모델의 bf16 기울기는 140 GB이고, 링 방식 all-reduce는 그 두 배 가까운 양을 실어 나른다. 스텝마다 280 GB가 노드 사이를 오간다. 이 트래픽은 InfiniBand 위에서만 돌고 MDS도 OST도 거치지 않는다. 스토리지 지표가 조용한데 네트워크가 포화라면 여기를 보고 있는 것이다.

한 가지 예외가 패브릭을 공유하는 구성이다. 스토리지도 같은 InfiniBand를 쓴다면 체크포인트를 쓰는 49초 동안 all-reduce와 대역폭을 다툰다. 저장이 끝난 뒤에도 몇 스텝 동안 스텝 시간이 길어지는 현상이 이것이다. 스토리지 트래픽용 레일을 따로 두거나 QoS로 나누면 갈라진다.

GitHub에서 열기

시간축에서 본 스토리지 부하

토큰 읽기는 바닥에 깔리고 체크포인트 쓰기만 주기적으로 상한까지 치솟는다

부하의 모양이 계획을 정한다. 평균으로 잡으면 틀린다. 980 GB를 1,800초로 나눈 평균은 0.54 GB/s 이지만, 실제로는 49초 동안 20 GB/s를 요구하고 나머지 시간에는 거의 놀린다. 40배 차이다. 스토리지 대역폭은 평균이 아니라 이 봉우리에 맞춰 잡는다.

저장하는 동안 계산이 멈춘다면 그 시간은 그대로 손실이다. 간격을 늘리면 정지는 줄지만 장애가 났을 때 되돌아가는 양이 늘어난다.

저장 간격 시간당 정지 정지 비율 장애 시 평균 손실
10분 294초 8.2% 5분
30분 98초 2.7% 15분
60분 49초 1.4% 30분
120분 24.5초 0.7% 60분

두 손실의 합이 가장 작아지는 지점이 있다. 저장에 걸리는 시간과 평균 무장애 시간만 알면 계산된다. 최적 간격은 저장 시간과 평균 무장애 시간을 곱해 2를 곱한 값의 제곱근이다.

평균 무장애 시간 최적 저장 간격
6시간 24분
12시간 34분
24시간 49분
72시간 84분

저장에 49초가 걸리고 하루에 한 번 노드가 빠지는 클러스터라면 49분 근처가 답이다. 노드가 자주 빠질수록 자주 저장해야 한다. 무장애 시간은 짐작하지 말고 작업 재시작 기록에서 뽑는다.

동시에 도는 작업이 여럿이면 봉우리가 겹치는 문제가 하나 더 생긴다. 20 GB/s짜리 스토리지에서 작업 네 개가 같은 순간에 저장하면 각자 5 GB/s로 떨어지고 정지 시간이 네 배가 된다. 저장 시각을 작업마다 흩거나, 간격에 약간의 무작위를 섞는다.

import random

# 작업마다 간격을 조금씩 어긋나게 두어 저장이 겹치지 않게 한다
interval = 2900 + random.randint(-300, 300)

용량도 봉우리 기준으로 잡는다. 체크포인트 하나가 980 GB이고 최근 세 개를 남긴다면 작업 하나에 2.9 TB다. 실험 다섯 개를 동시에 돌리면 15 TB가 상시 점유된다. 보존 개수를 정해 두지 않으면 이 값이 무한히 늘어난다.

GitHub에서 열기

체크포인트가 만드는 부하

체크포인트는 짧은 시간에 대량으로 쓴다. 크기는 모델 파라미터 수에서 계산한다. 파라미터 하나에 가중치 2바이트(bf16), 옵티마이저 상태 8바이트(Adam의 1차·2차 모멘트를 fp32로), 마스터 가중치 4바이트를 잡으면 파라미터당 14바이트 안팎이다.

모델 크기 가중치만 옵티마이저까지 포함한 전체 상태
7B 약 14 GB 약 98 GB
13B 약 26 GB 약 182 GB
70B 약 140 GB 약 980 GB

70B 모델의 전체 상태를 저장하면 1 TB에 가깝다. 스토리지가 20 GB/s를 낸다 해도 50초가 걸리고, 그동안 계산이 멈춘다면 그대로 손실이다. 대응은 세 가지다.

  • 분산 저장. ZeRO나 FSDP로 상태를 랭크마다 나눠 갖고 있다면 각자 자기 몫만 쓴다. 파일이 랭크 수만큼 생기므로 조각 수는 1로 두고 파일을 흩는 편이 낫다.
  • 비동기 저장. GPU 메모리에서 호스트 메모리로 먼저 복사하고, 디스크 쓰기는 백그라운드로 넘긴다. 계산은 복사가 끝나는 즉시 재개한다.
  • 간격 조정. 한 번 저장하는 데 걸리는 시간의 20배에서 50배 사이를 간격으로 잡는다. 저장에 1분이 걸린다면 20분에서 50분 간격이 출발점이다.

보존 개수도 정해 둔다. 최근 두세 개만 남기고 지우지 않으면 체크포인트가 파일시스템을 채운다. 용량 경보가 울리는 원인 중에 이것이 흔하다.

512개 파일이 같은 순간에 만들어진다. 랭크마다 자기 몫을 쓰면 파일 개수도 랭크 수만큼이다. 그 파일이 전부 한 디렉터리에 생기면 디렉터리를 담당하는 MDT 한 대가 512건의 생성 요청을 한꺼번에 받는다. 쓰기 대역폭은 남는데 파일이 만들어지지 않아 저장이 늘어지는 상황이 여기서 나온다. 디렉터리를 여러 MDT에 나눠 두면 갈라진다.

# 체크포인트 디렉터리를 MDT 4대에 나눈다. 만들 때 한 번만 하면 된다
lfs mkdir -c 4 /scratch/run-42/checkpoints
lfs getdirstripe /scratch/run-42/checkpoints

조각 수는 파일 하나에 1로 둔다. 파일이 512개나 되므로 파일 단위로 이미 흩어진다. 여기서 조각 수를 크게 잡으면 파일 하나가 여러 OST에 걸치면서 512 곱하기 조각 수만큼의 연결이 생기고, 서버마다 작은 쓰기가 잘게 쏟아져 오히려 느려진다.

lfs setstripe -c 1 /scratch/run-42/checkpoints

PyTorch는 이 형태의 저장을 표준으로 제공한다. 랭크마다 자기 샤드를 자기 파일에 쓰고, 불러올 때 다시 모은다.

import torch.distributed.checkpoint as dcp

# 랭크마다 자기 몫만 쓴다. 파일이 랭크 수만큼 생긴다
dcp.save(state_dict, checkpoint_id=f"/scratch/run-42/checkpoints/step-{step}")

# 되돌릴 때. world_size 가 달라져도 다시 나눠 읽는다
dcp.load(state_dict, checkpoint_id=f"/scratch/run-42/checkpoints/step-{last}")

저장이 끝났는지 확인하는 절차도 필요하다. 모든 랭크가 쓰기를 마치기 전에 작업이 죽으면 반쯤 쓰인 체크포인트가 남는다. 불러오기가 실패하는 것보다, 조용히 깨진 값을 읽는 쪽이 더 나쁘다. 마지막에 완료 표시 파일을 하나 남기고 불러올 때 그 파일이 있는 체크포인트만 쓴다.

if rank == 0:
    (path / "COMPLETE").write_text(str(step))     # 전원이 끝난 뒤에만 남긴다

GitHub에서 열기

메타데이터가 병목일 때

대역폭은 남는데 느린 경우가 있다. 대개 메타데이터 쪽이다. 한 디렉터리에 파일 수십만 개를 두거나, 학습 스텝마다 작은 파일 수천 개를 여는 패턴이 원인이다.

lfs df -h                          # OST별 용량과 사용률
lfs df -i                          # inode 사용률. 용량보다 먼저 차는 일이 흔하다
lctl get_param mdt.*.md_stats      # 메타데이터 요청 종류별 횟수
lctl get_param llite.*.stats       # 클라이언트 쪽에서 본 통계

해결은 대개 파일시스템 설정이 아니라 데이터 배치 쪽에 있다.

작은 파일을 묶는다. 이미지 수백만 장을 그대로 두지 말고 수백 MB 단위 묶음으로 만든다. WebDataset의 tar 샤드나 TFRecord가 이 목적이다. 파일 500만 개가 샤드 500개로 줄면 열기 요청도 그만큼 줄어든다. 순차 읽기가 되므로 대역폭도 잘 나온다.

# 이미지 디렉터리를 샤드로 묶는다
tar --sort=name -cf shard-000.tar $(ls images | head -10000)

디렉터리를 나눈다. 한 디렉터리의 파일 수를 만 단위 아래로 유지한다. 해시 앞 두 자리로 하위 디렉터리를 만드는 방식이 흔하다.

메타데이터를 여러 MDT에 흩는다. 디렉터리 단위로 MDT를 나눌 수 있다.

lfs mkdir -i -1 -c 4 /scratch/many-files    # 하위 디렉터리를 MDT 4개에 분산한다
lfs getdirstripe /scratch/many-files        # 현재 분산 상태

GitHub에서 열기

클라이언트 쪽 조정

서버가 멀쩡한데 특정 노드만 느리면 클라이언트 설정을 본다.

lctl get_param osc.*.max_rpcs_in_flight     # 동시에 날리는 요청 수, 기본 8
lctl get_param osc.*.max_dirty_mb           # 쓰기 버퍼 크기
lctl get_param llite.*.max_read_ahead_mb    # 미리 읽기
lctl set_param osc.*.max_rpcs_in_flight=16  # 대역폭이 남으면 늘려 본다

큰 파일을 순차로 읽는 워크로드는 요청 수와 미리 읽기를 늘리면 효과가 있다. 작은 파일이 많은 워크로드는 늘려도 소용이 없고 메모리만 쓴다.

체크섬은 CPU를 쓴다. 신뢰할 수 있는 네트워크에서 대역폭이 아쉬우면 끄는 선택지가 있다. 데이터 무결성과 맞바꾸는 결정이라 정책으로 정해 둔다.

lctl get_param osc.*.checksums

네트워크 계층은 따로 확인한다.

lnetctl net show                 # LNet 인터페이스
lctl ping <서버 NID>             # 서버까지 닿는지
lfs check servers                # MDS와 OST 연결 상태

GitHub에서 열기

운영에서 만나는 상황

OST 하나만 가득 찬다. 배치가 편중된 경우다. 남은 공간이 적은 OST는 새 파일 배치에서 빠지지만, 이미 그 위에 있는 큰 파일은 그대로다. 파일을 옮겨 균형을 맞춘다.

lfs df -h | sort -k5 -h                     # 사용률 순으로 본다
lfs find /scratch --ost 3 -size +10G        # 특정 OST의 큰 파일을 찾는다
lfs migrate -c 8 <파일>                      # 다시 배치한다

클라이언트가 축출된다. 서버가 응답 없는 클라이언트를 끊는 동작이다. dmesg에 evicted가 남고 그 노드의 작업은 I/O 오류로 죽는다. 원인은 대개 네트워크나 노드 부하다. 해당 노드를 드레인하고 LNet 상태부터 본다.

용량은 남는데 쓸 수 없다. inode가 찼거나 쿼터에 걸린 것이다.

lfs quota -u $USER /scratch
lfs quota -g team-ml /scratch

특정 노드만 느리다. 마운트 상태, LNet 경로, 클라이언트 설정을 차례로 본다. 같은 파일을 다른 노드에서 읽어 비교하면 노드 문제인지 파일시스템 문제인지 바로 갈린다.

GitHub에서 열기

다른 선택지

파일시스템 성격 맞는 곳
Lustre 대역폭 확장이 강하고 사례가 많다. 운영 난도가 있다 대규모 HPC, 큰 파일 위주
GPFS (Storage Scale) 기능과 관리 도구가 갖춰져 있다. 라이선스 비용이 든다 기업 환경, 혼합 워크로드
BeeGFS 설치와 운영이 상대적으로 쉽다 중간 규모
WekaIO NVMe 기반 상용 제품. 작은 파일과 메타데이터에 강하다 작은 파일이 많은 학습
객체 스토리지 S3 호환. 용량 비용이 낮고 POSIX가 없다 데이터 레이크, 아카이브

학습 데이터를 객체 스토리지에 두고 노드 로컬 디스크로 당겨 쓰는 구성이 늘고 있다. 공유 파일시스템의 메타데이터 부하를 피하면서 용량 비용을 낮출 수 있어서다. 대신 첫 접근 지연과 캐시 관리라는 문제가 새로 생긴다.

고르는 순서는 워크로드에서 시작한다. 파일이 크고 순차로 읽는지, 작고 많은지가 첫 갈림길이다.

확인할 것 그렇다면
파일 하나가 수백 MB 이상이고 순차로 읽는다 Lustre나 GPFS가 잘 맞는다
파일이 수백만 개이고 하나가 수백 KB다 먼저 샤드로 묶는다. 그래도 남으면 WekaIO 같은 제품을 본다
체크포인트 쓰기가 부하의 대부분이다 쓰기 대역폭 기준으로 OSS 수를 잡는다
같은 데이터를 여러 번 읽는다 노드 로컬 NVMe 캐시를 먼저 둔다. 공유 계층은 그 뒤다
운영 인력이 한두 명이다 Lustre는 부담이 크다. 관리형 서비스나 BeeGFS를 본다

용량과 대역폭은 따로 잡는다는 점을 기억한다. 500 TB가 필요하다고 500 TB짜리를 사면 대역폭이 모자라고, 20 GB/s가 필요하다고 그 값만 보면 용량이 모자란다. 둘 중 큰 쪽이 서버 대수를 정한다.

GitHub에서 열기

객체 스토리지를 계층으로 두기

병렬 파일시스템은 비싸다. 용량 단가로 보면 객체 스토리지가 몇 배 싸기 때문에, 원본 데이터는 객체 스토리지에 두고 학습에 쓰는 것만 병렬 파일시스템으로 올리는 계층 구성이 늘고 있다.

계층 무엇을 두나 성격
객체 스토리지 원본 데이터, 지난 체크포인트, 결과물 용량이 싸다. POSIX가 아니다
병렬 파일시스템 지금 도는 학습이 읽는 데이터, 최근 체크포인트 빠르다. 비싸다
노드 로컬 NVMe 반복해 읽는 샤드 가장 빠르다. 작업이 끝나면 사라진다

자체 구축에서 객체 스토리지를 올리는 방법은 두 갈래다. MinIO는 설치가 가볍고 S3 규약을 그대로 따르므로 작은 규모에서 빠르게 세운다. Ceph는 객체와 블록과 파일을 함께 제공하고 RADOS Gateway(RGW)가 S3 창구 역할을 한다. 운영 난도는 높지만 규모가 커질수록 유리하다.

# 객체 스토리지에서 학습 데이터를 병렬 파일시스템으로 올린다
mc alias set store https://s3.internal ACCESS SECRET
mc mirror --overwrite store/datasets/imagenet-shards /scratch/dataset/

# 끝난 실험의 체크포인트를 내려 용량을 비운다
mc mv --recursive /scratch/run-41/checkpoints store/archive/run-41/

객체 스토리지는 POSIX가 아니라는 점이 중요하다. 파일을 부분만 고쳐 쓸 수 없고, 디렉터리도 실제로는 이름 앞부분일 뿐이다. 학습 코드가 open()과 seek()을 그대로 쓰고 있다면 객체 스토리지를 직접 읽게 만들 수 없다. 세 가지 선택지가 있다.

  • 미리 내려받는다. 작업 시작 시 필요한 샤드만 로컬이나 공유 파일시스템으로 옮긴다. 가장 단순하고 성능이 예측된다. 데이터가 크면 시작이 오래 걸린다.
  • 읽기 라이브러리를 쓴다. WebDataset이나 각 프레임워크의 S3 데이터셋 구현이 객체를 스트림 으로 읽는다. 코드를 고쳐야 하지만 내려받기가 사라진다.
  • 파일시스템처럼 붙인다. s3fs 같은 도구로 마운트한다. 코드를 안 고쳐도 되지만 메타데이터 요청마다 네트워크를 타므로 작은 파일이 많으면 느리다. 학습 데이터 경로에는 권하지 않는다.
# 객체 스토리지의 샤드를 스트림으로 읽는 구성
import webdataset as wds

url = "pipe:mc cat store/datasets/imagenet-{000000..000999}.tar"
dataset = wds.WebDataset(url, shardshuffle=True).decode("pil")

계층 사이를 옮기는 일은 정책으로 자동화한다. 마지막 접근이 30일을 넘긴 데이터셋과 두 세대 이전 체크포인트를 객체 스토리지로 내리는 규칙을 두면 병렬 파일시스템 용량 경보가 크게 준다.

# 30일 이상 손대지 않은 데이터셋을 찾아 내린다
lfs find /scratch/dataset -atime +30 -type d -maxdepth 1

GitHub에서 열기

컨테이너에서 공유 스토리지 붙이기

Kubernetes 쪽 워크로드도 같은 데이터를 봐야 한다. 노드에 이미 마운트된 경로를 그대로 붙이는 방식이 가장 단순하다.

volumes:
  - name: scratch
    hostPath: { path: /scratch, type: Directory }

단순한 대신 파드가 노드의 마운트 상태에 그대로 묶인다. 마운트가 빠진 노드에 파드가 뜨면 빈 디렉터리를 보고 조용히 잘못된 결과를 낸다. 규모가 커지면 CSI 드라이버로 정식 볼륨으로 다루는 편이 낫다. 볼륨이 준비되지 않으면 파드가 아예 뜨지 않으므로 조용한 실패가 사라진다.

apiVersion: v1
kind: PersistentVolume
metadata:
  name: scratch-lustre
spec:
  capacity: { storage: 500Ti }
  accessModes: [ReadWriteMany]
  persistentVolumeReclaimPolicy: Retain
  csi:
    driver: lustre.csi.example.com
    volumeHandle: scratch
    volumeAttributes:
      mgsAddress: 10.0.0.10@o2ib
      fsName: scratch
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: scratch
spec:
  accessModes: [ReadWriteMany]
  storageClassName: ""
  volumeName: scratch-lustre
  resources:
    requests: { storage: 500Ti }
방식 좋은 점 나쁜 점
hostPath 설정이 없다. 성능 손실도 없다 마운트 누락을 파드가 모른다. 권한 통제가 없다
CSI 정적 볼륨 볼륨이 없으면 파드가 안 뜬다. 접근 제어가 붙는다 드라이버 설치와 관리가 필요하다
CSI 동적 프로비저닝 사용자가 알아서 잡는다 병렬 파일시스템에서는 잘 쓰지 않는다
객체 스토리지 직접 스토리지 계층이 필요 없다 POSIX가 아니라 코드를 고쳐야 한다

접근 권한은 파일시스템의 uid로 매겨진다는 점을 다시 확인한다. CSI로 붙여도 파드가 root로 돌면 Slurm 쪽에서 만든 파일과 권한이 어긋난다. securityContext에 실제 uid를 지정해야 양쪽이 같은 파일을 다룬다.

GitHub에서 열기

성능 재기

체감으로 판단하지 말고 도구로 잰다. 대역폭과 메타데이터를 따로 재야 어디가 막혔는지 갈린다.

# 대역폭. 노드 여러 대에서 동시에 돌려야 실제 값이 나온다
mpirun -np 32 ior -a POSIX -b 4g -t 4m -F -e -o /scratch/iortest

# 한 파일에 함께 쓰는 방식 (-F 를 뺀다). 락 경합이 드러난다
mpirun -np 32 ior -a POSIX -b 4g -t 4m -e -o /scratch/iortest-shared

# 메타데이터. 파일 생성과 조회 성능
mpirun -np 32 mdtest -n 10000 -u -d /scratch/mdtest

# 단일 노드에서 빠르게 확인할 때
fio --name=read --rw=read --bs=1m --size=8g --numjobs=8 --directory=/scratch

-e는 쓰기 후 동기화를 강제해 페이지 캐시 효과를 걷어낸다. 이것을 빼면 메모리 속도를 재게 되어 값이 부풀려진다.

측정은 노드 수를 늘려가며 기록한다. 노드를 두 배로 늘렸을 때 총 대역폭이 두 배 가까이 늘면 그 구간까지는 서버가 여유가 있다는 뜻이다. 어느 지점부터 총량이 늘지 않으면 거기가 서버 쪽 상한이고, 그 이상 노드를 붙여도 각자 몫만 줄어든다.

GitHub에서 열기

학습 워크로드에서 자주 겪는 일

증상 확인할 것 대개의 원인
첫 에폭만 느리다 페이지 캐시 적중률 정상이다. 데이터가 메모리보다 크면 계속 느리다
GPU 사용률이 톱니처럼 오르내린다 데이터 로더 워커 수, 샤드 크기 읽기가 계산을 못 따라간다
체크포인트 때마다 멈춘다 저장 방식과 간격 동기 저장으로 전체가 대기한다
특정 랭크만 느리다 그 노드의 마운트와 LNet 노드 개별 문제
파일 목록 조회가 오래 걸린다 디렉터리당 파일 수 메타데이터 부하
용량이 갑자기 찬다 체크포인트 보존 개수 오래된 체크포인트가 쌓였다

증상만으로 원인을 정하지 말고 순서대로 좁힌다. 스토리지 문제인지 아닌지부터 가른 뒤 안으로 들어간다.

# 1. 스토리지가 문제인지부터 가린다. 데이터를 로컬에서 읽어 같은 학습을 짧게 돌린다
srun -N1 --gres=gpu:8 python train.py --data /local/nvme/data --max-steps 50

# 2. 스토리지 쪽이라면 대역폭인지 메타데이터인지 가린다
lfs df -h                                   # OST 사용률 편중
lctl get_param mdc.*.stats | grep -E 'open|getattr'
lctl get_param llite.*.stats | grep -E 'read_bytes|write_bytes'

# 3. 특정 노드만 느린지 확인한다. 같은 파일을 노드마다 읽어 비교한다
srun -N8 --ntasks-per-node=1 bash -c \
  'echo -n "$(hostname) "; dd if=/scratch/dataset/shard-000.tar of=/dev/null bs=1M count=4096 2>&1 | tail -1'

1번에서 빨라지면 공유 스토리지가 원인이고, 그대로면 스토리지가 아니라 계산이나 통신 쪽이다. 이 한 번의 비교로 조사 범위가 절반으로 준다.

데이터 로더가 얼마나 기다리는지는 학습 코드에서 직접 재는 편이 정확하다. 배치를 꺼내는 데 걸린 시간을 스텝 시간과 함께 남기면 읽기가 계산을 따라오는지 바로 보인다.

import time

wait = 0.0
tick = time.perf_counter()
for batch in loader:
    wait += time.perf_counter() - tick        # 배치를 기다린 시간
    train_step(batch)
    tick = time.perf_counter()

print(f"데이터 대기가 전체의 {wait / total * 100:.1f}%")

이 값이 10퍼센트를 넘으면 읽기 쪽을 손볼 값어치가 있다. 워커 수를 늘리고, 샤드를 키우고, 반복해 읽는 데이터는 로컬 NVMe로 내린다. 5퍼센트 아래면 스토리지를 더 손봐도 학습 속도는 거의 그대로다.

GitHub에서 열기

06

스케줄링심화

토폴로지 인지 배치, 공정성, 하이브리드 운영

대기열이 길어질수록 스케줄링 정책이 곧 성능이자 비용이 된다. 자원을 어떤 기준으로 나누는지, 그 결정이 대기 시간과 활용률에 어떻게 돌아오는지를 본다.

토폴로지 인지 배치

GPU 8장이 한 노드에 있다고 해서 서로 같은 거리에 있는 것은 아니다. NVLink로 직접 붙은 쌍이 있고, PCIe 스위치를 거쳐야 하는 쌍이 있다. 4장을 쓰는 작업에 어느 4장을 주느냐에 따라 집합 통신 성능이 달라진다.

nvidia-smi topo -m

출력에 찍히는 기호가 곧 거리다.

기호 의미 상대 성능
NV# NVLink로 직접 연결. 숫자는 링크 수다 가장 빠르다
PIX 같은 PCIe 스위치 아래 빠르다
PXB PCIe 스위치 여러 개를 거친다 보통이다
SYS 소켓을 넘어 QPI/UPI를 거친다 가장 느리다

Slurm은 노드 안의 GPU 배치까지 이해하도록 설정할 수 있다. gres.conf에서 GPU마다 연결된 코어를 적어두면 스케줄러가 가까운 조합을 골라준다.

# gres.conf
Name=gpu File=/dev/nvidia0 Cores=0-15
Name=gpu File=/dev/nvidia1 Cores=0-15
Name=gpu File=/dev/nvidia4 Cores=64-79

노드 사이도 마찬가지다. topology.conf에 스위치 구성을 적으면 같은 리프 스위치 아래 노드를 우선 묶는다. 네트워크 홉이 줄어 allreduce 성능이 올라간다.

GitHub에서 열기

자원 파편화

활용률이 낮은 이유를 찾다 보면 자원이 없어서가 아니라 쓸 수 있는 모양으로 남아 있지 않아서인 경우가 많다. 작업이 들어오고 나가기를 반복하면 노드마다 GPU가 한두 장씩 흩어져 남는다. 전체로는 스무 장이 놀고 있는데 8장을 한 노드에서 요구하는 작업은 들어갈 자리가 없다.

호스트 1  [■■][□][■■■][□]      유휴 2장
호스트 2  [■■■][□][■■][□]      유휴 2장
호스트 3  [■][□][■■■■][□]      유휴 2장
          8장을 요구하는 작업은 어디에도 못 들어간다

대기열은 길어지는데 노드는 노는 상태가 이렇게 만들어진다. 할당률을 떨어뜨리는 요인 중에서 파편화가 가장 크다.

줄이는 방법은 셋이다.

  • 빈팩 배치. 노드를 골고루 채우는 대신 한 노드를 먼저 채운다. Kubernetes의 MostAllocated, Slurm의 CR_Pack_Nodes가 이 동작이다. 빈 노드가 통째로 남아 큰 작업이 들어갈 자리가 생긴다.
  • 크기별 구역 분리. 큰 작업만 받는 노드 묶음을 따로 둔다. 작은 작업이 그 구역을 잠식하지 못하게 하면 큰 작업의 대기 시간이 안정된다. 대신 그 구역의 유휴는 감수한다.
  • 정리 창구. 짧은 작업만 받는 시간대를 두거나, 선점 가능한 낮은 우선순위 작업으로 조각을 채운다. 큰 작업이 들어올 때 이들을 비우면 된다.

파편화가 얼마나 심한지는 유휴 GPU 수만 봐서는 모른다. 가장 큰 작업이 지금 들어갈 수 있는가를 같이 봐야 한다. 노드별 유휴 장수의 분포를 보면 판단이 선다. 유휴 20장이 노드 3대에 흩어져 있는 것과 노드 3대가 통째로 비어 있는 것은 전혀 다른 상태다.

GitHub에서 열기

대규모 학습 예약

노드 수십 대를 며칠씩 쓰는 학습은 일반 대기열에 그냥 넣으면 곤란해진다. 자원이 모일 때까지 계속 밀리거나, 반대로 시작하는 순간 다른 모든 작업이 멈춘다.

예약은 이 문제를 시간 축으로 푼다. 큰 실험에 대해 언제부터 언제까지 어느 구역을 쓸지 미리 잡아 두고, 그 구간에는 다른 작업이 들어오지 못하게 한다.

# 12월 1일 12시부터 사흘간 노드 16대를 team-ml 계정에 잡아 둔다
scontrol create reservation starttime=2026-12-01T12:00:00 duration=3-00:00:00 \
  nodes=gpu[01-16] accounts=team-ml reservationname=bigrun-a

scontrol show reservation                    # 예약 현황
sbatch --reservation=bigrun-a train.sh       # 예약 구간에 제출한다

얻는 것은 세 가지다. 언제 시작하는지 사용자가 알 수 있고, 운영자는 그 앞뒤로 일정을 짤 수 있고, 예약 구간이 끝나면 자원이 돌아온다는 보장이 생긴다. 백필과 함께 쓰면 예약 시작 전까지 짧은 작업으로 빈 구간을 채울 수 있다.

예약을 남발하면 반대로 활용률이 떨어진다. 예약해 놓고 쓰지 않는 구간이 통째로 노는 탓이다. 시작 후 일정 시간 안에 작업이 들어오지 않으면 예약을 푸는 규칙을 함께 두는 편이 낫다.

GitHub에서 열기

갱 스케줄링과 백필

분산 학습 작업은 요청한 노드가 전부 동시에 있어야 시작할 수 있다. 8노드 중 7개만 주면 아무 진전이 없이 자원만 점유한다. 갱 스케줄링은 전부 확보되기 전에는 시작하지 않는 방식이다.

문제는 큰 작업이 자원을 기다리는 동안 노드가 놀게 된다는 점이다. 백필이 이 빈틈을 메운다. 앞선 작업의 예상 시작 시각을 계산해두고, 그 전에 끝날 수 있는 짧은 작업을 먼저 밀어 넣는다.

대기 중인 큰 작업이 시작하기 전 빈 구간에 짧은 작업을 배치해 유휴를 줄인다

백필이 동작하려면 작업마다 실행 시간이 정확해야 한다. 모두가 최대 시간을 적으면 스케줄러가 빈틈을 계산할 수 없어 백필이 무력해진다. 사용자에게 실제에 가까운 --time을 적도록 안내하는 것이 활용률에 직접 영향을 준다.

# slurm.conf
SchedulerType=sched/backfill
SchedulerParameters=bf_window=4320,bf_resolution=300,bf_max_job_test=500

bf_window는 몇 분 앞까지 내다볼지, bf_max_job_test는 한 번에 몇 개를 검토할지 정한다. 값을 키우면 빈틈을 더 잘 찾지만 컨트롤러 부하가 늘어난다.

GitHub에서 열기

공정 배분과 우선순위

여러 팀이 한 클러스터를 쓰면 "많이 쓴 쪽이 잠시 뒤로 물러나는" 규칙이 필요하다. Slurm의 fairshare는 각 계정에 몫을 배정하고, 최근 사용량이 몫을 넘으면 우선순위를 낮춘다.

PriorityType=priority/multifactor
PriorityWeightFairshare=100000
PriorityWeightAge=10000
PriorityWeightPartition=10000
PriorityWeightJobSize=1000
PriorityDecayHalfLife=7-0        # 사용 이력은 7일 반감기로 잊는다

가중치의 절대값보다 비율이 중요하다. fairshare를 다른 요소보다 열 배 크게 두면 사용량 균형이 지배적으로 작동하고, age를 키우면 오래 기다린 작업이 앞서게 된다.

현재 상태는 다음 명령으로 본다.

sshare -a                        # 계정별 몫과 실제 사용량
sprio -l -u alice                # 대기 중인 작업의 우선순위 구성 요소

선점은 강한 도구다. 낮은 우선순위 작업을 중단시키고 자리를 비우게 하는데, 체크포인트가 없는 작업이 선점당하면 계산이 통째로 날아간다. 선점을 켤 때는 대상 파티션을 체크포인트가 있는 워크로드로 한정하는 편이 안전하다.

GitHub에서 열기

GPU 메모리와 전력 인지

GPU를 장수로만 세면 실제 제약을 놓친다. 80GB 카드 한 장을 요구하는 작업과 24GB면 되는 작업을 같게 다루면 활용률이 떨어진다.

Slurm에서는 GRES 타입을 나눠 표현한다.

# gres.conf
Name=gpu Type=a100_80g File=/dev/nvidia[0-3]
Name=gpu Type=a100_40g File=/dev/nvidia[4-7]
sbatch --gres=gpu:a100_40g:2 job.sh    # 필요한 만큼만 요청한다

전력도 제약이 된다. 랙 단위 전력 한도가 있으면 모든 노드가 동시에 최대 전력으로 도는 구성을 피해야 한다. 클럭 상한을 걸어 노드당 소비를 낮추는 방법이 있다.

nvidia-smi -pl 350           # 전력 상한을 350W로 건다
nvidia-smi -q -d POWER       # 현재 상한과 실제 소비

전력 상한을 낮추면 성능도 낮아지지만, 비율은 선형이 아니다. 상한을 20퍼센트 낮췄을 때 성능 저하가 그보다 작다면 랙에 노드를 더 넣는 편이 전체 처리량에서 유리하다.

측정은 같은 작업을 상한만 바꿔 돌려 비교한다. 상한마다 스텝 시간을 재고 실제 소비 전력을 함께 남긴다.

for cap in 700 600 500 400; do
  srun -N1 --gres=gpu:8 bash -c "
    nvidia-smi -pl $cap >/dev/null
    python train.py --max-steps 200 --report-step-time" | tail -1
done
전력 상한 스텝 시간 성능 비율 전력 비율 같은 전력에서의 처리량
700W 1.00초 100% 100% 1.00
600W 1.06초 94% 86% 1.10
500W 1.17초 85% 71% 1.19
400W 1.42초 70% 57% 1.23

마지막 열이 판단 기준이다. 랙 전력이 상한이고 노드를 더 넣을 여지가 있다면 이 값이 큰 쪽이 이득이다. 반대로 노드 수가 고정이고 한 작업을 빨리 끝내는 것이 목적이면 상한을 낮출 이유가 없다. 클러스터가 전력에 묶였는지 자리에 묶였는지부터 정해야 답이 나온다.

메모리 요구량은 사용자가 스스로 알기 어렵다. 지난 실행에서 실제로 얼마를 썼는지 보여 주면 과다 요청이 줄어든다.

# 끝난 작업이 실제로 쓴 GPU 메모리 최댓값
sacct -j 12345 --format=JobID,ReqTRES%40,MaxRSS,MaxVMSize
dcgmi stats --job 12345 --verbose | grep -i "memory"

과다 요청은 활용률을 갉아먹는 가장 흔한 원인이다. 80GB 카드를 잡고 12GB만 쓰는 작업이 대기열의 절반이면, 카드를 늘리는 것보다 요청을 바로잡는 편이 훨씬 싸다. 사용자에게 벌을 주기보다 지난 실행의 실제 사용량을 작업 종료 메일이나 대시보드에 자동으로 보여 주는 쪽이 효과가 있다.

GitHub에서 열기

Slurm과 Kubernetes를 함께 쓰기

학습은 Slurm으로, 추론 서비스는 Kubernetes로 운영하는 구성이 흔하다. 문제는 두 스케줄러가 같은 노드를 모르고 각자 배정할 때 생긴다. 같은 GPU를 둘 다 자기 것으로 알면 충돌한다.

현실에서 쓰는 방법은 셋이다.

방식 내용 적합한 상황
물리 분리 노드 풀을 아예 나눈다 가장 단순하고 사고가 없다. 활용률은 손해다
시간 분리 낮에는 추론, 밤에는 학습으로 노드를 옮긴다 수요 패턴이 뚜렷할 때
단일 스케줄러 한쪽으로 통일하고 다른 워크로드를 얹는다 운영 인력이 한 스택에 익숙할 때

동적으로 노드를 옮기는 구성은 매력적이지만 상태 전이 중 실패를 다루기가 까다롭다. 처음에는 물리 분리로 시작하고, 활용률 측정치가 분리 비용을 넘어설 때 옮기는 편이 안전하다.

분리 비용은 계산할 수 있다. 노드를 나누면 각 풀의 대기열이 짧아지는 대신, 한쪽이 놀 때 다른 쪽이 그 자원을 쓰지 못한다. 하루 단위로 두 풀의 유휴 GPU 시간을 각각 재고, 그 값이 서로 어긋나는 시간대가 있는지 본다. 학습 수요가 밤에 몰리고 추론 수요가 낮에 몰린다면 옮길 값어치가 있다. 두 수요의 성수기가 겹치면 옮겨도 얻는 것이 없고 이동 중 유휴 시간만 늘어난다.

# Slurm 풀의 유휴 GPU 시간을 하루 단위로 뽑는다
sreport cluster utilization start=2026-08-01 end=2026-08-08 -t hours

# Kubernetes 풀의 미할당 GPU 수를 같은 기간에 대해 본다
kubectl get nodes -l pool=inference -o json | \
  python -c "import json,sys; d=json.load(sys.stdin);
print(sum(int(n['status']['allocatable'].get('nvidia.com/gpu',0)) for n in d['items']))"

노드를 어떻게 나누고 무엇을 함께 쓰는지, 옮기는 절차를 어떻게 짜는지는 모듈 07의 공존 항목에 정리해 두었다.

GitHub에서 열기

학습과 추론을 함께 돌릴 때

같은 GPU를 쓰지만 학습과 추론은 요구가 정반대다. 이 차이를 모르고 한 대기열에 섞으면 둘 다 나빠진다.

항목 학습 추론
수명 몇 시간에서 며칠, 끝이 있다 상시 떠 있다, 끝이 없다
지연 요구 스텝 지연은 상관없고 처리량이 목표 요청당 응답 지연이 목표
자원 형태 여러 GPU를 한꺼번에, 통신이 촘촘 대개 소수 GPU, 요청 단위로 독립
부하 시작하면 꾸준히 높다 트래픽 따라 오르내린다
실패 처리 체크포인트에서 재시작 재시도하거나 다른 복제본으로

문제는 자원을 채우는 방향이 서로 반대라는 데서 나온다. 학습은 처리량이 목표라 GPU를 최대한 채워야 이득이고, 노는 시간이 곧 손해다. 추론은 응답 지연이 목표라 트래픽이 튈 여유를 늘 남겨 둬야 한다. 사용률 90%로 돌던 추론 서비스에 요청이 몰리면 큐가 쌓여 지연이 무너진다. 한쪽은 채우라 하고 다른 쪽은 비우라 하니, 한 자원 풀에서 두 목표를 같이 만족시킬 수 없다.

섞을 때 생기는 구체적 문제도 있다. 학습 작업이 GPU 메모리를 거의 다 잡으면 추론 복제본이 같은 카드에 못 들어간다. 학습의 집합 통신이 NVLink를 포화시키면 같은 노드의 추론 응답 지연이 튄다. 반대로 추론 트래픽이 몰려 카드를 오래 쥐면 학습 스텝이 밀린다. 서로의 꼬리 지연을 키우는 관계다.

분리 기준은 지연 보장이 필요한지로 가른다. 응답 지연에 약속(SLO)이 걸린 추론은 전용 자원에 두고 여유를 남긴다. 지연 약속이 없는 배치 추론이나 야간 대량 채점은 학습과 같은 풀에 넣어도 된다.

# 추론은 지연 여유를 남기려 노드당 복제본 수를 묶는다
kubectl scale deploy/serving --replicas=6
# 학습은 남은 노드를 채워 쓰도록 빈팩으로 배치한다
sbatch --gres=gpu:8 --constraint=train_pool train.sh

시간대별로 자원을 옮기는 정책이 흔한 절충이다. 추론 트래픽이 낮에 몰리고 학습 수요가 밤에 몰리면, 밤에는 추론 복제본을 줄이고 그 노드를 학습 풀로 돌린다.

# 밤 시간대에 추론을 줄이고 노드를 학습 파티션으로 넘기는 크론
0 22 * * * kubectl scale deploy/serving --replicas=2 && \
           scontrol update nodename=gpu[09-16] state=resume
0 8  * * * scontrol update nodename=gpu[09-16] state=drain reason=serving && \
           kubectl scale deploy/serving --replicas=6

이 방식의 한계는 셋이다. 트래픽 패턴이 뚜렷해야 하고, 옮기는 순간 상태 전이 중 실패를 다뤄야 하며, 넘기는 노드에서 돌던 작업을 안전하게 비워야 한다. 학습이 체크포인트 없이 돌고 있으면 노드를 회수하는 순간 계산이 날아간다. 트래픽이 예측을 벗어나 낮에도 학습 수요가 크면 옮길 값어치가 사라지고 전이 비용만 남는다. 두 수요의 성수기가 겹치는 클러스터라면 시간 이동이 아니라 물리 분리가 낫다.

GitHub에서 열기

자원을 자동으로 늘리고 줄이기

대기열이 길면 노드를 늘리고, 놀면 줄이는 것이 오토스케일링이다. CPU 웹 서비스에서는 흔한 방식이지만 GPU 워크로드에서는 어렵다. 늘리는 데 걸리는 시간과 줄일 때 잃는 것이 둘 다 크다.

늘리는 쪽이 느린 이유가 여러 겹이다.

단계 걸리는 시간 왜
노드 준비 수 분 클라우드 인스턴스 생성과 부팅
드라이버 초기화 수십 초에서 수 분 GPU 드라이버·fabric manager 기동
이미지 내려받기 수 분에서 수십 분 학습 이미지가 수 GB에서 수십 GB
상태 준비 경우에 따라 데이터셋 마운트·워밍업

새 노드 하나가 실제 일을 받기까지 십수 분이 걸리기도 한다. 대기열이 튀는 순간에 맞춰 늘려도 노드가 붙을 즈음이면 이미 대기가 빠져 있거나, 반대로 더 쌓여 있다.

줄이는 쪽은 상태를 가진 학습 작업 때문에 어렵다. 웹 서버는 요청이 끝나면 언제 꺼도 되지만, 학습은 며칠째 돌던 상태가 GPU 메모리에 있어 그냥 끄면 계산이 날아간다. 축소는 돌던 작업을 어떻게 다루느냐가 핵심이다. 체크포인트가 있으면 저장하고 회수하고, 없으면 작업이 끝날 때까지 그 노드는 축소 대상에서 뺀다. 대개 유휴 노드만 골라 끄고, 작업이 도는 노드에는 새 작업 배정을 막는(cordon) 쪽으로 뺀다.

# 도는 작업이 없는 노드만 축소 후보로 표시한다
kubectl cordon gpu-node-42              # 새 배정을 막는다
kubectl drain gpu-node-42 --ignore-daemonsets --grace-period=120
# Slurm은 작업이 빠진 뒤 전원을 내리는 상태로 돌린다
scontrol update nodename=gpu-node-42 state=power_down

확장 판단의 지표는 두 갈래다. 대기열 길이 기반은 밀린 작업 수와 그 작업이 요구하는 GPU 수를 보고 늘린다. 앞을 내다보기 좋지만 짧게 튀는 대기에 반응하면 노드를 켰다 끄기를 반복한다. 사용률 기반은 지금 GPU가 얼마나 도는지를 보고 줄인다. 낭비를 잡기 좋지만 이미 밀린 뒤에야 늘려 늦다. 현실에서는 늘릴 때는 대기열, 줄일 때는 사용률을 보는 식으로 섞어 쓴다.

# 대기 중인 작업이 요구하는 GPU 총량을 뽑아 확장 판단에 쓴다
squeue -t pending -h -o "%b" | grep -oP 'gpu:\K[0-9]+' | paste -sd+ | bc
# 유휴 상태가 이어진 노드를 축소 후보로 센다
sinfo -N -h -t idle -o "%N %t"

축소는 늘 쿨다운(cooldown)을 둔다. 늘린 직후에는 일정 시간 축소를 막아, 노드를 붙이자마자 다시 떼는 진동을 없앤다. 대기열 기반 확장에도 최소 유지 시간을 걸어 짧은 스파이크에 반응하지 않게 한다.

클라우드 버스팅은 온프레미스가 꽉 찼을 때 클라우드로 넘치게 하는 방식인데, 데이터 지역성이 발목을 잡는다. 학습 데이터가 사내 스토리지에 있으면 클라우드 노드가 매 스텝 데이터를 원격으로 당겨 와 네트워크가 병목이 되고, 붙는 대역폭이 GPU 속도를 못 따라가 값비싼 클라우드 GPU가 논다. 버스팅으로 넘길 작업은 데이터를 미리 클라우드 쪽으로 복제해 두거나, 데이터 이동이 적은 작업으로 고르는 편이 낫다. 이동 비용과 클라우드 GPU 단가를 견줘, 넘겨서 이득인 작업만 넘긴다.

GitHub에서 열기

여러 종류의 가속기가 섞인 클러스터

클러스터를 여러 해에 걸쳐 늘리면 세대가 다른 GPU가 섞인다. A100 노드에 H100 노드가 더해지고, 나중에는 GPU와 NPU가 한 대기열에 든다. 이때 아무 카드나 잡아 주면 성능이 튄다.

분산 학습에서 세대를 섞으면 안 되는 이유는 집합 통신이 가장 느린 랭크를 기다리기 때문이다. H100 네 장에 A100 네 장을 붙여 여덟 장으로 학습하면, 매 스텝 배리어에서 H100이 A100을 기다려 전체가 A100 속도로 떨어진다. 빠른 카드의 값을 못 쓰고 느린 카드에 맞춰지니, 섞는 순간 비싼 H100이 A100처럼 도는 셈이다. 그래서 한 학습 작업 안의 카드는 같은 종류로 묶어야 한다.

Slurm에서는 GRES 타입으로 종류를 나눠 표현하고, 요청할 때 타입을 못 박아 같은 종류만 잡게 한다.

# gres.conf, 세대를 타입으로 구분한다 (노드마다 해당 카드만 적는다)
Name=gpu Type=h100 File=/dev/nvidia[0-7]
Name=gpu Type=a100 File=/dev/nvidia[0-7]
sbatch --gres=gpu:h100:8 train.sh       # H100 여덟 장만 잡는다
sbatch --gres=gpu:a100:8 train.sh       # A100 여덟 장만 잡는다
# 타입을 안 적으면 섞일 수 있으니 작업 템플릿에서 늘 못 박는다

한 작업이 한 종류만 잡아도, 그 작업이 여러 노드에 걸칠 때 노드 간 세대가 섞이면 같은 문제가 난다. 노드 자체에 특성(feature)을 붙이고 제약(constraint)으로 같은 특성 노드만 고르게 한다.

# slurm.conf, 노드에 세대 특성을 붙인다
NodeName=gpu[01-08] Gres=gpu:h100:8 Feature=h100,nvlink4
NodeName=gpu[09-16] Gres=gpu:a100:8 Feature=a100,nvlink3
sbatch --constraint=h100 --nodes=4 train.sh    # H100 노드에서만 4대를 잡는다

세대별로 파티션을 나눌지, 한 파티션에 두고 제약으로 풀지는 규모와 운영 손으로 정한다.

방식 장점 단점
파티션 분리 경계가 뚜렷하고 사용자가 고르기 쉽다 한쪽이 놀아도 다른 쪽이 못 쓴다
단일 파티션 + 제약 자원을 합쳐 두고 유연하게 쓴다 사용자가 제약을 빠뜨리면 섞인다

세대가 둘뿐이고 사용자가 어느 카드를 쓰는지 뚜렷이 알아야 하면 파티션을 나누는 편이 헷갈림이 적다. 세대가 여럿이고 조합이 자주 바뀌면 제약으로 푸는 편이 자원을 덜 놀린다. 어느 쪽이든 작업 템플릿에 타입과 제약을 기본으로 박아, 사용자가 빠뜨려 섞이는 사고를 코드로 막는다. GPU와 NPU가 섞인 클러스터는 프레임워크 자체가 다른 경우가 많아, 아예 다른 대기열로 갈라 두는 편이 낫다.

GitHub에서 열기

워크플로로 단계를 잇기

학습 한 번은 대개 작업 하나가 아니다. 데이터를 받고, 전처리하고, 학습하고, 평가하고, 결과를 옮기는 단계가 이어진다. 각 단계는 필요한 자원도 걸리는 시간도 다르다.

단계 필요한 것 걸리는 시간
데이터 수집 CPU와 네트워크. GPU는 필요 없다 수십 분
전처리와 샤딩 CPU 여러 노드 수 시간
학습 GPU 수십에서 수백 장 며칠에서 몇 주
평가 GPU 몇 장 수십 분
결과 이관 네트워크 수십 분

전부를 GPU 작업 하나로 묶으면 전처리하는 몇 시간 동안 GPU가 논다. 단계를 나눠 제출하고 순서만 잇는 편이 자원을 아낀다. Slurm은 이 일을 의존성으로 한다.

prep=$(sbatch --parsable --partition=cpu prep.sh)
train=$(sbatch --parsable --dependency=afterok:$prep --gres=gpu:8 train.sh)
eval=$(sbatch --parsable --dependency=afterok:$train --gres=gpu:1 eval.sh)
sbatch --dependency=afterany:$eval --partition=cpu report.sh
의존 형태 언제 시작하나
afterok 앞 작업이 성공으로 끝났을 때
afternotok 앞 작업이 실패했을 때. 정리나 알림에 쓴다
afterany 성공이든 실패든 끝났을 때
after 앞 작업이 시작만 하면
singleton 같은 이름의 내 작업이 없을 때. 중복 제출을 막는다

의존성만으로는 부족한 경우가 있다. 단계가 열 개를 넘거나, 조건에 따라 갈라지거나, 매일 정해진 시각에 돌아야 하거나, 실패한 단계만 다시 돌려야 할 때다. 이때는 워크플로 도구를 얹는다.

도구 성격 HPC와 잇는 방법
Airflow 시각 기반 실행과 재시도에 강하다 DAG의 태스크가 sbatch 하고 상태를 기다린다
Kubeflow Pipelines 컨테이너 단위로 단계를 엮는다 Kubernetes 쪽 자원을 그대로 쓴다
Argo Workflows Kubernetes 원생. 가볍다 파드로 단계를 돌린다
Snakemake · Nextflow 파일 의존 관계로 단계를 정한다 Slurm 실행기가 내장돼 있다

Airflow를 쓴다면 태스크가 직접 계산하지 않는다. 작업을 제출하고 끝날 때까지 상태만 확인한다. 계산은 Slurm이 하고 Airflow는 순서와 재시도만 맡는 구조다.

from airflow.decorators import dag, task
from datetime import datetime
import subprocess, time

@dag(schedule="0 2 * * *", start_date=datetime(2026, 1, 1), catchup=False)
def nightly_finetune():

    @task
    def submit_and_wait(script: str, args: str = "") -> str:
        job = subprocess.check_output(
            ["sbatch", "--parsable", script, *args.split()], text=True).strip()
        while True:
            state = subprocess.check_output(
                ["sacct", "-j", job, "-n", "-X", "-o", "State"], text=True).strip()
            if state.startswith(("COMPLETED", "FAILED", "CANCELLED", "TIMEOUT")):
                break
            time.sleep(60)                       # 스케줄러를 자주 두드리지 않는다
        if not state.startswith("COMPLETED"):
            raise RuntimeError(f"{script} 가 {state} 로 끝났다")
        return job

    submit_and_wait("prep.sh") >> submit_and_wait("train.sh") >> submit_and_wait("eval.sh")

nightly_finetune()

상태를 확인하는 주기를 짧게 잡으면 워크플로 도구가 스케줄러를 두드리는 부하가 커진다. 작업이 몇 시간짜리라면 1분 간격으로 충분하다. 수백 개 태스크가 동시에 확인하면 그 자체가 문제가 되므로, 확인 주기와 동시 실행 수에 상한을 둔다.

워크플로 도구를 넣을지는 단계 수와 반복 여부로 정한다. 한 번 돌리고 마는 실험이라면 의존성으로 충분하고, 매일 도는 파이프라인이라면 도구를 얹을 값어치가 있다. 도구가 늘면 장애가 났을 때 어느 층이 원인인지 가리는 일이 하나 더 늘어난다는 점도 감안한다.

GitHub에서 열기

대기열을 나누는 기준

파티션을 어떻게 자를지가 사용자 경험을 크게 바꾼다. 하나로 두면 짧은 작업이 긴 작업 뒤에서 하염없이 기다리고, 너무 잘게 나누면 어느 쪽은 놀고 어느 쪽은 밀린다.

기준 예 얻는 것
실행 시간 short 4시간, long 72시간 짧은 작업의 응답이 빨라진다
하드웨어 a100, h100, cpu-only 필요한 자원만 정확히 요청하게 된다
조직 team-a, team-b, shared 팀별 보장량을 만들 수 있다
성격 interactive, batch 디버깅용 세션이 학습 뒤에 밀리지 않는다

파티션은 겹쳐도 된다. 같은 노드를 여러 파티션에 넣고 우선순위와 시간 한도를 다르게 주면, 평소에는 공유하다가 필요할 때 특정 팀이 앞서게 만들 수 있다.

PartitionName=interactive Nodes=gpu[01-02] MaxTime=02:00:00 Priority=100
PartitionName=short  Nodes=gpu[01-08] MaxTime=04:00:00 Priority=50
PartitionName=long   Nodes=gpu[01-16] MaxTime=72:00:00 Priority=10

GitHub에서 열기

활용률을 읽는 법

활용률이 높다고 좋은 상태는 아니다. 100퍼센트에 붙어 있으면 대기열이 계속 쌓인다는 뜻이고, 낮으면 자원이 논다. 무엇을 재는지도 구분해야 한다.

  • 할당률은 스케줄러가 노드를 작업에 배정한 비율이다. 그 작업이 실제로 계산하는지는 모른다.
  • GPU 사용률은 커널이 돌던 시간의 비율이다. 배정만 받고 노는 작업을 잡아낸다.
  • 대기 시간은 사용자가 체감하는 값이다. 활용률과 반대 방향으로 움직인다.

세 값을 같이 놓아야 판단이 선다. 할당률이 90퍼센트인데 GPU 사용률이 30퍼센트라면 자원을 잡아만 두고 쓰지 않는 작업이 있다는 뜻이고, 이때는 노드를 늘리는 대신 그 작업을 찾는 편이 빠르다.

sacct -a -S 2026-01-01 -E now --format=JobID,Account,AllocGRES,Elapsed,State -X | head
sreport cluster Utilization Start=2026-01-01 End=now

배정만 받고 노는 작업은 두 값을 맞춰 보면 찾아진다. 작업 번호별로 잡은 GPU 수와 그 GPU들의 실제 사용률을 같이 놓는다.

# 지금 도는 작업이 잡은 GPU 수
squeue -t running -o "%i %u %b %N" -h

# 그 노드들의 GPU 사용률
srun -w "$(squeue -t running -h -j 12345 -o %N)" --overlap \
     nvidia-smi --query-gpu=index,utilization.gpu --format=csv,noheader

몇 시간째 사용률이 한 자릿수인 작업은 대개 셋 중 하나다. 대화형 세션을 열어 두고 자리를 비웠거나, 디버거 앞에서 멈춰 있거나, 데이터를 기다리고 있다. 앞의 둘은 정책으로 줄인다. 대화형 세션에 짧은 시간 제한을 걸고, 유휴 상태가 일정 시간 이어지면 알림을 보낸다.

목표값은 워크로드 성격에 따라 다르다. 하나의 숫자를 모든 파티션에 들이대면 잘못된 결정을 한다.

파티션 성격 적당한 할당률 이유
대규모 학습 전용 85~95% 큰 작업이 들어갈 빈자리를 남겨 둬야 한다
일반 배치 90~98% 백필로 틈을 메울 수 있다
대화형과 디버그 40~60% 기다리지 않고 바로 잡히는 것이 목적이다
추론 서비스 50~70% 트래픽이 튈 여유를 둬야 한다

대화형 파티션의 낮은 활용률을 낭비로 보고 없애면 사용자가 배치 파티션에서 대화형 세션을 열기 시작한다. 그러면 큰 작업이 들어갈 자리가 사라지고 전체 처리량이 떨어진다. 활용률은 파티션마다 따로 읽는다.

GitHub에서 열기

정책을 바꿀 때

스케줄링 정책을 한 번에 크게 바꾸면 무엇 때문에 달라졌는지 알 수 없게 된다. 하나씩 바꾸고 효과를 확인한다.

바꾸기 전 값은 기록해 둔다. scontrol show config 출력을 저장해 두면 되돌릴 근거가 된다. 변경 뒤에는 대기 시간 분포와 활용률을 같은 기간으로 비교한다. 평균만 보면 소수의 긴 대기가 묻히므로 중앙값과 상위 백분위를 함께 본다.

사용자에게 알리는 것도 정책의 일부다. 우선순위 규칙이 바뀌면 같은 작업이 어제와 다른 시각에 시작하는데, 공지 없이 그러면 장애로 오해받는다.

바꾸기 전에 지금 값을 남기고, 무엇을 보고 판단할지 먼저 정한다.

scontrol show config > /var/backups/slurm-config-$(date +%F).txt
sacctmgr dump cluster > /var/backups/slurm-assoc-$(date +%F).cfg

# 최근 2주 대기 시간 분포. 평균이 아니라 분포를 본다
sacct -a -S now-14days -X -o JobID,Partition,Submit,Start,Elapsed,State \
      --parsable2 > /tmp/jobs.csv
import csv, statistics
from datetime import datetime

waits = []
with open("/tmp/jobs.csv") as f:
    for row in csv.DictReader(f, delimiter="|"):
        if row["Start"] in ("Unknown", "None", ""):
            continue
        fmt = "%Y-%m-%dT%H:%M:%S"
        waits.append((datetime.strptime(row["Start"], fmt)
                      - datetime.strptime(row["Submit"], fmt)).total_seconds() / 60)

waits.sort()
print(f"중앙값 {statistics.median(waits):.0f}분  "
      f"90퍼센타일 {waits[int(len(waits) * 0.9)]:.0f}분  "
      f"99퍼센타일 {waits[int(len(waits) * 0.99)]:.0f}분")

평균만 보면 소수의 긴 대기가 묻힌다. 불만은 평균이 아니라 상위 백분위에서 나온다. 중앙값이 줄었는데 99퍼센타일이 늘었다면 그 정책은 다수를 조금 낫게 만들고 소수를 크게 나쁘게 만든 것이다.

바꾸는 것 효과가 도는 데 걸리는 시간 함께 볼 지표
파티션 시간 제한 즉시 백필로 들어간 작업 수, 대기 시간 분포
fairshare 반감기 며칠 계정별 사용량 편차
우선순위 가중치 대기열이 한 바퀴 도는 시간 대기 시간 상위 백분위
선점 정책 즉시 선점당한 작업 수와 그중 재시작 실패 수
QoS 동시 실행 한도 즉시 특정 사용자의 대기 작업 수

한 번에 하나만 바꾸고 최소 한 주는 둔다. 대기열은 주 단위로 패턴이 도는 경우가 많아 하루 이틀 값으로 판단하면 요일 효과를 정책 효과로 착각한다. 되돌릴 조건도 미리 정해 둔다. 99퍼센타일 대기가 어느 값을 넘으면 되돌린다는 식으로 숫자를 적어 두면 논쟁이 줄어든다.

GitHub에서 열기

07

ML 인프라 오케스트레이션심화

Slurm과 Kubernetes에서 분산 학습을 띄우고 지키는 법

대규모 학습을 어디에서 돌릴 것인가. 두 플랫폼의 자원 모델이 어떻게 다른지, 각각에서 분산 학습을 어떻게 띄우는지, 노드가 빠졌을 때 무엇이 남는지를 본다.

두 플랫폼의 자원 모델

같은 GPU 클러스터를 두고도 Slurm과 Kubernetes는 다른 전제로 자원을 다룬다.

항목 Slurm Kubernetes
기본 단위 작업. 시작과 끝이 있다 파드. 계속 살아 있는 것을 전제한다
자원 요청 노드 수, 태스크 수, GRES 컨테이너별 requests와 limits
동시 시작 기본으로 보장한다 스케줄러 플러그인을 얹어야 한다
대기열 내장. 우선순위와 공정 배분이 있다 없다. Kueue나 Volcano로 더한다
실패 처리 작업이 끝난다. 재제출은 정책으로 정한다 재시작이 기본 동작이다
토폴로지 인지 topology.conf로 스위치를 안다 라벨과 어피니티로 흉내낸다
강점 대기열, 공정 배분, 노드 간 통신 최적화 서비스 운영, 롤아웃, 자동 확장

차이의 뿌리는 전제다. Slurm은 유한한 계산을 순서대로 처리하는 배치 시스템이고, Kubernetes는 오래 사는 서비스를 유지하는 시스템이다. 학습은 배치에 가깝고 추론은 서비스에 가깝다.

한쪽을 알면 다른 쪽은 말을 바꿔 읽으면 대체로 통한다.

Slurm Kubernetes 뜻
작업 Job 또는 Pod 실행 단위 하나
태스크 컨테이너 안의 프로세스 실제로 뜨는 프로세스
파티션 노드 라벨과 테인트로 만든 묶음 노드 그룹
GRES gpu:8 nvidia.com/gpu: 8 GPU 요청
sbatch kubectl apply 제출
squeue kubectl get pods 상태 확인
scancel kubectl delete 취소
sacct 기본 기능이 없다 지난 실행 이력
대기열 기본 기능이 없다. Kueue를 얹는다 자원이 없을 때 줄 세우기

말이 없는 칸이 중요하다. Kubernetes에는 대기열이라는 개념 자체가 없다. 자원이 모자라면 파드는 Pending으로 남을 뿐이고, 누가 먼저인지 정하는 규칙도 없다. 학습 작업을 여럿 돌리는 순간 이 빈칸을 무엇으로 메울지가 첫 번째 결정이 된다.

반대로 Slurm에는 살아 있는 서비스를 유지하는 개념이 없다. 작업이 죽으면 끝이고, 다시 띄우는 일은 사람이나 외부 스크립트가 한다. 추론 서비스를 Slurm으로 운영하기 어려운 이유가 이것이다.

GitHub에서 열기

랭크와 통신 계층

플랫폼과 무관하게 분산 학습의 뼈대는 같다. GPU 한 장마다 프로세스 하나를 띄우고, 프로세스마다 번호를 붙여 서로를 구분한다.

이름 뜻 예시 값
rank 전체에서 몇 번째 프로세스인지 0부터 511까지
local_rank 그 노드 안에서 몇 번째인지. 어느 GPU를 쓸지 정한다 0부터 7까지
world_size 프로세스 총 개수 512
MASTER_ADDR 서로를 찾을 때 기준이 되는 노드 주소 첫 번째 노드
MASTER_PORT 그 노드에서 열어 둘 포트 29500

노드마다 GPU에 랭크를 붙이고 노드 안은 NVLink, 노드 사이는 InfiniBand로 통신한다

가장 흔한 데이터 병렬은 세 단계로 돈다. 랭크마다 서로 다른 데이터 조각으로 같은 모델을 계산하고, 계산한 기울기를 전부 더해 평균을 내고, 그 평균으로 각자 모델을 갱신한다. 모든 랭크가 같은 값으로 갱신하기 때문에 모델은 항상 서로 같다. 중간의 더하는 연산이 all-reduce다. 모든 랭크가 가진 값을 합쳐서 그 결과를 다시 모든 랭크가 갖게 하는 동작이다.

병렬로 나누는 방법은 세 가지이고, 나누는 대상이 다르면 오가는 데이터도 다르다.

방식 무엇을 나누나 통신하는 것 쓰는 때
데이터 병렬 배치를 나눈다 스텝마다 기울기 전체 모델이 GPU 한 장에 들어갈 때
텐서 병렬 행렬 하나를 쪼갠다 층마다 활성값. 매우 잦다 노드 안에서만. NVLink가 필요하다
파이프라인 병렬 층을 구간으로 나눈다 구간 경계의 활성값 모델이 노드 하나에도 안 들어갈 때

큰 모델은 셋을 겹쳐 쓴다. 텐서 병렬은 통신이 잦아 노드 안 NVLink로 묶고, 파이프라인 병렬은 경계에서만 주고받으므로 노드를 넘겨도 견디며, 데이터 병렬이 그 묶음들을 다시 감싼다. 이 배치를 어긋나게 잡아 텐서 병렬이 노드를 넘어가면 스텝 시간이 몇 배로 늘어난다.

통신 비용은 계층마다 다르다. 같은 노드 안 NVLink가 가장 빠르고, 노드 사이 InfiniBand가 그 다음이며, 경로가 이더넷으로 떨어지면 몇 분의 일로 내려간다. 그래서 배치할 때 같은 작업의 프로세스를 가까이 모으는 것이 성능에 직접 영향을 준다.

오가는 양도 계산된다. 데이터 병렬에서 링 방식 all-reduce가 실어 나르는 양은 파라미터 크기의 두 배 가까이다. 70B 모델의 bf16 기울기가 140 GB이므로 스텝마다 280 GB가 패브릭을 지난다. 200Gb 포트 하나가 실효 20 GB/s를 낸다면 이 통신에만 14초가 든다. 스텝 시간이 그보다 짧아야 한다면 포트를 늘리거나 통신과 계산을 겹쳐야 한다.

GitHub에서 열기

Slurm에서 분산 학습 실행

srun이 프로세스를 띄우면 rank 관련 환경 변수가 자동으로 들어간다. 학습 코드는 그 값을 읽어 초기화한다.

#!/bin/bash
#SBATCH --job-name=train-7b
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=8          # GPU 1장당 프로세스 1개
#SBATCH --gres=gpu:8
#SBATCH --cpus-per-task=12           # 데이터 로더 몫
#SBATCH --time=24:00:00
#SBATCH --signal=B:USR1@600          # 종료 10분 전에 신호를 받는다
#SBATCH --requeue
#SBATCH --output=logs/%x-%j.out

export MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)
export MASTER_PORT=29500
export NCCL_DEBUG=WARN

srun --cpu-bind=cores python -u train.py
import os, torch, torch.distributed as dist

dist.init_process_group(
    backend="nccl",
    world_size=int(os.environ["SLURM_NTASKS"]),
    rank=int(os.environ["SLURM_PROCID"]),
)
local_rank = int(os.environ["SLURM_LOCALID"])
torch.cuda.set_device(local_rank)
model = torch.nn.parallel.DistributedDataParallel(model.cuda(), device_ids=[local_rank])

torchrun을 쓰고 싶다면 노드마다 하나씩 띄우고 그 안에서 프로세스를 나누게 한다. 이때는 --ntasks-per-node=1로 두어야 한다.

#SBATCH --ntasks-per-node=1
srun torchrun --nnodes=$SLURM_NNODES --nproc-per-node=8 \
     --rdzv-backend=c10d --rdzv-endpoint=$MASTER_ADDR:29500 train.py

컨테이너로 실행하려면 Pyxis와 Enroot를 쓴다. 데몬 없이 사용자 권한으로 돌기 때문에 다중 사용자 클러스터에 맞는다.

srun --container-image=nvcr.io#nvidia/pytorch:24.07-py3 \
     --container-mounts=/scratch:/scratch,/local/nvme:/cache \
     --container-workdir=/workspace \
     python train.py

GitHub에서 열기

Kubernetes에서 분산 학습 실행

GPU를 쓰려면 device plugin이 먼저 있어야 한다. 그다음 파드가 GPU를 자원으로 요청한다.

resources:
  limits:
    nvidia.com/gpu: 8

문제는 동시 시작이다. 기본 스케줄러는 파드를 하나씩 배치하므로 8개 중 5개만 자리를 잡고 나머지가 대기하면 진전 없이 자원만 점유한다. 여러 워크로드가 이 상태로 얽히면 교착에 빠진다. 갱 스케줄링을 지원하는 스케줄러를 얹어 해결한다.

도구 역할
Kubeflow Training Operator PyTorchJob 같은 리소스로 분산 학습을 선언한다
Volcano 갱 스케줄링, 큐, 공정 배분을 더한다
Kueue 배치 잡의 대기열과 할당량을 관리한다
Ray 파이썬 수준에서 클러스터를 다룬다. 학습과 추론을 함께 얹기 좋다
apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: train-7b
spec:
  runPolicy:
    schedulingPolicy:
      minAvailable: 4          # 넷이 다 모여야 시작한다
  pytorchReplicaSpecs:
    Master:
      replicas: 1
      template:
        spec:
          schedulerName: volcano
          containers:
            - name: pytorch
              image: myrepo/train:1.0
              resources:
                limits:
                  nvidia.com/gpu: 8
                  rdma/hca: 1        # RDMA 장치를 함께 요청한다
    Worker:
      replicas: 3
      template:
        spec:
          schedulerName: volcano
          containers:
            - name: pytorch
              image: myrepo/train:1.0
              resources:
                limits:
                  nvidia.com/gpu: 8
                  rdma/hca: 1

RDMA를 쓰려면 장치를 파드에 노출해야 한다. NVIDIA Network Operator나 SR-IOV device plugin이 이 일을 한다. 이 설정이 빠지면 통신이 조용히 TCP로 떨어지고, 오류 없이 몇 배 느려진다.

같은 작업의 파드를 가까이 모으려면 노드에 토폴로지 라벨을 붙이고 어피니티로 묶는다.

affinity:
  podAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels: {job-name: train-7b}
        topologyKey: topology.kubernetes.io/rack

GitHub에서 열기

통신 백엔드 확인

성능 문제의 상당수가 통신 경로에서 나온다. 학습을 띄우기 전에 경로부터 확인한다.

export NCCL_DEBUG=INFO
export NCCL_DEBUG_SUBSYS=INIT,NET
srun -N2 --ntasks-per-node=8 python -c "
import os, torch, torch.distributed as dist
dist.init_process_group('nccl')
t = torch.ones(1, device='cuda')
dist.all_reduce(t)
print(os.environ['SLURM_PROCID'], t.item())
"

로그에서 볼 것은 두 줄이다. NET/IB가 보이면 InfiniBand를 쓰는 것이고, NET/Socket이면 TCP로 떨어진 것이다. 후자라면 원인을 찾기 전에는 성능을 논할 수 없다.

변수 쓰임
NCCL_IB_HCA 쓸 HCA를 지정한다. 여러 장이면 나열한다
NCCL_SOCKET_IFNAME 부트스트랩에 쓸 인터페이스. docker0 같은 가짜를 고르는 사고를 막는다
NCCL_IB_GID_INDEX RoCE에서 GID를 고른다. 값이 틀리면 연결이 안 된다
NCCL_P2P_DISABLE NVLink 문제를 배제하고 비교할 때 임시로 쓴다
NCCL_ALGO / NCCL_PROTO 알고리즘을 고정해 비교한다. 평소에는 자동에 맡긴다

대역폭은 학습 전에 따로 잰다. nccl-tests의 all_reduce_perf가 표준 도구다. 노드 수를 늘리며 재면 어디서 꺾이는지 보인다.

GitHub에서 열기

장애를 견디는 설계

노드가 많아질수록 학습 중 한 대가 빠질 확률이 올라간다. 512 GPU로 일주일을 돌리는 작업에서 무중단을 기대할 수는 없다. 복구 설계가 성능 최적화보다 중요할 때가 많다.

신호를 받아 저장하고 다시 큐에 든다. Slurm은 종료 전에 신호를 보낼 수 있다.

import signal, sys

def on_signal(signum, frame):
    save_checkpoint(step)      # 마지막 상태를 남긴다
    sys.exit(0)                # requeue 가 걸려 있으면 Slurm 이 다시 넣는다

signal.signal(signal.SIGUSR1, on_signal)

탄력 학습을 쓴다. torchrun은 프로세스가 빠져도 남은 것으로 재구성할 수 있다. world_size가 바뀌므로 학습률과 배치 크기를 그에 맞춰 조정하는 코드가 필요하다.

torchrun --nnodes=4:8 --nproc-per-node=8 --max-restarts=3 \
         --rdzv-backend=c10d --rdzv-endpoint=$MASTER_ADDR:29500 train.py

시작 전에 노드를 검사한다. 문제 노드가 섞이면 학습 전체가 느려지거나 죽는다. 프롤로그에서 빠른 진단을 돌려 걸러낸다. 모듈 08의 헬스체크 항목과 함께 본다.

재시작을 관측한다. 몇 번 재시작했는지, 매번 어디서 죽었는지가 남아야 원인을 찾는다. 재시작 횟수를 지표로 내보내고 임계값을 넘으면 사람이 본다.

GitHub에서 열기

학습 작업에서 봐야 할 지표

작업 하나를 두고 볼 지표는 하드웨어 지표만이 아니다.

지표 어디서 무엇을 알려주는가
스텝 시간 학습 코드 전체 진행 속도. 가장 중요한 값이다
랭크별 스텝 시간 편차 학습 코드 특정 랭크가 뒤처지는지
데이터 로더 대기 시간 학습 코드 스토리지가 계산을 못 따라가는지
GPU 사용률 DCGM 계산 유닛이 노는지
통신 시간 비율 프로파일러 스케일이 통신에 먹히는지
체크포인트 소요 시간 학습 코드 저장이 학습을 멈추는지

하드웨어 지표만 보면 데이터 불균형이나 통신 비중을 알 수 없다. 학습 코드에서 남기는 값이 있어야 판단이 선다. 스텝 시간과 랭크 번호만 찍어도 조사 범위가 크게 준다.

남기는 방법은 어렵지 않다. 스텝마다 시간을 재고, 정해진 간격으로 랭크 0이 모아서 기록한다.

import time, torch, torch.distributed as dist

start = time.perf_counter()
loss = train_step(batch)                 # 계산
torch.cuda.synchronize()                 # 커널이 끝나기를 기다려야 시간이 맞는다
elapsed = time.perf_counter() - start

# 랭크별 스텝 시간을 모아 편차를 본다
times = torch.tensor([elapsed], device="cuda")
gathered = [torch.zeros_like(times) for _ in range(dist.get_world_size())]
dist.all_gather(gathered, times)
if dist.get_rank() == 0:
    values = torch.cat(gathered)
    print(f"step={step} avg={values.mean():.3f} max={values.max():.3f} "
          f"slowest_rank={values.argmax().item()}")

torch.cuda.synchronize()가 없으면 커널을 띄우는 시간만 재게 되어 값이 실제보다 훨씬 작게 나온다. 시간을 재는 코드에서 가장 흔한 실수다.

임계값은 절대값이 아니라 자기 클러스터의 평소 값에서 정한다. 처음 며칠은 기록만 하고, 안정된 구간의 값을 기준으로 삼는다.

지표 살펴볼 신호 그때 의심할 것
스텝 시간 평소보다 20% 이상 길어진다 느린 노드가 섞였거나 통신 경로가 바뀌었다
랭크별 편차 가장 느린 랭크가 평균의 1.3배를 넘는다 그 랭크의 GPU나 데이터 배분
데이터 로더 대기 스텝 시간의 10%를 넘는다 읽기가 계산을 못 따라간다
GPU 사용률 90% 아래에서 톱니처럼 흔들린다 데이터나 통신에서 기다린다
체크포인트 소요 스텝 시간의 20배를 넘는다 저장 방식을 바꾸거나 간격을 늘린다
재시작 횟수 하루 두 번을 넘는다 특정 노드를 의심하고 격리한다

느린 랭크 번호를 알면 노드를 특정할 수 있다. 랭크를 노드당 프로세스 수로 나눈 몫이 노드 순번이고, scontrol show hostnames로 이름을 얻는다. 같은 노드가 반복해서 걸리면 그 노드를 빼고 다시 돌려 비교한다.

GitHub에서 열기

한 클러스터에서 둘을 함께 쓰기

학습은 Slurm, 추론과 개발 환경은 Kubernetes로 두는 구성이 흔하다. 이때 지켜야 하는 규칙은 하나다. 노드는 반드시 한 스케줄러에만 속한다.

노드는 스케줄러마다 나눠 갖고 파일시스템과 이미지와 신원과 네트워크와 지표는 함께 쓴다

한 노드를 둘이 동시에 관리하면 Slurm은 자기 할당표만 보고 GPU 0번부터 7번을 작업에 내주고, kubelet도 자기 할당표만 보고 같은 GPU를 파드에 내준다. 서로의 할당을 모르기 때문에 두 프로세스가 같은 GPU에 올라가고 메모리가 터진다. 더 나쁜 것은 원인을 찾기 어렵다는 점이다. 학습 로그에는 OOM만 남고, 그 GPU를 누가 함께 쓰고 있었는지는 어느 쪽 로그에도 없다.

노드는 나누되 아래 다섯은 한 벌만 두고 함께 쓴다.

공유 파일시스템. 양쪽에서 같은 경로로 보여야 한다. Slurm 작업이 /scratch/run-42에 남긴 체크포인트를 Kubernetes 추론 파드가 읽으려면 경로가 같아야 한다. 파드에서는 노드에 이미 마운트된 경로를 그대로 붙이는 방식이 단순하다.

volumes:
  - name: scratch
    hostPath: { path: /scratch, type: Directory }

컨테이너 이미지. 레지스트리는 하나로 두되 형식이 다르다는 점을 감안한다. Kubernetes는 containerd가 OCI 이미지를 그대로 받는다. Slurm 쪽 Enroot는 같은 이미지를 squashfs 파일로 바꿔 쓴다. 변환을 작업마다 하면 시작이 느려지므로 공유 경로에 미리 만들어 둔다.

enroot import -o /shared/images/train-1.0.sqsh docker://myregistry#train:1.0
srun --container-image=/shared/images/train-1.0.sqsh python train.py

uid와 gid. 실무에서 가장 자주 걸리는 항목이다. 파일 권한은 uid로 매겨진다. Slurm 작업은 사용자 uid로 돌지만 Kubernetes 파드는 기본적으로 이미지에 적힌 사용자로 돈다. 그대로 두면 파드가 root로 만든 파일을 사용자가 지우지 못하고, 사용자 쿼터도 우회된다. 파드에 실제 uid를 지정해야 양쪽이 같은 파일을 다룬다.

securityContext:
  runAsUser: 10042        # 실제 사용자 uid
  runAsGroup: 2000
  fsGroup: 2000

GPU 드라이버. 충돌이 나는 지점이다. NVIDIA GPU Operator는 기본 설정에서 드라이버를 자기가 설치한다. Slurm 쪽은 호스트에 설치된 드라이버를 쓴다. 드라이버가 두 벌이 되면 버전이 어긋나 한쪽이 깨진다. 드라이버는 호스트에 한 벌만 두고 Operator의 설치 기능을 끈다.

helm install gpu-operator nvidia/gpu-operator --set driver.enabled=false

지표. DCGM exporter 한 벌이 양쪽 GPU를 다 본다. 다만 어느 워크로드가 쓰는지 구분할 라벨이 필요하다. Slurm 쪽은 작업 번호, Kubernetes 쪽은 네임스페이스와 파드 이름을 지표에 붙여야 사용률을 나눠 볼 수 있다. 라벨이 없으면 전체 사용률만 보이고 어느 쪽이 노는지 알 수 없다.

풀 크기는 회수에 걸리는 시간으로 정한다. 이동 가능 풀이 클수록 유연하지만, 노드를 되찾으려면 그 위에서 돌던 작업이 끝나기를 기다려야 한다. 학습 작업의 최대 실행 시간이 그대로 회수 지연의 상한이 된다. 24시간짜리 작업을 허용하는 파티션에 넣어 두면 노드 한 대를 되찾는 데 최대 24시간이 걸린다. 자주 옮길 노드는 짧은 작업만 받는 별도 파티션에 둔다.

# slurm.conf : 이동 가능 풀은 4시간짜리 작업까지만 받는다
PartitionName=flex Nodes=gpu[049-056] MaxTime=04:00:00 State=UP

GitHub에서 열기

노드를 두 풀 사이에서 옮기기

새 작업을 막고 돌던 작업이 끝나기를 기다린 뒤 넘긴다

두 스케줄러 모두 노드를 비우는 명령을 가지고 있지만 이름과 동작이 어긋난다. 이 차이를 모르면 돌던 작업을 죽인다.

명령 새 작업 차단 돌던 것 처리
scontrol update ... State=DRAIN 막는다 끝까지 둔다. 기다려야 한다
kubectl cordon 막는다 그대로 둔다
kubectl drain 막는다 다른 노드로 옮긴다

Slurm의 DRAIN은 Kubernetes의 cordon에 해당하고, Kubernetes의 drain에 해당하는 동작은 Slurm에 없다. 학습 작업은 다른 노드로 옮길 수 없기 때문이다. 그래서 Slurm에서 노드를 빼는 일은 항상 기다림을 포함한다.

#!/bin/bash
# Slurm 풀에서 Kubernetes 풀로 옮긴다
NODE=gpu049
LIMIT=$((6 * 3600))          # 6시간까지만 기다린다

scontrol update NodeName=$NODE State=DRAIN Reason="k8s 로 이동"

waited=0
while [ "$(squeue -w "$NODE" -h -t running | wc -l)" -gt 0 ]; do
  sleep 60
  waited=$((waited + 60))
  if [ "$waited" -ge "$LIMIT" ]; then
    echo "$NODE 에서 작업이 아직 돈다. 사람이 확인해야 한다." >&2
    exit 1                   # 강제로 끊지 않는다
  fi
done

kubectl uncordon "$NODE"
#!/bin/bash
# Kubernetes 풀에서 Slurm 풀로 되돌린다
NODE=gpu049

kubectl cordon "$NODE"
kubectl drain "$NODE" --ignore-daemonsets --delete-emptydir-data --timeout=900s || exit 1
scontrol update NodeName="$NODE" State=RESUME

기다리는 구간에서 그 노드는 어느 쪽에도 쓸모가 없다. Slurm은 새 작업을 안 넣고 Kubernetes는 아직 받지 못한다. 이동이 잦으면 이 빈 시간이 쌓여 분리로 얻은 이득을 까먹는다. 이동에 걸린 시간과 그동안 논 시간을 기록해서 정책이 실제로 이득인지 확인한다.

확인할 값 어디서 판단
이동 1회당 대기 시간 이동 스크립트 로그 평균이 작업 최대 시간에 가까우면 파티션 시간 제한을 줄인다
이동 빈도 이동 스크립트 로그 하루 여러 번이면 정적 분리가 낫다
이동 중 유휴 시간 합 위 둘의 곱 이 값이 분리로 얻은 활용률보다 크면 이동을 접는다

자동화할 때 지킬 것이 두 가지다. 대기에는 반드시 상한을 두고, 상한을 넘으면 사람에게 알리고 멈춘다. 그리고 돌던 작업을 강제로 끊는 자동화는 만들지 않는다. 며칠째 돌던 학습이 자동화 스크립트에 죽는 사고는 한 번으로 신뢰를 잃는다.

GitHub에서 열기

메모리를 아끼는 학습 기법

GPU 메모리가 모자라 배치를 못 키우는 상황은 흔하다. 계산을 줄이지 않고 메모리만 줄이는 방법이 몇 가지 있고, 각각 다른 것을 내준다.

기법 무엇을 줄이나 대가
혼합 정밀도 활성값과 기울기를 절반 크기로 수치 범위가 좁아진다
활성값 재계산 역전파에 쓸 중간값을 안 들고 있는다 계산이 20에서 30퍼센트 늘어난다
기울기 누적 한 번에 올리는 배치를 쪼갠다 스텝당 시간이 늘어난다
ZeRO · FSDP 옵티마이저 상태와 가중치를 랭크에 나눈다 통신이 늘어난다
오프로딩 안 쓰는 상태를 호스트 메모리로 내린다 PCIe를 타므로 느리다

혼합 정밀도가 가장 먼저 쓰는 방법이다. 계산은 16비트로 하고 가중치 갱신만 32비트로 유지한다. 메모리가 줄고 텐서 코어를 쓰게 되어 속도까지 오르므로 대가가 거의 없다.

정밀도 형식마다 성질이 다르다. fp16은 소수 자릿수가 많지만 표현 범위가 좁아 기울기가 0으로 내려앉는 일이 생긴다. bf16은 자릿수를 줄이고 범위를 fp32와 같게 잡아 그 문제가 없다. 최신 GPU에서는 bf16을 쓰는 편이 안전하다.

형식 지수 비트 가수 비트 성질
fp32 8 23 기준. 메모리를 가장 많이 쓴다
fp16 5 10 범위가 좁다. 손실 스케일링이 필요하다
bf16 8 7 범위는 fp32와 같다. 정밀도가 낮다
fp8 4 또는 5 3 또는 2 더 빠르다. 층마다 스케일 관리가 필요하다
import torch

scaler = torch.amp.GradScaler("cuda")          # fp16 을 쓸 때만 필요하다

for batch in loader:
    with torch.autocast("cuda", dtype=torch.bfloat16):
        loss = model(batch)                     # 계산은 16비트로
    scaler.scale(loss).backward()
    scaler.step(optimizer)                      # 갱신은 32비트 마스터 가중치로
    scaler.update()
    optimizer.zero_grad(set_to_none=True)

bf16을 쓰면 GradScaler가 필요 없다. fp16에서만 기울기가 0으로 내려앉는 것을 막으려고 손실에 큰 수를 곱했다 나누는 과정이 필요하다.

혼합 정밀도가 잘 도는지는 손실 곡선과 텐서 코어 사용률로 확인한다. 손실이 갑자기 nan이 되면 범위 문제이므로 bf16으로 바꾸거나 스케일을 조정한다. 속도가 안 오르면 텐서 코어를 안 쓰고 있을 수 있고, 행렬 크기가 8의 배수가 아니면 이런 일이 생긴다.

# 텐서 코어가 실제로 쓰이는지 커널 이름으로 확인한다
nsys profile -o mp --stats=true python train.py 2>&1 | grep -iE "s16816|hmma|bf16"

GitHub에서 열기

분산 학습 프레임워크 고르기

같은 데이터 병렬이라도 구현이 여럿이다. 무엇을 쓰든 통신은 NCCL이 하므로 성능 차이보다 쓰기 편한 정도와 기능 범위가 선택을 가른다.

도구 성격 맞는 자리
PyTorch DDP 표준 내장. 데이터 병렬만 모델이 GPU 한 장에 들어갈 때
PyTorch FSDP 내장. 가중치와 옵티마이저를 랭크에 나눈다 큰 모델을 PyTorch만으로
DeepSpeed ZeRO 1·2·3, 오프로딩, 파이프라인 병렬 메모리가 빠듯한 대형 학습
Megatron-LM 텐서와 파이프라인 병렬 구현 수백억 파라미터 이상
Horovod 프레임워크 중립. MPI 방식으로 띄운다 TensorFlow가 섞여 있을 때

DeepSpeed는 설정 파일로 단계를 고른다. 단계가 올라갈수록 메모리는 줄고 통신은 는다.

{
  "bf16": { "enabled": true },
  "zero_optimization": {
    "stage": 3,
    "offload_optimizer": { "device": "none" },
    "reduce_bucket_size": 5e8
  },
  "gradient_accumulation_steps": 4,
  "train_micro_batch_size_per_gpu": 2
}
# Slurm 위에서 DeepSpeed 를 띄운다. 호스트 파일 대신 srun 이 프로세스를 배치한다
srun --ntasks-per-node=8 python -m deepspeed.launcher.launch \
     --no_ssh --node_rank=$SLURM_NODEID train.py --deepspeed ds_config.json

Horovod는 MPI로 프로세스를 띄우므로 Slurm과 잘 맞는다. 다만 PyTorch만 쓴다면 굳이 고를 이유가 줄었다. 오래된 코드를 이어받는 경우가 대부분이다.

확인할 것 그렇다면
모델이 GPU 한 장에 들어간다 DDP로 충분하다
옵티마이저 상태가 안 들어간다 FSDP나 DeepSpeed ZeRO 2
가중치도 안 들어간다 ZeRO 3이나 텐서 병렬
층 하나가 GPU 한 장을 넘는다 텐서 병렬이 필요하다
이미 TensorFlow 코드가 있다 Horovod

GitHub에서 열기

추론을 같은 클러스터에서 돌릴 때

학습만 있는 클러스터는 드물다. 학습한 모델을 서비스하려면 성격이 다른 워크로드가 같은 자원을 쓰게 된다.

항목 학습 추론
수명 몇 시간에서 몇 주 계속 살아 있다
목표 처리량 응답 지연
자원 형태 GPU 여러 장을 한 덩어리로 GPU 한 장 또는 그 일부
부하 일정하다 시간대에 따라 변한다
실패 재시작하고 이어간다 즉시 다른 복제본이 받는다
여유 채울수록 좋다 남겨 둬야 한다

목표가 반대라 같은 노드에 섞으면 둘 다 나빠진다. 학습이 GPU를 채우면 추론 지연이 튀고, 추론을 위해 여유를 남기면 학습 처리량이 떨어진다. 노드를 나누는 것이 기본이고, 나눈 뒤에 남는 문제는 추론 쪽 자원이 노는 것이다.

추론은 GPU 한 장을 통째로 쓰지 않는 경우가 많으므로 나눠 쓰는 방법을 검토한다. 격리가 필요하면 MIG로 하드웨어에서 나누고, 처리량이 목적이면 MPS나 서빙 엔진의 배치 기능을 쓴다.

# MIG 인스턴스를 요청한다. 카드 한 장을 여럿이 나눠 쓴다
resources:
  limits:
    nvidia.com/mig-1g.10gb: 1

시간대별로 자원을 옮기는 정책이 통하려면 추론 부하가 실제로 시간을 탄다는 근거가 있어야 한다. 사내 서비스는 밤에 부하가 크게 주는 경우가 많아 그 시간대를 학습에 넘길 수 있다. 대외 서비스는 그렇지 않은 경우가 많으므로 옮기기 전에 지난 부하 곡선부터 본다.

# 지난 4주 시간대별 추론 GPU 사용률. 밤에 실제로 비는지 확인한다
cat <<'PROMQL'
avg by (hour) (
  avg_over_time(DCGM_FI_DEV_GPU_UTIL{pool="inference"}[4w])
)
PROMQL

GitHub에서 열기

GPU 소프트웨어 스택 맞추기

분산 학습이 도는 데 필요한 것은 층으로 쌓여 있고, 아래층이 위층의 상한을 정한다. 한 층이라도 어긋나면 조용히 느려지거나 시작조차 못 한다.

층 무엇 어긋나면
커널과 드라이버 NVIDIA 드라이버, OFED 장치가 안 보이거나 RDMA가 죽는다
CUDA 런타임 컨테이너 안의 CUDA 드라이버보다 높으면 실행이 안 된다
통신 라이브러리 NCCL, UCX 경로를 잘못 골라 TCP로 떨어진다
MPI OpenMPI, MPICH 프로세스가 서로를 못 찾는다
프레임워크 PyTorch, JAX 빌드된 CUDA 버전과 안 맞는다

CUDA 런타임은 드라이버보다 새로울 수 없다는 규칙이 가장 자주 걸린다. 컨테이너 이미지의 CUDA가 12.4인데 노드 드라이버가 12.2용이면 그 노드에서만 죽는다. 노드마다 드라이버 버전이 다르면 같은 이미지가 어떤 노드에서만 실패하는 현상이 나온다.

nvidia-smi | head -3                       # 드라이버와 지원 CUDA 버전
nvcc --version | tail -2                   # 빌드에 쓰인 CUDA
python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())"
ofed_info -s                               # OFED 버전

MPI는 노드를 넘는 프로세스를 띄우는 층이다. Slurm과 함께 쓰면 srun이 그 일을 대신하므로 호스트 파일을 만들 필요가 없다. OpenMPI를 Slurm 지원으로 빌드해 두면 srun이 직접 띄운다.

ompi_info | grep -E "Configure command line|MCA pmix|slurm"   # Slurm 연동이 들어갔는지
srun --mpi=list                                               # 쓸 수 있는 MPI 방식
srun --mpi=pmix -N4 --ntasks-per-node=8 ./my_mpi_app

--mpi=list에 pmix가 없으면 Slurm이 MPI 지원 없이 빌드된 것이라 mpirun을 따로 써야 한다. 이 경우 자원 할당과 프로세스 배치가 어긋나기 쉬우므로 가능하면 연동을 갖춘다.

통신 라이브러리가 어느 경로를 골랐는지는 반드시 확인한다. 설정이 빠지면 오류 없이 TCP로 떨어져 몇 배 느려지고, 그 상태로 몇 주를 돌리는 사고가 실제로 일어난다.

export NCCL_DEBUG=INFO
export NCCL_DEBUG_SUBSYS=INIT,NET
srun -N2 --ntasks-per-node=8 python -c "
import torch, torch.distributed as dist
dist.init_process_group('nccl'); dist.all_reduce(torch.ones(1, device='cuda'))" 2>&1 | grep -E "NET/"

NET/IB가 보이면 정상이고 NET/Socket이면 TCP로 떨어진 것이다. 대역폭 자체는 nccl-tests로 따로 잰다. 노드 수를 늘려가며 재면 어디서 꺾이는지 보인다.

srun -N8 --ntasks-per-node=8 all_reduce_perf -b 8 -e 8G -f 2 -g 1

버전 조합은 노드마다 같아야 한다. 한 대만 드라이버가 다르면 그 노드가 섞인 작업만 실패하고, 원인을 찾는 데 시간이 든다. 조합을 파일로 고정하고 배포 자동화가 그 값을 강제하게 두는 편이 안전하다. 모듈 10의 드라이버 배포 항목에서 그 방법을 정리했다.

GitHub에서 열기

무엇을 고를 것인가

상황 맞는 선택
학습이 주력이고 노드 간 통신이 중요하다 Slurm
사용자가 배치 제출에 익숙하다 Slurm
대기열과 공정 배분이 필요하다 Slurm, 또는 Kubernetes에 Kueue를 얹는다
추론 서비스를 같은 스택에서 운영한다 Kubernetes
컨테이너 기반 배포가 이미 표준이다 Kubernetes
팀이 이미 Kubernetes를 운영한다 Kubernetes

둘 다 운영하는 선택도 흔하다. 이때 비용은 스케줄러 두 벌이 아니라 두 스택을 아는 인력이라는 점을 감안해야 한다. 노드를 어떻게 나눌지는 모듈 06의 분리 전략에서 다룬다.

처음 고르는 자리라면 워크로드의 성격을 먼저 확인한다. 작업에 끝이 있고 시작할 때 필요한 자원이 정해져 있으면 배치 시스템이 맞는다. 요청이 오는 동안 계속 살아 있어야 하고 부하에 따라 개수가 변해야 하면 서비스 시스템이 맞는다. 학습은 앞쪽이고 추론은 뒤쪽이다.

물어볼 것 그렇다면
노드 여러 대가 동시에 시작해야 하는가 Slurm은 기본으로 보장한다. Kubernetes는 Volcano나 Kueue를 얹어야 한다
자원이 모자랄 때 줄을 세워야 하는가 Slurm은 내장이다. Kubernetes는 대기열 자체가 없다
사용자 여럿이 공정하게 나눠 써야 하는가 Slurm의 공정 배분이 성숙하다
실패했을 때 자동으로 다시 띄워야 하는가 Kubernetes의 기본 동작이다
트래픽에 따라 개수가 변해야 하는가 Kubernetes다. Slurm에는 그런 개념이 없다
노드 간 통신이 성능을 좌우하는가 Slurm의 토폴로지 인지 배치가 유리하다
이미 운영 중인 스택이 있는가 그쪽으로 맞추는 편이 대개 싸다

한쪽으로 통일할 수 있으면 통일하는 편이 낫다. 두 스택을 함께 두면 인증, 이미지, 파일 권한, 드라이버, 지표를 전부 두 번 맞춰야 하고, 문제가 났을 때 어느 쪽 문제인지 가리는 데 시간이 든다. 그래도 나누는 이유는 대개 학습과 추론의 요구가 정말 다르기 때문이다. 나눌 값어치가 있는지는 짐작이 아니라 유휴 시간 측정으로 정한다.

GitHub에서 열기

08

하드웨어 점검과 장애 분석실전

PCIe 링크, NVLink, Xid, DCGM

성능이 안 나오거나 작업이 이유 없이 죽었을 때 하드웨어를 어떻게 확인하는지, 평상시 헬스체크는 어떻게 걸어두는지를 본다.

GPU 노드의 물리 구성

장애를 읽으려면 무엇이 어디에 붙어 있는지부터 그려야 한다. GPU 서버 한 대는 대략 이렇게 구성된다.

소켓마다 메모리와 PCIe 스위치가 붙고 그 아래에 GPU와 네트워크 카드가 달린다

  • GPU 보드. 연산을 하는 카드다. 서버 한 대에 보통 4장이나 8장이 들어간다. 각 보드 안에 연산 유닛과 전용 메모리가 함께 있다.
  • HBM(High Bandwidth Memory, 고대역폭 메모리). GPU 보드에 바로 붙은 메모리다. GPU가 학습 데이터와 가중치를 올려 두는 곳으로, 한 장에 40~80GB가 흔하다. CPU 쪽 메모리보다 훨씬 빠르지만 용량은 작다. "GPU 메모리가 부족하다"는 이 HBM을 말한다.
  • PCIe 레인(lane). CPU와 GPU, NIC을 잇는 범용 통로다. 레인을 여러 개 묶어 폭을 만들고, x16은 레인 16개를 묶었다는 뜻이다. 세대(Gen4, Gen5)가 오를수록 레인당 속도가 오른다.
  • NVLink. GPU끼리 직접 잇는 전용 고속 링크다. PCIe보다 대역폭이 크다. 여러 GPU가 가중치를 주고받는 학습에서 이 링크가 처리량을 좌우한다.
  • NVSwitch. NVLink를 여러 GPU가 서로 잇도록 모아 주는 스위치다. 8장 노드에서 모든 GPU가 서로 최대 속도로 통신하게 한다. NVSwitch가 문제면 특정 GPU 쌍만이 아니라 여러 경로가 함께 느려진다.
  • HCA(Host Channel Adapter). InfiniBand 네트워크 카드다. 노드와 노드를 잇는다. 여러 노드로 학습을 키울 때 노드 사이 통신이 이 카드를 지난다. 이더넷 NIC의 InfiniBand 판이라고 보면 된다.

이 구성에서 문제는 층마다 다르게 나타난다. HBM은 ECC 오류로, GPU 사이 경로는 NVLink나 NVSwitch 오류로, 노드 사이 경로는 HCA와 PCIe 링크 문제로 드러난다. 아래 절들이 각 층을 순서대로 짚는다.

GitHub에서 열기

GPU 안의 구조

앞 절이 노드 안에서 GPU가 어디에 붙는지를 그렸다면, 이 절은 GPU 보드 한 장 안을 연다. 장애와 성능을 읽으려면 카드 안이 어떻게 나뉘어 도는지 알아야 한다.

GPU 연산의 기본 단위가 SM(Streaming Multiprocessor)이다. 카드 한 장에 SM 수십에서 백여 개가 들어 있고, 각 SM이 독립적으로 스레드 묶음을 돌린다. SM 안에는 연산기와 작은 공유 메모리, 레지스터가 함께 있다. GPU가 빠른 이유는 한 코어가 빨라서가 아니라 이런 SM이 많아 한꺼번에 도는 데 있다.

SM 안의 연산기는 종류가 갈린다.

연산기 맡는 연산 특징
CUDA Core 일반 부동소수점·정수 연산 요소별 연산, 폭넓게 쓰인다
Tensor Core 행렬 곱 누적(작은 행렬 단위) 딥러닝의 곱셈 누적을 몰아서 처리

CUDA Core는 값 하나하나에 대한 연산을 맡고, Tensor Core는 작은 행렬을 한 번에 곱해 더하는 연산에 특화됐다. 학습과 추론의 계산 대부분이 행렬 곱이라, 요즘 카드의 성능 수치는 Tensor Core에서 나온다. 같은 카드라도 Tensor Core를 쓰는 커널과 안 쓰는 커널의 처리량이 크게 벌어진다.

스케줄링 단위는 워프(warp)다. GPU는 스레드를 32개씩 묶은 워프 단위로 명령을 낸다. 한 워프의 32개 스레드가 같은 명령을 서로 다른 데이터에 적용한다. 그래서 워프 안의 스레드가 조건 분기로 서로 다른 길로 가면(분기 발산) 양쪽을 차례로 돌아 효율이 떨어진다. SM은 여러 워프를 쥐고 있다가, 한 워프가 메모리를 기다리는 동안 다른 워프를 올려 대기 시간을 숨긴다.

메모리는 HBM(High Bandwidth Memory) 스택으로 붙는다. HBM은 D램을 수직으로 쌓아 보드 위 GPU 옆에 바로 둔 메모리라 폭이 넓어 대역폭이 크다. GPU와 HBM 사이는 메모리 컨트롤러가 잇고, 이 대역폭이 학습 처리량을 좌우하는 경우가 많다. 연산기가 아무리 빨라도 HBM에서 데이터를 못 대면 SM이 논다.

세대별로 무엇이 달라졌는지 견줘 본다. 아래 수치는 SXM 기준의 대표값이다.

항목 A100 H100 H200
SM 수 108 132 132
HBM 용량 80GB (HBM2e) 80GB (HBM3) 141GB (HBM3e)
HBM 대역폭 약 2.0TB/s 약 3.35TB/s 약 4.8TB/s
NVLink 세대 3세대 4세대 4세대
지원 정밀도 FP64·TF32·FP16·BF16·INT8 위에 FP8 추가 H100과 동일

H100부터 Transformer Engine이 들어갔다. Transformer Engine은 FP8이라는 8비트 부동소수점으로 행렬 곱을 돌리되, 값의 범위를 층마다 재어 정밀도를 맞추는 장치다. FP8은 16비트보다 데이터가 절반이라 같은 HBM 대역폭으로 두 배를 나르고 Tensor Core 처리량도 오른다. 이득은 조건이 맞아야 나온다. 행렬 곱이 큰 트랜스포머 계열에서 크고, 작은 모델이나 통신·데이터 로딩이 병목인 워크로드에서는 FP8로 바꿔도 스텝 시간이 줄지 않는다.

카탈로그 수치를 실제 성능으로 바로 옮기면 어긋난다. 표에 적힌 대역폭과 최대 연산량은 이상적인 조건의 상한이라, 실제로는 그 절반을 밑도는 경우가 흔하다. 최대 연산량은 대개 희소성(sparsity)을 켜고 특정 정밀도를 쓴 값이라, 그 조건이 아니면 절반으로 봐야 한다. HBM 대역폭도 접근 패턴이 연속적일 때의 값이라, 조각난 접근에서는 크게 못 미친다. 실제 성능은 돌리려는 모델로 스텝 시간과 달성 대역폭을 재서 잡는다.

# 카드 모델과 정밀도별 클럭, 메모리를 확인한다
nvidia-smi --query-gpu=name,compute_cap,memory.total,clocks.max.sm --format=csv
# 실제 달성 대역폭과 연산 효율은 프로파일러로 잰다
ncu --metrics dram__throughput.avg.pct_of_peak_sustained_elapsed ./kernel_test
# Tensor Core를 실제로 쓰는지 커널 이름과 정밀도로 확인한다
nsys profile -t cuda --stats=true python train.py 2>&1 | grep -iE "gemm|tensor|fp8"

컴퓨트 능력(compute capability) 값은 세대를 코드로 구분하는 번호다. A100은 8.0, H100·H200은 9.0이라, 프레임워크가 FP8 커널을 쓸지 결정하는 근거가 된다. 이 값을 확인하면 카드가 특정 정밀도를 하드웨어로 지원하는지 가릴 수 있다.

GitHub에서 열기

nvidia-smi 출력 읽기

nvidia-smi를 인자 없이 실행하면 표 하나가 나온다. 필드마다 뜻과 위험 신호가 다르다.

nvidia-smi
필드 뜻 문제로 보는 값
Temp GPU 코어 온도(℃) 83℃ 부근에서 스로틀이 시작된다. 90℃ 이상은 위험
Pwr:Usage/Cap 현재 전력/전력 상한(W) 사용이 상한에 계속 붙어 있으면 전력 제한에 걸린 것
Memory-Usage HBM 사용/전체 전체에 근접하면 OOM 위험
GPU-Util 최근 표본에서 커널이 돈 시간 비율 학습 중인데 낮으면 GPU 밖이 병목
Perf 성능 상태 P0~P12 P0가 최대 성능. 부하 중인데 P2 이하면 스로틀 의심
ECC 메모리 오류 정정 상태 오류 카운터가 늘면 메모리 이상

GPU-Util은 오해하기 쉽다. 커널이 하나라도 돈 시간의 비율일 뿐이라, 100퍼센트여도 GPU를 효율적으로 쓴다는 보장은 없다. 실제 효율은 클럭과 전력, 처리량을 함께 봐야 안다. 온도와 클럭은 질의 형식으로 자세히 본다.

nvidia-smi --query-gpu=index,temperature.gpu,temperature.memory,\
clocks.sm,clocks.max.sm,power.draw,power.limit,pstate --format=csv

temperature.memory는 HBM 온도다. 코어 온도보다 HBM 온도가 먼저 한계에 닿는 경우가 있어, 둘을 따로 본다. clocks.sm이 clocks.max.sm보다 부하 중에 계속 낮으면 스로틀을 의심하고 열과 전력 문제 절로 넘어간다.

GitHub에서 열기

PCIe 링크 점검

GPU가 규격대로 붙어 있지 않은 경우가 실제로 있다. 물리적으로는 꽂혀 있어도 링크가 절반 폭이나 낮은 세대로 협상되면 데이터 전송 구간에서 성능이 떨어진다.

nvidia-smi --query-gpu=index,pcie.link.gen.current,pcie.link.gen.max,\
pcie.link.width.current,pcie.link.width.max --format=csv

current가 max보다 낮으면 확인이 필요하다. 다만 유휴 상태에서는 절전으로 링크를 낮추는 경우가 있으므로, 부하를 준 상태에서 다시 본다.

nvidia-smi -q | grep -A4 "GPU Link Info"
lspci -vv -s $(nvidia-smi --query-gpu=pci.bus_id --format=csv,noheader | head -1 | cut -d: -f2-) \
  | grep -E "LnkCap|LnkSta"

LnkCap은 이 슬롯이 낼 수 있는 최대치이고 LnkSta는 지금 상태다. Gen5 x16 카드가 Gen4 x8로 붙어 있다면 대역폭이 4분의 1이다. 재장착이나 슬롯 변경, 라이저 케이블 점검이 필요하다.

GitHub에서 열기

NVSwitch와 링크 오류 사례

8장 노드는 GPU가 NVSwitch를 거쳐 서로 붙는다. 이 스위치의 링크가 죽으면 특정 GPU 쌍만이 아니라 여러 경로가 함께 느려진다. 앞의 NVLink 절이 카운터 읽는 법을 짚었다면, 이 절은 NVSwitch가 있는 노드에서 링크가 죽었을 때 로그에 남는 형태와 판단을 짚는다.

링크가 죽으면 드라이버가 커널 로그에 흔적을 남긴다. NVSwitch 관련 줄은 SXid라는 코드로 찍히는데, GPU의 Xid에 해당하는 스위치판이다.

dmesg -T | grep -iE "nvswitch|SXid"
journalctl -k --since "1 hour ago" | grep -iE "nvswitch|fabric"

로그에 나오는 문구로 대략의 층을 가른다. 링크 마스크 갱신 실패(link mask update failed)나 링크가 올라오지 않았다는 줄은 그 링크가 협상 단계에서 붙지 못했다는 뜻이라, 물리 접속이나 커넥터를 의심한다. Rx 파이프라인 이벤트(Rx pipeline error) 같은 수신 경로 오류는 링크는 붙었으나 받는 쪽에서 오류가 난다는 뜻이라, 링크 품질이나 스위치 포트를 본다. 어느 쪽이든 SXid 뒤에 붙은 스위치와 포트 번호로 어느 링크인지 좁힌다.

오류 카운터는 시간에 따라 봐야 심각도가 갈린다. 절대 누적값은 부팅 이후 쌓인 것이라, 초기화한 뒤 증가 속도를 재는 것이 핵심이다.

nvidia-smi nvlink -e                     # 현재 오류 카운터
nvidia-smi nvlink -r 0                    # 카운터를 초기화한다
sleep 600
nvidia-smi nvlink -e                     # 10분 동안의 증가분

카운터 종류마다 뜻과 심각도가 다르다.

카운터 뜻 증가 속도로 본 판단
replay 전송 실패로 재전송했다 이따금은 정상, 분 단위 급증이면 물리 계층
recovery 링크가 잠깐 끊겨 복구했다 반복되면 케이블·커넥터
CRC 오류 검출 부호 불일치 지속 증가면 링크 품질, 교체 후보

10분에 몇 개 수준이면 지켜보고, 분마다 수십에서 수백으로 늘면 물리 링크나 스위치 포트로 본다. recovery와 CRC가 함께 오르면 링크가 붙었다 끊기기를 반복하는 상태라 더 나쁘다.

NVSwitch 노드는 fabric manager라는 데몬이 스위치와 링크를 초기화하고 관리한다. 이 서비스가 죽어 있으면 GPU가 서로 못 붙어 학습이 시작조차 안 되거나 링크가 반만 올라온다.

systemctl status nvidia-fabricmanager    # 데몬 상태
journalctl -u nvidia-fabricmanager --since today | grep -iE "error|degraded|failed"
nvidia-smi -q | grep -A10 "Fabric"       # GPU가 fabric에 붙은 상태

링크 하나가 빠지면 집합 통신 성능이 어떻게 떨어지는지는 통신 패턴에 달렸다. 모든 GPU가 고르게 주고받는 올리듀스에서는 가장 느린 경로가 전체를 잡아, 링크 하나가 죽어 그 GPU의 대역폭이 줄면 스텝 시간이 눈에 띄게 는다. 확인은 카드 안 진단이 아니라 실제 통신을 재서 한다.

# 노드 안 8장으로 올리듀스 대역폭을 재서 규격과 견준다
all_reduce_perf -b 8 -e 8G -f 2 -g 8
# 링크 상태와 대역폭을 함께 본다
nvidia-smi nvlink -s | grep -iE "Link|GB/s"

all_reduce_perf로 잰 버스 대역폭이 정상 노드보다 눈에 띄게 낮고, nvidia-smi nvlink -s에서 링크 수가 규격보다 적으면 죽은 링크가 통신을 누르는 것이다. 재시작으로 복구되는 경우와 교체가 필요한 경우는 이렇게 가른다. fabric manager를 재시작하거나 노드를 리셋해 링크가 규격대로 다시 올라오고 카운터가 더 안 늘면 일시적 문제라 반입한다. 리셋해도 링크가 반만 올라오거나, CRC가 리셋 뒤에도 계속 늘거나, 같은 포트가 반복해서 죽으면 스위치나 케이블 교체 대상으로 돌린다. 판단이 애매하면 노드를 드레인하고 dcgmi diag -r 3과 통신 벤치마크를 함께 돌려 근거를 모은다.

GitHub에서 열기

Xid 오류 읽기

Xid는 NVIDIA 드라이버가 커널 로그에 남기는 오류 코드다. GPU 관련 장애 조사는 대개 여기서 시작한다.

dmesg -T | grep -i xid
journalctl -k --since "2 hours ago" | grep -i xid

자주 보게 되는 코드는 스무 개 남짓이고, 그중에서도 이 정도가 대부분이다.

Xid 의미 대개의 원인
13 그래픽 엔진 예외 애플리케이션의 잘못된 메모리 접근
31 GPU 메모리 페이지 폴트 커널 코드의 버그. 사용자 코드 쪽인 경우가 많다
43 소프트웨어에 의한 중단 앞선 오류의 후속. 원인은 다른 코드에 있다
48 정정 불가 ECC 오류 하드웨어. 페이지 리타이어 또는 교체 대상
63, 64 페이지 리타이어 ECC 오류 후 해당 페이지를 격리했다
74 NVLink 오류 링크나 NVSwitch 문제
79 GPU가 버스에서 사라짐 전원, 과열, 물리 접속. 심각한 신호다

재현성과 범위로 가른다. 특정 사용자 코드에서만 나면 애플리케이션 쪽일 가능성이 높고, 같은 노드에서 사용자와 무관하게 반복되면 하드웨어를 의심한다. 여러 노드에서 한꺼번에 나면 드라이버 버전이나 전원 계통을 본다.

Xid가 특정 노드에서 반복되면 하드웨어를 의심하고 사용자 코드와 함께만 나면 애플리케이션을 본다

ECC 상태는 별도로 확인한다.

nvidia-smi -q -d ECC | grep -E "Volatile|Aggregate|Pending"
nvidia-smi --query-remapped-rows=gpu_bus_id,remapped_rows.pending,remapped_rows.failure \
           --format=csv

remapped_rows.failure가 1이면 더 이상 치환할 여유가 없다는 뜻이라 교체 대상이다.

GitHub에서 열기

ECC 오류와 교체 판단

ECC(Error-Correcting Code, 오류 정정 부호)는 메모리 비트가 뒤집혔을 때 이를 감지하고 고치는 장치다. HBM은 방사선이나 노화로 비트가 드물게 뒤집히는데, ECC가 이를 처리한다. 오류는 두 종류다.

  • 정정 가능 오류(correctable, 단일 비트). ECC가 스스로 고친다. 계산 결과는 정상이다. 이따금 나오는 것은 정상 범위이고, 한 GPU에서 급격히 늘면 그 메모리가 약해지는 신호다.
  • 정정 불가 오류(uncorrectable, 다중 비트). ECC가 감지는 하지만 고치지 못한다. 계산 결과를 믿을 수 없어 작업이 중단된다. Xid 48, 63, 64가 여기에 관련된다. 한 번이라도 나오면 심각하게 본다.
nvidia-smi -q -d ECC | grep -E "Volatile|Aggregate|Uncorrectable|Correctable"

Volatile은 부팅 이후 누적, Aggregate는 GPU 수명 전체 누적이다. 정정 불가 오류의 Aggregate가 늘고 있으면 그 카드를 신뢰하기 어렵다.

로우 리매핑(row remapping)은 문제가 생긴 메모리 행을 예비 행으로 바꿔 격리하는 기능이다. 최신 GPU는 불량 행을 예비 영역으로 치환해 계속 쓸 수 있다.

nvidia-smi --query-remapped-rows=gpu_bus_id,remapped_rows.correctable,\
remapped_rows.uncorrectable,remapped_rows.pending,remapped_rows.failure --format=csv
  • pending이 있으면 재부팅이나 GPU 리셋 후 치환이 반영된다. 그 전까지는 성능이 눌릴 수 있다.
  • failure가 1이면 예비 행을 다 썼다는 뜻이라 교체 대상이다.

RMA(Return Merchandise Authorization, 제조사 반품·교체)는 이런 조건에서 건다. 정정 불가 ECC 오류가 반복되거나, remapped_rows.failure가 1이거나, Xid 48·79가 재현되거나, 리셋으로도 링크나 카드가 돌아오지 않을 때다. 하나만으로 애매하면 노드를 드레인하고 전체 진단(dcgmi diag -r 3)을 돌려 근거를 모은 뒤 판단한다.

GitHub에서 열기

DCGM으로 헬스체크

수동 확인은 노드가 늘면 유지되지 않는다. DCGM은 진단과 상시 감시를 함께 제공한다.

dcgmi discovery -l                  # 인식된 GPU 목록
dcgmi health -g 0 -c                # 상시 감시 항목 점검
dcgmi diag -r 1                     # 빠른 진단, 수십 초
dcgmi diag -r 2                     # 중간 진단, 몇 분
dcgmi diag -r 3                     # 전체 진단, 수십 분. 노드를 비우고 돌린다

운영에서는 세 단계로 나눠 쓴다. 작업 시작 전에 -r 1로 걸러내고, 노드를 대기열에 넣기 전에 -r 2를 돌리고, 의심 노드는 드레인한 뒤 -r 3으로 확인한다.

Slurm의 프롤로그에 빠른 진단을 넣으면 문제 노드에 작업이 배정되는 것을 막을 수 있다.

# prolog.sh
if ! dcgmi diag -r 1 > /dev/null 2>&1; then
    scontrol update NodeName=$SLURMD_NODENAME State=DRAIN Reason="dcgm diag failed"
    exit 1
fi

프롤로그는 모든 작업 시작마다 돌기 때문에 오래 걸리는 진단을 넣으면 안 된다. -r 1도 부담이면 주기적 배치로 옮긴다.

GitHub에서 열기

어느 노드를 봐야 하는지 좁히기

노드 수백 대에서 학습이 느려졌을 때, 지표를 노드마다 하나씩 열어 보는 방식은 유지되지 않는다. 볼 대상을 먼저 좁히는 절차가 필요하다.

1단계, 규칙으로 거른다. 눈으로 찾기 전에 기계가 먼저 표시하게 한다. 사용률이 일정 시간 이상 바닥인 GPU, 온도나 전력이 한계에 붙은 GPU, Xid가 찍힌 노드처럼 명확한 조건을 규칙으로 정의해 두면 후보가 수백에서 몇 개로 준다.

규칙 조건 예 뜻하는 것
유휴 사용률 5% 미만이 10분 이상 할당은 됐는데 일을 안 한다
저효율 사용률은 높은데 처리량이 낮음 커널 효율이나 통신 문제
스로틀 클럭이 최대치 아래로 지속 전력이나 냉각 문제
오류 Xid 발생, ECC 카운터 증가 하드웨어 의심

2단계, 분포로 본다. 노드별 평균값 하나로는 튀는 노드가 묻힌다. 같은 작업에 참여한 GPU들의 지표를 나란히 늘어놓고 모양을 비교하면 하나만 다른 것이 눈에 띈다. 값의 분포가 다른지, 시간에 따른 패턴이 다른지는 서로 다른 문제라 따로 본다.

  • 분포가 다른 경우. 다른 GPU는 사용률이 90% 근처에 몰려 있는데 하나만 40%대에 퍼져 있다면 그 GPU가 뒤처지고 있다. 분포끼리의 거리는 젠슨-섀넌 발산 같은 척도로 수치화할 수 있다.
  • 패턴이 다른 경우. 평균은 같아도 다른 GPU가 규칙적으로 오르내릴 때 하나만 불규칙하면 통신 대기나 데이터 로딩을 의심한다. 시계열끼리의 유사도는 유클리드 거리나 상관계수로 잰다.

3단계, 묶어서 본다. 지표가 비슷한 노드끼리 군집으로 묶으면 다수 집단에서 떨어져 나온 소수가 드러난다. 노드 200대 중 3대만 다른 군집에 속한다면 그 3대부터 본다. 개별 임계값으로는 못 잡는 완만한 이상을 이 방법이 잡아낸다.

GitHub에서 열기

분산 학습의 불균형

집합 통신은 가장 느린 참여자를 기다린다. 랭크 하나가 10% 느리면 전체가 10% 느려진다. 그래서 평균 사용률이 아니라 랭크 사이의 편차를 봐야 한다.

# 작업에 참여한 노드들의 GPU 사용률을 같은 시각에 모아 비교한다
pdsh -w gpu[01-16] 'nvidia-smi --query-gpu=index,utilization.gpu,clocks.sm,power.draw \
  --format=csv,noheader' | sort

편차가 보이면 원인을 세 갈래로 나눠 확인한다.

관찰 확인할 것
특정 노드만 계속 낮다 그 노드의 스로틀, PCIe 링크 속도, NVLink 오류
매 스텝 같은 랭크가 늦다 데이터 분배 불균형. 샤드 크기가 고른지 본다
순서 없이 랜덤하게 늦다 공유 스토리지나 네트워크 경합

작업 안에서 랭크별 스텝 시간을 남기면 훨씬 빨리 좁혀진다. 학습 코드에서 스텝마다 랭크와 소요 시간을 로그로 찍고, 이후에 랭크별 분포를 비교하는 방식이다. 하드웨어 지표만으로는 데이터 불균형을 구분하지 못한다.

특정 랭크가 느릴 때는 네 층을 순서대로 걷어낸다. 위에서부터 확인하면 흔한 원인이 먼저 걸린다.

# 문제 랭크가 도는 노드에서 클럭·온도·ECC·친화도를 한 번에 본다
nvidia-smi --query-gpu=index,clocks.sm,clocks.max.sm,temperature.gpu,ecc.errors.uncorrected.volatile.total --format=csv
nvidia-smi topo -m                  # GPU와 NIC이 같은 NUMA 노드에 붙었는지
numastat -p <PID>                   # 원격 메모리 접근이 많은지
층 무엇을 보는가 판단 기준
하드웨어 클럭이 낮은가, 온도가 높은가, ECC가 늘었는가 스로틀·오류가 있으면 그 노드 하드웨어
배치 GPU와 코어·NIC이 같은 NUMA 노드인가 원격 접근이 많으면 친화도 문제
데이터 그 랭크의 샤드가 더 큰가 샤드 크기 편차가 크면 데이터 분배
통신 그 랭크만 경로가 다른가 NVLink 대신 PCIe·TCP면 통신 경로

하드웨어는 앞의 열과 전력, NVLink 절의 명령으로 확인한다. 배치는 nvidia-smi topo -m과 numastat으로 원격 메모리 접근이 잦은지 본다. 데이터는 샤드별 표본 수를 찍어 편차를 재고, 통신은 NCCL_DEBUG=INFO 로그에서 그 랭크의 경로가 다른 랭크와 같은지 확인한다.

같은 노드가 매번 걸리면 그 노드를 후보에서 격리한다. 드레인해 두고 나머지로 학습을 이어가며, 빈 시간에 dcgmi diag -r 3으로 원인을 확인한다. 노드가 바뀌어도 같은 랭크 번호가 계속 느리면 하드웨어가 아니라 데이터나 코드의 랭크 의존 로직을 본다.

GitHub에서 열기

열과 전력 문제 진단

오류 없이 느려지는 원인의 상당수가 열과 전력이다. GPU는 온도나 전력이 한계에 닿으면 스스로 클럭을 낮춰 자신을 보호한다. 이것을 스로틀링(throttling)이라고 한다.

nvidia-smi -q -d PERFORMANCE | grep -A12 "Clocks Throttle Reasons"

이 출력은 각 사유가 Active인지 Not Active인지 보여준다. Active인 줄이 지금 클럭을 누르는 원인이다.

사유 뜻 볼 곳
SW Power Cap 소프트웨어 전력 상한에 걸렸다 상한값이 의도한 것인지 확인한다
HW Thermal Slowdown 하드웨어가 온도 한계로 낮췄다 냉각, 먼지, 흡배기 온도
SW Thermal Slowdown 드라이버가 온도로 낮췄다 위와 같으나 여유가 조금 있다
HW Power Brake 외부 전원 신호로 급히 낮췄다 전원 케이블, PSU 용량, 랙 전력

전력 상한은 값으로 확인한다. 상한이 기본보다 낮게 잡혀 있으면 카드가 멀쩡해도 성능이 눌린다.

nvidia-smi -q -d POWER | grep -E "Power Limit|Power Draw"
nvidia-smi -pl 700              # 전력 상한을 700W로 되돌린다(관리자 권한)

열 문제는 한 노드 안에서도 자리를 탄다. 랙 위쪽이나 흡기구에서 먼 GPU가 먼저 뜨거워진다. 같은 노드의 GPU 온도를 나란히 놓고, 특정 슬롯만 계속 높으면 그 자리의 통풍이나 히트싱크 접촉을 본다.

nvidia-smi --query-gpu=index,temperature.gpu,temperature.memory,fan.speed \
           --format=csv -l 5

흡기 온도가 규격을 넘으면 GPU 잘못이 아니라 전산실 냉방 문제다. 여러 노드가 동시에 열 스로틀에 걸리면 개별 카드가 아니라 랙이나 공조를 본다.

GitHub에서 열기

성능 저하 진단

오류 없이 느려지는 경우가 조사하기 더 까다롭다. 스로틀링 여부부터 확인한다.

nvidia-smi -q -d PERFORMANCE | grep -A10 "Clocks Throttle Reasons"
nvidia-smi --query-gpu=index,clocks.sm,clocks.max.sm,temperature.gpu,power.draw \
           --format=csv -l 5

사유마다 볼 곳이 다르다.

사유 의미 대응
SW Power Cap 전력 상한에 걸렸다 상한 확인. 의도한 설정인지 본다
HW Thermal Slowdown 온도가 한계에 닿았다 냉각, 먼지, 랙 배치를 본다
HW Power Brake 전원 공급 문제 전원 케이블과 PSU 용량을 본다
SW Thermal Slowdown 드라이버가 온도로 낮췄다 위와 같으나 여유가 조금 있다

스로틀이 없는데 느리다면 GPU 밖을 본다. GPU 사용률이 낮으면 데이터 로더나 전처리가 병목이고, 사용률은 높은데 처리량이 낮으면 통신이나 커널 효율 문제다. 모듈 04의 NUMA와 네트워크 항목을 함께 확인한다.

GitHub에서 열기

교체 전 증거 수집

하드웨어를 빼거나 RMA를 걸기 전에 증거를 남긴다. 카드를 뽑고 나면 같은 상태를 다시 만들 수 없고, 제조사도 근거를 요구한다. 노드를 드레인한 상태에서 아래를 모은다.

nvidia-smi -q > evidence_nvsmi_$(hostname)_$(date +%F).txt   # 전체 상태 덤프
nvidia-bug-report.sh                            # 드라이버·커널·Xid를 한 파일로 모은다
dmesg -T > evidence_dmesg_$(hostname).txt
dcgmi diag -r 3 > evidence_diag_$(hostname).txt # 전체 진단 결과

남길 목록은 이렇다.

  • 로그. dmesg의 Xid와 ECC 줄, journalctl -k의 해당 구간, nvidia-bug-report.sh 출력.
  • 상태 덤프. nvidia-smi -q 전체와 리매핑 행 질의 결과.
  • 진단 결과. dcgmi diag -r 3의 실패 항목과 전문.
  • 재현 절차. 어떤 작업, 어떤 명령, 몇 번 만에 재현되는지. 재현이 안 되면 마지막 발생 시각과 그때의 지표.
  • 범위. 한 카드인지 노드 전체인지, 다른 노드에서도 나는지.

증거를 노드 이름과 날짜가 든 파일명으로 남기고, 자산 번호(시리얼)와 함께 티켓에 묶는다. 교체 후 같은 증상이 새 카드에서도 나면 카드가 아니라 슬롯이나 전원, 냉각을 의심하는 근거가 된다.

GitHub에서 열기

노드 반입 검사

새 GPU 노드를 받으면 대기열에 넣기 전에 검사를 돌린다. 초기 불량과 설정 누락을 이때 걸러야, 나중에 사용자 작업이 이상하게 죽는 일을 막는다. 검사는 구성 확인, 링크 확인, 부하 시험 순으로 한다.

구성이 기대와 맞는지 본다.

nvidia-smi -L | wc -l               # GPU 수가 사양과 같은가
nvidia-smi --query-gpu=name,vbios_version,driver_version --format=csv  # 모델과 버전 일치
nvidia-smi topo -m                  # NVLink 연결 형태가 정상인가
ibstat | grep -E "State|Rate"       # HCA 링크가 규격 속도로 붙었는가

각 링크가 규격 폭과 세대로 붙었는지 본다. PCIe가 x8로 협상되거나 NVLink 몇 개가 죽은 채로 오는 카드가 실제로 있다.

nvidia-smi --query-gpu=index,pcie.link.gen.max,pcie.link.width.max --format=csv
nvidia-smi nvlink --status          # 모든 링크가 기대 대역폭으로 잡혀야 한다

부하를 걸어 열과 오류를 본다. 전체 진단과 몇 시간짜리 부하 시험(burn-in)을 함께 돌린다.

dcgmi diag -r 3                     # 전체 진단. 실패 항목이 하나도 없어야 한다
# GPU에 지속 부하를 주며 온도와 스로틀, ECC 증가를 관찰한다
dcgmi diag -r 4 2>/dev/null || gpu-burn 3600

통과 기준을 미리 정해 두고 하나라도 어기면 반입을 보류한다.

항목 통과 기준
GPU 수와 모델 사양과 정확히 일치
드라이버·VBIOS 버전 클러스터 표준과 동일
PCIe 링크 모든 카드가 최대 세대·폭
NVLink/NVSwitch 모든 링크 정상, 오류 카운터 0
HCA 규격 속도로 링크 업
전체 진단 dcgmi diag -r 3 전 항목 통과
부하 시험 수 시간 동안 정정 불가 ECC 0, 열 스로틀 없음
노드 간 통신 기존 노드와의 NCCL 대역폭이 기준 이상

부하 시험 중 온도가 다른 노드보다 눈에 띄게 높거나, 정정 불가 ECC가 하나라도 나오면 반입하지 않고 초기 불량으로 반품한다. 검사 결과를 노드별로 남겨 두면, 나중에 그 노드가 이상해졌을 때 반입 시점과 비교할 기준이 된다.

GitHub에서 열기

10

자동화와 프로비저닝실무

Ansible로 노드를 찍어내고 장애 노드를 되돌리는 법

노드 200대를 손으로 맞추는 일은 처음 열 대까지만 버틴다. 규모가 커지면 사람의 기억과 손이 클러스터의 병목이 된다. 노드 구성을 코드로 적고, 그 코드로 프로비저닝과 복구를 반복 가능하게 만드는 방법을 이 모듈에 담는다. 도구는 Ansible을 쓰고, 예제는 Slurm과 GPU 노드를 가정한다.

손으로 고친 노드가 만드는 문제

클러스터를 처음 세울 때는 노드 열 대를 하나씩 손으로 설정한다. 드라이버를 올리고, 마운트를 걸고, 커널 파라미터를 손보고, 스케줄러에 등록한다. 열 대까지는 이 방식이 돈다. 문제는 노드가 200대가 되고, 그중 몇 대에서만 문제가 났을 때 그 몇 대를 그때그때 손으로 고치기 시작하는 순간이다. 고친 내용은 사람의 기억과 터미널 히스토리에만 남는다.

구성 드리프트(configuration drift)는 같은 이미지로 시작한 노드들이 시간이 지나며 조금씩 달라지는 현상이다. gpu07에서 작업이 자꾸 죽어 누군가 커널 파라미터 하나를 바꿔 급히 살렸다. 그 변경은 문서에 남지 않았다. 반년 뒤 gpu07을 재설치하면 그 파라미터는 사라지고, 같은 증상이 다시 난다. "그 노드만 이상하다"가 반복되는 이유는 노드가 이상해서가 아니라, 노드에 가한 변경이 어디에도 기록되지 않아서다.

드리프트가 쌓이는 경로 실제로 벌어지는 일
급한 손수정 장애를 급히 막으려 한 대만 고치고 나머지에 반영하지 않는다
부분 배포 새 드라이버를 절반만 올리다 중단해 버전이 섞인다
재설치 누락 노드를 다시 깔면서 그동안의 손수정이 통째로 날아간다
사람 의존 그 설정을 아는 사람이 휴가를 가면 아무도 손대지 못한다

같은 이미지로 깔았는데 특정 노드에서만 학습이 죽는 전형적 사례가 여기서 나온다. 컨테이너는 같고 데이터도 같은데 gpu13에서만 NCCL 통신이 멈춘다. 원인을 파보면 gpu13은 지난달 링크 장애로 누가 net.core.rmem_max를 손대고 원복하지 않은 노드였다. 사람이 노드마다 다른 상태를 기억으로 관리하는 한, 이런 사건은 규모에 비례해 늘어난다. 노드 200대의 상태를 사람의 머릿속이 아니라 코드로 옮기는 것이 자동화의 출발점이다.

GitHub에서 열기

선언형으로 관리한다는 것

명령형(imperative)은 무엇을 어떤 순서로 실행할지 적는 방식이다. apt install, systemctl restart처럼 동작을 나열한다. 선언형(declarative)은 노드가 어떤 상태여야 하는지를 적고, 그 상태로 맞추는 일은 도구에 맡기는 방식이다. "드라이버 550이 깔려 있어야 한다"만 적으면, 이미 깔려 있으면 아무 일도 하지 않고 없으면 설치한다.

이 차이의 핵심이 멱등성(idempotency)이다. 같은 작업을 몇 번 돌려도 결과가 같아야 한다는 성질 이다. 셸 스크립트로 짠 명령형 코드는 두 번 돌리면 문제가 생기기 쉽다.

# 명령형. 두 번 돌리면 fstab에 같은 줄이 두 번 들어간다
echo "10.0.0.1:/scratch /scratch nfs defaults 0 0" >> /etc/fstab
mount -a
# 선언형. 몇 번을 돌려도 fstab에 그 줄은 하나만 존재한다
- name: scratch 마운트 등록
  ansible.posix.mount:
    src: 10.0.0.1:/scratch
    path: /scratch
    fstype: nfs
    opts: defaults
    state: mounted

앞의 스크립트는 실행할 때마다 부작용이 쌓인다. 뒤의 태스크는 목표 상태를 적었으므로 몇 번을 돌려도 fstab에 그 줄은 하나다. 이미 마운트돼 있으면 ok로 지나가고, 없을 때만 changed로 바꾼다. 멱등성이 있으면 전체 플레이북을 언제든 다시 돌려 노드를 원하는 상태로 되돌릴 수 있다.

구분 명령형 선언형
적는 대상 실행할 동작의 순서 노드가 가져야 할 최종 상태
재실행 부작용이 쌓일 수 있다 같은 결과로 수렴한다
드리프트 교정 사람이 차이를 찾아 손댄다 다시 돌리면 목표 상태로 맞춰진다
검토 무엇이 바뀌는지 읽기 어렵다 원하는 상태가 코드에 그대로 있다

IaC(Infrastructure as Code)는 인프라 구성을 코드로 적어 버전 관리하는 방식이다. HPC 운영에서 IaC의 값어치는 재현성과 감사에 있다. 노드 200대가 같은 코드에서 나오면 드리프트가 원천적으로 줄고, 어떤 노드가 왜 그렇게 설정됐는지는 git 이력이 답한다. 노드를 잃어도 코드에서 다시 찍어 내면 되므로, 노드 한 대의 상태가 특정 개인의 기억에 묶이지 않는다.

GitHub에서 열기

Ansible의 구성 요소

Ansible은 SSH로 원격 노드에 접속해 정해진 상태로 맞추는 자동화 도구다. 노드에 별도 에이전트를 설치하지 않는다. 관리 노드에서 SSH로 붙어 파이썬 모듈을 잠깐 실행하고 빠지는 구조라, 노드 쪽에는 SSH와 파이썬만 있으면 된다. 에이전트가 없으니 에이전트 자체가 죽어 노드를 관리하지 못하는 상황이 없다. 처음 보는 독자를 위해 구성 요소를 하나씩 본다.

요소 역할
인벤토리 어떤 노드가 있고 어떻게 묶이는지 적은 목록
플레이북 어느 노드에 어떤 태스크를 돌릴지 적은 YAML 파일
롤 태스크·변수·템플릿·핸들러를 기능 단위로 묶은 재사용 단위
모듈 실제 작업을 수행하는 단위. apt, copy, mount 등
팩트 접속한 노드에서 자동으로 수집한 정보. OS, 메모리, GPU 등
핸들러 변경이 생겼을 때만 호출되는 태스크. 서비스 재시작에 쓴다

인벤토리는 관리 대상 노드의 목록이다. 가장 단순한 형태는 ini 파일이다.

[compute]
gpu[01:16]

[login]
login01

[compute:vars]
ansible_user=root

플레이북은 어느 노드 그룹에 무엇을 시킬지 적은 YAML이다. 가장 작은 플레이북 하나는 이렇다.

- name: 시간 동기화 데몬 보장
  hosts: compute
  become: true
  tasks:
    - name: chrony 설치
      ansible.builtin.apt:
        name: chrony
        state: present

    - name: chrony 활성화
      ansible.builtin.service:
        name: chrony
        state: started
        enabled: true

롤(role)은 관련된 태스크·변수·템플릿을 정해진 폴더 구조로 묶어 재사용하는 단위다. slurm 롤을 만들면 여러 플레이북에서 불러 쓴다.

roles/slurm/
├── tasks/main.yml       # 실행할 태스크
├── templates/slurm.conf.j2   # 설정 템플릿
├── handlers/main.yml    # 재시작 같은 조건부 동작
├── defaults/main.yml    # 기본 변수
└── files/               # 그대로 복사할 파일

변수는 여러 곳에서 정의할 수 있고, 겹치면 우선순위로 결정된다. 낮은 쪽부터 높은 쪽으로 덮어쓴다.

위치 우선순위
롤 defaults/main.yml 가장 낮다. 기본값 용도
인벤토리 그룹 변수 그룹 단위 설정
인벤토리 호스트 변수 특정 노드만 다르게
플레이북 vars 플레이북 안에서 지정
-e 명령행 인자 가장 높다. 일회성 덮어쓰기

팩트(fact)는 접속한 노드에서 자동으로 모으는 정보다. 조건 분기에 쓴다.

- name: 메모리가 큰 노드에만 적용
  ansible.builtin.debug:
    msg: "대용량 노드"
  when: ansible_memtotal_mb > 512000

이 여섯 조각이면 뒤 섹션의 플레이북을 다 읽는다. 인벤토리로 대상을 정하고, 플레이북과 롤로 할 일을 적고, 팩트로 노드마다 다른 조건을 처리하고, 핸들러로 변경 시에만 재시작한다.

GitHub에서 열기

인벤토리로 클러스터를 표현하기

인벤토리는 클러스터의 지도다. 노드를 역할별 그룹으로 묶으면 "GPU 노드 전부", "로그인 노드만" 같은 대상 지정이 한 줄로 끝난다. HPC 클러스터는 보통 로그인·컴퓨트·스토리지·관리 네 그룹으로 나뉘고, 컴퓨트는 다시 GPU 노드와 CPU 노드로 갈린다.

그룹 담는 노드 그룹 변수로 줄 것
login 사용자 접속용 노드 홈 마운트, 사용자 셸 환경
compute_gpu GPU 컴퓨트 노드 드라이버 버전, GRES 설정
compute_cpu CPU 전용 컴퓨트 노드 코어 수, 메모리 한도
storage 파일서버 노드 익스포트 경로, 쿼터
mgmt 스케줄러 컨트롤러 등 slurmctld, 모니터링 서버

YAML 인벤토리는 그룹의 상하 관계와 그룹 변수를 한 파일에 담기 좋다. GPU 노드와 CPU 노드를 나눈 실제 인벤토리는 이렇다.

all:
  children:
    login:
      hosts:
        login01:
    compute_gpu:
      hosts:
        gpu[01:16]:
      vars:
        nvidia_driver_version: "550.90.07"
        slurm_gres: "gpu:8"
    compute_cpu:
      hosts:
        cpu[01:32]:
      vars:
        slurm_gres: ""
    storage:
      hosts:
        nfs01:
    mgmt:
      hosts:
        ctld01:
  vars:
    ansible_user: root
    ntp_server: 10.0.0.10

gpu[01:16]은 gpu01부터 gpu16까지 열여섯 대를 한 줄로 표현하는 범위 문법이다. 노드 수가 늘어도 줄이 늘지 않는다. 그룹 변수는 그 그룹 노드 전부에 적용되므로, GPU 노드의 드라이버 버전을 한 곳에서만 바꾸면 열여섯 대에 동일하게 반영된다.

호스트 패턴(host pattern)은 플레이북이나 명령행에서 대상을 좁히는 표현이다. 그룹 이름과 논리 연산을 섞어 쓴다.

# GPU 노드 전부
ansible compute_gpu -m ping
# GPU 노드 중 gpu01만 빼고
ansible 'compute_gpu:!gpu01' -m ping
# 컴퓨트 노드이면서 storage 그룹에도 든 노드 (교집합)
ansible 'compute_gpu:&storage' -m ping

인벤토리를 짜면 실제로 어떻게 묶였는지 눈으로 확인한다. --graph가 그룹 트리를 그려 준다.

ansible-inventory -i inventory.yml --graph
# @all:
#   |--@login:
#   |  |--login01
#   |--@compute_gpu:
#   |  |--gpu01
#   |  |--gpu02
#   ...

한 노드가 어떤 변수를 받는지 헷갈리면 ansible-inventory --host gpu01 --vars로 그 노드에 최종 적용되는 변수를 펼쳐 본다. 인벤토리가 클러스터의 실제 구조와 어긋나면 뒤의 모든 배포가 엉뚱한 노드로 간다. 인벤토리를 코드 저장소의 첫 검토 대상으로 두는 이유다.

GitHub에서 열기

GPU 드라이버와 CUDA 배포

GPU 드라이버 버전은 클러스터 전체에서 고정해야 한다. 컨테이너 안의 CUDA 런타임은 호스트 드라이버 위에서 돈다. 드라이버가 노드마다 제각각이면, 같은 컨테이너가 어떤 노드에서는 돌고 어떤 노드에서는 CUDA driver version is insufficient 오류로 죽는다. 컨테이너의 CUDA 12.4를 쓰려면 호스트 드라이버가 그 이상을 지원해야 하므로, 드라이버 버전을 인벤토리에 박아 두고 전 노드를 그 버전에 맞춘다.

배포 롤의 태스크는 설치·검증·재부팅 순으로 짠다. 설치가 실패하면 그 노드에서 즉시 멈춰야 다음 검증 단계로 넘어가지 않는다.

# roles/nvidia_driver/tasks/main.yml
- name: 기존 드라이버 모듈 상태 확인
  ansible.builtin.command: nvidia-smi --query-gpu=driver_version --format=csv,noheader
  register: smi
  failed_when: false
  changed_when: false

- name: 드라이버 패키지 설치
  ansible.builtin.apt:
    name: "nvidia-driver-{{ nvidia_driver_version.split('.')[0] }}"
    state: present
  when: nvidia_driver_version not in smi.stdout
  notify: reboot node

핸들러는 변경이 있었을 때만 재부팅을 건다. 이미 맞는 버전이면 재부팅하지 않는다.

# roles/nvidia_driver/handlers/main.yml
- name: reboot node
  ansible.builtin.reboot:
    reboot_timeout: 600

배포 후 검증은 실제로 GPU가 보이는지 확인하는 태스크로 둔다. 설치만 되고 커널 모듈이 안 올라온 상태를 잡아낸다.

- name: 재부팅 후 드라이버 검증
  ansible.builtin.command: nvidia-smi --query-gpu=driver_version --format=csv,noheader
  register: verify
  changed_when: false
  failed_when: nvidia_driver_version not in verify.stdout

핵심은 한 번에 전 노드를 건드리지 않는 것이다. 드라이버 배포는 재부팅을 동반하므로, 전 노드를 동시에 재부팅하면 클러스터가 통째로 내려간다. serial은 한 번에 몇 대씩 나눠 처리할지 정하는 키워드다. 롤링(rolling) 배포로 앞 묶음이 검증까지 끝나야 다음 묶음으로 넘어간다.

- name: GPU 드라이버 롤링 배포
  hosts: compute_gpu
  become: true
  serial: 2          # 한 번에 2대씩
  max_fail_percentage: 0   # 한 대라도 실패하면 전체 중단
  roles:
    - nvidia_driver

serial: 2는 열여섯 대를 두 대씩 여덟 묶음으로 나눈다. max_fail_percentage: 0은 한 묶음에서 한 대라도 실패하면 나머지 묶음을 시작하지 않고 멈춘다. 배포 전에 그 묶음의 노드를 미리 드레인해 돌던 작업이 없게 하는 것이 안전하다.

검증 항목 확인 명령 통과 기준
드라이버 버전 nvidia-smi --query-gpu=driver_version 목표 버전과 일치
GPU 개수 nvidia-smi -L 줄 수 노드의 물리 GPU 수와 일치
ECC 상태 nvidia-smi -q -d ECC 예정한 설정과 일치
퍼시스턴스 nvidia-smi -q -d PERFORMANCE 퍼시스턴스 모드 켜짐

배포 후 GPU 개수가 물리 장착 수와 다르면 커널 모듈이 일부 GPU를 못 잡은 것이다. 이 노드는 드레인 상태로 두고 사람이 확인한다. 검증을 배포 롤 안에 넣어야, 배포가 끝났다는 말과 실제로 쓸 수 있다는 상태가 어긋나지 않는다.

GitHub에서 열기

Slurm 클러스터 구성 자동화

Slurm 클러스터에서 slurm.conf는 모든 노드가 같은 사본을 가져야 한다. 컨트롤러와 컴퓨트 노드가 서로 다른 설정을 보면 노드 인식이 어긋나 노드가 DOWN으로 떨어진다. 설정 파일을 노드마다 손으로 복사하면 언젠가 한 대가 빠지므로, 템플릿 하나에서 전 노드로 뿌리는 방식을 쓴다.

설정을 Jinja2 템플릿으로 만들면 노드 목록 같은 변경 지점을 변수로 뽑아낼 수 있다.

# roles/slurm/templates/slurm.conf.j2
ClusterName={{ slurm_cluster_name }}
SlurmctldHost={{ groups['mgmt'][0] }}

SelectType=select/cons_tres
GresTypes=gpu

{% for host in groups['compute_gpu'] %}
NodeName={{ host }} CPUs=128 RealMemory=1000000 Gres=gpu:8 State=UNKNOWN
{% endfor %}

PartitionName=train Nodes={{ groups['compute_gpu'] | join(',') }} MaxTime=72:00:00 Default=YES

이 템플릿은 인벤토리의 compute_gpu 그룹을 돌며 노드 정의를 자동으로 만든다. 노드를 인벤토리에 추가하면 설정에도 자동 반영되므로, 노드 목록이 인벤토리와 설정에서 어긋날 일이 없다.

munge는 Slurm 데몬 사이의 인증에 쓰는 키 기반 인증 서비스다. 모든 노드가 동일한 munge 키를 가져야 서로를 신뢰한다. 키가 다른 노드는 컨트롤러가 거부한다. 키는 컨트롤러에서 한 번 만들고 전 노드에 같은 파일로 뿌린다.

# roles/slurm/tasks/main.yml
- name: munge 키 배포
  ansible.builtin.copy:
    src: files/munge.key
    dest: /etc/munge/munge.key
    owner: munge
    group: munge
    mode: "0400"
  notify: restart munge

- name: slurm.conf 배포
  ansible.builtin.template:
    src: slurm.conf.j2
    dest: /etc/slurm/slurm.conf
    mode: "0644"
  notify: reconfigure slurm

컨트롤러와 컴퓨트 노드는 역할이 다르므로 실행할 데몬도 다르다. 롤을 하나 두고 그룹으로 나눠 적용한다.

노드 역할 그룹 실행 데몬
컨트롤러 mgmt slurmctld, munge
컴퓨트 compute_gpu, compute_cpu slurmd, munge
DB(선택) mgmt slurmdbd

설정을 바꾼 뒤에는 데몬을 재시작하지 않고 scontrol reconfigure로 다시 읽게 한다. 재시작은 돌던 작업에 영향을 줄 수 있지만, reconfigure는 설정만 다시 적용해 영향이 작다.

# roles/slurm/handlers/main.yml
- name: restart munge
  ansible.builtin.service:
    name: munge
    state: restarted

- name: reconfigure slurm
  ansible.builtin.command: scontrol reconfigure
  delegate_to: "{{ groups['mgmt'][0] }}"
  run_once: true

delegate_to와 run_once는 이 명령을 컴퓨트 노드마다가 아니라 컨트롤러에서 한 번만 돌리게 한다. 설정이 전 노드에서 같다는 보장이 있으면, 노드가 DOWN으로 떨어지는 구축 초기의 흔한 문제 대부분이 사라진다.

GitHub에서 열기

노드 초기화 플레이북

새 노드를 받아 클러스터에 넣는 과정은 여러 단계로 이뤄진다. 이걸 한 번에 도는 초기화 플레이북으로 묶으면, 노드를 꽂고 인벤토리에 한 줄 넣고 플레이북 한 번 돌리는 것으로 끝난다. 단계는 순서가 중요하다. 앞 단계가 성립해야 뒤 단계가 의미를 갖기 때문이다.

기본 설정부터 스케줄러 등록까지 단계마다 검증이 붙고 실패하면 멈춘다

순서 단계 왜 이 순서인가 검증
1 커널 파라미터 네트워크·메모리 튜닝이 뒤 단계의 전제 sysctl 값 확인
2 스토리지 마운트 드라이버·이미지가 공유 경로에 있을 수 있다 mountpoint 확인
3 시간 동기화 인증과 로그 정합성의 전제 chronyc tracking
4 GPU 드라이버 스케줄러가 GRES로 GPU를 셀 수 있어야 한다 nvidia-smi
5 모니터링 에이전트 등록 직후부터 지표가 남아야 한다 에이전트 포트 응답
6 스케줄러 등록 앞이 다 서야 작업을 받아도 된다 sinfo에 idle

초기화 플레이북은 각 단계를 롤로 부르고, 단계마다 검증을 붙인다.

- name: 신규 노드 초기화
  hosts: new_nodes
  become: true
  serial: 4
  roles:
    - kernel_tuning
    - storage_mounts
    - chrony
    - nvidia_driver
    - monitoring_agent
    - slurm
  post_tasks:
    - name: 스케줄러에 노드가 보이는지 확인
      ansible.builtin.command: sinfo -n {{ inventory_hostname }} -h -o "%t"
      delegate_to: "{{ groups['mgmt'][0] }}"
      register: nodestate
      changed_when: false
      failed_when: nodestate.stdout not in ['idle', 'mix', 'alloc']

커널 파라미터는 GPU 통신과 대용량 I/O에 맞춰 잡는다. sysctl 모듈이 값을 설정하고 유지되게 한다.

# roles/kernel_tuning/tasks/main.yml
- name: 네트워크 버퍼 확대
  ansible.posix.sysctl:
    name: "{{ item.name }}"
    value: "{{ item.value }}"
    sysctl_set: true
    state: present
    reload: true
  loop:
    - { name: net.core.rmem_max, value: "268435456" }
    - { name: net.core.wmem_max, value: "268435456" }
    - { name: vm.swappiness, value: "0" }

마운트와 시간 동기화는 앞서 본 모듈을 재사용한다. 중요한 것은 마지막에 스케줄러에 노드가 idle로 보이는지까지 검증한다는 점이다. 드라이버까지 다 깔렸는데 스케줄러가 노드를 못 보면, 그 노드는 클러스터에 들어온 것이 아니다. post_tasks의 확인이 통과해야 초기화가 끝났다고 본다. 이 검증이 없으면 "설치는 됐는데 작업이 안 들어간다"는 신고가 뒤늦게 뜬다. 각 단계가 자기 검증을 품고 있어야 초기화 플레이북 한 번으로 노드를 신뢰할 수 있다.

GitHub에서 열기

장애 노드 복구와 롤백

자동 복구는 어디까지 허용할지 선을 그어야 한다. 재부팅으로 낫는 일시적 문제는 자동으로 처리해도 되지만, 하드웨어 고장은 자동으로 반복 재부팅해 봐야 상태만 나빠진다. 자동 복구의 범위를 표로 고정해 두면 판단이 흔들리지 않는다.

증상 자동 복구 허용 처리
드라이버 모듈 언로드 허용 드레인 후 재부팅, 검증 후 복귀
일시적 슬럼드 응답 없음 허용 slurmd 재시작, 실패 시 재부팅 1회
Xid 하드웨어 오류 반복 불가 드레인 유지하고 사람에게 넘긴다
파일시스템 마운트 실패 조건부 재마운트 1회, 실패 시 사람
ECC 정정 불가 오류 불가 작업 보존하고 하드웨어 팀

복구 플레이북은 드레인, 재부팅, 재검증 순으로 돈다. 복구 시도 횟수를 제한하고, 초과하면 자동으로 멈춰 사람에게 넘긴다.

- name: 장애 노드 복구
  hosts: "{{ target }}"
  become: true
  serial: 1
  tasks:
    - name: 스케줄러에서 드레인
      ansible.builtin.command: >
        scontrol update NodeName={{ inventory_hostname }}
        State=DRAIN Reason="auto-recovery {{ ansible_date_time.iso8601 }}"
      delegate_to: "{{ groups['mgmt'][0] }}"

    - name: 재부팅
      ansible.builtin.reboot:
        reboot_timeout: 600

    - name: GPU 검증
      ansible.builtin.command: nvidia-smi -L
      register: gpus
      changed_when: false
      failed_when: gpus.stdout_lines | length != 8

    - name: 검증 통과 시에만 복귀
      ansible.builtin.command: >
        scontrol update NodeName={{ inventory_hostname }} State=RESUME
      delegate_to: "{{ groups['mgmt'][0] }}"

검증이 실패하면 마지막 복귀 태스크에 도달하지 못하므로 노드는 드레인 상태로 남는다. 자동 복구가 못 살린 노드를 억지로 큐에 되돌리지 않는다는 것이 이 설계의 안전장치다. 사람이 아침에 드레인 사유를 보고 하드웨어를 판단한다.

롤백을 위해 변경 전 상태를 남긴다. 설정 파일을 바꾸기 전에 타임스탬프 백업을 뜨면, 새 설정이 문제를 일으켰을 때 이전 파일로 되돌린다.

- name: 설정 백업 후 배포
  ansible.builtin.copy:
    src: files/munge.key
    dest: /etc/munge/munge.key
    backup: true      # 기존 파일을 timestamp 붙여 남긴다
    owner: munge
    mode: "0400"

자동화가 상황을 악화시키는 경우가 있다. 원인이 공유 스토리지 장애인데 복구 플레이북이 전 노드를 동시에 재부팅하면, 멀쩡한 노드까지 내려가 장애가 커진다. 그래서 복구도 serial: 1로 한 대씩 처리하고, 같은 증상이 여러 노드에서 동시에 뜨면 자동 복구를 멈추고 공용 요소를 먼저 의심한다. 한 대의 문제는 그 노드를, 여러 대의 동시 문제는 공유 자원을 본다는 원칙이 자동화에도 그대로 적용된다.

GitHub에서 열기

바꾸기 전에 확인하기

운영 중인 클러스터에 플레이북을 돌리는 일은 위험하다. 실수 하나가 200대에 동시에 퍼진다. 그래서 바꾸기 전에 무엇이 바뀔지 미리 보는 습관이 필요하다. --check는 실제로 바꾸지 않고 무엇이 바뀔지 예고만 하는 모드다. --diff는 파일이 어떻게 달라지는지 줄 단위로 보여 준다.

# 실제 변경 없이 무엇이 바뀔지 예고
ansible-playbook site.yml --check --diff

한 대에서 먼저 시험한 뒤 전체로 넓힌다. --limit은 대상을 좁히는 인자다.

# gpu01 한 대에만 먼저 적용
ansible-playbook site.yml --limit gpu01
# 통과하면 GPU 노드 전체로
ansible-playbook site.yml --limit compute_gpu

태그를 붙이면 플레이북의 일부만 실행할 수 있다. 드라이버는 그대로 두고 모니터링 설정만 바꾸고 싶을 때 쓴다.

- name: 모니터링 에이전트 설정
  ansible.builtin.template:
    src: agent.conf.j2
    dest: /etc/monitoring/agent.conf
  tags: [monitoring]
# monitoring 태그가 붙은 태스크만 실행
ansible-playbook site.yml --tags monitoring --limit gpu01 --check
확인 수단 하는 일
--check 바꾸지 않고 변경 예정만 표시
--diff 파일 변경 내용을 줄 단위로 표시
--limit 대상 노드를 한 대나 한 그룹으로 좁힘
--tags 플레이북의 일부 태스크만 실행
--start-at-task 특정 태스크부터 이어서 실행

운영 중 클러스터에서 절대 하지 말아야 할 것이 있다. 전 노드를 대상으로 --check 없이 처음 돌리는 것, 돌던 작업이 있는 노드를 드레인 없이 재부팅하는 것, 확인 안 된 플레이북을 serial 없이 전체에 거는 것이다. 새 플레이북은 항상 한 대에서 --check로 예고를 보고, 실제 적용을 한 대로 검증한 뒤, 묶음을 나눠 넓힌다. 이 순서를 건너뛰면 자동화가 사고를 자동으로 퍼뜨리는 도구가 된다.

GitHub에서 열기

변경을 기록하고 되돌리기

플레이북은 git 저장소에 둔다. 인벤토리, 롤, 플레이북, 그룹 변수를 한 저장소에 담으면 클러스터의 상태가 코드로 남고, 누가 언제 무엇을 왜 바꿨는지는 커밋 이력이 답한다. 저장소 구조는 역할별로 나눈다.

cluster-ansible/
├── inventory/
│   ├── production.yml
│   └── staging.yml       # 테스트 클러스터
├── group_vars/
│   ├── compute_gpu.yml
│   └── all.yml
├── roles/
│   ├── nvidia_driver/
│   └── slurm/
├── site.yml              # 전체 배포 진입점
└── .ansible-lint         # 린트 규칙

변경 이력과 작업 기록을 잇는다. 커밋 메시지에 변경 사유와 관련 장애 번호를 적으면, 나중에 "이 설정이 왜 이렇게 됐나"라는 질문이 git blame으로 풀린다. gpu13의 커널 파라미터 사건이 반복되지 않는 이유가 여기 있다. 손수정 대신 커밋으로 바꾸면, 그 변경은 코드와 이력에 남아 재설치에도 살아 남는다.

CI에서 문법 검사와 테스트 적용을 자동으로 돌린다. ansible-lint는 문법 오류와 위험한 패턴을 잡아내는 린터다.

# 문법과 모범 사례 위반을 검사
ansible-lint site.yml
# 실행 전 구문 검사
ansible-playbook site.yml --syntax-check

CI 파이프라인은 병합 전에 린트와 테스트 클러스터 적용을 강제한다.

# .github/workflows/ansible.yml
name: ansible-ci
on: [pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: 린트 실행
        run: ansible-lint site.yml
      - name: 구문 검사
        run: ansible-playbook site.yml --syntax-check
단계 검사 내용 통과 못 하면
린트 문법과 위험 패턴 병합 차단
구문 검사 플레이북 구조 오류 병합 차단
테스트 클러스터 적용 staging에서 실제 실행 검토 반려
승인 운영자 리뷰 프로덕션 배포 보류

프로덕션 인벤토리를 대상으로 하는 배포는 승인 절차를 둔다. 테스트 클러스터(staging)에서 먼저 돌려 문제가 없음을 확인하고, 최소 한 명의 운영자가 변경을 검토한 뒤 병합한다. 병합된 코드만 프로덕션에 적용한다. 이 절차가 있으면, 한 사람의 실수가 곧바로 200대에 퍼지는 경로가 막힌다.

GitHub에서 열기

무엇을 자동화하지 않을 것인가

자동화에는 비용이 있다. 플레이북을 짜고, 검증을 붙이고, 유지보수하는 시간이 든다. 한 번 쓰고 마는 작업을 자동화하면 만드는 데 든 시간을 회수하지 못한다. 노드 한 대에서 한 번만 할 진단 명령을 플레이북으로 만드는 것보다, 그냥 SSH로 들어가 실행하는 편이 빠르다. 자동화의 값어치는 반복 횟수와 노드 수에 비례한다.

사람의 판단이 필요한 지점은 자동화하지 않는다. 어느 노드를 언제 정비에서 뺄지, 장애의 근본 원인이 무엇인지, 하드웨어를 교체할지 수리할지는 상황을 읽어야 하는 결정이다. 자동화는 판단이 끝난 뒤의 반복 실행을 맡는 도구지, 판단 자체를 대신하지 못한다. 판단을 코드에 억지로 밀어 넣으면, 조건 분기가 끝없이 늘어난 플레이북이 되고, 그 플레이북을 읽는 일이 다시 사람의 부담이 된다.

항목 자동화가 위험한 이유 대신
전 노드 동시 재부팅 클러스터가 통째로 내려간다 serial로 나눠 처리
데이터 삭제 되돌릴 수 없다 목록 확인 후 사람이 실행
하드웨어 교체 판단 오진이면 멀쩡한 노드를 뺀다 사람이 진단 후 결정
원인 불명 장애 반복 복구 증상만 덮고 원인이 쌓인다 자동 복구 멈추고 조사
보안 키 재발급 순서 실수로 전 노드 인증 붕괴 검증된 절차로 단계 실행
파티션·쿼터 정책 변경 사용자 작업에 직접 영향 공지 후 승인받아 적용

자동화하지 않기로 한 것도 기록해 둔다. "이 작업은 수동으로 한다"는 결정과 그 이유를 문서에 남기면, 나중에 누가 그 부분을 자동화하려다 같은 위험을 다시 만나지 않는다. 자동화의 성숙도는 얼마나 많이 자동화했는가가 아니라, 무엇을 자동화하고 무엇을 사람에게 남길지 선을 얼마나 잘 그었는가로 드러난다. 반복되고 검증 가능한 일은 코드로, 판단과 되돌릴 수 없는 일은 사람에게 남기는 것이 원칙이다.

GitHub에서 열기

11

관측 체계 구축실무

지표와 로그를 모아 문제를 먼저 발견하는 구조

노드 수백 대에서 무슨 일이 벌어지는지 사람이 하나씩 열어 보는 방식은 오래가지 못한다. 지표와 로그를 한곳에 모아 두고, 문제가 사용자 문의로 올라오기 전에 화면과 알림으로 잡아내는 구조를 짜는 것이 이 모듈이다. 03의 모니터링 절이 뼈대라면, 여기서는 무엇을 어떻게 모으고 어디서 끊어 볼지까지 내려간다.

지표와 로그와 추적

관측의 재료는 세 갈래다. 셋은 답하는 질문이 서로 다르고, 하나로 나머지를 대신하지 못한다.

지표(metric)는 시간에 따라 찍히는 수치다. GPU 사용률 87퍼센트, 온도 72도, 대기 작업 40개처럼 숫자 하나가 일정 주기로 쌓인다. "얼마나", "몇 번", "지금 어느 수준인가"에 답한다. 값이 작고 규칙적이라 오래 보관해도 부담이 적고, 합치고 평균 내고 비율을 내기 좋다. 추세와 이상의 조짐을 보는 데 쓴다.

로그(log)는 그 순간 무슨 일이 있었는지를 문장으로 남긴 기록이다. slurmd가 노드를 잃었다는 줄, 커널이 찍은 Xid 줄, 학습 스크립트가 뱉은 스택 트레이스가 로그다. "그때 정확히 무슨 일이 났는가"에 답한다. 지표가 "72도에서 갑자기 튀었다"까지 보여주면, 그 시각의 로그가 "전력 상한에 걸려 클럭을 낮췄다"는 이유를 준다. 지표로 이상을 찾고 로그로 원인을 확인하는 순서가 기본이다.

추적(trace)은 요청 하나가 여러 구성 요소를 거치며 어디서 시간을 썼는지 잇는 기록이다. 웹 서비스에서는 핵심이지만, 배치 학습 위주의 GPU 클러스터에서는 비중이 낮다. 다만 추론 서빙이나 데이터 파이프라인이 여러 단계를 거친다면 단계별 지연을 잇는 용도로 쓸모가 있다. 학습 작업의 스텝 시간 분해도 넓은 의미의 추적에 든다.

무엇을 모을지는 질문에서 거꾸로 정한다. "이 카드가 정상인가", "이 학습이 자원을 쓰고 있는가", "자원을 더 사야 하는가"에 답하는 지표는 모은다. 답할 질문이 없는 지표는 모으지 않는다. 특히 초 단위로 찍히는 세밀한 커널 프로파일이나 모든 함수 호출 기록은 저장 비용이 크고 평소에는 볼 일이 없어, 필요할 때 해당 작업에서만 켜는 편이 낫다. 상시 수집은 넓고 성기게, 심층 수집은 좁고 촘촘하게 가른다.

재료 답하는 질문 GPU 클러스터에서의 쓸모 보관 성향
지표 얼마나, 몇 번, 어느 수준 사용률·온도·대기열 추세, 이상 조짐 길게, 싸게
로그 그때 무슨 일이 났는가 Xid, 노드 다운, 작업 실패 원인 중간, 라벨 최소로
추적 어디서 시간을 썼는가 서빙·파이프라인 단계별 지연 짧게, 표본만

세 재료를 하나의 축으로 잇는 열쇠가 작업 번호다. 지표에도 로그에도 작업 번호가 붙어 있으면, 느린 학습 하나를 잡고 그 작업의 GPU 사용률과 그 작업이 남긴 로그를 바로 오갈 수 있다. 이 연결을 처음부터 설계에 넣는 것이 관측 체계의 값을 좌우한다.

세 재료를 한 규약으로 다루려는 표준이 OpenTelemetry다. 계측 방식과 전송 형식을 정해 두고, 수집기가 받아서 원하는 저장소로 나눠 보낸다. GPU 클러스터에서 당장 필요한 물건은 아니지만, 추론 서빙이나 데이터 파이프라인처럼 여러 단계를 거치는 워크로드를 함께 운영한다면 값어치가 나온다. 학습 쪽 지표는 이미 각 수집기가 프로메테우스 형식으로 내보내므로 그대로 두고, 새로 붙이는 서비스 계층에만 적용하는 식으로 나눠 쓰는 구성이 무난하다.

# 수집기 하나가 여러 형식을 받아 각각의 저장소로 보낸다
receivers:
  otlp:
    protocols: { grpc: { endpoint: 0.0.0.0:4317 } }
  prometheus:
    config:
      scrape_configs:
        - job_name: dcgm
          static_configs: [{ targets: ["gpu07:9400"] }]

exporters:
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write
  otlphttp/logs:
    endpoint: http://loki:3100/otlp

service:
  pipelines:
    metrics: { receivers: [otlp, prometheus], exporters: [prometheusremotewrite] }
    logs: { receivers: [otlp], exporters: [otlphttp/logs] }

층을 하나 더 두는 만큼 장애가 났을 때 확인할 곳도 하나 늘어난다. 수집기가 죽으면 지표가 통째로 비므로, 수집기 자신의 상태도 감시 대상에 넣는다.

GitHub에서 열기

무엇을 모을 것인가

클러스터는 여러 층으로 쌓여 있고, 층마다 봐야 할 지표와 적당한 수집 주기가 다르다. 빠르게 변하는 값은 촘촘하게, 느리게 변하는 값은 성기게 찍는다. 온도는 초 단위로 튀지 않으니 10초면 충분하고, 대기열 길이는 15초면 되지만, GPU 사용률은 짧은 스파이크를 놓치지 않으려면 5초가 낫다. 주기를 줄일수록 저장 용량과 수집 부하가 함께 는다.

층 대표 지표 수집 주기 무엇을 보는가
노드 CPU·메모리·부하·디스크 여유 15초 서버가 건강한가
GPU 사용률·메모리·온도·전력·Xid 5~10초 카드가 일을 하는가
스케줄러 대기 작업·상태별 노드·대기 시간 15초 대기열이 흐르는가
파일시스템 용량·inode·메타데이터 지연 30초 저장이 막히는가
네트워크 링크 오류·드롭·대역폭 15초 통신이 성한가
학습 작업 스텝 시간·처리량·손실 작업이 정함 학습이 도는가

학습 작업 지표는 결이 다르다. 스텝 시간이나 처리량은 클러스터가 밖에서 재기 어렵고, 학습 코드가 스스로 내보내는 값이라 사용자에게 맡긴다. 클러스터는 GPU 사용률로 간접 추정하고, 정밀한 값은 작업이 푸시게이트웨이나 자체 exporter로 올린 것을 받는다.

층을 나누는 이유는 카디널리티(cardinality) 때문이다. 카디널리티는 라벨 조합의 가짓수다. 프로메테우스는 라벨 조합 하나하나를 별도 시계열로 저장하므로, 조합이 폭발하면 메모리와 저장이 같이 터진다. 노드 200대에 지표 종류 50개면 시계열 1만 개로 감당할 만하지만, 여기에 값이 계속 바뀌는 라벨을 붙이면 순식간에 수백만 개로 늘어난다.

라벨로 붙이면 안 되는 값은 정해져 있다.

위험한 라벨 왜 터지는가 대안
작업 번호 전부 하루 수만 개가 영원히 쌓인다 실행 중 작업만, 끝나면 시계열 소멸
사용자별 요청 ID 무한히 늘어난다 로그에만 남긴다
타임스탬프·에폭 매 순간 새 값 절대 라벨로 쓰지 않는다
파일 경로 경로 수만큼 폭발 상위 디렉터리로 묶는다

작업 번호는 미묘하다. 실행 중인 작업에만 라벨을 붙이고 작업이 끝나면 exporter가 그 시계열을 더 내보내지 않게 하면, 프로메테우스가 일정 시간 뒤 그 시계열을 정리한다. 이렇게 하면 동시에 도는 수백 개의 작업만 라벨로 유지되어 카디널리티가 유계로 묶인다. 끝난 작업까지 영구히 라벨로 남기면 안 되고, 과거 작업의 자원 사용은 스케줄러 회계(sacct)나 로그에서 따로 뽑는다.

GitHub에서 열기

수집 스택 구성

프로메테우스는 당겨오는(pull) 방식이다. 각 노드가 지표를 밖으로 밀어 보내는 대신, 노드마다 /metrics 경로로 현재 값을 노출하고 프로메테우스 서버가 주기적으로 긁어 간다. 긁는 쪽이 목록을 쥐고 있어, 어떤 노드가 응답을 안 하면 그 사실 자체가 신호가 된다. 밀어 보내는 방식은 노드가 죽으면 조용해질 뿐이라 죽음을 구별하기 어렵다.

노드와 GPU와 스케줄러의 지표는 프로메테우스로, 로그는 로키로 모여 대시보드와 알림이 같은 저장소를 본다

수집기(exporter)는 노드에 둔다. node_exporter는 CPU·메모리·디스크·네트워크를 내보내고, DCGM exporter는 GPU를, Slurm exporter는 스케줄러 상태를 내보낸다. 앞의 둘은 모든 노드에 두고, Slurm exporter는 스케줄러가 도는 관리 노드 한 곳에 둔다. 스케줄러 상태는 클러스터 전체에 하나뿐 이라 노드마다 둘 이유가 없다.

# prometheus.yml
global:
  scrape_interval: 15s
  scrape_timeout: 10s

scrape_configs:
  - job_name: node
    scrape_interval: 15s
    static_configs:
      - targets: ['gpu01:9100', 'gpu02:9100', 'gpu03:9100']

  - job_name: dcgm
    scrape_interval: 10s
    static_configs:
      - targets: ['gpu01:9400', 'gpu02:9400', 'gpu03:9400']

  - job_name: slurm
    scrape_interval: 15s
    static_configs:
      - targets: ['mgmt01:8080']

노드가 수백 대면 static_configs에 손으로 적는 대신 파일 기반 탐색을 쓴다. 노드 목록을 파일로 두고 프로메테우스가 그 파일을 다시 읽게 하면, 노드를 넣고 뺄 때 설정을 재시작하지 않아도 된다.

  - job_name: dcgm
    file_sd_configs:
      - files: ['/etc/prometheus/targets/dcgm/*.yml']
        refresh_interval: 1m
    relabel_configs:
      - source_labels: [__address__]
        regex: '([^:]+):.*'
        target_label: node
        replacement: '$1'

저장 용량은 곱셈으로 어림한다. 필요한 저장은 대략 시계열 개수 × (보존 기간 ÷ 수집 주기) × 표본당 바이트다. 프로메테우스는 표본 하나를 압축 후 평균 1~2바이트로 저장한다. 시계열 100만 개를 15초 주기로 30일 보관하면, 100만 × (30일 ÷ 15초) × 2바이트로 약 345GB가 나온다.

항목 값 계산
시계열 개수 1,000,000 노드 × 지표 종류 × 라벨 조합
초당 표본 66,667 1,000,000 ÷ 15초
30일 표본 수 1,728억 66,667 × 2,592,000초
저장 용량 약 345GB 표본당 2바이트

로컬 디스크로 30일이면 위 정도지만, 1년치 추세를 보려면 로컬 저장으로는 감당이 안 된다. 이때 원격 저장(remote write)으로 넘긴다. 프로메테우스가 긁은 지표를 Thanos나 Mimir 같은 장기 저장소로 계속 흘려보내면, 로컬은 최근 며칠만 쥐고 오래된 것은 객체 저장소에서 값싸게 보관한다.

remote_write:
  - url: http://mimir:9009/api/v1/push
    queue_config:
      max_samples_per_send: 5000
      capacity: 20000

원격 저장으로 넘길 때는 전부 넘기지 말고 오래 볼 지표만 규칙으로 골라 넘긴다. 초 단위 세밀 지표까지 1년 보관하면 저장소가 커지므로, 장기 보관은 사용률·전력·대기열처럼 추세가 의미 있는 것으로 좁힌다.

GitHub에서 열기

GPU 지표 수집

GPU 지표는 DCGM exporter가 맡는다. 배포 방식은 호스트에 직접 데몬으로 두는 방식과 컨테이너로 띄우는 방식 둘 다 된다. 베어메탈 노드가 고정돼 있으면 데몬이 단순하고, 쿠버네티스로 GPU를 관리하면 데몬셋 컨테이너가 맞는다.

호스트에 데몬으로 두는 경우, nv-hostengine이 뜬 상태에서 exporter를 서비스로 등록한다.

# 호스트 배포
systemctl enable --now nvidia-dcgm          # DCGM 호스트 엔진
dcgm-exporter -f /etc/dcgm-exporter/counters.csv &
curl -s localhost:9400/metrics | grep DCGM_FI_DEV_GPU_UTIL

컨테이너로 띄우는 경우, GPU 드라이버를 컨테이너 안으로 넣어야 하므로 --gpus all과 함께 호스트 PID 네임스페이스를 공유한다.

# 컨테이너 배포
docker run -d --gpus all --rm -p 9400:9400 \
  --cap-add SYS_ADMIN \
  -v /etc/dcgm-exporter/counters.csv:/etc/dcgm-exporter/default-counters.csv \
  nvcr.io/nvidia/k8s/dcgm-exporter:latest

무엇을 수집할지는 필드 선택 파일로 고른다. 기본값은 필드가 많아 카디널리티를 키우므로, 실제로 보는 필드만 남긴다. 파일은 필드 이름과 지표 종류, 설명을 한 줄씩 적는다.

# counters.csv
DCGM_FI_DEV_GPU_UTIL,        gauge, GPU 사용률(%)
DCGM_FI_DEV_MEM_COPY_UTIL,   gauge, 메모리 복사 사용률(%)
DCGM_FI_PROF_SM_ACTIVE,      gauge, SM 점유율(0~1)
DCGM_FI_DEV_FB_USED,         gauge, 프레임버퍼 사용(MiB)
DCGM_FI_DEV_FB_FREE,         gauge, 프레임버퍼 여유(MiB)
DCGM_FI_DEV_GPU_TEMP,        gauge, GPU 온도(C)
DCGM_FI_DEV_POWER_USAGE,     gauge, 전력(W)
DCGM_FI_DEV_ECC_DBE_VOL_TOTAL, counter, 정정 불가 ECC 누적
DCGM_FI_DEV_XID_ERRORS,      gauge, 최근 Xid 코드
DCGM_FI_DEV_CLOCK_THROTTLE_REASONS, gauge, 스로틀 사유 비트마스크

각 필드가 답하는 질문이 다르다. DCGM_FI_DEV_GPU_UTIL은 커널이 돈 시간 비율이라 "이 카드가 할당만 받고 놀고 있는가"를 잡고, DCGM_FI_PROF_SM_ACTIVE는 SM 점유율이라 "돌긴 도는데 실제로 연산 유닛이 차 있는가"까지 내려간다. 사용률이 100인데 SM 점유가 낮으면 커널이 성기게 도는 것이다. DCGM_FI_DEV_FB_USED는 OOM을 예측하고, DCGM_FI_DEV_GPU_TEMP와 DCGM_FI_DEV_POWER_USAGE는 스로틀의 원인을 가른다. DCGM_FI_DEV_XID_ERRORS와 ECC 카운터는 하드웨어 장애의 조기 신호이며, DCGM_FI_DEV_CLOCK_THROTTLE_REASONS는 클럭이 왜 낮은지를 비트로 알려준다.

필드 답하는 질문
DCGM_FI_DEV_GPU_UTIL 카드가 일을 하는가, 놀고 있는가
DCGM_FI_PROF_SM_ACTIVE 연산 유닛이 실제로 차 있는가
DCGM_FI_DEV_FB_USED 메모리가 곧 넘치는가
DCGM_FI_DEV_GPU_TEMP 열로 스로틀에 걸릴 상태인가
DCGM_FI_DEV_POWER_USAGE 전력 상한에 붙어 있는가
DCGM_FI_DEV_XID_ERRORS 방금 GPU 오류가 났는가

지표에 작업 번호와 사용자 라벨을 붙이면 카드 하나의 값을 학습 하나로 묶을 수 있다. DCGM exporter는 HPC 작업 매핑 디렉터리를 읽어 각 GPU가 지금 어느 작업에 배정됐는지 라벨로 붙인다. Slurm 프롤로그에서 배정 정보를 그 디렉터리에 적고, 에필로그에서 지운다.

# prolog: 이 노드에서 이 작업이 쓰는 GPU를 매핑 파일에 적는다
for g in $(echo $CUDA_VISIBLE_DEVICES | tr ',' ' '); do
  echo "$SLURM_JOB_ID" > /var/run/dcgm/job_$g
done

라벨이 붙으면 작업 단위 집계가 된다. avg by (hpc_job) (DCGM_FI_DEV_GPU_UTIL)로 작업별 평균 사용률을 내고, 그것이 낮으면 그 학습이 GPU 밖에서 막혀 있다는 뜻이다. 라벨이 없으면 노드 평균밖에 못 내, 한 노드에서 여러 작업이 섞여 돌 때 누가 놀고 있는지 가릴 수 없다.

GitHub에서 열기

스케줄러 지표 수집

GPU 지표가 "카드가 도는가"라면 스케줄러 지표는 "대기열이 흐르는가"에 답한다. Slurm exporter는 관리 노드에서 squeue, sinfo, sdiag를 주기적으로 읽어 프로메테우스 형식으로 내보낸다.

지표 뜻
slurm_queue_pending 대기 중인 작업 수
slurm_queue_running 실행 중인 작업 수
slurm_nodes_idle 비어 있는 노드 수
slurm_nodes_alloc 배정된 노드 수
slurm_nodes_down 죽은 노드 수
slurm_partition_gpus_alloc 파티션별 배정된 GPU 수

이 값으로 대기열 건강을 본다. 대기 작업이 계속 늘고 유휴 노드는 0이면 자원이 부족한 것이고, 대기 작업이 쌓였는데 유휴 노드가 남아 있으면 스케줄러 설정이나 자원 요청 조건이 잘못돼 배정이 안 되는 것이다. 후자는 자원을 더 사도 해결되지 않으므로 구별이 중요하다.

# 파티션별 대기 작업이 30분 넘게 50개 이상인가
slurm_queue_pending{partition="gpu"} > 50

# 유휴 GPU가 있는데 대기 작업이 쌓여 있는가
(slurm_nodes_idle > 0) and (slurm_queue_pending > 20)

exporter가 안 내보내는 값은 sacct에서 직접 뽑아 보조 지표로 만든다. 대기 시간, 작업별 GPU 점유 시간, 종료 코드 분포 같은 회계성 값이 여기 든다. 짧은 배치로 뽑아 푸시게이트웨이에 올리면 프로메테우스가 긁어 간다.

# 최근 한 시간에 끝난 작업의 대기 시간(제출~시작)을 초로 뽑는다
sacct -a -S "$(date -d '1 hour ago' +%FT%T)" -X \
  -o JobID,Submit,Start,State,ExitCode --parsable2 --noheader

GPU 지표와 스케줄러 지표는 작업 번호로 잇는다. DCGM 지표에 hpc_job 라벨이 있고 스케줄러 쪽에도 작업 번호가 있으면, 대기가 긴 작업 하나를 잡고 그 작업이 배정받은 GPU의 사용률을 바로 겹쳐 볼 수 있다. 대기는 길었는데 막상 사용률이 낮다면, 그 학습은 자원을 오래 기다려 잡고도 제대로 못 쓰는 것이라 자원 요청을 재검토할 근거가 된다.

# 작업 12345가 잡은 GPU들의 평균 사용률
avg(DCGM_FI_DEV_GPU_UTIL{hpc_job="12345"})

GitHub에서 열기

로그 수집

로그는 Loki로 모은다. 프로메테우스가 지표를 라벨로 색인하듯, Loki도 로그 줄 전체를 색인하지 않고 라벨만 색인한다. 라벨을 최소로 두고 본문은 압축해 저장하므로 저장이 싸고, 대신 검색은 라벨로 먼저 좁힌 뒤 본문을 훑는 식이다. 수집 에이전트는 promtail이 맡아 각 노드의 로그 파일을 읽어 Loki로 밀어 보낸다.

# promtail-config.yml
clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: slurm
    static_configs:
      - targets: [localhost]
        labels:
          job: slurmd
          host: gpu01
          __path__: /var/log/slurm/slurmd.log
  - job_name: kernel
    journal:
      matches: _TRANSPORT=kernel
      labels:
        job: kernel
        host: gpu01

무엇을 모을지는 조사에 실제로 쓰는 것으로 좁힌다. 관리 노드의 slurmctld는 스케줄링 결정과 작업 실패를, 계산 노드의 slurmd는 작업 시작과 종료를, dmesg와 커널 저널은 Xid와 하드웨어 오류를, 작업 stdout은 학습 코드의 예외를 남긴다. 넷이면 GPU 클러스터 조사의 대부분을 덮는다.

로그원 어디에 무엇을 잡는가
slurmctld 관리 노드 스케줄링 결정, 작업 거절
slurmd 계산 노드 작업 시작·종료, 프롤로그 실패
dmesg·커널 계산 노드 Xid, ECC, PCIe, OOM 킬
작업 stdout 스크래치 학습 예외, 스택 트레이스

로그 라벨 설계에서 카디널리티 함정은 지표와 똑같다. 라벨에 작업 번호나 요청 ID, 컨테이너 해시를 넣으면 라벨 조합이 폭발해 Loki 색인이 무너진다. 이런 값은 라벨이 아니라 로그 본문에 남기고, 검색은 본문 필터로 한다. 라벨은 job, host, partition처럼 값의 가짓수가 유계인 것만 둔다.

# 라벨로 좁히고 본문에서 작업 번호를 찾는다
{job="slurmd", host="gpu07"} |= "12345"

# 커널 로그에서 최근 Xid만
{job="kernel"} |= "Xid" | logfmt

지표와 로그를 오가는 축도 작업 번호다. 대시보드에서 사용률이 떨어진 작업 하나를 잡으면, 그 작업의 GPU 지표 옆에 같은 작업 번호로 필터한 로그를 붙여 둔다. Grafana에서 지표 패널의 시간 구간을 그대로 Loki 질의에 넘기면, 사용률이 꺼진 그 순간의 slurmd 로그와 커널 로그가 같은 화면에 뜬다. 이렇게 이어 두면 "사용률이 왜 떨어졌나"에서 "그 시각 커널이 Xid를 찍었다"까지 클릭 몇 번으로 내려간다.

GitHub에서 열기

대시보드 설계

한 화면에 모든 지표를 올리면 아무도 안 보게 된다. 패널이 마흔 개면 어느 것이 평소와 다른지 눈에 안 들어오고, 다들 화면을 열었다가 닫는다. 보는 사람과 목적에 따라 화면을 나누는 것이 기본이다. 같은 지표라도 운영자와 사용자와 장애 대응자는 다른 각도로 본다.

운영자용 화면은 클러스터 전체가 성한지 한눈에 보는 데 목적이 있다. 개별 카드가 아니라 집계와 비율, 그리고 평소와 다른 것만 도드라지게 둔다.

패널 지표
파티션별 유휴·배정·다운 노드 slurm_nodes_*
클러스터 전체 GPU 사용률 분포 DCGM_FI_DEV_GPU_UTIL 히스토그램
대기 작업 수와 대기 시간 추세 slurm_queue_pending, sacct 보조
최근 Xid·ECC 발생 노드 목록 DCGM_FI_DEV_XID_ERRORS, ECC 카운터
스토리지 사용률 상위 파일시스템 node_filesystem_avail_bytes

사용자용 화면은 "내 작업이 잘 돌고 있는가"에만 답한다. 작업 번호로 필터해 그 작업이 잡은 GPU만 보여주고, 클러스터 전체 상태는 감춘다. 사용자는 남의 노드에 관심이 없고, 자기 학습의 사용률과 메모리와 스텝 시간만 본다.

# 사용자용 패널: 선택한 작업의 GPU별 사용률
DCGM_FI_DEV_GPU_UTIL{hpc_job="$job"}

# 같은 작업의 메모리 사용 비율
DCGM_FI_DEV_FB_USED{hpc_job="$job"}
  / (DCGM_FI_DEV_FB_USED{hpc_job="$job"} + DCGM_FI_DEV_FB_FREE{hpc_job="$job"})

장애 대응용 화면은 노드 한 대에 집중한다. 의심 노드 하나를 고르면 그 노드의 GPU 8장, PCIe, NVLink, 온도, 전력, 그리고 그 노드의 커널 로그가 한 화면에 뜬다. 조사하는 사람은 이 노드에서 무슨 일이 나는지만 보면 되므로, 전체 집계는 방해가 된다.

화면 보는 사람 핵심 질문
전체 상태 운영자 클러스터가 성한가
내 작업 사용자 내 학습이 도는가
단일 노드 장애 대응자 이 노드가 왜 이상한가

화면을 나눌 때 변수(variable)를 쓰면 하나의 대시보드로 여러 대상을 본다. 노드 이름이나 작업 번호를 드롭다운 변수로 두고 패널 질의에 $node, $job으로 끼워 넣으면, 노드마다 대시보드를 복제하지 않아도 된다. 대시보드는 적게, 변수로 대상만 바꾸는 구조가 유지비가 낮다.

GitHub에서 열기

알림 설계

알림은 사람이 지금 당장 뭔가 해야 하는 것만 건다. 대시보드를 열면 보이는 것과 새벽에 사람을 깨우는 것은 기준이 다르다. 노드 다운, 정정 불가 ECC, 스토리지 포화처럼 방치하면 피해가 커지는 것만 알림으로 두고, 사용률이 낮다거나 대기가 조금 길다는 것은 화면에만 둔다.

임계값은 절대값이 아니라 자기 클러스터 기준으로 정한다. "온도 80도"라는 숫자는 카드 세대와 냉각 설계에 따라 정상일 수도 위험일 수도 있다. 평소 이 클러스터에서 부하 중 온도가 70도 근처면 85도가 이상이고, 평소 78도면 85도는 흔한 값이다. 남의 숫자를 그대로 가져오면 잘못된 알림이 쏟아진다. 며칠간 기록한 평소 분포에서 위쪽 꼬리를 임계로 잡는다.

순간 스파이크를 거르는 것이 for 절이다. 조건이 참이 되자마자 울리면, 잠깐 튀었다 돌아오는 값에도 매번 깨어난다. for: 10m을 붙이면 조건이 10분 내내 참일 때만 울려, 일시적 스파이크가 걸러진다.

# alert-rules.yml
groups:
  - name: gpu
    rules:
      - alert: GPUOverTemp
        expr: DCGM_FI_DEV_GPU_TEMP > 85
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.node }} GPU {{ $labels.gpu }} 온도 {{ $value }}C"

      - alert: NodeDown
        expr: up{job="dcgm"} == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.instance }} DCGM 수집 중단"

      - alert: UncorrectableECC
        expr: increase(DCGM_FI_DEV_ECC_DBE_VOL_TOTAL[10m]) > 0
        for: 0m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.node }} 정정 불가 ECC 발생"

정정 불가 ECC처럼 한 번이라도 나면 바로 봐야 하는 것은 for: 0m으로 즉시 울리고, 온도처럼 잠깐 튈 수 있는 것은 지속 시간을 준다. 심각도를 나눠 critical은 사람을 깨우고 warning은 채널에만 남기면, 밤중 호출이 준다.

알림 피로는 대개 같은 실수에서 온다.

실수 결과 고침
화면에 둘 것을 알림으로 울림이 잦아 무시하게 됨 행동이 필요한 것만 알림
for 절 없이 순간값 스파이크마다 깨어남 지속 시간을 준다
노드마다 같은 알림 한 원인에 수십 통 라벨로 묶어 한 통으로
남의 임계값 복사 오탐·미탐 동시에 자기 분포에서 잡음

한 원인이 여러 알림을 낳는 것도 묶는다. 스위치 하나가 죽어 노드 20대가 동시에 다운되면 알림 20통이 아니라 한 통이 와야 한다. 알림 매니저에서 같은 원인의 알림을 묶고, 반복은 억제하고, 정비 중인 노드는 잠재운다.

GitHub에서 열기

정상 상태를 먼저 정의하기

이상을 잡으려면 정상이 무엇인지 알아야 한다. 평소 값을 모른 채 임계값을 걸면, 정상인데 울리거나 이상인데 조용하다. 정상 상태는 감으로 두지 말고 숫자로 기록한다. 며칠에서 몇 주간 부하 중과 유휴 시의 사용률·온도·전력·스텝 시간 분포를 재서, 각 지표가 평소 어느 구간에 있는지를 기준선 (baseline)으로 남긴다.

정상은 하나의 값이 아니라 상황에 따라 다른 여러 값이다. 계절성(seasonality)을 무시하면 안 된다. 클러스터는 요일과 시간대에 따라 부하가 다르다. 논문 마감 전 주말에는 대기열이 꽉 차고, 평일 새벽에는 유휴 노드가 는다. 이 리듬을 모르고 "대기 작업 100개 이상이면 이상"으로 걸면 마감 주에 알림이 쏟아지고, "유휴 노드 50대 이상이면 이상"으로 걸면 새벽마다 울린다. 기준선은 요일별, 시간대별로 따로 잡는다.

절대 임계값이 아예 통하지 않는 지표도 있다. 스텝 시간은 모델과 배치 크기에 따라 절대값이 제각각이라, "스텝 500밀리초 이상이면 느림"이라는 고정선이 무의미하다. 이런 지표는 같은 작업 안에서의 상대 변화로 본다. 이 학습의 스텝 시간이 처음 안정 구간 대비 20퍼센트 늘었는가처럼, 자기 자신을 기준으로 잰다.

# 이 작업의 현재 사용률이 지난 1시간 중앙값보다 크게 낮아졌는가
DCGM_FI_DEV_GPU_UTIL{hpc_job="$job"}
  < 0.6 * quantile_over_time(0.5, DCGM_FI_DEV_GPU_UTIL{hpc_job="$job"}[1h])

통계 기반 이탈 탐지에는 한계가 뚜렷하다. 평균에서 표준편차 몇 배를 벗어나면 이상으로 보는 방식은, 분포가 한쪽으로 쏠려 있거나 정상 자체가 여러 봉우리를 가지면 오탐을 낸다. GPU 사용률은 0 근처와 100 근처에 몰리는 두 봉우리 분포라, 단순 평균·표준편차로는 정상과 이상을 못 가른다. 이런 지표는 분위수로 보거나 상태를 나눠 따로 본다.

지표 정상의 모양 맞는 판정법
GPU 사용률 0과 100에 몰린 두 봉우리 상태를 나눠 각각 분위수
온도 부하 중 한 봉우리 자기 분포의 상위 꼬리
스텝 시간 작업마다 다름 자기 기준 상대 변화
대기 작업 요일·시간대에 따라 계절성 반영 기준선

자동 탐지에 기대기 전에 갖출 것이 있다. 라벨이 제대로 붙어 있고, 기준선이 기록돼 있고, 정상의 계절성이 파악돼 있어야 자동 탐지가 쓸모를 낸다. 이것 없이 이상 탐지 알고리즘부터 얹으면 오탐의 바다에 빠진다. 그리고 자동 탐지가 무언가를 짚어도 마지막 판단은 사람이 한다. 통계가 "이 노드가 다르다"까지 좁혀 주면, 그것이 진짜 장애인지 정상적인 다른 작업인지는 로그와 맥락을 보고 사람이 정한다. 08의 노드 좁히기 절차가 그 판단의 순서를 잇는다.

GitHub에서 열기

병목이 어디인지 좁히기

학습이 느릴 때 원인은 크게 셋이다. 계산이 느리거나, GPU 사이 또는 노드 사이 통신이 막히거나, 데이터를 읽어 오는 저장이 막힌 것이다. 지표만으로도 셋 중 어디인지 상당히 좁힐 수 있다. 노드에 들어가기 전에 화면에서 방향을 잡는 절차다.

첫 갈림은 GPU가 일을 하고 있는가다. DCGM_FI_DEV_GPU_UTIL이 높고 DCGM_FI_PROF_SM_ACTIVE도 높으면 GPU가 실제로 연산에 차 있는 것이라, 병목은 계산 자체다. 이때는 모델이 크거나 커널이 비효율적인 것이라 저장이나 통신이 아니다. 반대로 사용률이 낮게 오르내리면 GPU가 무언가를 기다리는 것이라 계산 밖을 본다.

사용률이 규칙적으로 떨어졌다 오르는지, 아니면 계속 낮게 깔리는지가 다음 갈림이다. 규칙적으로 스텝마다 뚝 떨어지면 통신 대기를 의심한다. 올리듀스로 가중치를 주고받는 동안 GPU가 쉬기 때문 이다. 이때 NVLink나 InfiniBand 지표를 겹쳐 본다. 사용률이 계속 낮게 깔리고 그 사이 CPU와 디스크가 바쁘면 데이터 로딩이 병목이다.

# GPU는 노는데 노드 디스크가 포화인가 (저장 병목 신호)
(avg by (node) (DCGM_FI_DEV_GPU_UTIL) < 30)
  and (rate(node_disk_io_time_seconds_total[5m]) > 0.9)

# GPU는 노는데 IB 링크 오류가 늘고 있는가 (통신 병목 신호)
(avg by (node) (DCGM_FI_DEV_GPU_UTIL) < 30)
  and (rate(node_infiniband_port_rcv_errors_total[5m]) > 0)

판정은 지표 하나가 아니라 조합으로 한다. 한 지표만 보면 오해하기 쉽고, 두세 개를 겹쳐야 방향이 잡힌다.

판정 GPU 사용률 SM 점유 함께 보는 지표
계산 병목 높음 높음 전력이 상한 근처, 클럭 최대
통신 병목 스텝마다 뚝뚝 중간 NVLink 대역폭, IB 오류·대역폭
저장 병목 낮게 깔림 낮음 디스크 %util, CPU I/O 대기
스로틀 높지만 처리량 낮음 중간 온도·전력 상한, 스로틀 사유 비트
유휴 바닥 바닥 스케줄러는 배정, 작업은 대기

스로틀은 헷갈리기 쉬운 갈래다. 사용률은 높은데 스텝 시간이 늘면, 계산 병목처럼 보이지만 실은 클럭이 낮아진 것일 수 있다. DCGM_FI_DEV_CLOCK_THROTTLE_REASONS 비트가 서 있고 온도나 전력이 상한에 붙어 있으면 스로틀이라, 냉각과 전력 문제로 넘어간다. 여기서 좁힌 방향을 08의 하드웨어 점검 절차로 넘겨 노드 안에서 확인한다.

GitHub에서 열기

장애 리포트 쓰기

장애가 끝나면 무슨 일이 있었는지 남긴다. 기록이 없으면 같은 장애가 반복될 때마다 처음부터 조사하고, 원인을 아는 사람이 자리를 비우면 지식이 사라진다. 리포트는 기억이 아니라 지표와 로그로 쓴다.

리포트에 빠지면 안 되는 항목이 있다. 발생 시각과 복구 시각, 영향 범위, 증상, 확인한 것, 원인, 조치, 재발 방지다. 영향 범위는 몇 명이 몇 시간 동안 무엇을 못 했는지 숫자로 적는다. "느렸다"가 아니라 "노드 12대에서 3시간 동안 학습 47건이 중단됐다"로 적어야 우선순위가 선다.

지표 그래프와 로그를 근거로 붙인다. Grafana 패널은 장애 구간을 고정한 링크로 걸고, 그 순간의 커널 로그와 스케줄러 로그를 발췌해 넣는다. 그래프가 언제 무엇이 튀었는지 보여주고, 로그가 그 순간의 이유를 준다. 시각을 맞춰 나란히 두면 인과가 드러난다.

비난하지 않는 기록이 중요하다. "누가 잘못했다"로 쓰면 다음부터 사람들이 사실을 숨기고, 그러면 진짜 원인이 묻힌다. 사람이 아니라 구조를 본다. 왜 그 실수가 가능했는지, 왜 미리 못 막았는지를 적으면 재발 방지가 나온다. 개인을 지목하는 순간 리포트는 조사 도구가 아니라 처벌 도구가 되고, 아무도 솔직하게 쓰지 않게 된다.

# 장애 리포트: gpu07~gpu18 학습 중단

- 발생: 2026-08-14 02:14 KST
- 복구: 2026-08-14 05:31 KST (3시간 17분)
- 영향: gpu 파티션 노드 12대, 학습 47건 중단, 사용자 9명

### 증상
- 02:14부터 gpu07~gpu18에서 DCGM 수집 중단 알림(NodeDown)
- 해당 노드의 학습이 NCCL 통신 타임아웃으로 실패

### 확인한 것
- Grafana: 02:14에 12대 동시 다운 (링크)
- 커널 로그: gpu07 dmesg에 PCIe 링크 다운 없음
- 스위치 로그: leaf-switch-03가 02:13에 재부팅됨
- 12대 모두 leaf-switch-03 하위에 연결

### 원인
- leaf-switch-03의 펌웨어 결함으로 예기치 않은 재부팅
- 재부팅 동안 하위 노드의 IB 링크가 끊겨 NCCL 통신 실패

### 조치
- 스위치 재부팅 후 링크 자동 복구 확인
- 실패한 학습 47건 중 체크포인트 있는 41건 재제출

### 재발 방지
- leaf 스위치 펌웨어를 결함 수정 버전으로 교체 (담당·기한 명시)
- 스위치 재부팅 이벤트를 알림에 추가해 조기 인지
- 단일 leaf 하위 노드에 한 학습을 몰지 않도록 배치 규칙 검토

서식을 정해 두면 리포트마다 빠지는 항목이 없고, 나중에 같은 증상을 검색하기도 쉽다. 리포트는 쌓아 두는 것으로 끝이 아니라, 재발 방지 항목이 실제로 반영됐는지 다음 점검에서 확인해야 값을 낸다. 조치가 완료로 닫히지 않은 리포트는 열린 채로 추적한다.

GitHub에서 열기

12

비용과 자원 조달실무

GPU 한 장의 값을 계산하고 어디서 얼마나 확보할지 정하는 법

GPU 클러스터는 사는 순간이 아니라 돌리는 내내 돈이 나간다. 한 장이 시간당 얼마인지 계산하는 방법, 사서 쓸지 빌려 쓸지 정하는 기준, 이미 산 자원을 덜 낭비하는 방법을 정리한다.

GPU 한 시간의 원가

자원 요청을 줄여 달라고 말하려면 숫자가 있어야 한다. GPU 한 장의 시간당 원가는 구매 비용만으로 나오지 않는다.

장비 감가가 3분의 2를 차지하고 전력과 냉각과 운영이 나머지를 채운다

항목 잡는 법 8장 노드 한 대 기준 예시
장비 감가 구매가를 상각 기간으로 나눈다 3억 원을 4년으로 나눠 연 7,500만 원
전력 소비 전력 곱하기 가동 시간 곱하기 단가 10 kW, 연 8,760시간, kWh당 120원이면 연 1,050만 원
냉각 전력의 30에서 50퍼센트를 더 잡는다 연 400만 원
상면과 랙 랙 단위 임대료를 노드 수로 나눈다 연 300만 원
네트워크 스위치와 케이블 감가를 노드 수로 나눈다 연 500만 원
스토리지 공유 스토리지 감가를 노드 수로 나눈다 연 600만 원
운영 인력 인건비를 관리하는 노드 수로 나눈다 연 800만 원

합치면 연 1억 1,150만 원이고 노드 한 대에 GPU가 8장이므로 장당 연 1,394만 원이다. 8,760시간으로 나누면 시간당 약 1,590원이다. 이 값이 가동률 100퍼센트를 가정한 값이라는 점이 중요하다. 실제 가동률이 60퍼센트라면 실제로 쓴 한 시간의 원가는 2,650원으로 올라간다.

# 가동률이 원가를 어떻게 바꾸는지
annual = 111_500_000        # 노드 한 대의 연간 총비용
gpus, hours = 8, 8_760

for util in (0.4, 0.6, 0.8, 0.95):
    cost = annual / gpus / (hours * util)
    print(f"가동률 {util:.0%}  실사용 시간당 {cost:,.0f}원")

가동률을 60퍼센트에서 80퍼센트로 올리는 일은 GPU를 25퍼센트 더 사는 것과 같은 효과를 낸다. 장비를 늘리기 전에 활용률부터 보라는 말이 여기서 나온다.

GitHub에서 열기

사서 쓸 것인가 빌려 쓸 것인가

같은 GPU를 클라우드에서 빌리면 시간당 단가는 자체 구축보다 몇 배 높다. 그런데도 빌리는 쪽이 싼 경우가 있다. 기준은 가동률과 기간이다.

조건 유리한 쪽
24시간 내내 돌릴 일이 있고 3년 이상 쓴다 자체 구축
수요가 몇 달에 한 번 몰린다 빌린다
어떤 GPU가 필요한지 아직 모른다 빌려서 재 본다
데이터를 밖으로 내보낼 수 없다 자체 구축
운영할 사람이 없다 빌린다. 관리형이면 더 그렇다
몇 주짜리 실험이 불규칙하게 생긴다 기본은 자체, 넘치는 만큼만 빌린다

손익분기는 계산된다. 자체 구축의 실사용 시간당 원가가 2,650원이고 클라우드가 시간당 6,000원 이라면, 클라우드를 연 몇 시간 쓸 때까지 싼지 나온다.

own_annual = 111_500_000 / 8        # GPU 한 장의 연간 자체 비용
cloud_hourly = 6_000
breakeven = own_annual / cloud_hourly
print(f"연 {breakeven:,.0f}시간 미만이면 빌리는 쪽이 싸다")
print(f"가동률로는 {breakeven / 8760:.0%}")

연 2,323시간, 가동률 27퍼센트가 갈림선이다. 이보다 적게 쓸 자원이면 사지 않는 편이 낫다. 자체 구축을 정당화하려면 최소한 이 선을 넘게 쓸 계획이 있어야 한다.

계산에서 빠뜨리기 쉬운 항목이 몇 가지 있다. 자체 구축 쪽에서는 도입까지 걸리는 기간과 그동안 일을 못 하는 손실, 그리고 장비를 다 채우기 전까지의 낮은 가동률이 빠진다. 빌리는 쪽에서는 데이터를 넣고 빼는 요금과 스토리지 상시 비용이 빠진다. 계산이 GPU 시간 단가로만 이루어지면 양쪽 다 실제보다 싸 보인다.

자주 빠지는 항목 어느 쪽 크기
도입 기간 동안의 기회 손실 자체 발주에서 가동까지 3개월에서 9개월
초기 저가동 구간 자체 첫 분기 가동률이 절반 아래인 경우가 흔하다
데이터 내보내기 요금 클라우드 학습 결과와 로그를 가져올 때마다 든다
상시 스토리지 클라우드 계산을 안 해도 데이터가 있으면 계속 나간다
유휴 예약 자원 클라우드 약정을 크게 잡으면 안 쓰는 자리를 산다
운영 인력 학습 기간 양쪽 첫 6개월은 사람이 익히는 비용이 든다

GitHub에서 열기

조달에서 걸리는 것들

GPU는 돈이 있다고 바로 오지 않는다. 계획에 반드시 기간을 넣어야 한다.

단계 걸리는 시간 걸리는 지점
사양 확정과 견적 2주에서 6주 어떤 세대를 살지, 네트워크를 어디까지 갖출지
발주와 물량 배정 1개월에서 6개월 수요가 몰리면 배정 자체가 대기다
입고와 상면 준비 2주에서 2개월 전력과 냉각 공사가 장비보다 오래 걸리기도 한다
설치와 반입 검사 2주에서 1개월 초기 불량 교체가 여기서 나온다
가동 발주에서 여기까지 3개월에서 9개월

기간이 이렇게 길기 때문에 필요해진 뒤에 발주하면 이미 늦다. 학습 계획에서 필요한 규모를 역산해 미리 잡아야 한다. 반대로 너무 미리 잡으면 세대가 바뀌어 손해를 본다.

전력과 냉각이 장비보다 먼저 상한이 되는 경우가 많다. GPU 노드 한 대가 10 kW를 쓰면 랙 하나에 서너 대밖에 못 넣는다. 기존 전산실은 랙당 5에서 10 kW를 가정해 지어진 곳이 많아, 장비는 살 수 있어도 넣을 자리가 없는 상황이 생긴다. 구매 검토와 동시에 확인할 것이 있다.

  • 랙당 공급 가능한 전력과 현재 사용량
  • 냉각 방식과 배기 온도 한계. 공랭으로 감당되는 밀도인지
  • 바닥 하중. GPU 노드는 무겁다
  • 배전반 여유와 증설 공사 기간
  • 네트워크 포트 수. GPU 노드 한 대가 여러 포트를 쓴다

계약할 때 미리 정해 둘 것도 있다. 초기 불량 교체 기간, 예비 부품 보유 수량, 장애 대응 시간, 드라이버와 펌웨어 지원 기간이 여기 해당한다. 반입 검사에서 불량이 나왔을 때 어떻게 처리할지가 계약서에 없으면 협의에 몇 주가 든다.

GitHub에서 열기

클라우드에서 GPU를 확보하는 방식

같은 GPU라도 어떤 계약으로 잡느냐에 따라 단가와 안정성이 달라진다.

방식 단가 중간에 회수되는가 맞는 워크로드
온디맨드 기준가 없다 짧고 급한 작업
예약 · 약정 기준가의 40에서 60퍼센트 없다 상시 도는 기반 부하
스팟 · 선점형 기준가의 20에서 40퍼센트 있다. 몇 분 전에 통보 체크포인트가 있는 학습
전용 용량 예약 기준가 이상 없다. 자리를 미리 잡는다 시작 시각이 정해진 대형 학습

스팟(spot 또는 선점형)은 학습에 쓸 수 있다. 조건은 하나다. 회수 통보를 받고 체크포인트를 남길 수 있어야 한다. 통보에서 종료까지 주어지는 시간은 대개 2분 안팎이라, 그 안에 저장이 끝나야 한다.

import signal, sys

def on_reclaim(signum, frame):
    save_checkpoint(step, async_write=False)   # 2분 안에 끝나야 한다
    sys.exit(0)

signal.signal(signal.SIGTERM, on_reclaim)

체크포인트 저장에 49초가 걸리는 작업이라면 통보 시간 안에 들어간다. 5분이 걸린다면 스팟에 올리면 안 된다. 저장 시간이 스팟 사용 가능 여부를 정한다.

기반 부하는 약정으로 깔고, 그 위를 스팟으로 덮고, 급한 것만 온디맨드로 받는 구성이 대체로 싸다. 약정 비율은 지난 1년의 최저 사용량 근처로 잡는다. 그보다 많이 약정하면 안 쓰는 자리를 사는 셈이 된다.

# 지난 1년 중 매달 최저 동시 사용 GPU 수를 뽑아 약정 규모의 근거로 쓴다
sacct -a -S now-365days -X -o Start,End,AllocTRES --parsable2 \
  | python analyze_concurrency.py --percentile 10

GitHub에서 열기

낭비를 찾아내는 지표

비용을 줄이는 가장 싼 방법은 이미 산 자원을 덜 놀리는 것이다. 낭비는 몇 가지 모양으로 나타나고 각각 다른 지표에 걸린다.

낭비의 모양 걸리는 지표 대응
자리를 잡고 놀린다 할당률은 높은데 GPU 사용률이 낮다 유휴 작업을 찾아 알리고 정책으로 제한한다
필요보다 크게 요청한다 요청 메모리 대비 실사용 메모리 지난 실행의 실사용량을 보여 준다
대화형 세션을 켜 두고 나간다 사용률 한 자릿수가 몇 시간 이어진다 시간 제한과 유휴 종료
실패한 작업을 반복 제출한다 같은 사용자의 짧은 실패 작업이 반복된다 실패 원인을 알려 준다
체크포인트가 쌓인다 스토리지 사용량이 단조 증가한다 보존 개수 정책
세대가 낡은 노드가 남아 있다 노드별 처리량 대비 전력 교체 시점 계산

유휴 작업 하나를 찾아 정리하는 값어치도 계산할 수 있다. GPU 8장을 12시간 놀린 작업 하나는 시간당 2,650원 기준으로 25만 4천 원이다. 이런 작업이 한 달에 스무 건이면 연 6,100만 원이다. 노드 한 대 값의 절반이 넘는다.

# 사용률이 5% 아래로 30분 넘게 이어진 작업을 뽑는다
cat <<'PROMQL'
avg_over_time(DCGM_FI_DEV_GPU_UTIL[30m]) < 5
  and on(hostname) slurm_job_running > 0
PROMQL

찾아낸 뒤의 처리는 단계를 둔다. 바로 죽이면 사용자가 우회하는 방법을 배운다.

  • 30분 유휴가 확인되면 사용자에게 알린다. 작업 번호와 사용률을 함께 보낸다.
  • 2시간이 이어지면 다시 알리고 관리자에게도 보낸다.
  • 대화형 파티션에 한해 4시간 유휴면 종료한다. 배치 작업은 자동 종료하지 않는다.

배치 작업을 자동 종료하지 않는 이유는 사용률이 낮아도 정당한 경우가 있어서다. 체크포인트를 쓰는 중이거나, 데이터 전처리 구간이거나, 평가를 도는 중일 수 있다. 대화형 세션은 사람이 자리를 비운 것이 거의 확실하므로 다르게 다룬다.

과다 요청은 요청과 실사용의 비율로 잡는다. 지난 30일 작업을 모아 비율 분포를 보면 어느 팀이 얼마나 크게 잡는지 드러난다.

sacct -a -S now-30days -X --parsable2 \
      -o JobID,User,Account,ReqTRES,MaxRSS,Elapsed,State \
  | awk -F'|' '$7=="COMPLETED"' > /tmp/usage.psv

비율이 3배를 넘는 작업이 많은 팀에는 지난 실행의 실제 사용량을 자동으로 보내 준다. 대개는 몰라서 크게 잡는 것이지 일부러 그러는 것이 아니다.

GitHub에서 열기

낡은 노드를 언제 바꿀 것인가

GPU는 고장 나기 전에 경제적으로 먼저 수명이 끝난다. 새 세대가 같은 전력으로 몇 배를 하기 때문이다. 교체 판단은 처리량당 전력으로 한다.

세대 학습 처리량(상대) 노드 소비 전력 처리량당 전력(상대)
이전 세대 1.0 6.5 kW 1.00
현 세대 2.8 10.2 kW 0.56
차기 세대 4.5 13.0 kW 0.44

전력 단가와 상면 비용이 높은 곳일수록 교체 시점이 앞당겨진다. 랙 전력이 상한이라면 더 그렇다. 같은 랙에 넣을 수 있는 노드 수가 정해져 있으므로, 노드당 처리량이 곧 랙당 처리량이 된다.

낡은 노드를 버리지 않고 쓰는 방법도 있다. 대화형 개발, 소규모 파인튜닝, 데이터 전처리, 추론 같은 작업은 최신 세대가 필요하지 않다. 별도 파티션으로 내려 두고 대형 학습만 새 노드로 받는 구성이 흔하다. 다만 노드 종류가 늘면 운영 부담도 늘어난다는 점을 계산에 넣어야 한다.

교체를 미루는 비용도 계산된다. 이전 세대 노드가 같은 일을 하는 데 전력을 1.8배 쓴다면, 그 차액이 새 노드 값을 넘는 시점이 나온다.

old_kw, new_kw = 6.5, 10.2          # 노드당 소비 전력
old_tp, new_tp = 1.0, 2.8           # 상대 처리량
price_kwh, node_price = 120, 300_000_000

# 같은 처리량을 내는 데 드는 연간 전력비를 견준다
old_cost = (old_kw / old_tp) * 8760 * price_kwh
new_cost = (new_kw / new_tp) * 8760 * price_kwh
saving = old_cost - new_cost
print(f"연간 전력비 절감 {saving:,.0f}원, 회수까지 {node_price / saving:.1f}년")

전력비만으로 4년 안에 회수되지 않는 경우가 대부분이다. 교체를 정당화하는 것은 대개 전력이 아니라 랙 자리와 처리 속도다. 랙 전력이 상한이라 노드를 더 못 넣는 상황이거나, 학습 한 번에 걸리는 시간이 사업 일정에 걸릴 때 교체가 값어치를 한다.

교체를 미룰 만한 조건 교체를 앞당길 조건
랙과 전력에 여유가 있다 랙 전력이 이미 상한이다
워크로드가 작은 모델 위주다 새 세대에서만 되는 정밀도나 용량이 필요하다
가동률이 낮다 가동률이 높아 대기열이 길다
유지보수 계약이 남아 있다 지원이 끝나 부품 조달이 어렵다

GitHub에서 열기

사용량을 보여 주고 나누기

여러 팀이 같은 클러스터를 쓰면 누가 얼마를 썼는지 보여 주는 것만으로 행동이 바뀐다. 요금을 실제로 걷지 않더라도 마찬가지다.

# 계정별 GPU 시간을 뽑는다. 청구서의 기초가 된다
sreport cluster AccountUtilizationByUser start=2026-07-01 end=2026-08-01 \
        -t hours --tres=gres/gpu

# 작업별로 요청 대비 실사용을 뽑아 과다 요청을 드러낸다
sacct -a -S 2026-07-01 -X --parsable2 \
      -o Account,User,JobID,ReqTRES,Elapsed,State

보여 주는 방식은 세 단계로 올린다.

  • 보여 주기만 한다. 월별 사용량과 원가 환산액을 팀에 전달한다. 강제는 없다. 이것만으로도 과다 요청이 줄어든다.
  • 몫을 정한다. 팀별 GPU 시간 한도를 두고 넘으면 우선순위를 낮춘다. 공정 배분이 이 일을 한다. 자원을 뺏지는 않는다.
  • 실제로 청구한다. 내부 정산까지 간다. 관리 비용이 크므로 조직이 그 값어치를 필요로 할 때만 간다.

세 번째로 바로 뛰면 사용자가 자원을 숨기고 잡아 두는 행동을 배운다. 첫 단계에서 얻는 효과가 생각보다 크므로 순서대로 올린다.

무엇을 기준으로 나눌지도 정해야 한다. GPU 시간만 세면 메모리를 크게 잡고 GPU를 적게 쓰는 작업이 공짜가 된다. 자원 종류마다 가중치를 두고 하나의 값으로 합치는 방식이 흔하다.

자원 가중치 예시 근거
GPU 시간 1.00 원가의 대부분을 차지한다
CPU 코어 시간 0.01 GPU 노드의 부속 자원이다
호스트 메모리 GB 시간 0.002 코어와 함께 잡히는 경우가 많다
공유 스토리지 TB 월 0.05 상시 점유라 시간이 아니라 기간으로 센다

가중치는 앞에서 계산한 원가 비율에서 뽑는다. 임의로 정하면 사용자가 납득하지 않는다. 계산 근거를 문서로 공개해 두면 조정 요청이 논쟁 대신 계산으로 바뀐다.

집계 주기는 월 단위가 무난하다. 주 단위는 잡음이 많고, 분기 단위는 행동이 바뀌기 전에 시간이 너무 흐른다. 월별 추이를 함께 보여 주면 자기 팀이 늘고 있는지 줄고 있는지 알 수 있다.

GitHub에서 열기

관리형과 직접 운영 사이

GPU를 서비스로 받는 선택지도 있다. 클러스터를 직접 세우지 않고 스케줄러와 관측까지 딸린 환경을 빌린다.

형태 무엇까지 남이 하나 남는 일
장비 임대 하드웨어와 상면 운영체제부터 전부
관리형 클러스터 스케줄러와 드라이버까지 사용자 관리, 작업 정책
관리형 학습 플랫폼 작업 제출 인터페이스까지 코드와 데이터

관리형으로 갈수록 단가는 오르고 운영 인력은 준다. 인력 한 명의 연간 비용을 원가 계산에 넣으면 작은 클러스터에서는 관리형이 싼 경우가 자주 나온다. 노드가 수십 대를 넘어가면 반대가 된다.

섞어 쓰는 구성도 흔하다. 기반은 자체 클러스터로 두고, 데이터를 밖으로 낼 수 있는 작업만 외부로 보낸다. 이때 걸림돌은 대개 계산이 아니라 데이터다. 수백 TB를 옮기는 시간과 전송 비용이 계산 비용보다 클 수 있으므로, 옮길 데이터의 크기부터 확인하고 판단한다.

확인할 것 왜
옮길 데이터 크기와 전송 대역폭 전송에 며칠이 걸리면 탄력성이 사라진다
내보내기 요금 결과를 다시 가져올 때 드는 비용이 숨어 있다
데이터 반출 규정 기술 문제가 아니라 규정에서 막히는 경우가 많다
체크포인트 호환 양쪽에서 같은 체크포인트를 읽어야 오갈 수 있다

GitHub에서 열기

13

인프라 용어 사전참고

회의와 벤더 문서에서 막히는 말들

자주 나오지만 정의가 흐릿한 채로 쓰이는 말을 모았다. 순서대로 읽기보다 막힐 때 찾아보는 용도다. 같은 말이 벤더마다 다르게 쓰이는 경우에는 그 차이도 적었다.

처음 보는 사람을 위한 최소 지도

학습 한 번이 도는 과정을 층으로 늘어놓으면 나머지 용어가 어디에 붙는지 보인다.

층 하는 일 여기서 나오는 말
사용자 실행할 내용과 필요한 자원을 적어 제출한다 작업, 배치 스크립트, 대기열
스케줄러 언제 어느 노드에서 돌릴지 정한다 Slurm, 파티션, 갱 스케줄링, 백필, fairshare
노드 실제로 프로세스를 띄운다 랭크, CPU 코어, NUMA
가속기 행렬 계산을 맡는다 GPU, HBM, MIG, 사용률, MFU
노드 간 연결 랭크끼리 값을 주고받는다 InfiniBand, NCCL, 집합 통신, all-reduce
스토리지 데이터와 체크포인트를 읽고 쓴다 Lustre, OST, MDT, 스트라이핑

성능 이야기는 결국 셋 중 하나다. 계산이 느리거나, 값을 주고받느라 기다리거나, 데이터가 제때 오지 않는다. 어느 쪽인지부터 가리면 볼 지표가 정해진다.

숫자를 읽을 때 단위를 자주 혼동한다. 특히 네트워크는 비트, 저장과 메모리는 바이트를 쓴다.

표기 읽는 법 바꿔 보면
200 Gb/s 초당 200기가비트. 네트워크 표기다 8로 나눠 25 GB/s. 실효는 20 GB/s 안팎
25 GB/s 초당 25기가바이트. 저장과 메모리 표기다 8을 곱해 200 Gb/s
32 GT/s 초당 32기가전송. PCIe 표기다 레인 수와 인코딩을 곱해야 바이트가 된다
989 TFLOP/s 초당 989조 번의 부동소수점 연산 정밀도를 확인해야 한다. bf16과 fp8은 값이 다르다
80 GB 메모리 용량 파라미터 하나에 2바이트면 400억 개가 들어간다

비슷해 보이지만 다른 짝도 미리 갈라 둔다.

헷갈리는 짝 차이
대역폭과 지연 한 번에 얼마나 보내느냐와 한 번 보내는 데 얼마나 걸리느냐
GPU 사용률과 MFU 커널이 돌고 있었느냐와 연산 유닛을 얼마나 채웠느냐
노드와 랭크 물리 서버 한 대와 그 위에서 도는 프로세스 하나
작업과 태스크 제출 단위 하나와 그 안에서 실제로 뜨는 프로세스 하나
용량과 대역폭 얼마나 담느냐와 얼마나 빨리 오가느냐. 스토리지는 이 둘을 따로 늘린다

GitHub에서 열기

가속기와 메모리

값이 어디에 있느냐가 속도를 정한다. 아래로 갈수록 넓고 느리다.

레지스터에서 공유 스토리지까지 용량은 커지고 속도는 느려진다

Accelerator CPU가 잘 못하는 계산을 대신 맡는 장치를 통칭한다. GPU가 대표적이고 TPU, NPU, FPGA도 여기 들어간다. CPU는 분기가 많은 순차 처리에 강하고 가속기는 같은 연산을 대량으로 반복하는 데 강하다. 행렬 곱이 딥러닝의 대부분을 차지하기 때문에 가속기가 이 분야를 가져갔다.

HBM 가속기 패키지 안에 쌓아 올린 메모리다. 일반 DDR을 기판에 늘어놓는 대신 실리콘을 수직으로 적층해 폭을 극단적으로 넓혔다. H100의 HBM3는 초당 3TB 안팎을 낸다. DDR5 채널 하나가 초당 수십 GB인 것과 비교하면 두 자릿수 차이다.

용량은 반대로 작다. 80GB나 141GB에서 끊기기 때문에 모델이 커지면 곧바로 벽에 부딪힌다. GPU 메모리 부족은 대개 대역폭이 아니라 이 용량 때문에 생긴다.

메모리 대역폭 한계 연산 유닛이 놀고 있는데 성능이 안 나오면 대개 메모리에서 데이터를 못 받아오는 상태다. 연산량을 메모리 접근량으로 나눈 값을 산술 강도라 하고, 이 값이 낮은 연산은 아무리 좋은 가속기를 써도 대역폭에 묶인다. 요소별 덧셈이나 정규화가 여기 해당한다.

ECC 메모리 오류를 정정하는 기능이다. 1비트 오류는 고쳐서 넘어가고 2비트 오류는 고치지 못한 채 보고한다. 후자가 Xid 48로 나타난다. ECC를 끄면 가용 메모리가 조금 늘지만 학습 중 값이 조용히 틀어질 수 있어 계산 노드에서는 켜 둔다.

nvidia-smi --query-gpu=name,memory.total,memory.used --format=csv   # 용량과 사용량
nvidia-smi -q -d ECC | grep -A3 "Volatile"                          # ECC 오류 누적

GitHub에서 열기

연결과 통신

PCIe CPU와 장치를 잇는 범용 버스다. 세대와 폭으로 속도가 정해지며 Gen5 x16이 양방향 합쳐 초당 128GB 수준이다. GPU 사이 통신을 PCIe로만 하면 NVLink 대비 몇 배 느리다.

NVLink NVIDIA GPU끼리 직접 잇는 전용 링크다. PCIe를 거치지 않아 지연이 낮고 대역폭이 훨씬 크다. 링크 수만큼 대역이 늘어나며, nvidia-smi topo -m에서 NV#로 표시된다.

NVSwitch NVLink를 스위칭해 노드 안의 모든 GPU가 서로 같은 대역으로 통신하게 만드는 칩이다. 이것이 없으면 GPU 쌍마다 거리가 달라져 어떤 조합을 받느냐에 따라 성능이 달라진다.

InfiniBand 노드 사이를 잇는 저지연 네트워크다. 이더넷과 달리 커널을 거치지 않고 애플리케이션 메모리에서 곧바로 전송하는 RDMA를 쓴다. 집합 통신 지연이 분산 학습 속도를 좌우하기 때문에 대규모 학습 클러스터의 기본 선택지다.

RoCE 이더넷 위에서 RDMA를 구현한 방식이다. 기존 이더넷 장비를 쓸 수 있는 대신 무손실 설정을 제대로 잡아야 한다. 흐름 제어가 어긋나면 성능이 InfiniBand보다 크게 떨어진다.

GPUDirect RDMA 네트워크 카드가 GPU 메모리에 직접 접근하게 하는 기능이다. CPU 메모리를 거치는 복사가 사라져 지연이 준다. 다만 NIC과 GPU가 같은 PCIe 스위치 아래 있어야 효과가 크다.

NCCL NVIDIA의 집합 통신 라이브러리다. allreduce, allgather 같은 연산을 NVLink와 InfiniBand를 알아서 조합해 처리한다. NCCL_DEBUG=INFO를 켜면 어떤 경로를 골랐는지 로그에 남고, 여기서 NET/Socket이 보이면 RDMA를 못 쓰고 TCP로 돌고 있다는 뜻이다.

집합 통신 전체 프로세스가 함께 참여하는 통신이다. 데이터 병렬 학습에서 그래디언트를 평균 내는 allreduce가 대표적이고, 링이나 트리 형태로 구현한다. 프로세스 하나가 늦으면 전체가 기다리므로 가장 느린 노드가 속도를 정한다.

nvidia-smi topo -m              # GPU와 NIC이 어떤 링크로 붙어 있는지 표로 본다
ibstat                          # InfiniBand 포트 상태와 속도
lspci -vv | grep -i "LnkSta"    # PCIe 링크가 실제로 몇 세대 몇 레인으로 붙었는지

nvidia-smi topo -m의 표기가 곧 비용 순서다. NV#가 NVLink이고 숫자가 링크 수다. PIX는 같은 PCIe 스위치 아래, NODE는 같은 소켓 안, SYS는 소켓을 넘는 경로다. 오른쪽으로 갈수록 느리다.

GitHub에서 열기

GPU 분할과 공유

MIG GPU 한 장을 하드웨어에서 여러 인스턴스로 쪼갠다. 인스턴스마다 연산 유닛과 메모리가 물리적으로 나뉘어 한쪽 작업이 다른 쪽에 영향을 주지 않는다. 격리가 필요한 추론 서비스나 다중 사용자 환경에 맞는다. 나눈 뒤에는 개별 인스턴스가 작아지므로 큰 학습에는 쓰지 않는다.

MPS 여러 프로세스의 CUDA 커널을 하나의 컨텍스트에서 함께 돌린다. 컨텍스트 전환 비용이 줄어 작은 커널을 많이 던지는 워크로드에서 처리량이 오른다. 격리는 약해서 한 프로세스가 메모리를 다 쓰면 다른 쪽도 함께 실패한다.

시분할 스케줄러가 GPU를 번갈아 배정한다. 구현이 간단하고 어디서나 되지만 컨텍스트 전환 비용이 있고 지연이 튄다. 개발이나 실험 환경에 적당하다.

vGPU 하이퍼바이저 층에서 GPU를 가상 머신에 나눠 준다. VDI나 가상화 기반 환경에서 쓰며 별도 라이선스가 필요하다.

넷은 격리 수준과 쓰는 자리가 다르다.

방식 나누는 층 격리 메모리 맞는 자리
MIG 하드웨어 강하다. 서로 영향이 없다 인스턴스별로 고정 다중 사용자 추론, 교육용
MPS 드라이버 약하다. 한쪽이 죽으면 함께 죽는다 공유. 한쪽이 다 쓸 수 있다 작은 커널을 많이 던지는 추론
시분할 스케줄러 중간. 메모리는 공유다 공유 개발과 실험
vGPU 하이퍼바이저 강하다 프로파일별로 고정 가상 데스크톱

학습에는 넷 다 쓰지 않는다. 큰 학습은 GPU 한 장을 통째로 쓰고 여러 장을 묶는 방향이라 나누는 것과 반대다. 나눠 쓰는 논의는 추론과 개발 환경에서 나온다.

nvidia-smi -i 0 -mig 1                      # 0번 GPU 를 MIG 모드로 바꾼다. 재부팅이 필요할 수 있다
nvidia-smi mig -lgip                        # 만들 수 있는 인스턴스 종류
nvidia-smi mig -cgi 9,9,9 -C                # 인스턴스를 만들고 컴퓨트 인스턴스까지 붙인다
nvidia-smi -L                               # 나뉜 결과. UUID 가 MIG- 로 시작한다

MIG로 나눈 뒤에는 GPU 번호가 아니라 MIG UUID로 지정해야 한다. CUDA_VISIBLE_DEVICES=0이 동작하지 않아 당황하는 일이 흔하다.

GitHub에서 열기

개발과 분석 도구

nvcc CUDA 코드를 컴파일한다. 호스트 코드는 시스템 컴파일러에 넘기고 장치 코드만 직접 처리한다. -arch=sm_90 처럼 대상 아키텍처를 지정하는데, 값이 실제 GPU보다 낮으면 새 기능을 못 쓰고 높으면 실행이 안 된다.

PTX와 SASS PTX는 중간 표현이고 SASS는 실제 기계어다. 이미지에 PTX만 들어 있으면 처음 실행할 때 드라이버가 변환하느라 시간이 걸린다. 여러 세대를 지원하려면 세대별 SASS를 함께 넣는다.

HIP와 ROCm AMD GPU용 스택이다. HIP은 CUDA와 문법이 거의 같아 변환 도구로 옮길 수 있다. 실제로는 라이브러리 대응과 성능 조정에 손이 많이 간다. 커널 코드보다 주변 생태계가 이식의 난이도를 정한다.

AOCC AMD가 내는 C와 C++ 컴파일러다. LLVM 기반이고 자사 CPU에 맞춘 최적화가 들어간다. 전처리처럼 CPU를 많이 쓰는 구간에서 값어치가 나온다.

OpenACC와 OpenMP offload 지시문을 붙여 반복문을 가속기로 넘긴다. 기존 C나 Fortran 코드를 크게 안 고치고 옮길 때 쓴다. 손으로 쓴 CUDA만큼 나오지는 않지만 과학 계산 코드에서 값어치가 있다.

nsys CPU와 GPU에 걸친 타임라인을 본다. 어느 구간에서 GPU가 노는지, 통신과 계산이 겹치는지 확인한다. 병목의 위치를 모를 때 여기서 시작한다.

ncu 커널 하나를 뜯어본다. 메모리 대역폭에 묶였는지 연산에 묶였는지, 점유율이 얼마인지 알려 준다. 느린 커널을 이미 특정한 뒤에 쓴다.

compute-sanitizer 메모리 접근 오류와 경쟁 상태를 잡는다. 결과가 가끔 틀리거나 이유 없이 죽을 때 쓴다. 느려지므로 작은 입력으로 돌린다.

cuda-gdb 장치 코드를 단계 실행한다. 커널 안에서 죽는 원인을 찾을 때 쓴다.

nsys profile -o run --trace=cuda,nvtx,osrt python train.py   # 전체 타임라인
ncu --set full -k my_kernel -o kern ./app                    # 커널 한 개 분해
compute-sanitizer --tool memcheck ./app                      # 잘못된 메모리 접근
cuda-gdb --args python train.py                              # 장치 코드 디버깅

nvprof는 옛 도구이고 Volta 이후 세대에서는 nsys와 ncu가 대신한다. 오래된 문서를 볼 때 이름만 확인하면 된다.

GitHub에서 열기

플랫폼과 모델 운영

MLOps 모델을 만들고 배포하고 유지하는 과정을 자동화하는 방식을 통칭한다. 데이터 준비, 학습, 평가, 배포, 감시가 한 줄로 이어지는 것을 목표로 한다. 인프라 쪽에서 보면 학습 작업과 서빙 워크로드를 어떤 자원 위에 어떻게 올릴지가 접점이다.

AIOps 반대 방향이다. 운영 데이터에 기계학습을 써서 이상을 찾거나 원인을 좁힌다. 지표가 많아 사람이 다 못 볼 때 후보를 줄여 주는 용도다. 판단을 대신하지는 못한다.

실험 추적 하이퍼파라미터, 코드 버전, 데이터 버전, 결과 지표를 실행마다 기록한다. 기록이 없으면 좋았던 실험을 다시 만들지 못한다. 작업 번호와 커밋 해시를 함께 남기는 것이 최소선이다. MLflow, Weights & Biases, TensorBoard가 이 자리에 쓰인다. MLflow는 자체 호스팅이 되고 모델 레지스트리까지 함께 제공해서 외부로 데이터를 낼 수 없는 환경에서 자주 쓴다.

import mlflow, os

mlflow.set_tracking_uri("http://mlflow.internal:5000")
with mlflow.start_run():
    mlflow.log_params({"lr": 3e-4, "batch": 512, "model": "7b"})
    mlflow.set_tag("slurm_job", os.environ.get("SLURM_JOB_ID", ""))   # 지표와 작업을 잇는다
    mlflow.log_metric("loss", loss, step=step)

작업 번호를 태그로 남기는 것이 인프라 쪽에서 중요하다. 실험 기록과 클러스터 지표가 이 값으로 이어져야, 손실이 이상했던 실행이 어느 노드에서 돌았는지 되짚을 수 있다.

모델 레지스트리 학습이 끝난 모델을 버전과 함께 보관하고, 어떤 데이터와 코드로 만들었는지 연결해 둔다. 배포된 모델이 어느 실행에서 나왔는지 되짚을 수 있어야 문제가 생겼을 때 원인을 찾는다.

모델 버전 관리 가중치 파일은 크고 자주 바뀌므로 일반 소스 관리에 그대로 넣지 않는다. 객체 스토리지에 두고 해시로 참조하는 방식이 흔하다. 코드 저장소에는 참조만 남긴다.

서빙 엔진 학습한 모델을 요청에 응답하는 형태로 올린다. 여러 요청을 묶어 한 번에 계산하는 배치 기능과, 여러 모델을 한 GPU에 올리는 기능이 성능을 좌우한다.

모델 저장소 플랫폼 팀이 모델과 데이터셋을 공유하는 웹 환경이다. 접근 권한과 라이선스 관리가 실제 운영에서 중요한 부분이 된다.

임베딩 글이나 이미지를 의미가 가까울수록 가까이 놓이는 숫자 벡터로 바꾼 것이다. 차원은 수백에서 수천이고, 모델이 바뀌면 값이 달라져 함께 쓸 수 없다.

벡터 인덱싱 임베딩 수억 개에서 가까운 것을 빠르게 찾기 위한 자료 구조다. 전부 견주면 정확하지만 느리므로, 근처만 훑어 근사값을 내는 방식을 쓴다. HNSW는 그래프를 따라가고 IVF는 미리 나눈 구역만 본다. 정확도와 속도와 메모리 사이에서 고르는 문제다.

인프라 쪽에서 이 항목이 걸리는 지점은 메모리다. 인덱스를 통째로 메모리에 올려야 빠르고, 벡터 1억 개에 768차원 float32면 원본만 307 GB다. 양자화로 줄이거나 디스크 기반 인덱스를 쓰는 선택이 여기서 갈린다. GPU에 올려 검색을 가속하는 구성도 있고, 이때는 학습용 GPU와 자원을 다투게 되므로 파티션을 나눠 둔다.

# 인덱스 크기를 어림한다. 양자화 전 원본 기준
python -c "n, d, b = 1e8, 768, 4; print(f'{n * d * b / 1024**3:.0f} GB')"

GitHub에서 열기

이기종과 배치

NPU 신경망 연산에 특화한 가속기다. 전력당 성능이 좋은 대신 지원하는 연산과 프레임워크가 제한된다. 추론에 먼저 들어오는 경우가 많다.

DPU 네트워크와 스토리지 처리를 CPU에서 덜어 가는 장치다. 호스트 CPU를 계산에 더 쓰게 하고 가상화와 보안 기능을 하드웨어로 내린다.

이기종 클러스터 종류나 세대가 다른 가속기가 섞인 클러스터다. 분산 학습에서는 가장 느린 랭크가 전체 속도를 정하므로 한 작업 안에서 섞지 않는다. 스케줄러에서 종류별로 자원 유형을 나누고 요청할 때 지정하게 한다.

GPUaaS GPU를 서비스처럼 받아 쓰는 형태다. 장비를 사지 않고 필요할 때 계산 자원을 빌린다. 운영 부담이 줄지만 데이터를 옮기는 비용과 규정이 걸림돌이 된다.

하이브리드 배치 자체 클러스터를 기반으로 두고 넘치는 수요만 외부로 보낸다. 두 곳에서 같은 이미지와 같은 체크포인트를 읽을 수 있어야 실제로 오갈 수 있다.

자원 유형 스케줄러가 GPU를 셀 때 장수만 세면 세대 차이를 놓친다. 종류마다 이름을 붙여 구분하면 요청할 때 지정할 수 있다. Slurm은 GRES 타입으로, Kubernetes는 노드 라벨로 나눈다.

노드 라벨과 테인트 라벨은 노드에 붙이는 표식이고 테인트는 조건을 만족하지 않는 파드를 밀어내는 표식이다. 라벨만 쓰면 지정하지 않은 파드가 GPU 노드에 올라오므로 둘을 같이 쓴다.

데이터 지역성 계산을 데이터가 있는 곳으로 보내는 원칙이다. 수백 TB를 옮기는 시간이 계산 시간보다 길면 자원이 남아도 그쪽으로 보낼 수 없다. 이기종이나 하이브리드 구성에서 실제로 발목을 잡는 것은 대개 계산 자원이 아니라 이 문제다.

세대 혼용 분산 학습 한 작업 안에 다른 세대를 섞으면 가장 느린 랭크가 전체 속도를 정한다. 빠른 카드가 기다리느라 노는 셈이라 총 처리량이 오히려 준다. 파티션을 나누거나 요청에 제약을 걸어 한 작업이 한 종류만 잡게 한다.

sinfo -o "%P %G %D %t"                        # 파티션별 GRES 종류와 노드 수
sbatch --gres=gpu:h100:8 --constraint=h100 job.sh   # 한 종류만 잡는다
kubectl get nodes -L nvidia.com/gpu.product   # 노드마다 어떤 카드가 붙었는지

GitHub에서 열기

스케줄링

갱 스케줄링(gang scheduling) 작업이 요구한 자원 전부가 확보될 때까지 시작하지 않는 방식이다. 분산 학습처럼 프로세스가 서로를 기다리는 작업은 일부만 배치되면 진전 없이 자원만 잡는다.

백필(backfilling) 앞선 작업이 시작할 시각을 계산해두고, 그 전에 끝날 수 있는 짧은 작업을 빈 구간에 먼저 넣는다. 작업마다 적어 낸 실행 시간이 정확해야 동작하며, 모두가 최대치를 적으면 무력해진다.

fairshare 계정마다 몫을 정하고 최근 사용량이 몫을 넘으면 우선순위를 낮춘다. 사용 이력은 반감기를 두고 잊는다. 절대 사용량이 아니라 몫 대비 비율로 판단한다.

선점(preemption) 낮은 우선순위 작업을 중단시키고 자리를 비운다. 체크포인트가 없는 작업이 선점당하면 계산이 통째로 사라지므로 대상 파티션을 한정해 쓴다.

어피니티와 안티 어피니티(affinity · anti-affinity) 특정 조건의 노드에 붙이거나 떨어뜨리는 배치 규칙이다. 같은 학습 작업의 프로세스를 같은 리프 스위치 아래 모으면 통신이 빨라지고, 복제본을 서로 다른 랙에 흩으면 장애 영향이 준다. 목적이 반대라 한쪽을 얻으면 다른 쪽을 잃는다.

빈팩과 스프레드 빈팩은 노드를 채워 나가며 배치해 빈 노드를 남긴다. 큰 작업이 들어올 자리를 만들어 준다. 스프레드는 고르게 흩어 개별 작업의 자원 경합을 줄인다.

QoS 작업에 붙이는 정책 묶음이다. 우선순위, 동시 실행 한도, 최대 시간, 선점 가능 여부를 함께 정의한다. 팀별로 다른 규칙을 주고 싶을 때 파티션보다 유연하다.

sinfo -o "%P %a %l %D %t"       # 파티션별 상태와 시간 제한
squeue -o "%i %u %P %q %T %R"   # 작업별 QoS 와 대기 사유
sacctmgr show qos format=name,priority,maxwall,maxjobsperuser
sshare -a                       # 계정별 fairshare 몫과 최근 사용량

GitHub에서 열기

성능과 측정

FLOPS 초당 부동소수점 연산 횟수다. 카탈로그 값은 이론 최대치이고 실제로는 그 절반도 어렵다. 정밀도에 따라 값이 크게 달라지므로 FP16, BF16, FP8 중 무엇을 기준으로 한 수치인지 확인해야 한다.

MFU 실제 달성한 연산량을 하드웨어 이론 최대치로 나눈 비율이다. 대규모 학습에서 40에서 50퍼센트면 잘 나온 축이다. 이 값이 낮으면 통신, 데이터 로딩, 커널 효율 중 하나가 발목을 잡는다.

Roofline 산술 강도를 가로축에, 달성 성능을 세로축에 두고 어떤 한계에 걸렸는지 보는 그림이다. 왼쪽 기울어진 구간에 있으면 메모리 대역폭에, 오른쪽 평평한 구간에 있으면 연산량에 묶인 상태다.

Occupancy GPU의 스케줄링 단위가 얼마나 채워졌는지를 나타낸다. 높다고 항상 빠른 것은 아니지만 지나치게 낮으면 지연을 감출 여유가 없어진다.

GPU 사용률 nvidia-smi가 보여주는 값은 커널이 하나라도 돌고 있던 시간의 비율이다. 연산 유닛이 얼마나 바쁜지가 아니다. 사용률 100퍼센트인데 실제 처리량이 낮은 상황이 흔하며, 이때는 프로파일러로 커널 단위를 봐야 한다.

MFU는 직접 계산한다. 스텝 시간과 배치에 든 토큰 수만 알면 나온다.

# 토큰당 연산량은 파라미터 수의 6배 안팎이다. 활성값 재계산을 켜면 8배로 잡는다
flops_per_token = 6 * n_params
achieved = flops_per_token * tokens_per_step / step_seconds / n_gpus
mfu = achieved / peak_flops_per_gpu          # H100 bf16 이면 989e12
print(f"MFU {mfu:.1%}")

40퍼센트 아래면 원인을 찾을 값어치가 있다. 통신 비중, 데이터 로더 대기, 커널 효율 순으로 확인한다.

GitHub에서 열기

관측과 운영

Xid NVIDIA 드라이버가 커널 로그에 남기는 오류 코드다. 번호마다 의미가 다르고, 하드웨어 쪽과 애플리케이션 쪽이 섞여 있다. 모듈 08에서 자세히 다룬다.

DCGM NVIDIA의 GPU 관리와 진단 도구 모음이다. 지표 수집, 상시 감시, 단계별 진단을 함께 제공한다. Exporter를 붙이면 Prometheus로 지표가 넘어간다.

행 리매핑 ECC 오류가 난 메모리 행을 예비 행으로 바꾸는 기능이다. 예비가 소진되면 remapped_rows.failure가 1이 되고, 그 카드는 교체 대상이다.

스로틀링 전력이나 온도 한계에 닿아 클럭을 낮추는 동작이다. 사유가 SW Power Cap이면 전력 상한, HW Thermal Slowdown이면 냉각 문제다. 오류 없이 느려지는 상황의 첫 번째 용의자다.

프로파일러 nsys는 CPU와 GPU에 걸친 타임라인을 보여주고, ncu는 커널 하나를 세밀하게 분해한다. 병목이 어디인지 모를 때 nsys로 범위를 좁힌 뒤 ncu로 파고드는 순서가 무난하다.

드라이버와 런타임 버전 CUDA 런타임은 드라이버보다 새로울 수 없다. 컨테이너 안의 CUDA 버전이 호스트 드라이버보다 높으면 실행이 실패한다. 노드마다 드라이버 버전이 어긋나 있으면 같은 이미지가 어떤 노드에서만 죽는 현상이 생긴다.

GitHub에서 열기