Ollama가 모델을 로드하는 방식 — 그리고 실제로 GPU 레이어 수를 결정하지 않는 이유 thumbnail
분석2026년 7월 2일AI 인프라

Ollama가 모델을 로드하는 방식 — 그리고 실제로 GPU 레이어 수를 결정하지 않는 이유

대부분은 Ollama가 모델의 메모리를 추산하고 GPU에 올릴 레이어 수를 계산한다고 생각한다. v0.31.1을 읽어보니 실제로는 그렇지 않았다. Ollama는 상위 프로젝트인 llama.cpp의 llama-server를 실행하고, 기본적으로 GPU 분할 자동 감지를 맡긴 다음, 오프로딩된 레이어 수를 로그에서 다시 읽는 스케줄러다. Ollama 자체의 메모리 계산은 무엇이 들어가고 무엇을 퇴출할지 결정하는 데만 쓰이는, 의도적으로 거친 근삿값이다.

ArcYou Editorial. 방법론 섹션에 고정된 저장소 상태를 기준으로 읽었습니다.

결정 요약

이 소스 리드가 분명하게 하는 것

실제로 무엇인가
llama.cpp 서버를 감싸고 모델 수명 주기를 관리하는 로컬 LLM 매니저
잘 맞는 경우
모델을 쉽게 받고 실행하고 바꿔야 하는 개발자 노트북
피할 경우
GPU 레이어 배치와 엔진 동작을 직접 계산하고 통제해야 할 때
핵심 메커니즘
스케줄러, runner 프로세스, fit/evict 판단, llama.cpp 위임

읽은 기준: ollama-v0.31.1 @ v0.31.1

대부분의 사람에게 모델을 ollama run할 때 Ollama가 무슨 일을 하느냐고 물으면, “VRAM 용량을 파악하고 GPU에 오프로딩할 레이어 수를 결정한다”는 식의 답이 돌아온다. 나는 **v0.31.1, 커밋 710292f**의 소스를 읽었고, 실제 메커니즘은 그렇지 않았다. Ollama는 상위 프로젝트인 llama.cpp의 llama-server를 하위 프로세스로 실행하는 스케줄러이자 모델 관리자다. 기본적으로 GPU 레이어 분할은 llama.cpp가 고르게 두고, 그 결과를 서버 로그에서 다시 읽는다. Ollama는 자체 추론 엔진이 아니며, 사용자의 -ngl을 결정하는 주체도 아니다. 아래의 모든 주장은 소스의 특정 줄을 가리킨다.

실제로 무엇인가?

표준 GGUF 모델에서 실제로 토큰을 생성하는 엔진은 하위 프로세스로 실행되는 upstream **llama.cpp llama-server**다. 이를 구동하는 파일은 역할을 명확하게 밝힌다. “llama-server 바이너리를 하위 프로세스로 감싼다”(llama_server.go:1)고 쓰여 있고, 생성자의 주석은 더 직설적이다. “모든 GGML 모델은 upstream llama-server 하위 프로세스를 통해 제공된다”(server.go:107). Ollama는 llama-server-{cuda,rocm,darwin} 바이너리를 찾고(llama_binary.go:167), 이를 시작한 다음(llama_server.go:428), HTTP로 통신한다(llama_server.go:110).

그러므로 Ollama의 진짜 역할은 추론 주변의 모든 일이다. 모델을 가져와 등록하고, 어떤 모델을 로드할지 고르고, 메모리에 맞추고, 여러 모델을 동시에 실행하며, 유휴 상태가 되면 내린다. 이것이 스케줄러이고, 흥미로운 결정은 여기서 일어난다.

GPU에 몇 개의 레이어를 올릴지는 누가 결정하는가?

여기서 나는 의외의 사실을 발견했다. Ollama가 llama-server 명령줄을 만들 때, -ngl(GPU 레이어 수) 플래그는 사용자가 직접 num_gpu를 설정한 경우에만 전달된다(llama_server.go:396).

if launch.opts.NumGPU > 0 {
    params = append(params, "-ngl", strconv.Itoa(launch.opts.NumGPU))
} else if launch.opts.NumGPU == 0 {
    params = append(params, "-ngl", "0") // CPU only
}
// NumGPU == -1 (default): don't pass -ngl, let llama-server auto-detect

그리고 num_gpu의 기본값은 실제로 -1이다(types.go:1112). 따라서 평범하게 실행하면 Ollama는 -ngl을 아예 전달하지 않고, llama.cpp 자체의 메모리 맞춤 로직이 분할을 선택하게 둔다. 그렇다면 GPU에 최종적으로 몇 개의 레이어가 올라갔는지 Ollama는 어떻게 알까? llama-server의 로그 출력에서 그 숫자를 파싱한다. offloaded (\d+)/(\d+) layers to GPU라는 정규식이 있고(llama_server.go:2626), 여기서 캡처한 값은 실행기 상태에 곧바로 할당된다(llama_server.go:2676). 필드 설명 자체도 명확하다. gpuLayers // model layers loaded on GPU, parsed from llama-server logs(llama_server.go:123).

다시 읽어보자. 기본 경로에서 레이어 분할은 llama.cpp의 결정이고, Ollama는 그 답을 긁어온다. 강제로 지정하고 싶다면 num_gpu가 조절 장치다. Ollama가 -ngl을 내보내게 만드는 것은 이것뿐이다.1

Ollama는 llama-server 하위 프로세스를 스케줄링하지만 기본 GPU 레이어 맞춤은 llama.cpp에 위임한다

기본 num_gpu = -1 경로에서 Ollama는 스케줄링을 맡지만 -ngl은 전달하지 않는다. llama.cpp가 레이어를 맞추고 Ollama는 로그에서 결과를 읽는다.

그렇다면 Ollama의 메모리 계산은 무엇을 위한 것인가?

Ollama도 VRAM 추정치를 계산하기는 한다. 하지만 이는 레이어 분할이 아니라 스케줄링을 위한 것이고, 의도적으로 거칠다. PredictServerVRAM모델 파일 크기를 weight 추정치로 삼고 여기에 대략적인 f16 KV-cache 항을 더한다(llama_server.go:2562).

weights = os.Stat(modelPath).Size()
kvCache = 2 (K+V) * layers * kv_heads * head_dim * numCtx * 2 bytes

코드 자체의 주석도 이를 명시한다. “의도적으로 보수적이다. VRAM 경합을 피하기 위해 과대 추정한다”(llama_server.go:2564). 이를 호출하는 곳은 모두 스케줄러 안에 있고(sched.go:542), 여기서 답하려는 질문은 단 하나다. 이 모델이 들어맞는가, 아니면 먼저 무언가를 퇴출해야 하는가? 코드 트리에는 훨씬 상세한 레이어별 추정기도 있다(GraphSize, ggml.go:609). 하지만 이 commit에서는 적재 가능 여부를 결정하기 위해 이를 호출하는 곳이 없다. 서버가 구동된 뒤에는 llama-server가 로드 후 남긴 로그에서 실제 버퍼 크기를 파싱하여 추정치를 실측값으로 교체한다(llama_server.go:2520).

여기서 바로잡아야 할 관점이 이 글의 전부다. Ollama는 스케줄링을 위해 거칠게 추정하고, llama.cpp는 실행을 위해 정밀하게 맞추며, 이후 로그에서 실제 값을 읽어 둘을 조정한다.

내 모델은 왜 메모리에서 내려가는가?

타이머 때문이다. 로드된 실행기는 keep_alive 지속 시간에 따라 정리되며, 기본값은 5분이다(config.go:130. 음수는 “영원히 유지”, 영은 “즉시 내리기”를 뜻한다). 요청이 끝나 모델이 유휴 상태가 되면 스케줄러는 keep_alive 타이머를 건다. 타이머가 발동하면 모델을 내린다(sched.go:401). 기본값이 HTTP handler가 아니라 스케줄러의 로드 루프 깊숙한 곳에서 결정된다는 점에 주목하자(sched.go:518). 따라서 설정되지 않은 keep_alive는 그곳에서 5분짜리 환경변수 기본값을 상속한다.

모델이 사라지는 또 다른 이유는 메모리 압박이다. Ollama는 메모리에 계속 둘 모델 수를 기본적으로 GPU 수의 3배로 제한한다(defaultModelsPerGPU = 3, sched.go:87, sched.go:292에서 GPU 수를 곱한다). 새 모델이 들어맞지 않거나 이 한도에 도달하면 하나를 퇴출한다. 대상은 findRunnerToUnload가 고른다(sched.go:1682). 가장 짧은 keep_alive 순으로 정렬하고 유휴 실행기를 우선하며, 유휴 상태인 것이 없을 때만 작동 중인 것을 선택한다. 이 모든 과정은 단일 스케줄러 goroutine에서 실행되므로(sched.go:231), 로드와 퇴출 결정은 직렬화된다. 두 모델이 같은 VRAM을 차지하려고 벌이는 경합은 없다.

이 설계는 어디에 놓이는가?

로컬 추론에는 명확한 경계선이 있다. 당신은 엔진인가, 아니면 그 위에 있는 관리자인가?

llama.cpp와 그 llama-server는 엔진이다. GGUF 로드, GPU 맞춤, 토큰 생성을 수행한다. Ollama는 관리자다. 모델 레지스트리이자 REST API이며, 방금 본 스케줄러다. 어려운 수치 문제인 레이어 분할은 의도적으로 아래에 위임하고, 오케스트레이션 문제인 무엇을 로드할지, 무엇이 들어맞는지, 무엇을 퇴출할지, 언제 내릴지는 자신이 맡는다. 일관된 설계이고, Ollama가 간편하게 느껴지는 이유이기도 하다. 하지만 “왜 내 모델은 GPU에 20개 레이어만 올렸을까?“라는 질문의 답은 num_gpu로 직접 운전대를 잡지 않은 이상 Ollama가 아니라 llama.cpp에 있다는 뜻이기도 하다.

코드 트리에는 네이티브 Go 엔진을 위한 실험적 뼈대인 model/ 패키지가 있고, Apple MLX와 이미지 생성을 위한 별도 실행기도 있다(runner.go:10). 하지만 거의 모든 사람이 실행하는 GGUF LLM의 경로는 명백히 llama-server 하위 프로세스다.

언제 선택하면 좋은가?

관리자 계층이 필요하다면 Ollama를 선택하면 된다. 한 줄로 가져와 실행하기, 로컬 REST API, 자동 GPU 맞춤(llama.cpp에 위임되며, 합리적인 기본값이다), 유휴 상태의 모델을 내리는 손쉬운 다중 모델 로드를 제공한다. 다만 맞바꾸는 것은 알고 들어가야 한다. num_gpu를 설정하지 않으면 오프로드 분할을 직접 제어할 수 없고, 적재 가능 여부를 보수적으로 과대 추정하므로 llama.cpp라면 GPU에 밀어 넣을 수 있었을 모델을 Ollama가 올리지 않을 수 있으며, 최신 llama.cpp 릴리스보다 한 박자 늦다. 최첨단 기능이나 정확한 배치 제어가 필요하다면 llama-server를 직접 구동하는 쪽이 반대 진영이다. 관리자의 편의를 내려놓는 대신 엔진을 직접 제어하게 된다.

방법론과 범위

나는 **Ollama v0.31.1, 커밋 710292f**을 읽었고, server/sched.go, llm/llama_server.go, llm/server.go, llm/llama_binary.go, envconfig/config.go, api/types.go에 집중했다. 세 가지 핵심 주장, 즉 기본 경로에서 GPU 레이어 분할을 llama-server에 위임한 뒤 로그에서 결과를 읽는다는 것, 스케줄러의 keep_alive 기본값이 5분이고 GPU당 3개라는 로드 상한과 유휴 모델 우선 퇴출을 쓴다는 것, Ollama의 스케줄링 메모리 계산이 정확한 레이어별 추정치가 아니라 보수적인 파일 크기와 KV의 합을 근삿값으로 쓴다는 것은 각각 이 커밋에서 반증을 시도하는 주장 검증기를 거쳐 확인됐다. 줄 번호는 이 커밋에서 정확하다.

범위의 한계도 분명히 밝힌다.

  • 이 글은 v0.31.1에 한정된다. 이전 Ollama 버전은 레이어 분할을 직접 계산했다(고전적인 estimateGPULayers). 여기서 설명한 llama.cpp 위임 동작은 이 커밋의 코드가 하는 일이며, 분명히 계속 진화해 왔다.
  • llama.cpp 자체의 자동 맞춤 로직 내부는 읽지 않았다. 이 저장소 바깥에 있는 상위 엔진이기 때문이다. 내 주장은 llama-server가 어떻게 결정하는지가 아니라, Ollama가 무엇을 전달하고 무엇을 다시 읽는지에 관한 것이다.
  • 네이티브 model/ 엔진과 MLX/imagegen 실행기가 존재하지만, 표준 GGUF 경로에서는 벗어나 있다. 이 해부의 범위는 주류 흐름으로 한정했다.

Footnotes

  1. “Ollama가 분할을 건드리지 않는다”에는 한 가지 자동 예외가 있다. 멀티모달 프로젝터 OOM이다. 서버 시작이 메모리 부족 오류로 실패하고 모델에 비전 프로젝터가 있으면, Ollama는 프로젝터를 CPU에 강제로 올려 한 번 다시 시작한다(llama_server.go:998). 일반적인 레이어 계산이 아니라 목적이 한정된 재시도다.

방법론

이 글은 아래의 고정된 저장소 상태를 기준으로 읽었습니다. 디렉터리 이름은 편의용 키이고, 실제 고정 지점은 commit SHA입니다.

저장소 키버전커밋원본
ollama-v0.31.1v0.31.1710292ff4f19github.com/ollama/ollama

관련 분석

결정 축은 운영 계층이다. 바이너리 엔진을 직접 통제하느냐, 모델 매니저를 쓰느냐, 서버 처리량을 위해 엔진을 갈아 끼우느냐로 갈린다.