이미지 로딩 중...

LLM 추론 최적화와 배포 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 8. · 3 Views

LLM 추론 최적화와 배포 완벽 가이드

대규모 언어 모델의 추론 성능을 극대화하고 실제 프로덕션 환경에 배포하는 전략을 다룹니다. 양자화, KV 캐시 최적화, 배치 처리부터 분산 서빙까지 실무에 필요한 모든 기법을 상세히 설명합니다.


목차

  1. 모델_양자화
  2. KV_캐시_최적화
  3. Flash_Attention
  4. 동적_배치_처리
  5. 모델_프루닝
  6. 추론_엔진_통합
  7. 분산_서빙
  8. 모니터링과_로깅

1. 모델_양자화

시작하며

여러분이 70억 파라미터 LLM을 서비스하려는데 GPU 메모리가 부족해서 고민하고 있나요? FP32로 저장된 모델은 28GB의 메모리를 요구하지만, 보유한 GPU는 16GB만 지원하는 상황.

이런 경우 클라우드 비용을 늘리거나 모델 크기를 줄여야 할까요? 이 문제는 실제 LLM 배포 시 가장 먼저 마주치는 현실적인 제약입니다.

메모리 부족은 단순히 모델을 로드하지 못하는 것을 넘어, 배치 크기를 줄여야 하고 결국 처리량이 떨어지는 악순환으로 이어집니다. 또한 다중 모델 서빙이 불가능해지고 비용 효율성도 크게 저하됩니다.

바로 이럴 때 필요한 것이 모델 양자화입니다. 정밀도를 낮춰 메모리 사용량을 2-4배 줄이면서도, 모델 성능은 거의 유지할 수 있는 강력한 최적화 기법입니다.

개요

간단히 말해서, 모델 양자화는 가중치와 활성화 값의 정밀도를 낮춰 메모리 사용량과 연산량을 줄이는 기술입니다. 왜 이것이 필요한가요?

LLM은 일반적으로 FP32(32비트 부동소수점)로 학습되는데, 이는 매우 높은 정밀도이지만 추론 시에는 과도합니다. INT8(8비트 정수)이나 FP16(16비트 부동소수점)으로 변환하면 메모리를 75-50% 줄일 수 있습니다.

예를 들어, 고객 문의 응답 시스템에서 여러 도메인별 모델을 동시에 서빙해야 하는 경우, 양자화 없이는 GPU 수를 늘려야 하지만 양자화를 적용하면 단일 GPU로도 충분할 수 있습니다. 기존에는 모델을 그대로 로드해 메모리가 부족하면 더 큰 GPU를 사용했다면, 이제는 양자화로 동일한 하드웨어에서 더 큰 모델이나 더 많은 모델을 실행할 수 있습니다.

핵심 특징은 세 가지입니다: (1) 메모리 사용량 대폭 감소, (2) 추론 속도 향상(낮은 비트 연산이 빠름), (3) 최소한의 정확도 손실(적절한 캘리브레이션 시 1-2% 이내). 이러한 특징들이 중요한 이유는 프로덕션 환경에서 비용과 성능의 균형을 맞추는 것이 성공적인 서비스의 핵심이기 때문입니다.

코드 예제

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# 모델 로드 및 INT8 양자화 적용
model_name = "meta-llama/Llama-2-7b-hf"
tokenizer = AutoTokenizer.from_pretrained(model_name)

# load_in_8bit: 자동으로 INT8 양자화 적용
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    device_map="auto",  # 자동으로 GPU에 분산
    load_in_8bit=True,  # INT8 양자화 활성화
    torch_dtype=torch.float16  # 중간 계산은 FP16 사용
)

# 추론 실행
inputs = tokenizer("AI의 미래는", return_tensors="pt").to("cuda")
outputs = model.generate(**inputs, max_length=50)
print(tokenizer.decode(outputs[0]))

설명

이 코드가 하는 일은 7B 파라미터 LLM을 메모리 효율적으로 로드하고 추론하는 것입니다. 일반적으로 28GB가 필요한 모델을 약 7-14GB로 줄여 실행합니다.

첫 번째 단계에서 load_in_8bit=True 파라미터가 핵심입니다. 이것이 활성화되면 Hugging Face의 bitsandbytes 라이브러리가 자동으로 작동하여 모델의 가중치를 INT8로 변환합니다.

각 레이어를 로드할 때마다 FP32/FP16 가중치를 INT8로 매핑하는데, 이 과정에서 스케일 팩터를 계산하여 정보 손실을 최소화합니다. 왜 이렇게 하는가?

32비트를 8비트로 줄이면 메모리가 4분의 1로 줄어들지만, 단순히 잘라내면 정확도가 크게 떨어지므로 스케일링과 클리핑을 통해 중요한 정보를 보존합니다. 두 번째 단계에서 device_map="auto"가 실행되면서 모델 레이어들이 사용 가능한 GPU 메모리에 자동으로 분산됩니다.

만약 단일 GPU에 모델이 들어가지 않으면 여러 GPU에 나눠 배치하고, 그래도 부족하면 일부를 CPU 메모리로 오프로드합니다. 내부적으로는 각 레이어의 크기를 분석하고 메모리 맵을 생성하여 최적의 배치를 결정합니다.

세 번째 단계에서 실제 추론이 실행될 때, 가중치는 INT8로 저장되어 있지만 연산은 torch_dtype=torch.float16에 의해 FP16으로 수행됩니다. 이는 INT8 가중치를 읽어와 FP16으로 디퀀타이즈(역양자화)한 후 계산하는 방식입니다.

최종적으로 생성된 토큰들이 디코딩되어 텍스트로 반환됩니다. 여러분이 이 코드를 사용하면 다음과 같은 구체적인 효과를 얻을 수 있습니다: (1) 16GB GPU에서 7B 모델 실행 가능, (2) 배치 크기를 2-4배 늘려 처리량 향상, (3) 다중 모델 서빙 시 GPU 수 절감으로 인프라 비용 50% 이상 감소, (4) 추론 속도 20-40% 향상(메모리 대역폭 병목 완화), (5) 정확도 손실은 일반적으로 1% 미만으로 대부분의 실무 애플리케이션에서 무시 가능한 수준입니다.

실전 팁

💡 INT8 양자화는 파라미터 수가 많을수록 효과적입니다. 7B 미만 모델에서는 성능 저하가 더 크므로 13B 이상 모델에 우선 적용하세요.

💡 양자화 후 반드시 벤치마크 데이터셋으로 정확도를 검증하세요. MMLU, HellaSwag 같은 표준 벤치마크로 원본 모델과 비교하여 1-2% 이상 차이나면 재조정이 필요합니다.

💡 mixed precision 전략을 활용하세요. 중요한 레이어(첫/마지막 레이어, 정규화 레이어)는 FP16으로 유지하고 나머지만 INT8로 양자화하면 성능 손실을 더욱 줄일 수 있습니다.

💡 QLoRA(Quantized LoRA)를 사용하면 양자화된 모델을 파인튜닝할 수도 있습니다. 메모리 효율적인 학습이 가능해져 24GB GPU에서도 65B 모델 튜닝이 가능합니다.

💡 프로덕션 환경에서는 GPTQ나 AWQ 같은 고급 양자화 기법도 고려하세요. bitsandbytes보다 추론 속도가 2-3배 더 빠르며, 전용 커널을 사용해 효율성이 높습니다.


2. KV_캐시_최적화

시작하며

여러분이 챗봇 서비스를 운영하는데 긴 대화를 이어갈수록 응답 속도가 느려지는 문제를 겪고 있나요? 첫 응답은 1초인데, 10턴 대화 후에는 5초가 걸리는 상황.

사용자는 계속 기다려야 하고 서버 리소스는 계속 증가합니다. 이 문제의 근본 원인은 LLM의 자기회귀적 생성 방식에 있습니다.

매 토큰 생성 시마다 전체 시퀀스의 어텐션을 다시 계산하면, 시퀀스 길이에 제곱으로 비례하는 연산량이 발생합니다. 100 토큰 컨텍스트에서 1개 토큰 생성 시 10,000번의 어텐션 계산이 필요하고, 200 토큰이 되면 40,000번으로 4배 증가합니다.

바로 이럴 때 필요한 것이 KV 캐시입니다. 이전에 계산한 Key와 Value를 저장해두고 재사용하면, 새로운 토큰에 대한 계산만 추가하면 되므로 속도를 수십 배 향상시킬 수 있습니다.

개요

간단히 말해서, KV 캐시는 트랜스포머 어텐션 메커니즘에서 이전 토큰들의 Key와 Value 텐서를 메모리에 저장해두고 재사용하는 기법입니다. 왜 이것이 필요한가요?

자기회귀 생성에서 매번 전체 시퀀스의 어텐션을 재계산하는 것은 엄청난 낭비입니다. 이미 생성된 토큰들의 Key/Value는 변하지 않으므로 한 번만 계산하면 됩니다.

예를 들어, 긴 문서 요약 서비스에서 2048 토큰 입력에 대해 512 토큰을 생성할 때, KV 캐시 없이는 각 토큰마다 2048+n번의 어텐션을 계산하지만, KV 캐시를 사용하면 1번만 계산하므로 100배 이상 빨라질 수 있습니다. 기존에는 매 스텝마다 전체 컨텍스트를 다시 인코딩했다면, 이제는 증분 방식으로 새 토큰만 처리합니다.

핵심 특징은: (1) 추론 속도 10-100배 향상(시퀀스 길이에 비례), (2) 메모리 사용량 증가(캐시 저장 공간 필요), (3) 구현이 간단하며 모든 트랜스포머 모델에 적용 가능. 이것이 중요한 이유는 실시간 대화형 AI 서비스의 필수 요소이며, 사용자 경험을 결정짓는 핵심 최적화이기 때문입니다.

코드 예제

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

model_name = "gpt2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name).to("cuda")

# KV 캐시를 사용한 증분 생성
input_text = "인공지능의 발전은"
input_ids = tokenizer(input_text, return_tensors="pt").input_ids.to("cuda")

# past_key_values에 KV 캐시 저장
past_key_values = None
generated_tokens = []

for _ in range(20):  # 20개 토큰 생성
    # use_cache=True로 KV 캐시 활성화
    outputs = model(input_ids, past_key_values=past_key_values, use_cache=True)

    # 다음 토큰 예측 및 캐시 업데이트
    next_token = outputs.logits[:, -1, :].argmax(dim=-1)
    generated_tokens.append(next_token.item())

    # 캐시 재사용을 위해 저장
    past_key_values = outputs.past_key_values
    # 다음 반복에서는 새 토큰만 입력
    input_ids = next_token.unsqueeze(0)

print(tokenizer.decode(generated_tokens))

설명

이 코드가 하는 일은 KV 캐시를 명시적으로 관리하면서 토큰을 하나씩 생성하는 증분 디코딩을 구현하는 것입니다. 전체 시퀀스를 매번 재계산하는 대신, 이전 계산 결과를 재사용합니다.

첫 번째 반복에서 past_key_values=None으로 시작하므로 초기 입력 시퀀스 전체에 대해 어텐션을 계산합니다. 모델은 모든 레이어에서 Key와 Value 텐서를 계산하고 이를 outputs.past_key_values에 튜플 형태로 반환합니다.

각 레이어마다 (key, value) 쌍이 있으며, 형태는 [batch_size, num_heads, sequence_length, head_dim]입니다. 왜 이렇게 저장하는가?

다음 토큰 생성 시 이 캐시를 재사용하면 이전 토큰들에 대한 어텐션 계산을 건너뛸 수 있기 때문입니다. 두 번째 반복부터는 past_key_values가 존재하므로 상황이 달라집니다.

input_ids를 새로운 단일 토큰으로 업데이트하면, 모델은 이 토큰에 대해서만 Key/Value를 계산합니다. 내부적으로 past_key_values의 기존 캐시와 새 Key/Value를 시퀀스 차원에서 연결(concatenate)하여 전체 어텐션을 계산합니다.

만약 캐시에 50 토큰이 있고 새 토큰 1개를 추가하면, 51 토큰에 대한 어텐션이 계산되지만 실제 연산은 1 토큰에 대해서만 수행됩니다. 세 번째로 중요한 점은 메모리 관리입니다.

매 스텝마다 캐시 크기가 sequence_length만큼 증가합니다. 12개 레이어, 12개 헤드, 768 차원 모델에서 1 토큰당 약 0.3MB의 캐시가 추가됩니다.

1000 토큰 생성 시 300MB가 필요하므로, 배치 처리나 긴 컨텍스트에서는 메모리가 빠르게 소진될 수 있습니다. 따라서 실무에서는 최대 시퀀스 길이를 제한하거나 캐시 압축 기법을 사용합니다.

여러분이 이 코드를 실무에 적용하면: (1) 100 토큰 컨텍스트에서 추론 속도 약 50배 향상, (2) 실시간 채팅봇에서 응답 지연 2-3초 → 0.1초 미만으로 단축, (3) 동일한 GPU로 처리 가능한 동시 요청 수 3-5배 증가, (4) 하지만 메모리 사용량은 시퀀스 길이에 비례해 증가하므로 배치 크기 조절 필요, (5) vLLM 같은 추론 엔진과 결합하면 PagedAttention으로 메모리 효율성까지 확보 가능합니다.

실전 팁

💡 KV 캐시는 메모리를 많이 사용하므로 최대 시퀀스 길이를 명시적으로 제한하세요. 4096 토큰 이상은 대부분의 실무 케이스에서 불필요하며, 2048 제한만으로도 메모리 사용량을 절반으로 줄일 수 있습니다.

💡 배치 처리 시 각 시퀀스 길이가 다르면 패딩으로 인해 메모리가 낭비됩니다. 유사한 길이끼리 그룹화하거나 동적 패딩을 사용해 효율성을 높이세요.

💡 멀티턴 대화에서는 캐시를 세션 단위로 관리하세요. Redis 같은 외부 캐시에 past_key_values를 직렬화해 저장하면 서버 재시작 후에도 대화를 이어갈 수 있습니다.

💡 vLLM의 PagedAttention은 KV 캐시를 페이지 단위로 관리해 메모리 단편화를 방지합니다. 자체 구현 대신 vLLM을 사용하면 메모리 효율이 2-3배 향상됩니다.

💡 긴 문서 처리 시 슬라이딩 윈도우 방식을 고려하세요. 최근 N 토큰의 캐시만 유지하고 오래된 것은 버리면 메모리는 일정하게 유지하면서도 성능 저하가 미미합니다.


3. Flash_Attention

시작하며

여러분이 LLM 추론을 프로파일링했더니 어텐션 계산이 전체 시간의 60% 이상을 차지하는 것을 발견했나요? 특히 긴 시퀀스에서 GPU 활용률은 낮은데 메모리 대역폭은 포화 상태.

모델 크기를 줄일 수도 없고, 하드웨어를 업그레이드하기에는 비용이 너무 높습니다. 이 문제는 전통적인 어텐션 구현의 근본적인 한계에서 비롯됩니다.

어텐션 스코어 행렬을 전부 메모리에 로드하면 시퀀스 길이의 제곱에 비례하는 메모리가 필요합니다. 2048 토큰 시퀀스에서는 약 16MB지만, 4096 토큰이 되면 64MB로 4배 증가하고, GPU HBM(High Bandwidth Memory)과 SRAM 간 데이터 전송이 병목이 됩니다.

실제 연산은 빠른데 데이터 이동이 느려 GPU가 놀게 되는 겁니다. 바로 이럴 때 필요한 것이 Flash Attention입니다.

어텐션 계산을 타일 단위로 나누고 SRAM에서 융합 연산을 수행해, 메모리 접근을 최소화하면서 수학적으로 정확히 동일한 결과를 얻을 수 있습니다.

개요

간단히 말해서, Flash Attention은 어텐션 메커니즘을 메모리 효율적으로 재구성한 알고리즘으로, GPU의 SRAM을 최대한 활용하여 HBM 접근을 줄입니다. 왜 이것이 필요한가요?

기존 어텐션은 Q@K^T → Softmax → @V 순서로 진행되는데, 각 단계마다 중간 결과를 HBM에 쓰고 읽습니다. 하지만 HBM 대역폭은 제한적이고(A100 기준 1.5TB/s), SRAM(19MB, 대역폭 수백 TB/s)보다 훨씬 느립니다.

예를 들어, 긴 컨텍스트 검색 서비스에서 8192 토큰을 처리할 때, 표준 어텐션은 256MB의 HBM 트래픽을 발생시키지만 Flash Attention은 약 10MB로 줄여 25배 빠르게 만듭니다. 기존에는 정확도를 유지하려면 전체 어텐션 행렬을 계산해야 했다면, 이제는 타일링과 온라인 소프트맥스로 동일한 결과를 메모리 효율적으로 얻습니다.

핵심 특징은: (1) 메모리 복잡도를 O(N²)에서 O(N)으로 감소, (2) 추론 속도 2-4배 향상(특히 긴 시퀀스), (3) 수학적으로 표준 어텐션과 완전히 동일한 출력. 이것이 중요한 이유는 긴 컨텍스트 처리가 필수인 문서 분석, 코드 생성, 긴 대화 등에서 실용성을 크게 높여주기 때문입니다.

코드 예제

import torch
from flash_attn import flash_attn_func

# 입력 텐서 준비 (배치, 시퀀스, 헤드, 차원)
batch_size, seq_len, num_heads, head_dim = 2, 2048, 12, 64
q = torch.randn(batch_size, seq_len, num_heads, head_dim, device='cuda', dtype=torch.float16)
k = torch.randn(batch_size, seq_len, num_heads, head_dim, device='cuda', dtype=torch.float16)
v = torch.randn(batch_size, seq_len, num_heads, head_dim, device='cuda', dtype=torch.float16)

# Flash Attention 실행 - 메모리 효율적 어텐션
output = flash_attn_func(
    q, k, v,
    dropout_p=0.0,  # 추론 시 드롭아웃 비활성화
    softmax_scale=None,  # 자동으로 1/sqrt(d) 적용
    causal=True,  # 자기회귀 마스킹 (왼쪽만 참조)
)

# output 형태: [batch_size, seq_len, num_heads, head_dim]
print(f"출력 형태: {output.shape}")
print(f"메모리 사용량 절감: ~{(seq_len**2 * 2 * 4) / (seq_len * head_dim * 2)}x")

설명

이 코드가 하는 일은 2048 토큰 시퀀스에 대해 메모리 효율적인 어텐션을 계산하는 것입니다. 표준 어텐션으로는 약 192MB의 중간 버퍼가 필요하지만, Flash Attention은 약 6MB만 사용합니다.

첫 번째로 입력 텐서들이 특정 형태로 준비됩니다. [batch, seq, heads, dim] 순서는 Flash Attention 커널이 요구하는 레이아웃입니다.

FP16을 사용하는 이유는 Flash Attention이 Tensor Core를 활용하기 때문인데, FP32보다 2배 빠르면서도 어텐션 계산에서는 정확도 차이가 거의 없습니다. 모든 텐서가 GPU에 있어야 하며, CPU 텐서를 전달하면 에러가 발생합니다.

두 번째로 flash_attn_func가 실행되면 내부에서 무슨 일이 벌어질까요? 알고리즘은 Q, K, V를 블록 단위(예: 128 토큰)로 나눕니다.

각 블록에 대해 Q 블록과 K 블록의 내적을 계산하고, 소프트맥스를 온라인 방식으로 적용합니다. 핵심은 전체 어텐션 행렬을 만들지 않는다는 것입니다.

대신 각 Q 블록마다 필요한 K/V 블록만 SRAM으로 로드하고, 그 안에서 어텐션을 계산한 뒤 부분 결과를 누적합니다. 온라인 소프트맥스는 전체 시퀀스를 보지 않고도 정확한 소프트맥스를 계산하는 수학적 트릭으로, 현재까지의 최댓값과 합을 추적하면서 점진적으로 업데이트합니다.

세 번째로 causal=True는 자기회귀 마스킹을 적용합니다. 각 토큰은 자신보다 앞의 토큰만 볼 수 있도록 제한되는데, 표준 구현에서는 마스크 행렬을 만들어 어텐션 스코어에 더하지만, Flash Attention은 루프 범위를 조정해 필요한 블록만 계산합니다.

이로 인해 연산량이 절반으로 줄어듭니다(삼각 행렬만 계산). 여러분이 이 코드를 프로덕션에 적용하면: (1) 2048 토큰 시퀀스에서 어텐션 속도 2.5-3배 향상, (2) 8192 토큰 이상의 긴 컨텍스트 처리가 가능해짐(기존에는 OOM 발생), (3) 동일 GPU에서 배치 크기 2배 증가 가능, (4) 에너지 효율성 향상으로 추론 비용 20-30% 절감, (5) Llama 2, GPT-NeoX 등 최신 모델들은 기본으로 Flash Attention을 지원하므로 즉시 활용 가능합니다.

실전 팁

💡 Flash Attention은 Ampere(A100) 이상 GPU에서 최적화되어 있습니다. V100 같은 구형 GPU에서는 성능 향상이 제한적이므로, 가능하면 A100이나 H100을 사용하세요.

💡 시퀀스 길이가 512 미만일 때는 오히려 표준 어텐션이 빠를 수 있습니다. Flash Attention의 오버헤드가 있기 때문에, 짧은 시퀀스에서는 선택적으로 사용하세요.

💡 Hugging Face Transformers 4.36 이상에서는 attn_implementation="flash_attention_2" 파라미터로 쉽게 활성화할 수 있습니다. 별도 코드 수정 없이 모델 로드 시 지정만 하면 됩니다.

💡 Flash Attention 2는 1보다 헤드당 최대 2배 더 빠릅니다. 가능하면 최신 버전을 사용하고, pip install flash-attn --no-build-isolation으로 설치하세요(컴파일 시간 10-20분 소요).

💡 멀티 GPU 환경에서는 텐서 병렬화와 결합하세요. 각 GPU가 헤드 일부를 담당하고 Flash Attention을 적용하면, 통신 오버헤드를 줄이면서도 메모리 효율을 유지할 수 있습니다.


4. 동적_배치_처리

시작하며

여러분이 LLM API 서비스를 운영하는데 요청마다 길이가 천차만별이어서 GPU 활용률이 30%밖에 안 되는 상황을 겪고 있나요? 짧은 요청(50 토큰)과 긴 요청(1500 토큰)을 같은 배치로 묶으면 패딩으로 메모리가 낭비되고, 개별 처리하면 GPU가 놀게 됩니다.

피크 시간에는 요청이 밀려 지연이 발생하고, 한가한 시간에는 리소스가 놀아서 비효율적입니다. 이 문제는 정적 배칭의 한계입니다.

미리 정해진 배치 크기로만 처리하면 요청 도착 패턴과 맞지 않습니다. 10개가 모일 때까지 기다리면 첫 요청의 지연이 길어지고, 즉시 처리하면 배치 크기가 1이 되어 처리량이 떨어집니다.

또한 시퀀스 길이가 다양하면 최장 시퀀스에 맞춰 패딩해야 하므로 메모리와 연산이 낭비됩니다. 바로 이럴 때 필요한 것이 동적 배치 처리입니다.

요청이 도착하는 대로 큐에 넣고, 일정 조건이 만족되면(배치 크기 제한, 대기 시간 제한, 메모리 제한) 즉시 배치를 구성해 처리합니다.

개요

간단히 말해서, 동적 배치는 고정된 배치 크기 대신 실시간으로 요청을 모아 최적의 배치를 구성하는 기법입니다. 왜 이것이 필요한가요?

실제 서비스에서 요청은 무작위로 도착하고 길이도 제각각입니다. 정적 배칭은 이런 변동성을 처리하지 못해 리소스를 낭비하거나 지연을 초래합니다.

동적 배칭은 대기 중인 요청들을 실시간으로 분석하여 가장 효율적인 조합을 만듭니다. 예를 들어, 고객 지원 챗봇에서 동시에 100개 요청이 들어오면 유사한 길이끼리 그룹화하여 10개 배치로 나누고, 각 배치를 순차적으로 빠르게 처리하면 평균 응답 시간과 처리량을 모두 최적화할 수 있습니다.

기존에는 배치 크기 8로 고정하고 8개가 모일 때까지 기다렸다면, 이제는 50ms 대기 또는 메모리 80% 도달 시 즉시 배치를 실행합니다. 핵심 특징은: (1) GPU 활용률 최대화(60-90%로 향상), (2) 지연 시간 감소(대기 시간 제한으로 SLA 보장), (3) 처리량 증가(효율적인 배칭으로 2-3배 향상).

이것이 중요한 이유는 프로덕션 환경에서 비용과 성능, 사용자 경험의 균형을 맞추는 핵심 요소이기 때문입니다.

코드 예제

import asyncio
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from collections import deque
from typing import List

class DynamicBatcher:
    def __init__(self, model, tokenizer, max_batch_size=16, max_wait_ms=50):
        self.model = model
        self.tokenizer = tokenizer
        self.max_batch_size = max_batch_size
        self.max_wait_ms = max_wait_ms / 1000  # 초 단위 변환
        self.queue = deque()  # 요청 큐

    async def add_request(self, text: str):
        # 요청을 큐에 추가하고 결과를 기다림
        future = asyncio.Future()
        self.queue.append((text, future))
        return await future

    async def process_batches(self):
        while True:
            if not self.queue:
                await asyncio.sleep(0.01)  # 큐가 비면 대기
                continue

            # 배치 구성: 최대 크기 또는 대기 시간 도달 시
            batch_start = asyncio.get_event_loop().time()
            batch = []

            while len(batch) < self.max_batch_size and self.queue:
                # 대기 시간 초과 체크
                if asyncio.get_event_loop().time() - batch_start > self.max_wait_ms and batch:
                    break
                batch.append(self.queue.popleft())

            if batch:
                await self._process_batch(batch)

    async def _process_batch(self, batch: List):
        texts = [item[0] for item in batch]
        futures = [item[1] for item in batch]

        # 토크나이징 및 패딩 (동적 길이에 맞춤)
        inputs = self.tokenizer(texts, return_tensors="pt", padding=True).to("cuda")

        # 배치 추론 실행
        with torch.no_grad():
            outputs = self.model.generate(**inputs, max_new_tokens=50)

        # 결과를 각 요청의 Future에 전달
        for i, future in enumerate(futures):
            result = self.tokenizer.decode(outputs[i], skip_special_tokens=True)
            future.set_result(result)

설명

이 코드가 하는 일은 비동기적으로 도착하는 추론 요청들을 효율적으로 배치 처리하는 시스템을 구현하는 것입니다. 각 요청을 즉시 처리하지 않고 짧은 시간 동안 모아서 한 번에 처리합니다.

첫 번째로 add_request 메서드가 요청을 받으면 큐에 추가하고 Future 객체를 반환합니다. 이 Future는 나중에 결과가 준비되면 완료되는 비동기 프로미스입니다.

클라이언트는 await로 기다리며, 이 동안 이벤트 루프는 다른 요청을 받을 수 있습니다. 왜 이런 설계를 사용하나요?

동기 방식으로 즉시 추론하면 한 번에 하나만 처리되지만, 비동기로 큐잉하면 여러 요청을 모을 수 있습니다. 두 번째로 process_batches 루프가 백그라운드에서 지속적으로 실행됩니다.

큐를 모니터링하다가 두 조건 중 하나가 만족되면 배치를 구성합니다: (1) 배치 크기가 max_batch_size에 도달, (2) 첫 요청 도착 후 max_wait_ms 경과. 이 트레이드오프가 핵심입니다.

배치가 클수록 GPU 효율은 높지만 지연이 길어지고, 대기 시간이 짧을수록 응답은 빠르지만 배치가 작아집니다. 50ms는 실무에서 좋은 균형점으로, 사용자는 거의 느끼지 못하면서 평균 5-10개 요청을 모을 수 있습니다.

세 번째로 _process_batch에서 실제 추론이 실행됩니다. padding=True는 배치 내에서 최장 시퀀스 길이에 맞춰 나머지를 패딩하는데, 여기서 중요한 점은 전체 데이터셋이 아닌 현재 배치만 고려한다는 것입니다.

만약 배치의 길이가 [50, 60, 55] 토큰이면 60으로 패딩되지만, 다른 배치가 [200, 190]이면 200으로 패딩됩니다. 이렇게 배치마다 독립적으로 패딩하면 메모리 낭비를 최소화할 수 있습니다.

추론 후 각 결과를 해당 Future에 설정하면 add_request를 호출한 클라이언트가 결과를 받습니다. 여러분이 이 시스템을 프로덕션에 적용하면: (1) GPU 활용률이 30%에서 70-80%로 증가, (2) 동일 하드웨어로 처리 가능한 QPS(Queries Per Second) 2-3배 향상, (3) P95 지연 시간을 50ms 이하로 보장하면서도 배치 효율 확보, (4) 비용 효율성 크게 개선(GPU당 처리량 증가로 필요한 인스턴스 수 감소), (5) 스파이크 트래픽에도 안정적 대응(큐가 버퍼 역할).

실전 팁

💡 max_wait_ms는 SLA에 맞춰 조정하세요. 실시간 챗봇은 50ms, 배치 작업은 500ms까지 허용하면 더 큰 배치를 만들어 효율성을 높일 수 있습니다.

💡 길이 기반 버킷팅을 추가하면 패딩 낭비를 더 줄일 수 있습니다. 큐에서 요청을 꺼낼 때 유사한 길이끼리 그룹화하면(예: 0-100, 100-500, 500+ 토큰), 각 배치의 패딩 비율이 10% 미만으로 떨어집니다.

💡 우선순위 큐를 사용해 프리미엄 사용자나 긴급 요청을 먼저 처리하세요. heapq로 우선순위를 관리하면 비즈니스 로직에 맞는 스케줄링이 가능합니다.

💡 메모리 기반 배치 제한도 고려하세요. 현재 GPU 메모리 사용량을 모니터링하다가 80% 도달 시 더 이상 배치에 추가하지 않으면 OOM을 방지할 수 있습니다.

💡 Ray Serve나 Triton Inference Server는 동적 배칭을 기본 지원합니다. 직접 구현 대신 이런 프레임워크를 사용하면 추가로 auto-scaling, 모니터링, 로드 밸런싱 기능도 얻을 수 있습니다.


5. 모델_프루닝

시작하며

여러분이 모바일 앱에 LLM을 임베딩하려는데 모델이 너무 커서 배포가 불가능한 상황에 처했나요? 3B 파라미터 모델은 6GB인데 앱 크기 제한은 200MB, 그리고 추론 속도도 너무 느려 사용자 경험이 나쁩니다.

양자화만으로는 부족하고 더 작은 모델로 재학습하기에는 시간과 비용이 과도합니다. 이 문제의 핵심은 모든 파라미터가 실제로 필요한 것은 아니라는 점입니다.

연구에 따르면 LLM의 파라미터 중 20-40%는 제거해도 성능이 거의 떨어지지 않습니다. 특정 레이어, 뉴런, 또는 어텐션 헤드는 거의 활성화되지 않거나 출력에 미미한 영향만 줍니다.

하지만 어떤 것을 제거해야 할지 수동으로 찾는 것은 거의 불가능합니다. 바로 이럴 때 필요한 것이 모델 프루닝입니다.

중요도가 낮은 파라미터를 자동으로 식별하고 제거하여 모델을 경량화하면서도 성능은 최대한 유지합니다.

개요

간단히 말해서, 모델 프루닝은 학습된 모델에서 중요도가 낮은 가중치나 구조를 제거하여 모델 크기와 연산량을 줄이는 기법입니다. 왜 이것이 필요한가요?

대규모 모델은 일반화를 위해 과잉 파라미터화되어 있습니다. 학습 시에는 다양한 데이터를 처리하기 위해 많은 용량이 필요하지만, 특정 도메인에 배포할 때는 전체 용량의 일부만 활용됩니다.

예를 들어, 의료 문서 분석 시스템에서는 일반 대화 능력보다 의학 용어 이해가 중요하므로, 관련 없는 뉴런들을 제거해도 태스크 성능은 유지됩니다. 프루닝하면 모델 크기를 50%까지 줄이고 추론 속도를 2배 향상시킬 수 있습니다.

기존에는 전체 모델을 그대로 사용하거나 처음부터 작은 모델을 학습했다면, 이제는 큰 모델의 지식을 유지하면서 크기만 줄입니다. 핵심 특징은: (1) 모델 크기 30-50% 감소, (2) 추론 속도 1.5-2배 향상, (3) 적절한 프루닝 시 정확도 손실 3% 이내.

이것이 중요한 이유는 엣지 디바이스 배포, 모바일 앱, 리소스 제약 환경에서 LLM을 실용적으로 만들기 때문입니다.

코드 예제

import torch
from transformers import AutoModelForCausalLM
import torch.nn.utils.prune as prune

# 모델 로드
model = AutoModelForCausalLM.from_pretrained("gpt2").to("cuda")

# 각 레이어에 magnitude-based pruning 적용
def apply_pruning(model, pruning_ratio=0.3):
    for name, module in model.named_modules():
        # Linear 레이어에만 프루닝 적용
        if isinstance(module, torch.nn.Linear):
            # L1 norm 기반으로 하위 30% 가중치 제거
            prune.l1_unstructured(
                module,
                name='weight',
                amount=pruning_ratio  # 30% 파라미터 제거
            )
            # 프루닝을 영구적으로 적용
            prune.remove(module, 'weight')

    return model

# 프루닝 실행
pruned_model = apply_pruning(model, pruning_ratio=0.3)

# 프루닝 효과 확인
total_params = sum(p.numel() for p in model.parameters())
zero_params = sum((p == 0).sum().item() for p in pruned_model.parameters())
print(f"전체 파라미터: {total_params:,}")
print(f"제로 파라미터: {zero_params:,} ({zero_params/total_params*100:.1f}%)")
print(f"실제 파라미터: {total_params - zero_params:,}")

설명

이 코드가 하는 일은 학습된 GPT-2 모델에서 중요도가 낮은 가중치 30%를 찾아 제거하는 것입니다. 제거된 가중치는 0으로 설정되어 연산에서 제외됩니다.

첫 번째로 apply_pruning 함수가 모델의 모든 모듈을 순회하면서 Linear 레이어만 선택합니다. 왜 Linear 레이어인가?

LLM의 대부분 파라미터는 어텐션과 FFN의 Linear 레이어에 집중되어 있으며(전체의 95% 이상), 임베딩이나 정규화 레이어는 프루닝하면 성능 저하가 큽니다. Linear 레이어는 행렬 곱셈이므로 일부 가중치를 0으로 만들어도 구조는 유지됩니다.

두 번째로 prune.l1_unstructured가 실행되면 각 가중치의 절댓값(L1 norm)을 계산합니다. 절댓값이 작은 가중치일수록 출력에 미치는 영향이 작다는 가정입니다.

예를 들어, 가중치가 [0.8, -0.05, 0.3, 0.02, -0.6]이면 절댓값은 [0.8, 0.05, 0.3, 0.02, 0.6]이고, 하위 30%인 0.02와 0.05가 제거 대상입니다. 내부적으로는 마스크를 생성하여 해당 위치를 0으로 만들고, 순전파 시 이 마스크를 곱해 효과적으로 제거합니다.

amount=0.3은 30%를 제거한다는 의미입니다. 세 번째로 prune.remove는 프루닝을 영구적으로 적용합니다.

기본적으로 프루닝은 마스크로만 적용되어 가중치는 그대로 있지만, remove를 호출하면 마스크를 가중치에 직접 곱해 0으로 만들고 마스크를 제거합니다. 이렇게 하면 메모리가 절약되고, 나중에 저장할 때 희소 행렬 포맷으로 압축할 수 있습니다.

하지만 주의할 점은 unstructured pruning은 0이 흩어져 있어 실제 연산 속도 향상은 제한적입니다. Structured pruning(전체 뉴런이나 헤드 제거)을 사용하면 진짜 크기가 줄어듭니다.

여러분이 이 코드를 사용하면: (1) 모델 파일 크기는 30% 감소(희소 포맷 저장 시), (2) 하지만 dense 연산에서는 속도 향상이 5-10%에 그칩니다(0 곱셈도 실행되므로), (3) structured pruning으로 전환하면 진짜 30% 속도 향상 가능, (4) 정확도는 파인튜닝 없이도 1-2%만 하락, (5) 프루닝 후 소량의 데이터로 재학습하면 원래 성능으로 복구 가능합니다.

실전 팁

💡 Unstructured pruning은 희소 행렬 지원이 필요합니다. PyTorch는 기본적으로 dense 연산이므로, NVIDIA Sparse Tensor Cores나 DeepSparse 같은 전용 런타임을 사용해야 실제 속도 향상을 얻을 수 있습니다.

💡 Structured pruning으로 전환하세요. prune.ln_structured로 전체 뉴런이나 어텐션 헤드를 제거하면 모델 구조 자체가 작아져 일반 추론 엔진에서도 빨라집니다. 12개 헤드 중 4개를 제거하면 어텐션 연산이 33% 줄어듭니다.

💡 프루닝 비율을 점진적으로 증가시키세요. 한 번에 50% 제거하면 성능이 급락하지만, 10% → 20% → 30%로 단계적으로 진행하면서 매번 재학습하면 안정적입니다.

💡 Task-specific pruning이 효과적입니다. 범용 모델 대신 타겟 태스크의 데이터로 중요도를 평가하면 더 공격적으로 프루닝해도 성능이 유지됩니다. 예: 감성 분석용으로 프루닝하면 40%까지 가능.

💡 지식 증류와 결합하세요. 프루닝된 모델(student)을 원본 모델(teacher)의 출력으로 학습시키면 성능 손실을 최소화하면서 크기는 대폭 줄일 수 있습니다. DistilBERT는 이 방식으로 40% 작으면서도 97% 성능을 유지합니다.


6. 추론_엔진_통합

시작하며

여러분이 Hugging Face Transformers로 LLM을 서비스하고 있는데 처리량이 너무 낮아 고민하고 있나요? 단일 GPU로 초당 5개 요청밖에 처리하지 못하고, 메모리 사용량도 비효율적이며, KV 캐시를 직접 관리하다 버그도 발생했습니다.

코드를 최적화하려 해도 어디서부터 손대야 할지 막막합니다. 이 문제는 범용 프레임워크의 한계입니다.

Transformers는 연구와 프로토타이핑에는 훌륭하지만, 프로덕션 추론에 최적화되어 있지 않습니다. KV 캐시 관리, 배치 처리, 메모리 할당 등이 최적이 아니며, 많은 오버헤드가 있습니다.

또한 최신 GPU 기능(Tensor Cores, Flash Attention 등)을 완전히 활용하지 못합니다. 바로 이럴 때 필요한 것이 전문 추론 엔진입니다.

vLLM, TensorRT-LLM, Text Generation Inference(TGI) 같은 도구들은 LLM 추론에만 집중하여 처리량을 5-20배 향상시킵니다.

개요

간단히 말해서, 추론 엔진은 LLM 서빙에 특화된 최적화된 런타임으로, PagedAttention, continuous batching, 커널 융합 등의 기법을 통합하여 성능을 극대화합니다. 왜 이것이 필요한가요?

프로덕션 환경에서는 추론 성능이 곧 비용과 사용자 경험입니다. 처리량이 2배 증가하면 필요한 GPU가 절반으로 줄어들고, 지연 시간이 절반이 되면 사용자 만족도가 크게 향상됩니다.

예를 들어, 대규모 검색 증강 생성(RAG) 시스템에서 하루 100만 쿼리를 처리할 때, 표준 구현으로는 A100 10개가 필요하지만 vLLM을 사용하면 2-3개로 충분해 연간 수억 원의 비용을 절감할 수 있습니다. 기존에는 Transformers로 직접 추론을 구현했다면, 이제는 전문 엔진에 위임하여 최고 성능을 얻습니다.

핵심 특징은: (1) 처리량 5-20배 향상(PagedAttention과 continuous batching), (2) 메모리 효율성 2-3배 개선(동적 메모리 관리), (3) 최신 하드웨어 완전 활용(Tensor Cores, Flash Attention). 이것이 중요한 이유는 실제 서비스 성공의 핵심이 인프라 비용과 응답 속도이기 때문입니다.

코드 예제

from vllm import LLM, SamplingParams

# vLLM 엔진 초기화 - 최적화된 추론 설정
llm = LLM(
    model="meta-llama/Llama-2-7b-hf",
    tensor_parallel_size=1,  # GPU 수 (병렬화)
    dtype="float16",  # FP16 사용
    max_num_seqs=256,  # 동시 처리 시퀀스 수 (continuous batching)
    max_num_batched_tokens=4096,  # 배치당 최대 토큰
)

# 샘플링 파라미터 설정
sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=100,
)

# 여러 요청을 동시에 처리 (continuous batching 자동 적용)
prompts = [
    "AI의 미래는",
    "양자 컴퓨팅의 원리는",
    "기후 변화 해결을 위해",
]

# 배치 추론 실행 - 자동으로 최적 배치 구성
outputs = llm.generate(prompts, sampling_params)

# 결과 출력
for output in outputs:
    print(f"프롬프트: {output.prompt}")
    print(f"생성: {output.outputs[0].text}\n")

설명

이 코드가 하는 일은 Hugging Face Transformers 대신 vLLM을 사용하여 동일한 모델을 훨씬 빠르게 실행하는 것입니다. 여러 요청을 자동으로 배치하고 메모리를 효율적으로 관리합니다.

첫 번째로 LLM 객체 초기화 시 여러 최적화가 자동으로 적용됩니다. max_num_seqs=256은 동시에 처리할 수 있는 최대 시퀀스 수인데, vLLM의 continuous batching이 핵심입니다.

기존 배칭은 모든 시퀀스가 끝날 때까지 기다리지만, continuous batching은 끝난 시퀀스를 즉시 제거하고 새 요청을 추가합니다. 예를 들어, 배치에 [100, 200, 50] 토큰 생성 작업이 있으면 50이 끝나는 순간 새 요청을 추가해 GPU가 항상 가득 차도록 합니다.

이로 인해 GPU 활용률이 90% 이상 유지됩니다. 두 번째로 PagedAttention이 자동으로 활성화됩니다.

KV 캐시를 운영체제의 가상 메모리처럼 페이지 단위로 관리하여, 필요한 만큼만 할당하고 공유 가능한 프롬프트는 재사용합니다. 예를 들어, "번역: [텍스트]" 형태의 요청 100개가 들어오면 "번역: " 부분의 KV 캐시는 한 번만 계산하고 공유합니다.

또한 시퀀스가 끝나면 페이지를 즉시 반환해 메모리 단편화를 방지합니다. 이로 인해 동일 메모리에서 배치 크기를 2-3배 늘릴 수 있습니다.

세 번째로 generate 호출 시 내부에서 여러 최적화 커널이 실행됩니다. Flash Attention 2, 융합된 MLP, 최적화된 샘플링 등이 자동으로 적용됩니다.

또한 max_num_batched_tokens=4096은 한 번의 forward pass에서 처리할 최대 토큰 수로, 이를 통해 짧은 시퀀스 여러 개나 긴 시퀀스 몇 개를 유연하게 조합합니다. CUDA 그래프를 사용해 반복적인 커널 호출 오버헤드도 제거합니다.

여러분이 vLLM을 프로덕션에 적용하면: (1) A100에서 Llama-2-7B 처리량 약 2500 tokens/s (Transformers는 300-500), (2) 동일 메모리에서 배치 크기 2-3배 증가, (3) P99 지연 시간 50% 감소(continuous batching 덕분), (4) 인프라 비용 60-80% 절감(필요한 GPU 수 급감), (5) API 서버로 바로 사용 가능(OpenAI 호환 엔드포인트 제공).

실전 팁

💡 vLLM은 오픈소스이며 설치가 쉽습니다. pip install vllm으로 설치하고 기존 Transformers 코드를 몇 줄만 수정하면 됩니다. 가장 빠르게 성능 향상을 얻을 수 있는 방법입니다.

💡 TensorRT-LLM은 NVIDIA GPU에서 더 빠르지만 설정이 복잡합니다. vLLM보다 20-30% 더 빠르지만, 모델별로 빌드가 필요하고 디버깅이 어렵습니다. 초기에는 vLLM으로 시작하고, 최적화가 필요하면 TensorRT-LLM을 고려하세요.

💡 Text Generation Inference(TGI)는 Hugging Face의 공식 추론 엔진으로, Docker 컨테이너로 배포가 쉽습니다. vLLM과 성능이 비슷하며, Hugging Face Hub 통합이 강점입니다.

💡 tensor_parallel_size로 다중 GPU 병렬화가 가능합니다. 70B 모델은 단일 A100에 안 들어가지만, tensor_parallel_size=4로 4개 GPU에 분산하면 실행 가능합니다. 통신 오버헤드는 NVLink로 최소화됩니다.

💡 프로덕션 배포 시 OpenAI API 호환 모드를 사용하세요. vllm serve로 서버를 띄우면 /v1/completions 엔드포인트를 제공해, 기존 OpenAI 클라이언트 코드를 그대로 사용할 수 있습니다.


7. 분산_서빙

시작하며

여러분이 65B 파라미터 모델을 서비스하려는데 단일 GPU 메모리로는 도저히 불가능한 상황에 처했나요? A100 80GB조차 부족하고, 여러 GPU를 사용하려 해도 어떻게 모델을 나눠야 할지, 통신 오버헤드는 어떻게 관리할지 막막합니다.

또한 여러 GPU 간 로드 밸런싱도 고민입니다. 이 문제는 대규모 모델의 필연적 과제입니다.

70B 모델은 FP16으로 140GB가 필요한데, 가장 큰 GPU도 80GB입니다. CPU 메모리로 오프로드하면 너무 느리고, 모델을 분할하려면 복잡한 병렬화 전략이 필요합니다.

또한 여러 GPU 간 데이터 전송은 병목이 되어 속도를 저하시킵니다. 바로 이럴 때 필요한 것이 분산 서빙입니다.

모델을 여러 GPU에 지능적으로 분산하고, 통신을 최적화하며, 요청을 효율적으로 라우팅하여 대규모 모델을 실용적으로 서비스합니다.

개요

간단히 말해서, 분산 서빙은 모델을 여러 디바이스에 나누고 병렬 처리하여 대규모 모델을 효율적으로 실행하는 기법입니다. 왜 이것이 필요한가요?

최신 LLM은 단일 GPU 용량을 초과합니다. GPT-3는 175B 파라미터로 350GB가 필요하고, LLaMA-65B도 130GB입니다.

이런 모델들은 분산 없이는 실행 자체가 불가능합니다. 분산 서빙을 사용하면 모델을 파이프라인 병렬화(레이어별 분할), 텐서 병렬화(레이어 내 분할), 또는 데이터 병렬화(복제)로 나눠 여러 GPU에서 협력 실행합니다.

예를 들어, 기업 내부 GPT 서비스에서 65B 모델을 4개 A100에 텐서 병렬화로 분산하면, 각 GPU가 레이어의 1/4을 담당하여 메모리 문제를 해결하고 추론 속도도 유지합니다. 기존에는 모델 크기가 GPU 메모리에 맞아야 했다면, 이제는 필요한 만큼 GPU를 추가하여 임의 크기 모델을 실행합니다.

핵심 특징은: (1) 대규모 모델 실행 가능(단일 GPU 용량 초과), (2) 처리량 증가(다중 GPU 활용), (3) 고가용성(GPU 장애 시 재라우팅). 이것이 중요한 이유는 최고 성능 모델을 실제로 배포할 수 있게 만들기 때문입니다.

코드 예제

from vllm import LLM, SamplingParams
import ray

# Ray 초기화 (분산 처리 프레임워크)
ray.init()

# 텐서 병렬화로 모델을 4개 GPU에 분산
llm = LLM(
    model="meta-llama/Llama-2-70b-hf",
    tensor_parallel_size=4,  # 4개 GPU에 텐서 병렬화
    dtype="float16",
    trust_remote_code=True,
)

# 샘플링 파라미터
sampling_params = SamplingParams(temperature=0.7, max_tokens=100)

# 추론 실행 - 각 레이어가 4개 GPU에 분산 처리됨
prompts = ["대규모 언어 모델의 미래는"]
outputs = llm.generate(prompts, sampling_params)

print(outputs[0].outputs[0].text)

# 선택사항: Ray Serve로 API 서버 배포
from ray import serve

@serve.deployment(num_replicas=2, ray_actor_options={"num_gpus": 4})
class LLMDeployment:
    def __init__(self):
        # 각 replica가 4개 GPU 사용
        self.llm = LLM(model="meta-llama/Llama-2-70b-hf",
                       tensor_parallel_size=4)

    async def __call__(self, request):
        prompt = await request.json()
        outputs = self.llm.generate([prompt["text"]], sampling_params)
        return {"response": outputs[0].outputs[0].text}

# API 배포 (총 8개 GPU 사용: 2 replicas × 4 GPUs)
serve.run(LLMDeployment.bind(), route_prefix="/generate")

설명

이 코드가 하는 일은 70B 파라미터 모델을 4개 GPU에 분산하여 실행하고, Ray Serve로 확장 가능한 API 서버를 구축하는 것입니다. 각 GPU는 모델의 일부를 담당하여 협력 추론합니다.

첫 번째로 tensor_parallel_size=4가 핵심입니다. 텐서 병렬화는 각 Transformer 레이어를 여러 GPU에 나눕니다.

예를 들어, 어텐션의 Q, K, V 행렬이 [hidden_size, hidden_size] 크기라면, 이를 열 방향으로 4등분해 각 GPU가 [hidden_size, hidden_size/4]를 담당합니다. GPU0은 헤드 0-7, GPU1은 헤드 8-15 식으로 분할됩니다.

순전파 시 입력이 모든 GPU에 복제되고, 각 GPU가 자기 파트를 계산한 후 결과를 all-reduce로 합칩니다. 왜 이 방식을 사용하나요?

파이프라인 병렬화보다 통신 빈도가 낮고, 모든 GPU가 항상 바쁘게 일해 효율이 높습니다. 두 번째로 통신 오버헤드 관리가 중요합니다.

All-reduce는 GPU 간 집합 통신인데, NVLink가 있으면 초당 600GB/s로 매우 빠릅니다. 하지만 PCIe만 있으면 10배 느려져 병목이 됩니다.

vLLM은 NCCL(NVIDIA Collective Communications Library)을 사용해 통신을 최적화하며, 여러 레이어의 통신을 파이프라이닝하여 연산과 중첩시킵니다. 예를 들어, 레이어 N의 all-reduce가 진행되는 동안 레이어 N+1의 연산을 시작합니다.

세 번째로 Ray Serve 부분은 프로덕션 배포를 다룹니다. num_replicas=2는 모델 인스턴스 2개를 띄우는데, 각각이 4개 GPU를 사용하므로 총 8개 GPU가 필요합니다.

요청이 들어오면 Ray가 자동으로 로드 밸런싱하여 덜 바쁜 replica로 라우팅합니다. 한 replica가 장애 나도 다른 replica가 처리하므로 고가용성이 확보됩니다.

또한 auto-scaling 설정을 추가하면 트래픽에 따라 replica를 동적으로 조정할 수 있습니다. 여러분이 이 시스템을 실제로 구축하면: (1) 70B 모델을 A100 4개로 실행 가능(각 GPU 약 35GB 사용), (2) 처리량은 단일 GPU 7B 모델의 약 80% (통신 오버헤드 20%), (3) 2 replica 설정으로 가용성 99.9% 이상 달성, (4) 피크 트래픽 시 replica 증가로 자동 확장, (5) GPU 장애 시 자동 재시작 및 재배포로 다운타임 최소화.

실전 팁

💡 NVLink 연결이 필수입니다. 텐서 병렬화는 GPU 간 통신이 빈번하므로 NVLink 없이는 성능이 50% 이상 저하됩니다. AWS p4d, GCP a2-ultragpu 인스턴스는 NVLink를 제공합니다.

💡 파이프라인 병렬화와 결합하세요. 80개 레이어를 4개 GPU에 20개씩 나누고(파이프라인), 각 레이어를 다시 2개 GPU에 텐서 병렬화하면 총 8개 GPU를 효율적으로 활용합니다.

💡 통신 프로파일링을 하세요. NCCL_DEBUG=INFO로 통신 시간을 측정하여 병목을 찾습니다. 통신이 전체 시간의 30% 이상이면 병렬화 전략을 재고해야 합니다.

💡 Ray Serve 대신 Kubernetes로 배포할 수도 있습니다. KServe나 Seldon Core로 모델을 배포하고 Istio로 트래픽을 관리하면 엔터프라이즈급 기능(A/B 테스트, 카나리 배포)을 얻을 수 있습니다.

💡 비용 최적화: 단일 큰 GPU(A100 80GB) 대신 여러 작은 GPU(A10G 24GB 4개)를 사용하면 비용이 30-40% 절감됩니다. 클라우드 스팟 인스턴스와 결합하면 추가로 70% 절감 가능합니다.


8. 모니터링과_로깅

시작하며

여러분이 LLM API를 배포했는데 갑자기 응답 속도가 느려지거나 오류율이 급증하는데 원인을 찾을 수 없나요? 로그는 산더미처럼 쌓이지만 어디서 문제가 생겼는지 파악이 안 되고, GPU 사용률은 어떤지, 메모리는 충분한지, 병목은 어디인지 알 방법이 없습니다.

문제가 발생하면 사용자가 먼저 알려주는 상황입니다. 이 문제는 가시성 부족입니다.

프로덕션 시스템은 복잡하고 동적이라 실시간 모니터링 없이는 문제를 조기 발견할 수 없습니다. 추론 지연, 메모리 누수, GPU 과부하, 비정상 출력 등은 적절한 지표가 없으면 감지되지 않다가 심각한 장애로 이어집니다.

또한 디버깅을 위한 충분한 로그가 없으면 문제 해결에 몇 시간씩 소요됩니다. 바로 이럴 때 필요한 것이 체계적인 모니터링과 로깅입니다.

핵심 지표를 추적하고, 이상 징후를 자동 탐지하며, 상세한 로그로 빠르게 원인을 파악합니다.

개요

간단히 말해서, 모니터링과 로깅은 시스템의 상태와 동작을 실시간으로 관찰하고 기록하여 문제를 조기 발견하고 신속히 대응하는 체계입니다. 왜 이것이 필요한가요?

프로덕션 LLM 서비스는 24/7 운영되며 수천 명의 사용자에게 서비스합니다. 작은 문제도 빠르게 확대될 수 있어, 조기 경보와 빠른 진단이 필수입니다.

모니터링은 지연 시간, 처리량, 오류율, 리소스 사용량 등의 지표를 실시간으로 추적하고, 임계값을 초과하면 알림을 보냅니다. 로깅은 각 요청의 세부 정보(입력, 출력, 처리 시간, 오류)를 기록해 사후 분석을 가능하게 합니다.

예를 들어, 금융 챗봇에서 응답 시간이 평소 200ms에서 2초로 증가하면 즉시 알림을 받고, 로그를 분석해 특정 유형의 쿼리가 문제인지, GPU 메모리가 부족한지 파악할 수 있습니다. 기존에는 문제가 발생하면 사후 대응했다면, 이제는 사전 탐지로 장애를 예방합니다.

핵심 특징은: (1) 실시간 지표 대시보드(Grafana), (2) 자동 알림(Prometheus Alertmanager), (3) 구조화된 로깅(Elasticsearch). 이것이 중요한 이유는 서비스 신뢰성과 운영 효율성을 결정하기 때문입니다.

코드 예제

import time
import logging
from prometheus_client import Counter, Histogram, Gauge, start_http_server
from functools import wraps

# Prometheus 메트릭 정의
REQUEST_COUNT = Counter('llm_requests_total', 'Total LLM requests', ['status'])
REQUEST_LATENCY = Histogram('llm_request_duration_seconds', 'LLM request latency')
GPU_MEMORY = Gauge('llm_gpu_memory_usage_bytes', 'GPU memory usage')
ACTIVE_REQUESTS = Gauge('llm_active_requests', 'Currently processing requests')

# 구조화된 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='{"time": "%(asctime)s", "level": "%(levelname)s", "message": "%(message)s"}',
)
logger = logging.getLogger(__name__)

# 모니터링 데코레이터
def monitor_inference(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        ACTIVE_REQUESTS.inc()  # 활성 요청 증가
        start_time = time.time()

        try:
            result = func(*args, **kwargs)
            REQUEST_COUNT.labels(status='success').inc()
            logger.info(f'{{"event": "inference_success", "duration": {time.time() - start_time}}}')
            return result
        except Exception as e:
            REQUEST_COUNT.labels(status='error').inc()
            logger.error(f'{{"event": "inference_error", "error": "{str(e)}"}}')
            raise
        finally:
            duration = time.time() - start_time
            REQUEST_LATENCY.observe(duration)  # 지연 시간 기록
            ACTIVE_REQUESTS.dec()  # 활성 요청 감소

            # GPU 메모리 추적
            import torch
            if torch.cuda.is_available():
                GPU_MEMORY.set(torch.cuda.memory_allocated())

    return wrapper

# 추론 함수에 모니터링 적용
@monitor_inference
def generate_text(prompt):
    # 실제 모델 추론 (예시)
    time.sleep(0.1)  # 추론 시뮬레이션
    return f"Generated response for: {prompt}"

# Prometheus 메트릭 서버 시작 (포트 8000)
start_http_server(8000)

# 테스트 실행
for i in range(10):
    try:
        result = generate_text(f"Test prompt {i}")
        print(result)
    except Exception as e:
        print(f"Error: {e}")

설명

이 코드가 하는 일은 LLM 추론 시스템의 성능과 상태를 실시간으로 모니터링하고 상세히 기록하는 것입니다. Prometheus 지표와 JSON 로그를 생성하여 Grafana와 Elasticsearch에서 분석할 수 있습니다.

첫 번째로 Prometheus 메트릭 정의 부분입니다. Counter는 누적 카운트로 총 요청 수를 추적하며, labels(status='success')로 성공/실패를 구분합니다.

이를 통해 오류율(실패 수 / 전체 수)을 계산할 수 있습니다. Histogram은 지연 시간의 분포를 기록하는데, 내부적으로 여러 버킷(예: 0.1s, 0.5s, 1s)으로 나눠 P50, P95, P99 같은 백분위수를 계산합니다.

왜 평균이 아닌 백분위수를 사용하나요? 평균은 이상치에 민감하지만, P95는 "95%의 요청이 이 시간 안에 완료된다"는 SLA를 명확히 보여줍니다.

Gauge는 현재 값으로 GPU 메모리나 활성 요청 수처럼 증감하는 지표를 추적합니다. 두 번째로 monitor_inference 데코레이터는 모든 추론 함수에 적용할 수 있는 재사용 가능한 모니터링 로직입니다.

함수 실행 전에 ACTIVE_REQUESTS.inc()로 동시 처리 중인 요청을 증가시키고, 완료 후 감소시킵니다. 이를 모니터링하면 시스템이 과부하 상태인지 알 수 있습니다(예: 활성 요청이 100을 초과하면 경고).

예외 발생 시 status='error'로 카운트하고, finally 블록으로 성공/실패 상관없이 지연 시간과 리소스를 기록합니다. 이렇게 하면 실패한 요청도 지표에 포함되어 정확한 분석이 가능합니다.

세 번째로 로깅은 JSON 형태로 구조화됩니다. {"event": "inference_success", "duration": 0.123} 같은 형식은 Elasticsearch나 Splunk 같은 로그 분석 도구에서 쉽게 파싱하고 쿼리할 수 있습니다.

비구조화 텍스트("Inference completed in 0.123s")보다 훨씬 강력합니다. 예를 들어, "duration > 1.0인 이벤트의 분포"나 "error 이벤트의 시간별 추이"를 SQL 같은 쿼리로 분석할 수 있습니다.

GPU 메모리도 로깅하면 메모리 누수를 탐지할 수 있습니다(메모리가 계속 증가하면 문제). 여러분이 이 코드를 프로덕션에 적용하면: (1) Grafana 대시보드로 실시간 지표 시각화(초당 요청 수, P95 지연, GPU 사용률), (2) Alertmanager로 임계값 초과 시 Slack/이메일 알림(예: P95 > 1초, 오류율 > 5%), (3) Elasticsearch에 로그 전송하여 Kibana로 분석(특정 오류 패턴 검색, 느린 요청 분석), (4) 장애 발생 시 MTTR(평균 복구 시간) 50% 이상 단축, (5) 용량 계획에 활용(트래픽 증가 추세 분석하여 스케일링 시점 결정).

실전 팁

💡 핵심 지표는 RED(Rate, Errors, Duration) 또는 USE(Utilization, Saturation, Errors)로 정의하세요. 너무 많은 지표는 오히려 혼란을 주므로, 초기에는 10-15개만 추적하고 점진적으로 확장하세요.

💡 분산 추적(Distributed Tracing)을 추가하세요. OpenTelemetry로 요청이 여러 서비스를 거치는 경로를 추적하면(예: API Gateway → 추론 서버 → 데이터베이스), 병목이 어디인지 명확히 보입니다.

💡 로그 레벨을 동적으로 조정하세요. 평상시는 INFO로 운영하다가 장애 발생 시 DEBUG로 전환하면 상세 정보를 얻으면서도 평소 로그 비용은 절감할 수 있습니다.

💡 비용 최적화: 모든 요청을 로깅하면 비용이 높으므로 샘플링하세요. 1% 샘플링(100개 중 1개)만으로도 통계적으로 유의미한 분석이 가능하며, 로그 스토리지 비용을 99% 줄입니다.

💡 자동 이상 탐지를 설정하세요. Prometheus의 rate() 함수로 시계열 데이터의 추세를 분석하고, 평균에서 3 표준편차 이상 벗어나면 알림을 보냅니다. 이는 수동 임계값보다 훨씬 정확합니다.


#Python#LLM#Inference#Optimization#Deployment#AI

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.