LLM

LLM 모델의 서빙과 최적화 개념

mokpolar 2026. 8. 8. 17:26
반응형

8월부터 CloudNeta 팀의 Hands-On LLM Serving and Optimization Study에 참여하게 되어 챕터별로 스터디한 내용을 정리해보겠습니다.

 

7주간 매주 Oreilly의 Hands-On LLM Serving and Optimization 책 내용을 기반으로 학습한 내용을 정리하는 형태로 글을 작성합니다.

 

첫 번째 주제로  LLM 모델의 서빙과 최적화 개념에 대해서 공부한 내용을 작성해봅니다.

 

 

모델에 대해서

모델은 하나의 파일이 아니라 다음과 같은 계층을 가집니다.

구성 요소 내용
모델 데이터 weight, bias, configuration + 실행 메타데이터(배치 크기, 입출력 텐서, 임베딩 등)
모델 아키텍처 레이어 구조, 연결 방식, 연산 정의. 입력이 어떤 계산 경로로 출력이 되는지 결정
모델 실행 코드 아키텍처 초기화 → weight 로드 → eval mode → inference 실행

 

PyTorch 코드를 기준으로 생각해보면 아래와 같은 내용이 됩니다.

torch.save(model.state_dict(), "model_weights.pt")   # 모델 데이터
class TheModelClass(nn.Module): ...                   # 아키텍처 (서빙 환경에도 같은 클래스 정의 필요)
model.load_state_dict(torch.load("model_weights.pt")) # 실행 코드
model.eval(); pred = model(inputs)

아키텍처와 weight를 분리 저장하는 이유는 버전 변경, 부분 로딩, fine-tuning, 레이어 추가 같은 운영 시나리오에 유연하기 때문입니다.

 

새 구조와 기존 checkpoint의 key가 일부 안 맞아도 호환되는 파라미터만 골라 로드할 수 있게 됩니다.

HuggingFace 모델 저장소 파일들도 이 구분으로 읽힙니다.

 

  • config.json / generation_config.json → 아키텍처 정보, 생성 기본 파라미터
  • pytorch_model.bin / model.safetensors → weight
  • tokenizer.json, vocab 파일들 → NLP/LLM용 토크나이저

 

safetensors는 왜 "안전"하고 왜 빠를까요

책에서는 "safetensors가 최신 표준, 더 안전하고 빠르다"고 명시되어 있습니다.

그럼 반대로 pickle이 위험한 이유가 있을 거 같습니다.

 

torch.load()의 기본 직렬화는 pickle인데, pickle은 역직렬화 과정에서 __reduce__에 정의된 임의 객체 생성 코드를 실행합니다.

 

그러니까 모델 파일을 여는 행위 자체가 코드 실행이 될 수 있습니다. HuggingFace Hub에 악성 pickle 모델이 실제로 올라온 사례가 있습니다.

 

이는 PyTorch가 2.6부터 torch.load(weights_only=True)를 기본값으로 바꾼 배경이 됩니다.

 

이제 safetensors 구조는 이렇게 됩니다.
8바이트 헤더 길이 + JSON 헤더(텐서 이름 → dtype/shape/offset 매핑) + raw 텐서 데이터

실제 파일을 앞에서부터 바이트 단위로 뜯어보면 이런 모양입니다.

[0x00 ~ 0x07]   헤더 길이 N (8바이트 정수)
[0x08 ~ 8+N]    JSON 헤더 - 평문이라 사람이 그대로 읽을 수 있음
{
  "model.embed_tokens.weight":
      {"dtype": "F16", "shape": [32000, 4096], "data_offsets": [0, 262144000]},
  "model.layers.0.self_attn.q_proj.weight":
      {"dtype": "F16", "shape": [4096, 4096], "data_offsets": [262144000, 295698432]},
  "__metadata__": {"format": "pt"}
}
[8+N ~ 파일 끝]  raw 텐서 데이터 — 위 offset 순서대로 숫자 바이트가 그냥 이어져 있음

data_offsets를 검산해보면 구조가 그대로 확인됩니다.

embed_tokens는 32,000 × 4,096 × 2바이트(FP16) = 262,144,000바이트라서 구간이 [0, 262144000]이고, 거기서부터 q_proj가 이어집니다.

 

그러니까 헤더만 읽으면 "어떤 텐서가 파일의 몇 번째 바이트에 있는지"를 알 수 있습니다.
아래처럼 확인해볼 수 있습니다.

import json, struct

with open("model.safetensors", "rb") as f:
    header_len = struct.unpack("<Q", f.read(8))[0]  # 앞 8바이트 = 헤더 길이
    header = json.loads(f.read(header_len))          # JSON 헤더만 읽기 (수 KB)

print(list(header.keys())[:5])  # 14GB 파일이어도 텐서 목록이 즉시 나옴

또 safetensors는 빠르다고 하는 이유도 있습니다. 데이터가 연속된 raw byte라 mmap으로 zero-copy 로딩이 되고, 역직렬화 오버헤드가 생기지 않기 때문입니다.

 

pickle 로딩과 단계별로 비교해서 이 내용을 확인히보겠습니다.

pickle 파일 로딩은 사실 "작은 프로그램 실행"입니다.

  1. 파일을 읽으면 "dict를 만들어라", "tensor 객체를 만들어라" 같은 명령(opcode)이 나옵니다
  2. 인터프리터가 이 명령을 순서대로 실행하며 Python 객체를 하나씩 생성합니다
  3. 생성한 객체에 데이터를 복사해서 채웁니다

14GB 모델이면 14GB를 읽고 → 해석하고 → 새 메모리에 복사하는 작업을 전부 거칩니다.

이 "명령 해석 + 객체 생성" 과정이 역직렬화(deserialization)이고, 앞서 본 보안 문제(임의 코드 실행)도 이 단계에서 나옵니다.

 

safetensors는 이 과정이 없습니다. 디스크에 저장된 바이트가 메모리에 올라간 텐서의 바이트와 동일합니다.

예를 들어 FP32 텐서 [1.0, 2.0]은 메모리에서 00 00 80 3F 00 00 00 40이라는 8바이트인데, safetensors 파일에도 이 8바이트가 그대로 적혀 있습니다. 변환할 게 없습니다.

 

그래서 mmap이 가능해집니다. mmap(memory map)은 OS에 "이 파일을 내 프로세스의 메모리 주소 공간에 붙여달라"고 요청하는 시스템 콜입니다. 매핑되면 파일 내용이 복사 없이 곧바로 메모리처럼 보이고, 실제 디스크 읽기는 해당 주소를 처음 접근하는 순간 OS가 페이지 단위로 알아서 처리합니다.

 

"zero-copy"는 이 과정에서 파일 → 애플리케이션 버퍼로의 데이터 복사가 일어나지 않는다는 뜻입니다.

헤더의 data_offsets만 보고 원하는 텐서의 바이트 구간으로 바로 점프할 수 있으니, 전체 파일을 읽지 않고 특정 텐서만 꺼낼 수 있습니다.

 

safetensors 공식 벤치마크에서 CPU 로딩이 PyTorch pickle 대비 수십 배 빠르게 나오는 이유가 그러합니다.

 

pickle vs safetensors 비교

 

두 포맷을 항목별로 비교하면 다음과 같습니다.

항목 pickle 계열 (.bin / .pt / .pth) safetensors
저장 방식 Python 객체 직렬화 JSON 헤더 + raw 텐서 데이터
저장 가능 범위 텐서 외 임의 Python 객체까지 (모델 객체를 통째로 저장 가능) 텐서만 (코드·객체 저장 불가)
보안 역직렬화가 곧 코드 실행 → 위험 실행 코드가 들어갈 구조가 없음
로딩 전체를 역직렬화해야 함 mmap zero-copy
부분 로딩 어려움 헤더의 offset으로 특정 텐서만 읽기 가능
프레임워크 PyTorch 종속 중립 (PyTorch/TF/JAX 등)
현재 위상 레거시, 구형 체크포인트 HuggingFace Hub 기본 포맷

 

pickle은 torch.save(model)처럼 아키텍처가 포함된 모델 객체를 통째로 저장할 수 있지만, 그 부분이 보안적으로 문제가 됩니다. safetensors는 weight만 담기 때문에 안전합니다

 

부분 로딩은 서빙 관점에서 의미가 큽니다. 대형 모델은 weight가 여러 shard 파일로 나뉘는데, safetensors는 헤더만 읽고 필요한 텐서 위치로 바로 접근할 수 있어서 tensor parallel 로딩 시 각 GPU가 자기 몫의 텐서만 읽으면 됩니다.

 

그럼 서빙시 모델 포맷은?

 

pickle과 safetensors는 "학습 프레임워크의 체크포인트 포맷"이고, 서빙할 때는 배포 대상에 맞춰 한 번 더 포팅한 "배포 포맷"을 쓸 수 있습니다.

  • 체크포인트 포맷 - 학습 산출물의 weight 저장: pickle 계열(.bin/.pt/.pth), safetensors
  • 배포 포맷 - 서빙 환경에 맞춰 포팅한 결과물: GGUF, ONNX, TensorRT 엔진, TorchScript, Core ML/LiteRT

 

배포 포맷 각각의 용도는 다음과 같습니다.

  • GGUF - llama.cpp 계열. 양자화 정보까지 단일 파일에 담는 설계라 로컬/엣지 배포에서 표준처럼 쓰임
  • ONNX - 프레임워크 중립의 연산 그래프 포맷. 아키텍처+weight를 함께 담아 ONNX Runtime으로 실행
  • TensorRT 엔진(.plan) - 특정 GPU에 맞춰 컴파일된 실행 계획. 빠르지만 하드웨어 종속
  • TorchScript - PyTorch 모델을 실행 그래프로 export한 것. Python 클래스 정의 없이 C++ 런타임에서 실행 가능
  • Core ML / LiteRT(.tflite) - Apple/Android 온디바이스 런타임 전용

변환을 거칠 필요가 없는 경우도 있습니다. vLLM 같은 서버 GPU 서빙 엔진은 HuggingFace의 safetensors를 변환 없이 그대로 로드합니다.

 

변환이 필수가 되는 건 온디바이스/엣지(GGUF, .tflite), 하드웨어 특화 최적화(TensorRT), 그리고 서빙 서버가 특정 포맷을 요구할 때입니다.

 

특정 포맷을 원하는 경우로 모델 서빙 프레임워크로서 많이 쓰이는 Triton Inference Server을 예로 들어보겠습니다.

Triton은 model repository라는 디렉터리 규약을 요구합니다.

체크포인트를 그냥 주는 게 아니라, 설정 파일과 backend별 포맷으로 포팅한 모델 파일을 정해진 구조로 배치해야 합니다.

model_repository/
└── my_model/
    ├── config.pbtxt      # backend 종류, 입출력 텐서 스펙, instance 수, dynamic batching 설정
    └── 1/                # 버전 디렉터리
        └── model.plan    # backend별 모델 파일 (아래 표)
Triton backend 요구 파일 만드는 법
TensorRT model.plan TensorRT로 체크포인트를 컴파일
ONNX Runtime model.onnx torch.onnx.export()
PyTorch (LibTorch) model.pt torch.jit.trace() / script() → TorchScript
TensorFlow model.savedmodel SavedModel export
Python / vLLM backend model.py + 체크포인트 직접 작성 (LLM은 이 경로로 safetensors를 그대로 사용)

 

여기서 헷갈리기 쉬운 부분이 있습니다. 같은 .pt 확장자라도 체크포인트의 .pt(state_dict를 pickle로 저장)와 Triton에 넣는 model.pt(TorchScript)는 다릅니다.

 

TorchScript는 아키텍처와 weight를 실행 그래프 하나에 담기 때문에, "서빙 환경에도 모델 클래스 정의가 필요하다"는 제약이 사라집니다. 학습 코드(Python 클래스) 없이도 모델을 실행 가능하게 만듭니다.

 

모델 라이프사이클에서의 서빙

모델의 라이프 사이클은 데이터 수집 → 학습 → 평가 → 배포 → 서빙 → 최적화 → 재학습으로 순환합니다.

학습 단계의 관심사는 정확도와 학습 효율이지만, 서빙 단계의 관심사는 이와 다릅니다.
지연시간, 처리량, 안정성, 비용, 보안, 모니터링. 이런것들이 프로덕션 레벨의 서빙에서 더 중요합니다.

 

LLM inference에서 일어나는 일

 

LLM inference는 두 단계로 나뉩니다.

단계 하는 일 병목 특성
Prefill 프롬프트 전체를 한 번에 병렬 처리, KV cache 생성 compute-bound GPU 연산기를 꽉 채움. TTFT를 결정
Decode 토큰을 하나씩 자기회귀 생성 memory-bound 매 토큰마다 weight 전체를 HBM에서 읽음. ITL을 결정

 

decode가 memory-bound라는 게 무슨 뜻일까요.

 

토큰 하나를 만들 때마다 모델 weight 전체를 GPU 메모리에서 읽어야 하므로, 단일 요청의 상한은 대략 메모리 대역폭 ÷ 모델 크기가 됩니다.

H100 HBM3 대역폭 ≈ 3.35TB/s, 7B FP16 모델 = 14GB
→ 3,350 / 14 ≈ 초당 약 240 토큰이 단일 스트림의 물리적 상한

 

이때 GPU는 거의 놀게 됩니다.

 

배치를 키우면 weight를 한 번 읽어 여러 요청의 토큰을 동시에 만들 수 있으므로 배치는 처리량에서 중요합니다.

 

성능 지표를 생각해보면 TTFT(Time To First Token)는 prefill 성능, TPOT/ITL(토큰 간 간격)은 decode 성능, 그리고 GPU를 얼마나 쓰는지는 MFU(연산 활용률)/MBU(대역폭 활용률)로 봅니다.

 

서빙의 구조를 잘 알아야 하는 이유

 

어떤 모델을 어떤 인프라에 올릴지, latency/throughput 목표를 어떻게 맞출지, GPU 비용을 어떻게 통제할지, autoscaling과 routing을 어떻게 설계할지를 결정하기 위해서는 서빙의 구조를 잘 알아야 합니다.

 

결국 모든 상황에 맞는 단일 서빙 솔루션은 없습니다. 모델 크기, 트래픽 패턴, 보안 요구, 비용 구조, 지연시간 목표에 따라 설계가 달라지기 때문입니다.

 

API vs 자체 호스팅, 손익분기는 어디인지를 고민하는 상황을 생각해보겠습니다.

 

  • 비용 구조: LLM API는 토큰 종량제(쓴 만큼), 자체 호스팅은 GPU 시간당 고정비(안 써도 나감). 그래서 손익분기는 결국 GPU 활용률이 중요합니다. 트래픽이 꾸준해서 GPU를 계속 채울 수 있으면 자체 호스팅이 싸지고, 트래픽이 불규칙하면 유휴 GPU 비용 때문에 API가 낫게 됩니다.
  • 호스팅과 API: H100 1장을 시간당 $2~3에 빌려 최적화된 vLLM으로 수천 tok/s를 뽑는다면, 같은 토큰량을 API 종량제로 사는 비용과 비교하면 됩니다. 결국 트래픽이 있어서 그 정도의 처리량이 필요한지가 중요하겠습니다.
  • 비용 외 변수: 데이터가 외부로 나가면 안 되는 규제/보안 요구(금융권이 특히), 특정 모델·파인튜닝 필요 여부, 그리고 조직의 운영 역량 등을 고려해야 합니다.
  • SLO를 잡을 때는 평균이 아니라 tail을 봐야 합니다. p50 latency는 좋은데 p99가 튀는 서비스는 사용자 입장에선 그냥 느린 서비스가 됩니다. 그러니 "TTFT p99 < 1s, ITL p99 < 50ms" 같은 식으로 퍼센타일 기준을 봐야 합니다.

 

LLM 서빙 최적화는 왜 중요할까요

 

최적화 없이 서빙하면 실사용자가 몰릴 때 바로 문제가 생기게 될 수 있습니다.

  • 부하 시 latency 증가
  • throughput이 하드웨어 성능보다 훨씬 낮은 수준에서 정체
  • 사용량 증가에 따라 비용이 선형 이상으로 증가

LLM은 연산 요구량이 커서 문제가 될 가능성이 큽니다.

 

최적화의 목표 지표로는 낮은 latency, 높은 throughput, 높은 GPU 활용률, 낮은 cost per request, 안정적인 tail latency, 효율적인 메모리 사용 등이 있겠습니다

 

LLM 서빙은 일반 모델 서빙 지식보다 더 많은 걸 알아야 합니다. 토큰 생성 방식, KV Cache, attention 비용, batching, decoding, GPU 메모리 병목 같은 LLM 고유 특성을 알아야 비용과 성능을 제어할 수 있습니다.

 

KV Cache는 실제로 얼마나 클까요

KV cache 크기를 예를 들어 계산해보겠습니다.

토큰 1개당 KV cache = 2(K,V) × 레이어 수 × KV head 수 × head 차원 × 정밀도 바이트

Llama-2-7B (MHA: 32 layers, 32 heads, head_dim 128, FP16):
  2 × 32 × 32 × 128 × 2B = 512KB/token
  → 4K 토큰 시퀀스 = 2GB

H100 80GB에서 weight 14GB를 빼면 KV cache에 쓸 수 있는 건 (activation 등 제외하고 후하게 잡아도) 60GB 남짓이 됩니다.

4K 시퀀스 기준 동시에 30개 요청이 물리적 한계라는 뜻이 됩니다.

 

그래서 모델 아키텍처 자체는 KV cache를 줄이는 방향으로 진화합니다.

기법 아이디어 효과 (위 계산 기준)
GQA (Llama-3-8B 등) Query head 32개가 KV head 8개를 공유 512KB → 128KB/token, 4배 절감
MLA (DeepSeek-V2/V3/R1) KV를 저차원 latent 벡터로 압축 저장 MHA 대비 90% 이상 절감
Sliding Window (Mistral) attention 범위를 윈도우로 제한 캐시 크기 상한 고정

 

vLLM에 대해서

vLLM은 high-throughput, low-latency LLM 서빙을 위한 대표적인 오픈소스 엔진입니다.

PagedAttention, continuous batching, OpenAI-compatible API server, tensor parallelism 등의 기법을 제공합니다.

 

아키텍처 구성은 이렇습니다.

LLMEngine(API와 코어 사이 orchestration) → EngineCore(scheduler, KV cache manager, model executor 조율) → Scheduler(요청을 어느 iteration에 넣을지 결정) → ModelExecutor(worker 관리, 분산 실행) → GPUWorker/GPUModelRunner(실제 forward 수행).

# gpt-oss 20B, H100 80GB, 설정값 하나하나가 최적화 포인트가 됩니다.
python -m vllm.entrypoints.openai.api_server \
  --model openai/gpt-oss-20b \
  --dtype bf16 \                     # 계산 정밀도
  --gpu-memory-utilization 0.9 \     # GPU 메모리 90% 사용
  --max-num-seqs 16 \                # 동시 시퀀스 수
  --max-num-batched-tokens 16384 \   # 배치당 최대 토큰
  --tensor-parallel-size 2           # GPU 2개에 모델 분할

 

PagedAttention과 continuous batching의 원리

 

주요 기법인 PagedAttention과 continuous batching에 대해서 자세히 알아보겠습니다.

 

PagedAttention은 OS 가상 메모리의 페이징을 KV cache에 그대로 가져온 것입니다.

  • 기존 방식의 문제: 시퀀스가 얼마나 길어질지 모르니 요청마다 최대 길이만큼 연속 메모리를 예약 → 내부 파편화 + 예약만 하고 안 쓰는 공간. 기존 시스템은 KV cache 메모리의 60~80%를 낭비
  • 해법: KV cache를 고정 크기 블록(기본 16토큰)으로 쪼개 비연속으로 저장하고, block table이 논리 블록 → 물리 블록을 매핑. OS의 페이지 테이블과 정확히 같은 구조가 되고 낭비가 4% 미만으로 떨어집니다.
  • 추가로 copy-on-write가 가능해져서 parallel sampling, beam search에서 프롬프트 KV를 요청 간 공유를 할 수 있게 됩니다.
  • 메모리 낭비가 줄면 같은 GPU에 더 큰 배치가 들어가고, decode는 memory-bound라 배치가 곧 처리량이 됩니다. 파편화 제거 → 배치 증가 → 처리량 폭증 이 됩니다.

 

Continuous batching은 배치의 단위를 "요청"에서 "iteration"으로 바꾼 것입니다.

  • static batching은 배치 안에서 가장 긴 요청이 끝날 때까지 전원 대기하게 됩니다. 100토큰 요청이 5,000토큰 요청과 같이 들어오면 나머지 시간 내내 슬롯을 낭비하게 됩니다.
  • iteration-level scheduling은 매 토큰 스텝마다 배치를 재구성합니다. 끝난 요청은 즉시 빠지고 대기 중이던 요청이 처리되므로 노는 구간이 생기지 않습니다.
반응형