How Ollama Loads a Model — and Why It Doesn't Actually Decide Your GPU Layers thumbnail
TeardownJul 2, 2026AI infrastructure

How Ollama Loads a Model — and Why It Doesn't Actually Decide Your GPU Layers

Everyone assumes Ollama estimates your model's memory and computes how many layers to put on the GPU. Read at v0.31.1, that's not what happens: Ollama is a scheduler that spawns an upstream llama.cpp llama-server, lets it auto-detect the GPU split by default, and reads the offloaded layer count back from its logs. Ollama's own memory math is a deliberately rough proxy used only to decide what fits and what to evict.

By ArcYou Editorial. Source read against the pinned repository states listed in Methodology.

Decision brief

What this source read clarifies

What it really is
A local LLM manager that wraps the llama.cpp server and manages model lifecycle
Best for
Developer laptops where models should be easy to pull, run, and swap
Avoid if
You need to calculate and control GPU layer placement and engine behavior yourself
Core mechanism
scheduler, runner process, fit/evict decisions, llama.cpp delegation

Read at: ollama-v0.31.1 @ v0.31.1

Ask most people what Ollama does when you ollama run a model and you’ll hear some version of “it figures out how much VRAM you have and decides how many layers to offload to the GPU.” I read the source at v0.31.1, commit 710292f, and that’s not the mechanism. Ollama is a scheduler and model manager that spawns the upstream llama.cpp llama-server as a subprocess — and by default it lets llama.cpp pick the GPU layer split, then reads the result back out of the server’s logs. Ollama isn’t its own inference engine, and it isn’t the thing deciding your -ngl. Every claim below points at a line.

What is it, really?

For a standard GGUF model, the actual token-generating engine is the upstream llama.cpp llama-server, run as a child process. The file that drives it says so plainly: “wraps the llama-server binary as a subprocess” (llama_server.go:1), and the constructor’s comment is even blunter — “All GGML models are served via the upstream llama-server subprocess” (server.go:107). Ollama finds a llama-server-{cuda,rocm,darwin} binary (llama_binary.go:167), starts it (llama_server.go:428), and talks to it over HTTP (llama_server.go:110).

So Ollama’s real job is everything around inference: pull and register models, pick which one is loaded, fit it in memory, run several at once, and unload them when idle. That’s the scheduler, and it’s where the interesting decisions live.

Who decides how many layers go on the GPU?

Here’s the part that surprised me. When it builds the llama-server command line, Ollama only passes the -ngl (number-of-GPU-layers) flag if you set num_gpu yourself (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

And num_gpu’s default is -1 (types.go:1112). So on a normal run, Ollama passes no -ngl at all and lets llama.cpp’s own fitting logic choose the split. How does Ollama then know how many layers ended up on the GPU? It parses the number out of llama-server’s log output. There’s a regex — offloaded (\d+)/(\d+) layers to GPU (llama_server.go:2626) — whose captures are assigned straight into the runner’s state (llama_server.go:2676). The field even documents itself: gpuLayers // model layers loaded on GPU, parsed from llama-server logs (llama_server.go:123).

Read that back: in the default path, the layer split is llama.cpp’s decision, and Ollama scrapes the answer. If you want to force it, num_gpu is your lever — that’s the only thing that makes Ollama emit -ngl.1

Ollama schedules a llama-server subprocess but delegates default GPU layer fitting to llama.cpp

In the default num_gpu = -1 path, Ollama owns scheduling but does not pass -ngl; llama.cpp fits the layers and Ollama reads the result from logs.

Then what is Ollama’s memory math for?

Ollama does compute a VRAM estimate — but for scheduling, not for the layer split, and it’s deliberately crude. PredictServerVRAM uses the model file size as the weights estimate plus a rough f16 KV-cache term (llama_server.go:2562):

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

Its own comment calls it out: “intentionally conservative — it overestimates to avoid VRAM contention” (llama_server.go:2564). Every caller of it lives in the scheduler (sched.go:542), where it answers one question: will this model fit, or do I need to evict something first? There’s a much more detailed per-layer estimator in the tree (GraphSize, ggml.go:609) — but at this commit nothing calls it for the fit decision. Once the server is up, Ollama replaces the estimate with reality by parsing the actual buffer sizes from llama-server’s post-load logs (llama_server.go:2520).

The mental-model correction is the whole story here: Ollama estimates coarsely to schedule; llama.cpp fits precisely to run; the truth is reconciled from logs afterward.

Why does my model get unloaded?

Because of a timer. Loaded runners are reaped on a keep_alive duration whose default is 5 minutes (config.go:130; a negative value means “stay forever,” zero means “unload immediately”). When a request finishes and the model goes idle, the scheduler arms a timer for keep_alive; when it fires, the model is unloaded (sched.go:401). Note the default is resolved deep in the scheduler’s load loop, not at the HTTP handler (sched.go:518) — so an unset keep_alive inherits the 5-minute env default there.

The other reason a model disappears is pressure. Ollama caps how many models stay resident at 3 × your GPU count by default (defaultModelsPerGPU = 3, sched.go:87, multiplied by the GPU count at sched.go:292). When a new model won’t fit — or that cap is hit — it evicts one, chosen by findRunnerToUnload (sched.go:1682): sort by shortest keep_alive, prefer an idle runner, and only take a busy one if nothing idle is available. All of this runs in a single scheduler goroutine (sched.go:231), so load and evict decisions are serialized — there’s no race between two models trying to claim the same VRAM.

Where does this fit?

Local inference has a clean dividing line: are you the engine, or are you the manager on top of one?

llama.cpp (and its llama-server) is the engine — it does the GGUF loading, the GPU fitting, the token generation. Ollama is the manager: a model registry, a REST API, and the scheduler you just saw. It deliberately delegates the hard numerical part (the layer split) downward and keeps the orchestration part (what’s loaded, what fits, what to evict, when to unload) for itself. That’s a coherent design — it’s why Ollama feels effortless — but it also means the answer to “why did my model only put 20 layers on the GPU?” lives in llama.cpp, not Ollama, unless you took the wheel with num_gpu.

There’s experimental scaffolding for a native Go engine in the tree (the model/ package), and separate runners for Apple’s MLX and for image generation (runner.go:10) — but for the GGUF LLMs almost everyone runs, the path is the llama-server subprocess, full stop.

When would you reach for it?

Reach for Ollama when you want the manager layer: a one-line pull-and-run, a local REST API, automatic GPU fitting (delegated to llama.cpp, which is a fine default), and hands-off multi-model loading with idle unloading. Go in knowing the trade: you don’t directly control the offload split unless you set num_gpu, the fit decision is a conservative overestimate (so Ollama may keep a model off the GPU that llama.cpp could have squeezed on), and you’re a release behind whatever llama.cpp just shipped. If you need the bleeding edge or exact placement control, driving llama-server yourself is the other camp — you trade the manager’s conveniences for direct control of the engine.

Methodology & scope

I read Ollama v0.31.1 at commit 710292f, focused on server/sched.go, llm/llama_server.go, llm/server.go, llm/llama_binary.go, envconfig/config.go, and api/types.go. Three headline claims — that the default path delegates the GPU layer split to llama-server and reads it back from logs, that the scheduler’s keep_alive default is 5 minutes with a 3×GPU load cap and idle-first eviction, and that Ollama’s scheduling memory math is a conservative file-size-plus-KV proxy rather than an exact per-layer estimate — were each run through an adversarial claim-verifier at this commit and confirmed. Line numbers are exact there.

Scope caveats, stated plainly:

  • This is v0.31.1-specific. Earlier Ollama versions computed the layer split themselves (the classic estimateGPULayers); the delegation-to-llama.cpp behavior described here is what the code does at this commit, and it has clearly evolved.
  • I did not read inside llama.cpp’s own auto-fit logic — that’s the upstream engine, out of this repo. My claims are about what Ollama passes it and reads back, not how llama-server decides.
  • The native model/ engine and the MLX/imagegen runners exist but are off the standard GGUF path; I scoped the teardown to the mainstream flow.

Footnotes

  1. There’s one automatic exception to “Ollama doesn’t touch the split”: a multimodal projector OOM. If the server fails to start with an out-of-memory error and the model has a vision projector, Ollama restarts it once with the projector forced onto the CPU (llama_server.go:998) — a targeted retry, not a general layer calculation.

Methodology

This post is read against the pinned repository states below. Directory names are convenience keys; the real pin is the commit SHA.

Repo keyVersionCommitUpstream
ollama-v0.31.1v0.31.1710292ff4f19github.com/ollama/ollama

Related analysis

The decision axis is the operating layer. The fork is whether you control the binary engine, use a model manager, or swap engines for server throughput.