방금 거대한 멀티모달 모델 학습을 마쳤는데, GPU 인스턴스가 절반이나 유휴 상태로 방치되는 바람에 추론 비용이 예상보다 40%나 더 높게 나왔습니다. 청구서를 받아보고 나서야, 정교하게 구축한 쿠버네티스 클러스터가 배치를 실행하는 시간보다 스팟 인스턴스의 선점(preemption)을 관리하는 데 더 많은 시간을 쓰고 있었다는 사실을 깨닫게 됩니다. 이는 가상의 시나리오가 아닙니다. 범용 클라우드 플랫폼에서 본격적인 AI 워크로드를 실행하려는 팀들이 매주 스탠드업 미팅에서 겪는 현실입니다.
현대 AI의 경제학은 냉혹합니다. 모델 크기는 기하급수적으로 커지고 있지만, 기반이 되는 클라우드 인프라는 GPU 집약적이고 변동성이 크며 결함 허용(fault-tolerant)이 필요한 컴퓨팅 요구 사항을 따라잡지 못하고 있습니다. 범용 클라우드는 GPU를 그저 수많은 인스턴스 유형 중 하나로 취급하기 때문에 활용도가 떨어지고, 오케스트레이션이 복잡해지며, 비용 예측이 불가능해집니다. 이러한 불일치 때문에 CoreWeave와 같은 전문 GPU 클라우드가 주목받고 있습니다. 이들은 오직 AI만을 위해 설계된 하드웨어 스택과 소프트웨어 레이어를 제공합니다.
개발자에게 있어 이러한 변화는 단순히 다른 VM을 빌리는 것 이상의 의미를 갖습니다. 하드웨어 우선 접근 방식에 맞춘 새로운 아키텍처 패턴을 채택해야 한다는 뜻입니다. 잘못된 코드 패턴은 수천 달러의 GPU 시간을 낭비하게 만들지만, 올바른 패턴은 추론 지연 시간과 학습 시간을 획기적으로 줄여줄 수 있습니다. 인프라형 코드(IaC)부터 커널 수준에 이르기까지, 성패를 가르는 5가지 구체적인 코딩 관점을 살펴보겠습니다.
1. 코드형 인프라(IaC): GPU를 소중한 자산이 아닌 휘발성 자원으로 취급하기
전통적인 클라우드에서는 GPU 인스턴스를 프로비저닝한 후 이를 금지옥엽 관리합니다. 하지만 CoreWeave에서는 모델이 다릅니다. NVIDIA의 최신 하드웨어(H100, A100, L40S)에 직접 액세스할 수 있고 고가용성이 보장되므로, 장애와 일시성을 염두에 두고 설계할 수 있습니다. 여러분의 IaC도 이를 반영해야 합니다.
Terraform이나 Pulumi를 사용하면 데이터 손실 없이 삭제하고 다시 생성할 수 있는 워크로드를 정의할 수 있습니다. 핵심은 상태 저장 스토리지(모델 가중치나 학습 데이터셋 등)를 컴퓨팅 레이어와 분리하는 것입니다. CoreWeave의 Hot Pools(NVMe 기반)나 오브젝트 스토리지와 같은 스토리지 솔루션은 CSI 드라이버를 통해 마운트되므로 이러한 분리가 깔끔하게 이루어집니다.
다음은 이러한 휘발성을 고려하여 설계된 추론 서비스를 배포하는 Pulumi 예시(TypeScript)입니다. CoreWeave의 커스텀 리소스 정의(CRD)를 통한 쿠버네티스 네이티브 API를 사용합니다.
import * as k8s from "@pulumi/kubernetes";
import * as pulumi from "@pulumi/pulumi";
const appName = "llm-inference";
// 1. GPU 워크로드를 위한 VirtualServer(CoreWeave CRD) 정의
const inferenceServer = new k8s.apiextensions.CustomResource(appName, {
apiVersion: "virtualservers.coreweave.com/v1alpha1",
kind: "VirtualServer",
metadata: {
name: appName,
namespace: "tenant-myteam", // 사용자의 CoreWeave 네임스페이스
},
spec: {
region: "ORD1", // 시카고 데이터 센터
os: {
type: "linux",
},
resources: {
gpu: {
type: "A100_PCIE_80GB", // 명시적인 하드웨어 선택
count: 2,
},
cpu: {
cores: 16,
},
memory: "120Gi",
},
storage: {
// 휘발성 루트 디스크. 영구 데이터는 여기에 저장하지 않음.
root: {
size: "100Gi",
storageClassName: "block-nvme-ord1", // 고성능 로컬 NVMe
},
// 오브젝트 스토리지에서 마운트된 모델용 영구 볼륨
additionalDisks: [{
name: "model-store",
size: "500Gi",
storageClassName: "object-storage", // S3 호환, 느리지만 영구적임
mountPath: "/models",
}],
},
// 부팅 시 실행되는 사용자 데이터 스크립트
userData: `#!/bin/bash
# 영구 저장소에서 최신 모델 가중치 동기화
aws s3 sync s3://my-bucket/models/llama-3-70b /models/llama-3 --endpoint-url=$S3_ENDPOINT
# /models에서 로드하여 추론 서버 시작
python -m vllm.entrypoints.api_server --model /models/llama-3 --tensor-parallel-size=2 &
wait
`,
network: {
public: true, // 공인 IP 할당
tcp: {
ports: [8000], // vLLM의 API 포트 노출
},
},
initializeRunning: true,
},
});
// 2. 즉시 사용을 위한 공인 IP 출력
export const publicIP = inferenceServer.status.apply(s => s?.network?.publicIP);
여기서 중요한 사고의 전환은 `storage` 블록에 있습니다. 루트 디스크는 빠른 NVMe이지만 휘발성입니다. 모델 가중치는 별도의 영구 오브젝트 스토리지 마운트에 위치합니다. `userData` 스크립트는 부팅 시 가중치를 동기화합니다. 인스턴스가 선점되거나 실패하더라도, 다음 인스턴스가 최신 가중치를 가져와 서비스를 시작합니다. 영속성이 아닌 회복 탄력성을 위해 코딩하는 것입니다.
성능 고려 사항
부팅 때마다 100GB 이상의 모델을 동기화하면 콜드 스타트 지연 시간이 발생합니다. 이를 완화하려면 CoreWeave의 Inference Cache를 사용하거나, 로드 밸런서 뒤에 미리 로드된 인스턴스의 웜 풀(warm pool)을 유지하세요. 학습의 경우, 네트워크 스토리지 I/O 병목 현상을 피하기 위해 체크포인트 저장 시 `block-nvme` 클래스를 사용하십시오.
2. 컨테이너 오케스트레이션: 고도로 병렬화된 학습을 위한 쿠버네티스 잡(Job)
하이퍼파라미터 튜닝, 대규모 데이터셋 전처리, 모델 평가는 고도로 병렬화 가능한 작업입니다. CoreWeave에서는 거대한 멀티 노드 클러스터를 요청하고 관리할 필요가 없습니다. 대신 *N*개의 독립적인 GPU 포드를 요청하는 쿠버네티스 `Job` 또는 `PyTorchJob`(Kubeflow 오퍼레이터 사용)을 정의하면 됩니다. 스케줄러는 사용 가능한 하드웨어가 서로 다른 물리적 노드에 흩어져 있더라도 이를 찾아내 할당합니다.
이는 정적 클러스터를 관리하는 것보다 효율적이고 비용 효과적입니다. 실제 병렬 실행이 이루어지는 동안의 GPU 시간에 대해서만 비용을 지불하며, 유휴 상태의 상호 연결 오버헤드 비용은 지불하지 않습니다. 다음 예시는 `completion` 모드를 사용하는 `Job`을 통해 50개의 하이퍼파라미터 실험을 병렬로 실행하는 방법입니다.
apiVersion: batch/v1
kind: Job
metadata:
name: hyperparam-sweep
namespace: tenant-myteam
spec:
completions: 50 # 총 50개의 포드 실행
parallelism: 10 # 동시에 10개의 포드 실행
completionMode: Indexed # 각 포드에 고유 인덱스 부여 (0-49)
template:
spec:
nodeSelector:
# 일관성을 위해 특정 GPU 유형 타겟팅
gpu.nvidia.com/class: A100_PCIE_80GB
containers:
- name: trial-runner
image: myregistry.com/training:py3.12-torch2.3
resources:
limits:
# 포드당 정확히 하나의 GPU 요청
nvidia.com/gpu: 1
cpu: 8
memory: 60Gi
env:
- name: TRIAL_INDEX
valueFrom:
fieldRef:
fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
command: ["python", "/app/run_trial.py"]
args:
- "--learning-rate=$(LR)"
- "--batch-size=$(BATCH)"
# 각 실험을 위한 빠른 스크래치 공간 마운트
volumeMounts:
- name: scratch-nvme
mountPath: /scratch
volumes:
- name: scratch-nvme
ephemeral:
volumeClaimTemplate:
spec:
storageClassName: block-nvme-ord1
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 200Gi
restartPolicy: Never
backoffLimit: 1
---
# 각 인덱스에 대한 파라미터를 정의하는 ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: trial-parameters
data:
# 50개의 고유한 파라미터 쌍 생성. 실제로는 프로그램으로 생성합니다.
parameters.json: |
[
{"LR": "0.001", "BATCH": "32"},
{"LR": "0.001", "BATCH": "64"},
{"LR": "0.0005", "BATCH": "128"},
# ... 47개 항목 더 있음
]
포드는 `TRIAL_INDEX` 환경 변수를 사용하여 ConfigMap에서 자신만의 파라미터 세트를 선택합니다. 각 포드는 중간 데이터를 위해 전용의 빠른 NVMe 스크래치 디스크(`block-nvme-ord1`)를 할당받습니다. `parallelism: 10` 설정을 통해 동시 실행 수를 제어함으로써 스케줄러나 데이터 소스에 과부하가 걸리는 것을 방지합니다.
주의사항: 송신(egress) 비용에 유의하세요. 각 포드가 공용 인터넷에서 1TB 데이터셋을 다운로드한다면 엄청난 비용 청구서를 받게 될 것입니다. 항상 대규모 데이터셋은 CoreWeave의 오브젝트 스토리지에 미리 배치하거나 Dataset Caching 기능을 활용하세요.
3. 저수준 GPU 제어: CUDA 그래프로 활용도 극대화하기
H100 사용료를 초 단위로 지불할 때는 GPU 활용도를 100%로 유지하는 것이 목표입니다. 추론 서버에서 흔히 발생하는 병목 현상은 아주 작은 GPU 커널을 실행할 때 발생하는 Python 런칭 오버헤드입니다. CUDA 그래프(CUDA Graphs)는 일련의 커널(예: 모델의 단일 순전파 단계)을 하나의 재사용 가능한 단위로 캡처하여 이 문제를 해결합니다. 이를 통해 런칭 지연 시간을 제거하고 소규모 배치 추론의 처리량을 2~10배까지 향상시킬 수 있습니다.
이를 위해 직접 CUDA C++를 작성할 필요는 없습니다. PyTorch나 NVIDIA의 TensorRT-LLM 같은 프레임워크에는 내장 지원 기능이 있습니다. 다음은 CoreWeave에서 흔히 사용되는 vLLM 추론 설정에서 CUDA 그래프를 명시적으로 활성화하고 벤치마킹하는 방법입니다.
# inference_benchmark.py
import argparse
import torch
from vllm import LLM, SamplingParams
import time
def run_without_graph(model_id: str, prompt: str, num_iters: int = 100):
"""기준점: CUDA 그래프를 사용하지 않는 표준 vLLM 엔진."""
llm = LLM(
model=model_id,
tensor_parallel_size=2, # 2개의 GPU 사용
enable_cuda_graph=False, # 명시적으로 비활성화
gpu_memory_utilization=0.9,
)
sampling_params = SamplingParams(temperature=0.0, max_tokens=512)
start = time.perf_counter()
for _ in range(num_iters):
# 호출마다 Python->CUDA 런칭 오버헤드 발생
llm.generate([prompt], sampling_params)
torch.cuda.synchronize() # 모든 GPU 작업 대기
elapsed = time.perf_counter() - start
print(f"CUDA 그래프 미사용: {num_iters/elapsed:.2f} iters/sec, 총 {elapsed:.2f}초")
def run_with_graph(model_id: str, prompt: str, num_iters: int = 100):
"""최적화: 커널 시퀀스 재생을 위해 CUDA 그래프 사용."""
llm = LLM(
model=model_id,
tensor_parallel_size=2,
enable_cuda_graph=True, # 핵심 플래그
cuda_graph_batch_size=1, # 실제 사용할 배치 크기와 정확히 일치해야 함
gpu_memory_utilization=0.9,
)
sampling_params = SamplingParams(temperature=0.0, max_tokens=512)
# 그래프 캡처를 위한 워밍업 실행. 이 단계는 느립니다.
print("CUDA 그래프 캡처 중...")
llm.generate([prompt], sampling_params)
start = time.perf_counter()
for _ in range(num_iters):
# 이 반복문은 미리 캡처된 그래프를 재생하여 오버헤드를 최소화합니다.
llm.generate([prompt], sampling_params)
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
print(f"CUDA 그래프 사용: {num_iters/elapsed:.2f} iters/sec, 총 {elapsed:.2f}초")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model-path", type=str, default="/models/llama-3-8b")
args = parser.parse_args()
test_prompt = "양자 컴퓨팅을 한 문장으로 설명해 주세요."
iterations = 500
run_without_graph(args.model_path, test_prompt, iterations)
run_with_graph(args.model_path, test_prompt, iterations)
`enable_cuda_graph=True` 플래그는 vLLM에 특정 배치 크기(이 경우 1)에 대한 커널 시퀀스를 캡처하도록 지시합니다. 첫 번째 실행은 캡처 때문에 비용이 많이 들지만, 이후 실행은 훨씬 빠릅니다. CoreWeave A100에서 테스트했을 때, 단일 요청이 지속적으로 들어오는 스트림에서 토큰당 지연 시간이 약 40% 감소하는 것을 확인했습니다.
트레이드오프: CUDA 그래프는 유연성이 떨어집니다. 그래프는 정확한 배치 크기, 시퀀스 길이 및 모델 구성에 맞춰 컴파일됩니다. 워크로드가 매우 가변적이라면(배치 크기가 1에서 32까지 변하는 경우), 캐시 미스로 인해 오히려 성능이 저하될 수 있습니다. 예측 가능하고 처리량이 높은 추론 엔드포인트에만 사용하세요.
4. 네트워크 인식 데이터 로딩: 800 Gb/s 인피니밴드 활용 극대화
70B 파라미터 모델을 학습시키려면 효율적인 멀티 노드 통신이 필수적입니다. CoreWeave의 베어메탈 서버는 NVIDIA Quantum-2 인피니밴드로 연결되어 400-800 Gb/s의 대역폭을 제공합니다. 네트워크 스토리지에서 데이터를 로드하는 방식이 서투르면 병목 현상이 발생하여 이 값비싼 링크를 제대로 활용하지 못하게 됩니다.
해결책은 데이터 전처리, CPU-to-GPU 전송(H2D), 그리고 GPU 연산을 중첩(overlap)시키는 것입니다. PyTorch의 DataLoader에서 멀티 워커와 `pin_memory`를 사용하는 것이 도움이 되지만, 분산 환경에서 최대 처리량을 얻으려면 파이프라인이 필요합니다. 다음은 프리페칭(prefetching) 데이터 로더와 함께 PyTorch의 `DistributedDataParallel`(DDP)을 사용하는 패턴입니다.
# distributed_train.py
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler
from datasets import load_dataset
import numpy as np
from functools import partial
def collate_fn(batch, tokenizer, max_length=2048):
"""CPU에서의 최적화된 콜레이션(Collation)."""
texts = [item["text"] for item in batch]
# CPU에서 병렬로 토큰화
encodings = tokenizer(
texts,
truncation=True,
padding="max_length",
max_length=max_length,
return_tensors="pt",
)
return encodings["input_ids"], encodings["attention_mask"]
def create_high_throughput_loader(dataset, tokenizer, batch_size, world_size, rank):
"""GPU에 끊임없이 데이터를 공급하도록 설계된 DataLoader 생성."""
sampler = DistributedSampler(
dataset,
num_replicas=world_size,
rank=rank,
shuffle=True,
seed=42,
)
# 주요 파라미터:
loader = DataLoader(
dataset,
batch_size=batch_size,
sampler=sampler,
num_workers=8, # 권장 사항: 노드당 GPU 수 * 4~8
pin_memory=True, # 빠른 H2D 비동기 복사 활성화
prefetch_factor=4, # 각 워커가 4개의 배치를 미리 가져옴
persistent_workers=True, # 에포크마다 워커를 재시작하는 것을 방지
collate_fn=partial(collate_fn, tokenizer=tokenizer),
)
return loader
def main():
# 분산 프로세스 그룹 초기화 (인피니밴드 기반 NCCL)
dist.init_process_group("nccl")
rank = dist.get_rank()
local_rank = int(os.environ["LOCAL_RANK"])
world_size = dist.get_world_size()
torch.cuda.set_device(local_rank)
device = torch.device(f"cuda:{local_rank}")
# 모델 및 옵티마이저
model = MyLargeModel().to(device)
model = DDP(model, device_ids=[local_rank])
# 데이터셋 - NVMe 스토리지에 미리 다운로드되었다고 가정
dataset = load_dataset("parquet", data_files="/nvme_data/train/*.parquet", split="train")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3-8b")
loader = create_high_throughput_loader(dataset, tokenizer, 32, world_size, rank)
for epoch in range(10):
sampler.set_epoch(epoch) # 에포크 간 셔플링을 위해 필수
for batch_idx, (input_ids, attention_mask) in enumerate(loader):
# 데이터는 이미 고정 메모리(pinned memory)에 있음. GPU로 비차단(non-blocking) 전송.
input_ids = input_ids.to(device, non_blocking=True)
attention_mask = attention_mask.to(device, non_blocking=True)
# GPU 연산
outputs = model(input_ids, attention_mask=attention_mask)
loss = outputs.loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
if batch_idx % 100 == 0 and rank == 0:
print(f"에포크 {epoch}, 배치 {batch_idx}, 손실: {loss.item():.4f}")
if __name__ == "__main__":
main()
마법은 `DataLoader` 설정에 있습니다. `num_workers=8`은 CPU 코어 전체에서 데이터 로딩과 토큰화를 병렬화합니다. `pin_memory=True`는 페이지 고정(page-locked) 호스트 메모리를 할당하여, GPU로의 `non_blocking=True` 전송이 커널 실행과 겹칠 수 있게 합니다. `prefetch_factor=4`는 항상 준비된 배치의 버퍼가 있도록 보장합니다.
팁: `NVIDIA Nsight Systems`나 PyTorch 프로파일러로 프로파일링하세요. GPU 유휴 시간(`CUDA Kernel` 간극)이 보이면 `num_workers`나 `prefetch_factor`를 늘리세요. CPU가 포화 상태라면 데이터셋을 미리 토큰화하고 직접 메모리 매핑이 가능한 바이너리 파일로 저장해야 할 수도 있습니다.
5. 관측 가능성 및 비용 귀속: 모든 GPU 초(Second) 단위 측정
강력한 GPU 파워에는 막중한 비용 책임이 따릅니다. 어떤 팀, 실험, 또는 API 엔드포인트가 리소스를 소비하고 있는지 알아야 합니다. CoreWeave는 메트릭을 제공하지만, 비용을 정확하게 귀속시키려면 애플리케이션 코드를 계측해야 합니다. 구조화된 로깅과 Prometheus 메트릭을 활용하세요.