10 분 소요

이번 블로그에서 중요한 LLM 최적화 기법을 하나씩 자세히 다룬다. 어떤 상황에서 어떤 방법으로 왜 이 기법들을 사용해야 하는지 판단할 수 있도록 하는 것이 목적이다. 최적화의 핵심 개념을 이해하고 대부분의 실무 목표를 달성하는 데 필요한 기본 기법에 집중한다.

1. 전체 요약

  • 요청 배치(request batching)와 스케줄링으로 병렬성과 GPU 활용률을 높이는 방법
  • 어텐션 최적화(attention optimization)로 연산 효율을 높이고 연산량을 줄이며 메모리를 더 효율적으로 관리하는 방법
  • 모델 압축으로 모델 크기, 메모리 이동량, 연산량을 줄이는 방법
  • 이전 프롬프트를 저장하고 재사용하는 프리픽스 캐싱(prefix caching)과 캐시 적중률(cache-hit rate)을 높이는 방법

2. 요청 배치와 스케줄링 수준 최적화

LLM 서빙은 오프라인 서빙과 실시간 온라인 서빙으로 구분했다. 실시간 온라인 서빙에서는 사용자가 보내는 순서대로 요청이 도착한다. 반면 오프라인 서빙에서는 처리할 요청을 이미 확보하고 있으므로 요청을 한꺼번에 묶어 큰 텐서 입력으로 만든 뒤 모델에 전달할 수 있다. 요청을 하나씩 보내지 않아도 된다. 서빙 중 여러 요청을 묶으면 응답이 다소 늦어질 수 있지만 처리량(throughput)은 높아진다.

1) 실시간 서빙에 배치가 필요한 이유

LLM 서빙에는 프리필(prefill)과 디코드(decode)라는 두 단계가 있다. 프리필 단계에서 모델은 입력 프롬프트를 처리한다. 입력 프롬프트의 토큰은 병렬로 처리할 수 있으므로 산술 집약도가 높고 연산 병목(compute-bound) 워크로드가 된다. 디코드 단계에서는 LLM이 한 번에 토큰 하나를 생성한다. 이 단계는 자기회귀(autoregressive) 방식으로 동작하므로 메모리 대역폭 병목(memory bandwidth-bound)이 발생한다. 토큰 하나를 만들기 위해 수십억 개에 이르는 모델 매개변수를 사실상 모두 읽어야 하므로 GPU 메모리 대역폭 관점에서 매우 비효율적이다. 그림 6-1이 이 차이를 보여준다.

그림 6-1. 프리필과 디코드에서 토큰을 처리하는 방식

그림 6-1. 프리필에서는 입력 프롬프트의 토큰을 한 번에 병렬 처리하고, 디코드에서는 새 토큰을 하나씩 생성한다.

GPU 연산 자원을 충분히 활용하려면 배치로 요청을 더 추가해 함께 처리할 수 있다. 그림 6-1의 구성에 이를 적용해 보자. 이 절의 모든 그림에서는 최대 배치 크기(max batch size)를 3으로 가정한다. 입력 프롬프트 세 개 prompt1, prompt2, prompt3을 하나의 배치로 묶어 모델에 보낸다. 디코드 단계는 여전히 한 번의 반복(iteration)에서 요청마다 토큰 하나만 생성한다. 그러나 요청 세 개를 함께 처리하므로 한 번에 새 토큰 3개로 요청마다 하나씩 생성할 수 있다. 그림 6-2와 같다. 모델 가중치는 한 번 읽지만 더 많은 연산을 수행해 더 많은 토큰을 생성하므로 산술 집약도가 인위적으로 높아진다.

요약하면 배치는 모델이 한 번에 토큰 하나를 생성하는 디코드 단계에서 특히 효과가 크다. 여러 요청을 묶으면 전체 처리량과 GPU FLOPS 활용률이 높아진다. 반면 프리필 단계는 이미 모든 입력 토큰을 병렬로 처리하므로 배치의 이점이 제한적이다. 입력 프롬프트가 아주 짧지 않는 다고 가정하면, 1,024토큰 이상일 때 프리필만으로도 GPU 연산 자원을 쉽게 포화시킨다. 이때 배치로 병렬성을 더 높여도 효과가 크지 않다.

그림 6-2. 디코드 중 세 요청을 배치로 처리하는 방식

그림 6-2. 디코드에서 요청 세 개를 배치로 처리하면 새 토큰 세 개를 한꺼번에 생성할 수 있다.

2) 온라인 추론의 동적 배치

앞의 예에서는 배치의 장점을 확인했다. 그러나 실제 온라인 추론에서는 미리 처리할 프롬프트 목록을 확보하기 어렵다. 이런 환경에서 배치를 만드는 한 가지 방법은 클라이언트-사이드 배치(client-side batching)다. 클라이언트가 요청을 모으다가 미리 정한 배치 크기에 도달하면 한꺼번에 서버로 보낸다. 서버에서도 비슷하게 처리할 수 있다. 서버가 들어오는 요청을 모아 정해진 배치 크기를 모두 채운 뒤 처리하는 방식이며, 이를 정적 배치(static batching)라고 한다.

두 방식 모두 오프라인 작업에는 적합하지만 온라인 추론에는 이상적이지 않다. 오프라인 배치는 흔하고 단순하지만 온라인 배치는 훨씬 복잡하다. 실제 요청은 불규칙하게 도착하며 요청 사이의 시간 간격도 클 수 있다. 최대 배치 크기를 채우는 데 오래 걸리면 지연 시간(latency)이 커진다.

예를 들어, 배치 크기가 10이라고 하자. 첫 9개 요청은 1초 안에 들어오지만 열 번째 요청은 5분 뒤에 도착한다. 첫 9개 요청은 열 번째 요청이 올 때까지 처리되지 않고 5분 동안 대기한다. 이 문제를 해결하는 방법이 동적 배치(dynamic batching)다. 추론 시점에 정의된 규칙에 따라 들어오는 요청을 묶어 지연 시간과 처리량의 균형을 동적으로 맞춘다. 이 규칙은 일반적으로 두 가지 핵심 매개변수를 사용한다.

  • 배치 크기(batch size): 최대 배치 크기(max batch size), 선호 배치 크기(preferred batch size), 최대 시퀀스 수(max number of sequences)라고도 한다. 모델로 보내기 전에 함께 묶을 수 있는 요청 수를 결정한다.
  • 최대 대기 시간(max delay time): 최대 배치 크기를 채우기 위해 다른 요청을 기다리는 동안 기존 요청을 보류할 수 있는 최대 시간이다.

대기 중인 요청 수가 최대 배치 크기에 도달하면 최대 대기 시간이 지나지 않았더라도 즉시 배치를 보낸다. 반대로 최대 대기 시간에 도달하면 요청이 하나뿐이어도 즉시 현재 배치를 보낸다. 작은 나룻배 사업에 비유할 수 있다. 배 한 척이 한 번에 최대 10명을 태워 강을 건넌다고 하자. 승객은 서로 다른 시각에 선착장에 도착한다. 운영자는 승객의 대기 시간을 줄이면서 빈자리가 많은 배를 보내는 일도 피해야 한다.

배치를 사용하지 않으면 승객 한 명이 올 때마다 배 한 척을 보낸다. 승객 경험은 좋지만 사업 운영은 매우 비효율적이고 비용도 많이 든다. 정적 배치를 사용하면 10명이 모두 찰 때까지 배가 선착장에서 기다린다. 낭비는 줄지만 승객이 천천히 모이면 첫 승객이 매우 오래 기다려야 한다.

동적 배치는 지연 시간과 처리량 사이의 적절한 중간점을 찾는다. 최대 배치 크기는 10명으로 유지하면서 최대 대기 시간을 5분으로 정할 수 있다. 5분 안에 8명만 도착하면 더 기다리지 않고 출발한다. 5분이 되기 전에 10명이 모이면 즉시 출발한다. 동적 배치를 사용할 때는 지연 시간과 처리량 요구사항에 맞춰 최대 배치 크기와 최대 대기 시간을 조정해야 한다. 일반적으로 지연 시간 서비스 수준 협약(SLA)을 지키는 범위에서 가능한 한 큰 배치를 만드는 것이 좋다.

그러나 배치 크기를 무조건 키울 수는 없다. 배치가 커질수록 처리 지연 시간과 GPU 또는 CPU 메모리 사용량이 증가하고, 결국 메모리 부족이 발생할 수 있다. 최대 대기 시간도 조정해야 한다. 큰 배치 크기와 긴 대기 시간을 함께 쓰면 이미 도착한 요청이 지나치게 오래 기다린다. 반대로 대기 시간이 너무 짧으면 배치를 제때 채우지 못해 실제 처리 배치가 작아진다.

3) LLM 온라인 추론의 연속 배치

동적 배치는 대부분의 모델에서 잘 동작하며 전통적인 머신러닝(ML) 서빙 환경에서도 널리 사용한다. 그러나 LLM에는 더 크고 고유한 문제가 있다. 요청마다 입력 길이와 출력 길이가 달라 하나의 배치에 속한 요청의 처리 시간이 크게 달라진다. 동적 배치에서는 배치 안의 모든 요청이 완료된 뒤 결과를 반환하므로 가장 길고 느린 요청이 전체 완료 시간을 결정한다. 그림 6-3은 프리필과 디코드를 생략해 단순화한 예다. 요청 길이가 서로 다르면 하나의 긴 요청 때문에 다른 요청이 오랫동안 유휴 상태로 남는다.

앞의 나룻배 비유를 이어 가자. 배가 승객을 강 건너편의 각자 집까지 데려다줘야 한다고 가정한다. LLM 요청의 길이가 서로 다른 것처럼 집까지의 이동 시간도 다르다. 가장 먼 집의 승객을 내려 준 뒤에야 선착장으로 돌아올 수 있으므로 시간이 갈수록 배의 빈자리가 늘어난다.

그림 6-3. 길이가 다른 요청으로 생기는 GPU 유휴 시간

그림 6-3. 요청 1과 요청 2가 요청 3의 완료를 기다리므로 서로 다른 요청 길이가 상당한 GPU 유휴 시간을 만든다.

이 문제를 완화하기 위해 LLM 서빙에 연속 배치(continuous batching)를 도입했다. 실행 중 배치인 인플라이트 배칭(inflight batching) 또는 반복 배치(iterative batching)라고도 한다. 동적 배치는 일정 시간 동안 기다린 뒤 배치 단위로 요청을 처리하지만, 연속 배치는 고정된 배치 크기나 타임 윈도우(time window)을 기다리지 않는다. 백엔드 모델에 요청을 추가하며 실행 중에 바로 묶는다. 배치에서 실행 중인 요청 하나가 끝나는 즉시 대기열의 다음 요청을 배치에 넣는다.

그림 6-4에서는 처음에 요청 1, 2, 3을 모델로 보내 처리한다. 요청 1이 끝나면 연속 배치가 새로 도착한 요청 4를 즉시 실행에 넣는다. 요청 2가 끝나면 요청 5를 넣고, 요청 5가 끝나면 요청 6을 넣는다. 동적 배치라면 새로 도착한 요청 4, 5, 6은 요청 3이 끝날 때까지 기다린 뒤에야 실행된다.

그림 6-4. 연속 배치가 GPU 유휴 시간을 줄이는 방식 그림 6-4. 연속 배치는 요청 하나가 끝나는 즉시 다른 요청을 추가해 GPU 유휴 시간을 최소화한다.

나룻배 비유에서는 10명이 타는 배 한 척을 빈 상태로 운항하지 않도록 조심스럽게 관리해야 했다. 이 배를 한 명씩 탈 수 있는 작은 배 10척으로 바꾼다고 생각해 보자. 사람이 도착하면 작은 배 한 척이 즉시 출발할 수 있다. 집까지의 거리가 서로 달라도 낭비가 없다. 배는 승객 한 명을 데려다준 뒤 돌아와 다음 대기 승객을 태우면 된다.

연속 배치는 이전 요청 하나가 끝나면 대기열의 다음 요청을 바로 처리한다. 따라서 동적 배치처럼 최대 대기 시간을 인위적으로 설정할 필요가 없다. 다만 최대 배치 크기는 여전히 관리하고 조정해야 한다. 동적 배치와 마찬가지로 최대 배치 크기가 클수록 더 많은 요청을 병렬로 처리할 수 있다. 그러나 연속 배치에서는 이 값이 절대적인 최대 배치 크기를 넘지 않도록 제한하는 상한으로만 사용된다.

최근의 여러 LLM 서빙 프레임워크는 요청을 대기열에 둘지 배치에 추가할지를 제어하기 위해 최대 배치 토큰 수(max number of batched tokens)라는 매개변수도 제공한다. 최대 배치 크기가 요청 수준에서 특정 시점에 처리할 수 있는 최대 요청 수를 결정한다면, 최대 배치 토큰 수는 토큰 수준에서 더 세밀하게 제어한다. LLM 서빙의 입력 길이는 크게 달라질 수 있으므로 이 제어가 중요하다.

예를 들어 입력 길이가 각각 20토큰인 요청 10개를 묶는 것과 입력 길이가 각각 100,000토큰인 요청 2개를 묶는 것은 전혀 다른 워크로드다. 요청 수만 제한하면 토큰 길이의 차이를 반영할 수 없어서 너무 적거나 너무 많은 작업을 하나의 배치로 묶을 수 있다. 그림 6-5가 이 매개변수의 관계를 보여준다.

그림 6-5. 최대 배치 크기, 최대 토큰 수, 최대 모델 길이의 관계 그림 6-5. 최대 배치 크기, 최대 토큰 수, 최대 모델 길이(max model length)의 관계.

여기서는 최대 배치 크기를 3으로 설정했으므로 요청 세 개를 한 배치에서 함께 처리할 수 있다. 각 요청 막대의 길이는 토큰 수를 나타낸다. 이 값은 모델의 최대 모델 길이, 즉 최대 컨텍스트 크기(max context size)보다 작아야 한다. 최대 토큰 수는 스케줄러가 하나의 배치로 허용하는 전체 토큰 수를 제어한다. 스케줄러가 최대 토큰 수에 쉽게 도달하는 매우 긴 요청 몇 개를 받으면 더 많은 요청이 배치에 들어오는 것을 막는다.

최대 배치 크기와 최대 토큰 수라는 두 제약을 함께 적용하면 프리필과 디코드 단계에서 처리할 요청 수를 세밀하게 조정할 수 있다. 프리필 단계에서는 입력이 훨씬 길기 때문에 최대 토큰 수가 핵심 제약이다. 디코드 단계의 병렬성은 일반적으로 최대 배치 크기의 제한을 받는다.

최대 토큰 수는 충분히 크게 설정해야 한다. 너무 작으면 프리필 중 GPU가 병렬로 처리할 토큰을 충분히 보내지 못해 GPU 연산 자원을 포화시키지 못한다. 예제 6-1은 vLLM에서 이 두 매개변수를 설정하는 코드다.

예제 6-1. vLLM에서 최대 배치 토큰 수(--max-num-batched-tokens)와 최대 배치 크기(--max-num-seqs) 설정

vllm serve \
  Qwen/Qwen2.5-7B-Instruct \
  --max-num-batched-tokens 4096 \
  --max-num-seqs 128

4) 청크 분할 프리필을 적용한 연속 배치

연속 배치는 요청 길이가 서로 다른 문제를 해결하는 데 효과적이다. 그러나 LLM 서빙의 또 다른 고유한 특성을 빠뜨리고 있다. 프리필과 디코드는 서로 다른 종류의 워크로드다. 앞 절에서 동적 배치와 연속 배치를 설명할 때는 그림에서 프리필과 디코드를 구분하지 않고 요청 길이만 막대로 나타냈다. 이제 연속 배치가 실제로 어떻게 동작하는지 자세히 살펴보고 개선할 부분을 찾아보자.

프리필은 모델이 입력 프롬프트를 처음 처리하는 단계다. 산술 집약도가 높으므로 GPU에서 효율적으로 실행하기 위해 배치의 도움을 크게 받을 필요가 없다. 디코드는 모델이 자기회귀 방식으로 새 토큰을 생성하는 단계다. 일반적으로 산술 집약도가 낮아 배치에서 큰 이점을 얻는다.

앞에서 배치의 장점을 설명할 때는 그림 6-6과 비슷한 구성을 사용했다. 모든 조건이 정확히 맞아떨어지도록 고른 이상적인 성공 경로(happy path)다. 모든 요청의 입력 프롬프트 길이가 같고, 생성하는 토큰 수도 같으며, 실행 시작 시점도 같다. 첫 번째 반복에서는 요청 세 개의 프리필을 하나의 배치로 처리한다. 이 반복이 끝나면 모델은 두 번째 배치 반복을 시작하고 세 요청의 디코드를 함께 실행한다.

그림 6-6. 프리필과 디코드가 완벽히 정렬된 이상적인 연속 배치 그림 6-6. 모든 요청의 프리필과 디코드가 완벽하게 정렬된 이상적인 성공 경로.

실제 환경에서는 요청의 입력과 출력 길이가 불규칙할 뿐 아니라 연속 배치에서 시작 시점도 서로 다르다. 요청은 다른 시각에 도착하며 시스템은 가능한 한 빨리 처리한다. 첫 번째 요청이 이미 디코드 중일 때 두 번째 요청이 도착해 프리필을 시작하려 한다면 어느 작업을 우선해야 할까. 한 요청의 디코드와 다른 요청의 프리필을 같은 반복에 묶을 수 있을까.

먼저 프리필과 디코드를 함께 배치하지 않는 방법을 살펴보자. 혼합 워크로드를 실행하려면 더 복잡한 GPU 커널(kernel)이 필요한 경우가 많기 때문이다. 그림 6-7에서는 처음에 요청 1만 도착한다. 반복 1에서 프리필을 끝내고 반복 2에서 디코드 한 단계를 수행한다. 그 뒤 요청 2와 요청 3이 도착한다.

이제 프리필과 디코드 중 무엇을 우선할지 결정해야 한다. 일반적으로 프리필을 우선한다. 프리필이 챗봇 같은 대화형 사용 사례에서 중요한 지연 시간 지표인 첫 토큰 생성 시간(time to first token, TTFT)을 결정하기 때문이다. 그러나 반복 3에서 요청 2와 3의 프리필을 수행하는 동안 요청 1은 완전히 유휴 상태로 기다린다. 요청 2와 3의 프롬프트가 그림처럼 길다면 요청 1의 종단 간 지연 시간(end-to-end latency)과 토큰 간 지연 시간(inter-token latency, ITL)이 크게 나빠진다.

그림 6-7. 프리필을 우선하면서 프리필과 디코드를 함께 묶지 않은 연속 배치 그림 6-7. 프리필과 디코드를 같은 배치로 묶지 않고 프리필을 우선하는 연속 배치.

이번에는 그림 6-8처럼 프리필과 디코드를 함께 묶어 보자. 요청 1의 두 번째 디코드를 반복 3으로 옮겨 요청 2와 3의 프리필과 같은 배치 반복에서 실행한다. 하지만 여전히 개선 효과가 크지 않다. 토큰 하나를 디코드하는 시간은 프리필을 끝내는 시간보다 훨씬 짧을 수 있으며 입력 프롬프트가 길수록 지연이 두드러진다.

그림 6-8. 프리필과 디코드를 같은 반복에 묶은 연속 배치 그림 6-8. 반복 3에서 디코드와 프리필을 함께 배치한 연속 배치.

이를 완화하는 방법이 청크 분할 프리필(chunked prefill)이다. 긴 입력 프롬프트를 더 작은 청크(chunk)로 나눈다. 그림 6-9에서는 이전의 긴 프리필 막대를 이상적으로 디코드 상자와 크기와 처리 시간이 비슷한 여러 개의 작은 프리필 상자로 나눴다.

요청 2와 3이 배치 반복에 들어오는 동안 요청 1은 디코드를 계속하고, 다른 두 요청은 반복 5에서 각자의 작은 청크 프리필을 시작한다. 이후 반복 10에서 요청 2는 요청 3보다 프리필이 짧으므로 자연스럽게 디코드로 전환한다.

그림 6-9. 청크 분할 프리필을 적용한 연속 배치 그림 6-9. 청크 분할 프리필을 적용한 연속 배치.

입력 프롬프트가 길더라도 여러 개의 작은 청크로 나누면 디코드 작업이 긴 프리필에 막혀 대기하지 않고 ITL이 개선된다. 다만 프리필에 더 많은 작업이 추가되므로 TTFT가 길어진다. 청크 분할 프리필은 종단 간 지연 시간을 크게 개선하지 않으며, 작은 프리필을 여러 번 계산하는 오버헤드 때문에 오히려 나빠지는 경우가 많다. 사용 사례와 SLA에 따라 선택해야 하는 절충안이다.

반면 처리량은 일반적으로 좋아진다. 유휴 시간의 빈틈에 작은 프리필 작업을 넣어 배치 효율과 GPU 활용률을 높이기 때문이다.긴 프리필을 몇 개의 작은 프리필로 나눌지도 조정해야 한다. 다시 말해 작은 프리필 작업 하나가 처리할 토큰 수를 결정해야 한다. 이 값도 최대 배치 토큰 수의 일부다.

토큰 수를 매우 크게 정하면 청크 분할이 거의 일어나지 않는다. 최대 모델 길이까지 키우면 프리필을 전혀 나누지 않는 것과 같다. 반대로 작은 프리필의 토큰 수가 너무 적으면 오버헤드가 커지고, 한 번의 반복에서 GPU 연산 자원을 포화시킬 만큼 충분한 토큰을 묶지 못한다. 따라서 오버헤드와 낮은 GPU 활용률을 만들 정도로 작지도 않고 청크 분할의 목적을 없앨 정도로 크지도 않은 중간값을 찾아야 한다.

현재 연속 배치는 이미 몇 년 동안 운영 수준 LLM 서빙의 업계 표준으로 자리 잡았다. 청크 분할 프리필과 그 변형도 긴 컨텍스트 워크로드 같은 여러 사용 사례에서 널리 쓰인다. 더 고급 기법인 프리필-디코드 분리(prefill-decode disaggregation)는 프리필과 디코드 작업을 서로 다른 GPU, 나아가 서로 다른 노드로 완전히 분리한다. 이 장에서 기본 지식을 쌓은 뒤 다음 장에서 다룬다.

댓글남기기