Performance tuning
keep_alive, concurrency and model-load limits, context and output caps, GPU offload, and measuring tokens per second instead of guessing.
Server-side settings
# environment variables read by the ollama server at startup
OLLAMA_KEEP_ALIVE=30m # keep a model resident for 30 minutes after last use
OLLAMA_NUM_PARALLEL=4 # concurrent requests per model
OLLAMA_MAX_LOADED_MODELS=2 # how many models may be resident at once
OLLAMA_MAX_QUEUE=512 # requests queued before rejecting new ones
OLLAMA_NUM_GPU=1 # GPUs to use on a multi-GPU host
OLLAMA_FLASH_ATTENTION=1 # lower KV-cache memory on supported hardware
OLLAMA_HOST=127.0.0.1:11434 # bind address
ollama serve| Variable | Default | Effect |
|---|---|---|
OLLAMA_KEEP_ALIVE | 5 minutes | Cost of reloading a large model versus memory held |
OLLAMA_NUM_PARALLEL | auto | Concurrent generations; divides the available memory |
OLLAMA_MAX_LOADED_MODELS | auto | Prevents two models competing for VRAM |
OLLAMA_MAX_QUEUE | 512 | Backpressure before requests are rejected |
OLLAMA_NUM_GPU | all | Restrict to fewer GPUs to share the machine |
OLLAMA_FLASH_ATTENTION | off | Lower memory at long context on newer GPUs |
OLLAMA_KEEP_ALIVE=0unloads immediately after every request, which is the right setting for a laptop and the wrong one for a server.- Parallelism is not free: each concurrent request needs its own KV cache. Raising
OLLAMA_NUM_PARALLELon a machine with just enough memory causes offloading and a slowdown under load. - Total throughput usually falls as concurrency rises past the point where memory is saturated. More parallel requests give lower latency for more users, not more total tokens per second.
- Apply these on the server process, not in the client. A client cannot raise the concurrency the server allows.
Per-request controls
import requests
import time
BASE = "http://localhost:11434"
def run(prompt, model="llama3.2:3b-instruct-q4_K_M", **options):
options.setdefault("num_ctx", 4096)
options.setdefault("num_predict", 256)
start = time.perf_counter()
payload = {"model": model, "prompt": prompt, "stream": False,
"keep_alive": "20m", "options": options}
data = requests.post(f"{BASE}/api/generate", json=payload, timeout=300).json()
seconds = time.perf_counter() - start
eval_count = data.get("eval_count", 0)
eval_duration = data.get("eval_duration", 1) / 1e9
return {
"text": data["response"],
"seconds": round(seconds, 2),
"generated_tokens": eval_count,
"tokens_per_second": round(eval_count / max(eval_duration, 1e-6), 1),
"prompt_tokens": data.get("prompt_eval_count", 0),
"load_ms": round(data.get("load_duration", 0) / 1e6, 1),
}
print(run("Summarise the benefits of caching in three bullets."))
print(run("Summarise the benefits of caching in three bullets.",
num_ctx=2048, num_predict=128))num_predictcaps generated tokens. Without it, a chatty model can generate far more than the answer needs and dominate your latency budget.num_ctxsets the context window and therefore the KV-cache size. Asking for 32k context when your prompts are 2k wastes memory and slows attention.- The response includes
load_duration: a large value means the model was reloaded, so yourkeep_aliveis too short for your traffic pattern. keep_alivecan be set per request, which is useful for a nightly batch job that should release memory when it finishes.
Measuring and interpreting
import json
import statistics
import time
import requests
BASE = "http://localhost:11434"
def benchmark(prompts, model="llama3.2:3b-instruct-q4_K_M", repeats=3):
rates, first_token = [], []
for _ in range(repeats):
for prompt in prompts:
payload = {"model": model, "prompt": prompt, "stream": True,
"options": {"num_ctx": 4096, "num_predict": 128}}
with requests.post(f"{BASE}/api/generate", json=payload,
stream=True, timeout=300) as r:
generated = 0
start = None
loaded = 0.0
for line in r.iter_lines():
if not line:
continue
chunk = json.loads(line)
now = time.perf_counter()
if start is None:
start = now
loaded = chunk.get("load_duration", 0) / 1e9
if chunk.get("response"):
generated += 1
if generated == 1:
first_token.append(now - start)
if chunk.get("done"):
total = now - start
rates.append(generated / max(total - loaded, 1e-6))
return {
"median_tokens_per_second": round(statistics.median(rates), 1),
"median_first_token_ms": round(statistics.median(first_token) * 1000, 1),
"samples": len(rates),
}
print(benchmark(["Write one sentence about caching."], repeats=2))- Always warm up before measuring. The first call after a load is not representative and will make every configuration look bad.
- Track time to first token separately from tokens per second. Streaming makes the first number the user-facing one and the second the throughput one.
- Compare configurations at the same context length and output length. A shorter
num_ctxwill always look faster because attention costs less. - If throughput drops when two requests run together, you are memory-bound and the fix is a smaller model or quantisation, not more parallelism.
💡
The two numbers that matter in production are time to first token and total tokens per second, measured under your real concurrency. A benchmark of a single idle request tells you the hardware's ceiling, not the service's behaviour.
FAQ
Why is the first request so slow?
The model is being loaded from disk into memory. Increase
OLLAMA_KEEP_ALIVE, or send a warm-up request at service startup so the first real user does not pay for it.How many concurrent users can one GPU serve?
Divide the memory left after the weights by the KV-cache size per request, then measure. In practice a 7B q4 model on a 12 GB GPU serves two to four concurrent conversations at usable latency.
Related
Running models locally Troubleshooting and the limits of local models
Last refreshed 2026-09-18.