Homelab
Proof

U4 / Apps under test

Ollama on CPU only: tokens per second on 4 cores

Tested on Ollama 0.40.13

Test result

With 4 vCPU and no GPU, Ollama 0.40.13 generated 26 to 28 tokens per second with qwen3:1.7b 13.1 to 13.8 with gemma3:4b and 12.7 to 13.4 with qwen3:4b. qwen3:8b (5.2 GB download) failed in a 6 GB VM: the kernel's out-of-memory killer terminated the model process.

Line drawing: a CPU chip with abstract neural-network nodes and lines floating above it, and a small hourglass beside it.

The question for a home server without a GPU is which model size is still pleasant to use. This test ran four pinned models from the Ollama library on 4 vCPU of an AMD Ryzen 5 PRO 3600 (a shared host) in a VM with 6 GB of RAM, with Open WebUI running next to Ollama as in the stack.

Models

The tags were checked in the registry before the run: qwen3:1.7b (1.36 GB), qwen3:4b (2.50 GB), gemma3:4b (3.35 GB) and qwen3:8b (5.23 GB), 12.44 GB of downloads in total, with the image IDs Ollama reported (8f68893c685c, 359d7dd4bcda, 68ceab7e468a, 500a1f067a9f) saved in the evidence. The qwen3 and gemma3 pages in the Ollama library list these sizes. Pulling all four took about 16 s, 27 s, 37 s and 54 s on this VM’s connection.

Open WebUI chat page with the model list open showing qwen3:8b, qwen3:4b, gemma3:4b and qwen3:1.7b

Speed

Three fixed prompts (a two-sentence explanation, a three-bullet summary of a 150-word paragraph about backups, and a small Python function) were each run three times per model with temperature 0, seed 42 and num_predict 200, thinking disabled. Tokens per second is eval_count / eval_duration from Ollama’s own response, not a client-side estimate.

Generation speed on 4 CPU cores, no GPU (Ollama 0.40.13)
ModelPromptTokens per second (3 runs)Time to first token, median (range) over 3 runs (s)Prompt tokensGenerated tokens
qwen3:1.7bshort-answer28.2 (28.1 to 28.4)0.05 (0.04 to 0.05)3553
qwen3:1.7bsummary26.3 (25.6 to 26.3)0.05 (0.04 to 1.48)18290
qwen3:1.7bcode26.9 (26.9 to 27.1)0.05 (0.05 to 0.42)48200
gemma3:4bshort-answer13.8 (13.7 to 13.8)0.19 (0.18 to 0.21)2856
gemma3:4bsummary13.1 (13.1 to 13.2)0.24 (0.21 to 3.34)176118
gemma3:4bcode13.3 (13.2 to 13.3)0.21 (0.20 to 0.92)42200
qwen3:4bshort-answer13.4 (13.3 to 13.4)0.09 (0.08 to 0.09)29200
qwen3:4bsummary12.7 (12.6 to 12.7)0.11 (0.10 to 3.71)176200
qwen3:4bcode13.4 (13.4 to 13.4)0.10 (0.09 to 0.88)42200

Tokens per second = eval_count / eval_duration from Ollama. Temperature 0, seed 42, num_predict 200 (a run that reaches 200 was cut off), default context. Median of 3 runs with the range in parentheses. Thinking disabled with think=false. The upper end of each time-to-first-token range is the first request of that prompt (not yet cached); the median is the cached repeat. VM 4 vCPU, 6 GB.

  • qwen3:1.7b: 26.3 to 28.2 tokens/s. Both 4B models were close to each other and to half of that: gemma3:4b 13.1 to 13.8 and qwen3:4b 12.7 to 13.4.
  • The three prompts barely changed the rate for any model, so generation speed here looks bound by the model size and not by the prompt.
  • The last column shows which answers were cut off: “200” means the run reached the cap. The 1.7B answers to the first two prompts ended on their own (53 and 90 tokens), while qwen3:4b reached 200 on all three.
  • Time to first token: the first request with the 176 to 182 token summary prompt took 1.48 s (qwen3:1.7b), 3.34 s (gemma3:4b) and 3.71 s (qwen3:4b), all spent in prompt evaluation. The second and third identical requests answered in 0.04 to 0.24 s. That points to Ollama reusing the already-evaluated prompt, which was not isolated in this test, so the high end of each range is the number for a new prompt.

Memory and load

Memory and load time per model
ModelDownload size`ollama ps` sizeOllama container RAM, peak (MiB)Median while loaded (MiB)Cold load (s)First token, cold (s)
qwen3:1.7b1.36 GB1.9 GB234222751.82.2
gemma3:4b3.35 GB2.9 GB393338064.75.4
qwen3:4b2.50 GB3.2 GB348533114.14.8

RAM from docker stats of the Ollama container (page cache excluded) over the whole test of that model, docker stats samples about 1.5 to 2 s apart. Cold = first request after the model was unloaded. Open WebUI ran in the same VM.

The Ollama container used 2,342 MiB at its peak with qwen3:1.7b, 3,485 MiB with qwen3:4b and 3,933 MiB with gemma3:4b, measured with docker stats over each model’s run (weights plus context; the samples are about 1.5 to 2 s apart, not 0.5 s). ollama ps reported all three as 100 % CPU with a 4096-token context. A cold load took 1.8 to 4.7 s after the model files had been pulled; a first load after a reboot was not measured. With the 6 GB VM, the 3.4 to 3.8 GiB (3,485 to 3,933 MiB) peaks leave little room for Open WebUI and the OS, so gemma3:4b or qwen3:4b is about the ceiling for this box.

What failed

Models that did not run
ModelResultContainer OOMKilled / restartsKernel OOM lines
qwen3:8b{"http": 500, "body": "{\"error\":\"llama-server process has terminated: signal: killed\"}"}true restarts=0[ 641.205962] Out of memory: Killed process 5983 (llama-server) total-vm:5661328kB, anon-rss:4789528kB, file-rss:112kB, shmem-rss:0kB, UID:0 pgtables:9516kB oom_score_adj:0

Error text is what the Ollama API returned. The VM had 6 GB and no swap.

Loading qwen3:8b returned HTTP 500 (“llama-server process has terminated: signal: killed”). The VM’s kernel log shows Out of memory: Killed process (llama-server) with a resident size of 4,789,528 kB, about 4.6 GiB, and Docker flagged the Ollama container as OOM-killed without restarting it. Both containers were still up afterwards. The VM had no swap (free in raw/after.txt shows 0), so the 8B result is for 6 GB with no swap. Whether the 8B model would load with Open WebUI stopped, with a smaller context, or with 8 GB of RAM was not tried.

Steps

docker compose up -d                                  # Ollama + Open WebUI; the first account created in Open WebUI becomes its admin, so create it before exposing the port (the compose binds it to 127.0.0.1)
docker compose exec ollama ollama pull qwen3:1.7b     # likewise gemma3:4b, qwen3:4b, qwen3:8b
python3 bench.py http://<ollama-container-ip>:11434 ~/out qwen3:1.7b gemma3:4b qwen3:4b qwen3:8b
compose.yml, as tested (Ollama + Open WebUI 0.40.13 / 0.12.5)
services:
  ollama:
    image: ollama/ollama:0.40.13
    restart: unless-stopped
    volumes:
      - ollama:/root/.ollama
  open-webui:
    image: ghcr.io/open-webui/open-webui:v0.12.5
    restart: unless-stopped
    depends_on: [ollama]
    environment:
      OLLAMA_BASE_URL: http://ollama:11434
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - openwebui:/app/backend/data
volumes:
  ollama:
  openwebui:
lab/tests/ollama/bench.py
Show the full file
#!/usr/bin/env python3
"""Runs in the VM. bench.py <ollama_url> <outdir> <model> [<model> ...]
Per model: unload, one cold request (load time, time to first token), then 3 prompts x 3 repetitions with temperature 0, seed 42 and
num_predict 200, streamed. Tokens per second = eval_count / eval_duration (Ollama's own counters). Time to first token is the client-side time
until the first non-empty chunk. A failed load (HTTP error) is recorded with the message and the container's OOM flag."""
import sys, os, json, time, subprocess, urllib.request, urllib.error
url, out = sys.argv[1], sys.argv[2]; models = sys.argv[3:]; os.makedirs(out, exist_ok=True)
TEXT = ("A home server backup plan usually has three parts. First, a copy of the data is taken on a schedule, for example every night, by a tool that can detect which files changed. "
        "Second, the copy is stored somewhere that does not share a failure with the original: a second disk in another machine, a drive that is unplugged afterwards, or a remote object store. "
        "Third, the copy is restored on a spare machine at least once, because a backup that has never been restored is only a hope. Many people stop after the first part and discover the gaps "
        "during their first real failure: a database that was copied while it was being written, a volume that was left out of the job, or an encryption key that was kept only on the machine that died.")
PROMPTS = {"short-answer": "Explain in two sentences what a reverse proxy does and why someone would run one at home.",
           "summary": TEXT + "\n\nSummarize the passage above in three bullet points.",
           "code": "Write a Python function that parses one nginx access log line in the combined format and returns the HTTP status code and the request path. Include a short docstring."}
def post(path, body, stream=False, timeout=900):
    r = urllib.request.Request(url + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
    return urllib.request.urlopen(r, timeout=timeout)
def sh(c): return subprocess.run(c, shell=True, capture_output=True, text=True).stdout
oname = sh("docker ps --format '{{.Names}}' | grep ollama").strip().splitlines()[0]
def gen(model, prompt):
    body = {"model": model, "prompt": prompt, "stream": True, "think": False, "keep_alive": "10m", "options": {"temperature": 0, "seed": 42, "num_predict": 200}}
    t0 = time.perf_counter(); first = None; final = None; text = ""
    with post("/api/generate", body) as r:
        for line in r:
            d = json.loads(line)
            if d.get("response") and first is None: first = time.perf_counter() - t0
            text += d.get("response", "")
            if d.get("done"): final = d
    return {"ttft_s": first, "wall_s": time.perf_counter() - t0, "eval_count": final["eval_count"], "eval_duration_s": final["eval_duration"] / 1e9, "tps": final["eval_count"] / (final["eval_duration"] / 1e9),
            "prompt_eval_count": final["prompt_eval_count"], "prompt_eval_s": final["prompt_eval_duration"] / 1e9, "load_s": final["load_duration"] / 1e9, "done_reason": final.get("done_reason"), "text_head": text[:120]}
res = {"ollama_container": oname, "models": {}}
for m in models:
    r = {"model": m}; res["models"][m] = r
    try: post("/api/generate", {"model": m, "keep_alive": 0}).read()
    except Exception as e: r["unload_note"] = str(e)[:100]
    time.sleep(3)
    csv = f"{out}/mem-{m.replace(':','_')}.csv"; sp = subprocess.Popen(f"while true; do echo $(date +%s.%N),$(docker stats --no-stream --format '{{{{.MemUsage}}}},{{{{.CPUPerc}}}}' {oname}); sleep 0.5; done > {csv}", shell=True, preexec_fn=os.setsid)
    r["t_start"] = time.time(); time.sleep(2)
    try:
        r["cold"] = gen(m, PROMPTS["short-answer"]); print(m, "cold", {k: v for k, v in r["cold"].items() if k != "text_head"}, flush=True)
        r["ps"] = sh(f"docker exec {oname} ollama ps")
        r["warm"] = []
        for name, p in PROMPTS.items():
            for rep in range(1, 4):
                x = gen(m, p); x.update(prompt=name, rep=rep); r["warm"].append(x); print(m, name, rep, round(x["tps"], 2), "tok/s", round(x["ttft_s"], 2), "s ttft", x["eval_count"], "tokens", flush=True)
    except urllib.error.HTTPError as e:
        r["error"] = {"http": e.code, "body": e.read().decode()[:500]}; print(m, "FAILED", r["error"], flush=True)
    except Exception as e:
        r["error"] = {"exception": repr(e)[:300]}; print(m, "FAILED", r["error"], flush=True)
    r["t_end"] = time.time(); time.sleep(2); os.killpg(os.getpgid(sp.pid), 15)
    r["oom_killed"] = sh(f"docker inspect -f '{{{{.State.OOMKilled}}}} restarts={{{{.RestartCount}}}}' {oname}").strip()
    r["dmesg_oom"] = sh("sudo dmesg | grep -i 'killed process\\|out of memory' | tail -3")
    r["logs_tail"] = sh(f"docker logs --tail 15 {oname} 2>&1 | cut -c1-260")
    json.dump(res, open(f"{out}/bench.json", "w"), indent=1)
    try: post("/api/generate", {"model": m, "keep_alive": 0}).read()
    except Exception: pass
res["free_m"] = sh("free -m"); json.dump(res, open(f"{out}/bench.json", "w"), indent=1)
Idle footprint, measured on the reference test box. Median of 12 samples after settling.
StackVersionRAM at idleCPU at idleImages on diskReady after
Ollama + Open WebUI0.40.13 / 0.12.51.26 GiB0.39%14910.7 MB47.5 s

What this test did not cover

Sources and how this was made

lab/tests/ollama/run.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# ollama: CPU-only Ollama 0.40.13 (Open WebUI v0.12.5 in front) on 4 vCPU and 6 GB. Pinned small models, tokens per second from Ollama's own
# counters for 3 fixed prompts, time to first token, RAM of the Ollama container while loaded, and what fails by running out of memory.
set -uo pipefail
export LAB_MEM=6144
source "$(dirname "$0")/../_e/lib.sh"
T=ollama; D="$ROOT/lab/tests/$T"; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"
MODELS=(qwen3:1.7b gemma3:4b qwen3:4b qwen3:8b)
rm -rf "$OUT"; mkdir -p "$RAW" "$ROOT/public/shots/$T"
start_vm e-$T || exit 1
collect_env "$OUT"
vm scp-to "$D/bench.py" bench.py; gx 'mkdir -p ~/ol'; vm scp-to "$ROOT/lab/stacks/open-webui/compose.yml" ol/compose.yml
gx 'cd ~/ol && docker compose pull -q 2>&1 | tail -1; docker images --format "{{.Repository}}:{{.Tag}} {{.ID}}" | sort' > "$RAW/images.txt" 2>&1
gx 'cd ~/ol && docker compose up -d >/dev/null 2>&1; for i in $(seq 1 120); do curl -fs http://127.0.0.1:8080/health >/dev/null && exit 0; sleep 3; done; exit 1'
gx 'cd ~/ol && docker compose exec -T ollama ollama --version; nproc; lscpu | grep -E "Model name|Flags" | cut -c1-300' > "$RAW/versions.txt" 2>&1
for M in "${MODELS[@]}"; do
  n=${M%%:*}; v=${M##*:}
  gx "curl -sI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' https://registry.ollama.ai/v2/library/$n/manifests/$v | grep -i docker-content-digest; curl -s -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' https://registry.ollama.ai/v2/library/$n/manifests/$v | python3 -c 'import sys,json;d=json.load(sys.stdin);print(sum(l[\"size\"] for l in d[\"layers\"]))'" > "$RAW/manifest-${M//:/_}.txt" 2>&1
  S=$(now_ms); gx "cd ~/ol && docker compose exec -T ollama ollama pull $M 2>&1 | tail -2"; echo "$M pull_ms=$(( $(now_ms) - S ))" >> "$RAW/pulls.txt"
done
gx 'cd ~/ol && docker compose exec -T ollama ollama list' > "$RAW/ollama-list.txt" 2>&1
gx 'df -BM /var/lib/docker | tail -1; sudo du -sb /var/lib/docker/volumes/ol_ollama | cut -f1' > "$RAW/disk.txt"
gx 'sleep 30; docker stats --no-stream --format "{{.Name}} {{.MemUsage}}"' > "$RAW/mem-idle-before-models.txt"
IP=$(gx "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ol-ollama-1" | tr -d '\r')
gx "python3 bench.py http://$IP:11434 ~/out ${MODELS[*]}" 2>&1 | tee "$RAW/bench.log" | cut -c1-220
mkdir -p "$RAW/vm"; vm scp-from '~/out/.' "$RAW/vm/"; cp "$RAW/vm/bench.json" "$RAW/bench.json"
gx 'free -m; docker ps -a --format "{{.Names}} {{.Status}}"' > "$RAW/after.txt" 2>&1
# Open WebUI: first signup becomes admin; screenshot
gx 'curl -s -X POST http://127.0.0.1:8080/api/v1/auths/signup -H "Content-Type: application/json" -d "{\"name\":\"Lab Admin\",\"email\":\"admin@lab.invalid\",\"password\":\"lab-only-not-a-secret\"}" | head -c 150' > "$RAW/webui-signup.txt" 2>&1
tunnel 18092 8080
( cd "$D" && node shot.mjs http://127.0.0.1:18092 "$ROOT/public/shots/$T/webui.png" ) > "$RAW/shot.txt" 2>&1
untunnel
python3 "$D/summarize.py" "$RAW" "$OUT"

How this page was made: the commands above were run by an AI agent on the test box on November 2, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.