RSS
AI 자동화

vLLM과 Ollama의 차이: 연속 배칭과 PagedAttention

작성자
듀오랩스 대표·20분 읽기

Ollama 공식 FAQ의 동시 요청 항목에는 이런 기본값이 적혀 있습니다. OLLAMA_NUM_PARALLEL, 모델 하나가 동시에 처리하는 요청 수의 최댓값, 기본값 1. 바로 아래 줄에는 필요한 메모리가 OLLAMA_NUM_PARALLEL 곱하기 OLLAMA_CONTEXT_LENGTH만큼 늘어난다고 쓰여 있습니다(Ollama FAQ).

이 두 줄을 그냥 넘기면 흔한 그림이 하나 생깁니다. 서버에 Ollama를 설치하고 OLLAMA_HOST=0.0.0.0으로 포트를 열면 그게 곧 LLM 서비스이고, 실행기는 다 비슷하니 중요한 것은 어떤 모델을 고르느냐뿐이라는 그림입니다. 혼자 쓸 때는 이 그림이 틀린 데가 없습니다. 요청이 한 번에 하나씩 오면 Ollama와 vLLM은 같은 모델로 비슷한 일을 합니다. 차이는 요청이 동시에 몰릴 때만 드러나고, 그때는 꽤 크게 드러납니다.

한 사람과 한 대의 기계를 전제로 한 Ollama의 설계

Ollama가 잘하는 일을 먼저 적어 두겠습니다. 설치 스크립트 한 줄로 맥, 윈도, 리눅스에 깔리고, ollama run 한 번이면 모델을 받아서 바로 대화가 됩니다. 모델은 메모리에 올라간 뒤 기본 5분 동안 쓰이지 않으면 내려가고, 이 시간은 keep_alive 파라미터나 OLLAMA_KEEP_ALIVE로 바꿉니다(Ollama FAQ). 모델이 GPU에 다 안 들어가면 일부를 시스템 메모리에 두고 돌리는데, ollama ps가 48%/52% CPU/GPU처럼 나눠진 비율을 보여 줍니다.

모델 형식은 GGUF가 중심입니다. Ollama의 README는 지원 백엔드로 llama.cpp를 적어 두었고, safetensors 가중치도 가져올 수 있지만 GGUF는 가져오는 과정에서 따로 양자화하지 않으니 llama.cpp의 llama-quantize 같은 도구로 미리 만들어 두라고 안내합니다(Ollama 가져오기 문서). 지원 하드웨어는 NVIDIA, AMD Radeon, 애플의 Metal입니다. 노트북의 애플 실리콘이나 게이밍 GPU 한 장이 이 도구가 처음부터 염두에 둔 환경입니다.

모델 저장소와 실행기가 어떻게 나뉘는지는 허깅페이스와 Ollama의 차이: 모델 저장소와 실행기에서 따로 다뤘습니다. 이 글은 그다음 질문을 다룹니다. 똑같이 실행기 역할을 하는 두 도구가 동시 요청 앞에서는 왜 다르게 움직이는지입니다.

GPU가 노는 시간을 만드는 두 가지 원인

LLM은 토큰을 하나씩 만듭니다. 한 토큰을 만들 때마다 모델 가중치 전체를 GPU 메모리에서 읽어야 하는데, 요청 하나만 처리하면 이 읽기 비용에 비해 계산이 너무 적습니다. 그래서 여러 요청을 한 배치로 묶어 가중치 한 번 읽을 때 여러 토큰을 같이 만드는 것이 처리량을 올리는 기본 수단입니다. vLLM 논문도 배칭의 이점을 여기서 설명합니다. 요청들이 같은 가중치를 공유하므로 가중치를 옮기는 비용이 배치 안의 요청들에 나눠진다는 것입니다(Kwon et al., SOSP 2023).

문제는 배치를 어떻게 짜느냐입니다. 논문은 단순한 배칭이 두 군데서 막힌다고 짚습니다. 첫째, 요청은 서로 다른 시각에 도착합니다. 단순하게 묶으면 먼저 온 요청이 나중 요청을 기다리거나, 새 요청이 앞 배치가 끝날 때까지 기다립니다. 둘째, 요청마다 입력과 출력 길이가 크게 다릅니다. 길이를 맞추려고 패딩을 채우면 그만큼 계산과 메모리가 버려집니다.

여기에 메모리 문제가 하나 더 붙습니다. 이미 만든 토큰의 어텐션 키와 값을 저장해 두는 KV 캐시입니다. 다음 토큰을 만들 때 앞 토큰들을 다시 계산하지 않으려고 쓰는 저장 공간인데, 생각보다 큽니다. 논문의 계산으로는 OPT 13B 모델에서 토큰 하나의 KV 캐시가 800KB이고, 최대 길이 2048 토큰을 다 채우면 요청 하나에 1.6GB가 듭니다. 같은 논문의 그림 1은 A100 40GB에서 13B 모델을 돌릴 때 메모리의 약 65%가 가중치, 30% 가까이가 KV 캐시라고 보여 줍니다. 가중치는 고정이니, 동시에 몇 개의 요청을 받을 수 있는지는 결국 KV 캐시를 얼마나 알뜰하게 쓰느냐로 정해집니다.

끝난 요청을 바로 빼고 새 요청을 바로 넣는 연속 배칭

첫 번째 문제에 대한 답은 2022년 OSDI에 실린 Orca 논문에서 나왔습니다(Yu et al., OSDI 2022). 이 논문이 지적한 기존 서빙 시스템의 한계는 이렇습니다. 처리 중인 배치를 바꿀 수 없어서, 일찍 끝난 요청도 배치 전체가 끝날 때까지 응답을 돌려주지 못하고, 새로 도착한 요청은 지금 배치가 완전히 끝나야 들어갑니다.

Orca가 제안한 것은 반복 단위 스케줄링(iteration-level scheduling)입니다. 스케줄러가 요청 단위가 아니라 모델의 한 반복, 곧 토큰 하나를 만드는 한 걸음 단위로 실행을 지시합니다. 한 걸음이 끝날 때마다 끝난 요청은 배치에서 빠지고 대기 중인 요청이 들어옵니다. 새 요청이 기다리는 시간은 배치 전체가 아니라 한 걸음입니다. 지금은 이 방식을 흔히 연속 배칭(continuous batching)이라고 부릅니다. Orca 논문은 GPT-3 175B 모델에서 NVIDIA FasterTransformer 대비 같은 지연 수준에서 처리량 36.9배를 보고했습니다. 이 숫자는 그 논문 저자들이 자기 환경에서 잰 값입니다.

저는 연속 배칭을 버스에 빗대면 가장 잘 보인다고 생각합니다. 정적 배칭은 승객이 다 찰 때까지 출발하지 않고, 출발하면 종점까지 아무도 내리고 타지 못하는 버스입니다. 연속 배칭은 정류장마다 내리고 타는 버스입니다. 이 차이는 승객이 한 명일 때는 아무 의미가 없습니다.

운영체제 페이징을 빌려 온 KV 캐시 관리

연속 배칭으로 계산 낭비를 줄여도, 한 배치에 몇 개의 요청을 넣을 수 있는지는 여전히 GPU 메모리가 정합니다. vLLM 논문이 겨냥한 것이 이 두 번째 문제입니다.

논문이 진단한 기존 방식은 요청마다 KV 캐시를 연속된 메모리 한 덩어리로 잡는 것이었습니다. 대부분의 딥러닝 프레임워크가 텐서를 연속 공간에 두도록 요구하기 때문입니다. 그런데 KV 캐시는 토큰이 생길 때마다 자라고, 얼마나 길어질지는 끝나 봐야 압니다. 그래서 기존 시스템은 요청의 최대 길이(예: 2048 토큰)만큼을 미리 잡아 둡니다. 실제 응답이 짧으면 나머지는 버려지고(내부 단편화), 요청마다 잡는 크기가 다르니 덩어리 사이에 쓸 수 없는 틈이 생깁니다(외부 단편화). 논문의 측정으로는 기존 시스템에서 KV 캐시 메모리 중 실제 토큰 상태를 담은 비율이 20.4%에서 38.2%에 그쳤습니다(arXiv 2309.06180).

PagedAttention은 운영체제의 가상 메모리와 페이징을 그대로 빌려 왔습니다. KV 캐시를 일정한 수의 토큰을 담는 고정 크기 블록으로 나누고, 블록들이 메모리상에서 연속일 필요가 없게 어텐션 계산을 다시 짰습니다. 논문의 표현을 그대로 옮기면 블록은 페이지, 토큰은 바이트, 요청은 프로세스입니다. 블록은 필요할 때 하나씩 할당하니 내부 단편화는 마지막 블록 하나로 줄고, 모든 블록의 크기가 같으니 외부 단편화는 사라집니다. 같은 요청에서 갈라진 여러 출력(병렬 샘플링, 빔 서치)이 앞부분 블록을 공유하는 것도 이 구조 덕분에 가능해졌습니다. 논문의 그림 2에서 vLLM의 실제 토큰 상태 비율은 96.3%이고, 기본 블록 크기는 16 토큰입니다.

처리량에 대해 논문 초록은 같은 지연 수준에서 FasterTransformer, Orca 같은 당시 최신 시스템보다 2~4배 높다고 보고합니다. 차이는 시퀀스가 길수록, 모델이 클수록, 디코딩 알고리즘이 복잡할수록 커진다고 합니다. 하나 덧붙이면, 이 비교의 Orca는 공개되지 않은 시스템이라 vLLM 저자들이 직접 다시 구현한 것이고, 출력 길이를 미리 안다고 가정한 Orca(Oracle)부터 늘 최대 길이를 잡는 Orca(Max)까지 세 가지로 나눠 비교했습니다. ShareGPT 데이터셋에서 vLLM이 비슷한 지연을 유지하며 견딘 요청률은 Orca(Oracle)의 1.7~2.7배, Orca(Max)의 2.7~8배였습니다. 모두 논문 저자들의 숫자이고, 2023년의 모델과 GPU 위에서 나온 값입니다.

vLLM은 메모리가 부족해지는 순간에 대한 답도 갖고 있습니다. KV 캐시가 모자라면 일부 요청을 선점(preemption)해서 블록을 비우고, 나중에 공간이 나면 다시 계산합니다. 긴 프롬프트는 청크 단위로 잘라 진행 중인 디코딩과 같은 배치에 섞는 chunked prefill이 기본으로 켜져 있습니다(vLLM 최적화 문서). 둘 다 요청이 많아야 의미가 생기는 장치입니다.

자리 수를 정하고 자리마다 메모리를 잡는 Ollama의 동시성

다시 Ollama로 돌아가 보겠습니다. FAQ는 동시 처리를 두 층으로 설명합니다. 메모리가 충분하면 여러 모델을 동시에 올리고, 모델 하나 안에서는 병렬로 요청을 처리합니다. 이를 조절하는 서버 설정이 셋입니다(Ollama FAQ).

설정 뜻 기본값
OLLAMA_MAX_LOADED_MODELS 동시에 올려 둘 수 있는 모델 수 GPU 수 × 3, CPU 추론이면 3
OLLAMA_NUM_PARALLEL 모델 하나가 동시에 처리하는 요청 수 1
OLLAMA_MAX_QUEUE 바쁠 때 줄 세워 둘 요청 수, 넘으면 503 512

더 중요한 것은 메모리 계산입니다. FAQ는 병렬 처리가 컨텍스트 크기를 병렬 수만큼 늘린다고 적고, 2K 컨텍스트에 병렬 4면 8K 컨텍스트만큼 메모리를 더 잡는다는 예를 듭니다. 기본 컨텍스트 길이는 4096 토큰입니다. FAQ의 설명대로 읽으면, 동시에 받을 자리 수를 정해 두고 자리마다 컨텍스트 전체 길이만큼을 잡는 구조입니다. vLLM 논문이 비판한 방식, 곧 최대 길이만큼 미리 예약하는 방식과 닮았습니다. 짧은 질문 네 개가 들어와도 예약은 컨텍스트 네 개분입니다.

자리가 다 차면 나머지는 큐에서 기다리고, 큐가 512를 넘으면 서버는 503을 돌려줍니다. 새 모델을 올릴 메모리가 없으면 그 모델로 가는 요청은 앞 모델이 내려갈 때까지 줄을 섭니다. 혼자 쓰는 환경에서는 합리적인 설계입니다. 여러 사람이 같은 모델을 동시에 부르는 환경에서는 NUM_PARALLEL을 올리는 만큼 메모리가 선형으로 빠져나갑니다.

여기서 제가 단정하지 않는 부분이 있습니다. Ollama가 한 모델 안의 병렬 자리들을 내부에서 어떤 단위로 묶어 계산하는지는 FAQ가 말하지 않습니다. Ollama가 백엔드로 적어 둔 llama.cpp의 서버는 연속 배칭이 기본으로 켜져 있다고 문서에 밝히고 있지만, Ollama 문서에는 그 동작을 그대로 따른다는 설명이 없습니다. 그래서 이 글에서 Ollama의 한계로 짚는 것은 문서에 적힌 자리 수 고정과 자리당 메모리 예약까지입니다.

동시성 밖에서 갈리는 모델 형식과 GPU 구성

동시성 말고도 두 도구가 갈리는 지점이 몇 있습니다. 먼저 한눈에 놓고 보겠습니다.

항목 Ollama vLLM
주력 모델 형식 GGUF (safetensors 가져오기 지원) Hugging Face 형식 가중치, GGUF는 실험적
양자화 GGUF 양자화 모델을 그대로 사용 AWQ, GPTQ, BitsAndBytes, FP8 등
하드웨어 NVIDIA, AMD Radeon, Metal, 모자라면 CPU와 나눠 실행 NVIDIA, AMD, Intel GPU와 CPU, 애플 실리콘은 vLLM-Metal
여러 GPU 쓰기 한 장에 안 들어가면 가진 GPU 전체에 나눠 올림 텐서 병렬, 파이프라인 병렬을 직접 지정
OpenAI 호환 API 일부 지원 Completions, Chat 등 구현

GGUF 지원은 vLLM 문서가 직접 경고하고 있습니다. GGUF 지원은 "highly experimental and under-optimized"이고 다른 기능과 호환되지 않을 수 있으며, 지금은 별도 플러그인(vllm-gguf-plugin)으로 옮겨졌다고 적혀 있습니다(vLLM GGUF 문서). Ollama에서 쓰던 GGUF 파일을 그대로 vLLM으로 옮기겠다는 계획은 그래서 출발부터 불리합니다. vLLM 쪽 양자화는 AutoAWQ, GPTQModel, BitsAndBytes, LLM Compressor의 FP8과 INT8, INT4 같은 형식이 정식 목록입니다(vLLM 양자화 문서).

모델이 GPU 한 장에 안 들어갈 때의 답도 다릅니다. vLLM 문서는 노드 하나에 GPU가 여러 장이면 텐서 병렬(--tensor-parallel-size)을, 노드 하나로도 모자라면 텐서 병렬과 파이프라인 병렬을 함께 쓰라고 안내합니다(vLLM 병렬화 문서). Ollama는 결정을 대신 내려 줍니다. FAQ에 따르면 모델이 GPU 한 장에 다 들어가면 그 한 장에 올리고(PCI 버스로 오가는 데이터를 줄이려는 것입니다), 안 들어가면 가진 GPU 전체에 나눠 올립니다(Ollama FAQ). 어떤 방식으로 나누는지는 FAQ가 설명하지 않습니다. 그래도 모자라면 남는 부분을 시스템 메모리로 넘겨 CPU가 맡습니다. vLLM은 큰 모델을 빠르게 돌리는 방법을 사용자가 고르게 하고, Ollama는 큰 모델을 일단 돌게 만드는 쪽을 알아서 고릅니다.

OpenAI 호환 API는 둘 다 있지만 깊이가 다릅니다. Ollama 문서는 스스로 OpenAI API의 "subset"을 지원한다고 쓰고, OpenAI API로는 컨텍스트 크기를 정할 방법이 없으니 num_ctx를 넣은 Modelfile로 새 모델을 만들라고 안내합니다(Ollama OpenAI 호환 문서). vLLM은 Completions와 Chat API를 구현한 HTTP 서버를 제공합니다(vLLM OpenAI 호환 서버 문서). 같은 문서에는 눈여겨볼 경고가 하나 있습니다. --api-key는 /v1, /v2, /inference 아래 경로만 막고, 같은 추론 기능을 내는 /invocations 같은 경로는 인증하지 않으니 키 하나만 믿지 말고 리버스 프록시 뒤에 두라는 내용입니다. Ollama는 기본으로 127.0.0.1:11434에만 붙습니다. 어느 쪽이든 포트를 바깥에 여는 순간 인증과 요청 제한은 앞단에서 따로 챙겨야 합니다.

하드웨어 칸은 오해하기 쉬워서 덧붙입니다. vLLM 설치 문서에는 CPU(x86, ARM, 애플 실리콘)와 vLLM-Metal을 통한 애플 실리콘 GPU도 올라 있습니다(vLLM 설치 문서). 그러니 vLLM이 데이터센터 GPU에서만 돈다는 말은 정확하지 않습니다. 다만 PagedAttention과 연속 배칭이 값을 하는 곳은 동시 요청이 많은 GPU 서버이고, 저는 이 도구의 설계 중심이 거기 있다고 봅니다.

GGUF 쪽의 llama.cpp 서버, 서빙 쪽의 SGLang

두 도구 사이에 다른 이름도 몇 있습니다. llama.cpp가 직접 내놓은 HTTP 서버는 OpenAI 호환 경로, 여러 사용자를 위한 병렬 디코딩, 연속 배칭을 기능 목록에 적어 두었습니다. 서버 슬롯 수는 --parallel로 정하고 연속 배칭은 기본으로 켜져 있습니다. GGUF를 쓰면서 Ollama보다 설정을 직접 쥐고 싶을 때 자연스러운 다음 단계입니다.

SGLang은 vLLM처럼 대규모 서빙을 겨냥한 오픈소스 추론 프레임워크로, README는 에이전트 워크로드와 대규모 서빙에 맞췄다고 소개합니다. 허깅페이스의 TGI는 README 첫머리에 유지보수 모드로 들어갔다고 밝혔고, 앞으로는 vLLM, SGLang, 그리고 llama.cpp나 MLX 같은 로컬 엔진을 권한다고 적었습니다. 새로 고른다면 TGI는 후보에서 빼는 것이 맞다고 봅니다.

사용자 수로 가르는 선택 기준

여기서부터는 제 판단입니다.

혼자 쓰는 개발자라면 Ollama가 맞습니다. 모델을 바꿔 가며 써 보기 쉽고 노트북에서도 돈다는 것이 이 단계에서 가장 값진 성질입니다. 이 단계에서 vLLM을 고르면 얻는 것보다 치르는 것이 큽니다. 모델 형식을 맞춰야 하고, GPU 메모리의 대부분을 KV 캐시용으로 미리 잡아 두는 엔진(그 비율이 --gpu-memory-utilization입니다)을 데스크톱에 띄워야 합니다.

몇 명이 함께 쓰는 사내 도구라면 아직 Ollama로 버틸 여지가 있습니다. 동시에 들어오는 요청이 두세 개 수준이고 컨텍스트가 길지 않다면 OLLAMA_NUM_PARALLEL을 올리고 메모리를 확인하는 것으로 충분할 수 있습니다. 저라면 이 단계에서 쓰는 모델을 하나로 고정하겠습니다. 모델이 여럿이면 OLLAMA_MAX_LOADED_MODELS와 메모리 사이에서 올렸다 내렸다를 반복하고, 그 사이의 요청은 줄을 섭니다.

여러 사람에게 공개하는 서비스라면 저는 처음부터 vLLM이나 SGLang 같은 서빙 엔진으로 가겠습니다. 동시 요청이 늘어날 때 응답 시간이 어떻게 변하는지가 이 단계의 품질이고, 그 곡선을 결정하는 것이 이 글에서 다룬 배칭과 메모리 관리입니다. 모델을 Hugging Face 형식으로 준비하는 수고는 이 단계에서 치를 만한 비용입니다.

직접 재 보기 전에는 알 수 없는 교차점

제가 확신하지 못하는 부분도 적어 두겠습니다. 몇 명부터 Ollama를 떠나야 하는지에 대한 정답 숫자는 없습니다. 그 지점은 GPU 메모리 크기, 모델 크기와 양자화, 프롬프트와 응답의 길이 분포, 요청이 몰리는 모양에 따라 움직입니다. 이 글에 나온 2~4배, 36.9배 같은 숫자는 모두 논문 저자들이 자기 하드웨어와 데이터셋에서 낸 값이고, 그 비교 대상도 Ollama가 아니라 FasterTransformer와 Orca였습니다. Ollama와 vLLM을 같은 조건에 놓고 잰 공식 비교는 이 글을 쓰며 찾지 못했습니다.

그래서 저는 실제로 들어올 요청의 길이와 동시 접속 수를 흉내 낸 부하를 두 엔진에 똑같이 걸어 보고, 처리량보다 동시 요청이 늘 때 첫 토큰까지 걸리는 시간이 어떻게 무너지는지를 보라고 권하겠습니다. 요청이 하나일 때 두 엔진이 비슷하게 빠른 것은 당연한 결과이고, 그 숫자로는 아무것도 고를 수 없습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.