Lokale KI ohne GPU: Ich mache mein Homelab befragbar

Lokale KI ohne GPU: Ich mache mein Homelab befragbar

Übersicht

Lokale KI wird immer interessanter, sei es für den eigenen Chatbot, eine durchsuchbare Homelab-Dokumentation oder als Unterstützung bei der Administration. Meist landet man dabei aber schnell bei der Aussage, dass dafür zwingend eine leistungsstarke Grafikkarte nötig sei.

In diesem Artikel probieren wir deshalb einmal das Gegenteil aus: Kann ein großes Sprachmodell ausschließlich auf der CPU sinnvoll betrieben werden? Und welche Rolle spielen dabei Speicherbandbreite, NUMA und moderne Serverprozessoren?

Dafür schauen wir uns einen außergewöhnlichen AMD EPYC Server an, hosten ein großes LLM mit llama.cpp, untersuchen den Einfluss eines Dual-Sockel-Systems auf die Performance und bauen anschließend mit AnythingLLM einen lokalen KI-Assistenten, der unsere Homelab-Dokumentation durchsuchen und Fragen dazu beantworten kann.

Zum Schluss werfen wir außerdem einen Blick darauf, wie sich das Konzept auch mit normaler Consumer-Hardware und einer einzelnen Grafikkarte umsetzen lässt.


AMD Hardware

Vorab in eigener Sache: Dieser Artikel und das dazugehörige Video entstehen in Zusammenarbeit mit AMD. Sie haben mir für diesen Test einen Server zur Verfügung gestellt, den ich mir sonst niemals hätte hinstellen können, und mich ansonsten machen lassen.

Und dieser Server ist, gelinde gesagt, absurd. Zwei AMD EPYC 9575F mit jeweils 64 Kernen, macht zusammen 128 Kerne und 256 Threads. Dazu 1,5 TB Arbeitsspeicher, verteilt auf 24 Riegel, die alle 24 Speicherkanäle voll bestücken.

Drei Dinge davon sind für das, was wir gleich vorhaben, wirklich entscheidend:

Der Arbeitsspeicher. 1,5 TB klingen nach Angeberei, sind aber der Grund, warum wir gleich ein KI-Modell laden können, das auf keine Consumer-Grafikkarte passt.

Die Speicheranbindung. 12 Kanäle pro Prozessor, ein normaler Desktop hat zwei. Bei der Textgenerierung eines LLMs ist nämlich nicht die Rechenleistung der Flaschenhals, sondern wie schnell das Modell aus dem Speicher gelesen werden kann. Genau hier spielt diese Plattform ihre Stärke aus.

Die zwei Sockel. Und die sind, so komisch das klingt, gleichzeitig unser größtes Problem. Warum, schauen wir uns beim Thema NUMA genauer an.

Was hier übrigens nicht läuft: Es steckt keine einzige Grafikkarte in dieser Maschine. Alles, was gleich passiert, passiert auf der CPU.


LLM-Server mit llama.cpp

Um ein LLM selbst zu hosten, gibt es mittlerweile die verschiedensten Tools: Ollama, vLLM oder LM Studio, um nur einige zu nennen.

Wir werden heute llama.cpp verwenden. Das Tool ist etwas umständlicher zu bedienen und hat keine schicke Oberfläche, ist dafür aber für reine CPU-Inferenz die ausgereifteste Lösung und gibt uns vor allem die Kontrolle, auf die es hier ankommt: Wir können genau bestimmen, welche NUMA-Knoten verwendet werden sollen. Genau das ist in diesem Setup entscheidend, und warum, schauen wir uns direkt nach der Installation an.

Als kleine Randnotiz: Ollama basiert intern selbst auf llama.cpp, nimmt einem aber genau die Schalter ab, die wir gleich brauchen.

Installation

llama.cpp wird mit wenigen Befehlen installiert. Ich habe das Ganze einmal aus der Repo selber gebaut und zusätzlich alle nötigen Tools mit installiert, wie unter anderem numactl. Alles mit folgenden Befehlen:

sudo apt update && sudo apt install -y numactl hwloc git build-essential cmake libcurl4-openssl-dev
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
cmake -B build -DGGML_NATIVE=ON && cmake --build build -j --config Release

Das LLM-Modell

Ich habe einige Modelle getestet und bin am Ende für dieses Setup bei dem Modell Qwen3.5-122B-A10B gelandet. Dies ist ein großes Modell mit vielen Parametern, was die Power der CPU besser zeigen kann. Damit das Ganze in diesem Ausmaß auf der CPU funktioniert, handelt es sich hierbei um ein sogenanntes MoE-Modell. Das bedeutet, dass 122B Parameter enthalten sind, aber immer nur 10B aktiv.

Das Modell wird mit seinen ca. 78 GB komplett in den RAM geladen. Der Prozessor greift dabei über 12 Speicherkanäle darauf zu, jeder davon mit einem RAM-Riegel bestückt. Zum Vergleich: Ein normaler Desktop hat zwei oder maximal vier. Und genau darauf kommt es an, denn bei der Textgenerierung ist nicht die Rechenleistung der Flaschenhals, sondern wie schnell das Modell aus dem Speicher gelesen werden kann.

Das Modell laden wir mit folgendem Befehl herunter. Ich habe den Cache-Pfad noch angepasst auf eine zweite größere Platte.

echo 'export LLAMA_CACHE=/opt/models/llama-cache' >> ~/.bashrc
source ~/.bashrc

./build/bin/llama-cli -hf bartowski/Qwen_Qwen3.5-122B-A10B-GGUF:Q4_K_M --no-warmup -p "test" -n 1

NUMA

Kommen wir kurz zur NUMA-Thematik, bevor wir mit dem Setup weitermachen. Bei dem hier gezeigten System handelt es sich um ein Dual-Sockel-System, wir haben also zwei CPUs statt nur einer. Das klingt erst mal nach doppelter Leistung, ist für unser LLM aber gar nicht automatisch gut.

Denn jede der beiden CPUs hat ihren eigenen Arbeitsspeicher, der direkt an ihr hängt, bei uns jeweils 12 Speicherkanäle. Laden wir das Modell einfach über beide CPUs hinweg, kann es passieren, dass CPU 1 auf Speicher zugreifen muss, der eigentlich an CPU 2 hängt. Dieser Umweg über die Verbindung zwischen den Sockeln nennt sich Cross-Socket Access und kostet deutlich Zeit. Und da bei der Textgenerierung ständig aus dem Modell gelesen wird, summiert sich das massiv.

Genau das beschreibt NUMA: Non-Uniform Memory Access, also ungleichmäßiger Speicherzugriff. Der Name sagt es schon, nicht jeder Zugriff auf den Arbeitsspeicher ist gleich schnell. Bei einem normalen Desktop mit einer einzigen CPU spielt das keine Rolle, dort erreicht jeder Kern den gesamten Speicher gleich schnell. Auf einem Server mit mehreren Sockeln ist das anders, und deshalb müssen wir dem System sagen, wo es rechnen und wo es seinen Speicher holen soll. Genau dafür gibt es numactl.

Vergleichen von 1 vs 2 Sockel

Um das in der Praxis einmal zu sehen, machen wir einen kurzen Benchmark-Test mit unserem Modell.

Benchmark mit zwei Sockeln

jusec@jusec-CHALUPA:~$ MODEL=/opt/models/llama-cache/models--bartowski--Qwen_Qwen3.5-122B-A10B-
jusec@jusec-CHALUPA:~$ ~/llama.cpp/build/bin/llama-bench -m "$MODEL" -t 128 -p 512 -n 128 -r 3 --numa numactl
| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| qwen35moe 122B.A10B Q4_K - Medium |  72.28 GiB |   124.64 B | CPU        |     128 |           pp512 |        178.11 ± 6.25 |
| qwen35moe 122B.A10B Q4_K - Medium |  72.28 GiB |   124.64 B | CPU        |     128 |           tg128 |         10.70 ± 0.01 |

build: f449e0553 (9733)

Benchmark mit einem Sockel

jusec@jusec-CHALUPA:~$ MODEL=/opt/models/llama-cache/models--bartowski--Qwen_Qwen3.5-122B-A10B-GGUF/snapshots/f89fb67573c0155d8e5b6556204d86c75cdce0d8/Qwen_Qwen3.5-122B-A10B-Q4_K_M/Qwen_Qwen3.5-122B-A10B-Q4_K_M-00001-of-00002.gguf
jusec@jusec-CHALUPA:~$ numactl --cpunodebind=0,1,2,3 --interleave=0,1,2,3 --   ~/llama.cpp/build/bin/llama-bench -m "$MODEL" -t 64 -p 512 -n 128 -r 3 --numa numactl
| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| qwen35moe 122B.A10B Q4_K - Medium |  72.28 GiB |   124.64 B | CPU        |      64 |           pp512 |        186.67 ± 0.26 |
| qwen35moe 122B.A10B Q4_K - Medium |  72.28 GiB |   124.64 B | CPU        |      64 |           tg128 |         22.12 ± 0.01 |

build: f449e0553 (9733)

In der Ausgabe sind zwei Werte interessant: pp512 steht für Prompt Processing, also das Einlesen der Frage, tg128 für Text Generation, das Schreiben der Antwort. Im Alltag merkt man vor allem Letzteres.

Und genau da ist der Unterschied deutlich: Über beide Sockel kommen wir auf 10,7 Token pro Sekunde, auf einem einzigen Sockel sind es 22,1. Die halbe Maschine ist also gut doppelt so schnell wie die ganze. Dabei nutzen beide Läufe ihre Hardware voll aus, 128 Threads sind alle Kerne beider CPUs, 64 Threads alle Kerne einer CPU.

Spannend ist auch der Blick auf das Prompt Processing: Dort liegen beide fast gleichauf (178 gegen 186). Das passt genau zu dem, was wir vorhin über NUMA gesagt haben. Das Einlesen ist rechenlastig, da bringen die zusätzlichen Kerne des zweiten Sockels tatsächlich etwas. Das Schreiben der Antwort hängt dagegen am Speicherzugriff, und genau dort kostet der Umweg über die Sockelgrenze so viel, dass der zweite Sockel unterm Strich sogar schadet.

llama.cpp Server starten

Mit diesem Wissen können wir also jetzt unser LLM-Modell über einen Sockel starten, um die beste Performance zu haben. Den 2. Sockel nehmen wir später für den Embedder von Anything LLM.

Einen llama-server im Hintergrund laufen zu lassen, geht am einfachsten mit tmux. Das installieren wir mit folgendem Befehl:

sudo apt install tmux

Anschließend haben wir die Möglichkeit, mehrere Sessions zu starten und diese auch in den Hintergrund zu verlegen. Für unseren LLM-Server starten wir also einmal eine neue tmux-Session. Ich nenne die Session chat, da dies unser Chat-Part wird.

tmux new -s chat

In der Session angekommen, können wir jetzt unser Modell mit angegebenen NUMA-Knoten und ein paar Einstellungen starten:

numactl --cpunodebind=0,1,2,3 --interleave=0,1,2,3 --   ~/llama.cpp/build/bin/llama-server   -hf bartowski/Qwen_Qwen3.5-122B-A10B-GGUF:Q4_K_M   --alias chat-model   -t 64 --no-mmap -c 16384 --parallel 1   --numa numactl   --reasoning off --no-mmproj   --host 0.0.0.0 --port 8080 --jinja -b 4096 -ub 4096

Ein paar Worte zu den wichtigsten zusätzlichen Befehlen.

--reasoning off deaktiviert das Denken von dem Modell, dazu habe ich mich entschieden, da es bei mir irgendwie die Anfragen etwas zerdacht hat, aber am Ende die selbe Antwort rauskam.

--alias chat-model vergibt den Namen, unter dem Anything LLM das Modell später anspricht.

-c 16384 ist die Kontextgröße, also wie viel Text das Modell gleichzeitig verarbeiten kann.

--no-mmap lädt das Modell komplett in den RAM.

Das Starten dauert einen kleinen Moment, da jetzt das komplette Modell einmal in den RAM geladen wird. Um jetzt die Session in den Hintergrund zu packen, drücken wir strg+b und anschließend die Taste d.

Erster Chat

Jetzt können wir das Modell direkt einmal in einem normalen Chat ausprobieren, direkt über die mitgelieferte Oberfläche. Dafür navigieren wir zu der IP-Adresse des Servers und dem genutzten Port im Browser.

Das Modell funktioniert und antwortet wirklich schnell.

Embedder-Modell für AnythingLLM

Damit Anything LLM gleich korrekt funktioniert, brauchen wir dafür noch ein zweites kleines Modell als Embedder. Der Embedder verarbeitet die hochgeladenen Dokumente in AnythingLLM und weiß am Ende quasi, wo was steht.

Stellen wir später eine Frage, geht diese erst einmal an den Embedder. Der sucht die passenden Stellen aus unseren Dokumenten heraus und legt sie dem eigentlichen Chat-Modell vor. Das liest diese Ausschnitte und formuliert daraus die Antwort.

Man kann sich das wie eine Bibliothek mit zwei Angestellten vorstellen: Der Embedder ist der Bibliothekar, der die passenden Seiten aus dem Regal holt. Das Chat-Modell ist der Kollege, der sie liest und einem erklärt, was drinsteht. Und weil Suchen die deutlich einfachere Aufgabe ist, reicht dafür ein sehr kleines Modell, während für die Antwort das große Kaliber zum Einsatz kommt.

Embedder Modell herunterladen

Wir brauchen ein deutlich kleineres Modell. In meinem Setup verwende ich das Modell bge-m3-Q4_K_M-GGUF.

~/llama.cpp/build/bin/llama-embedding --hf-repo groonga/bge-m3-Q4_K_M-GGUF --hf-file bge-m3-q4_k_m.gguf -p "test"

Embedder für Anything LLM starten

Wir erstellen auch hier wieder eine neue Session mit tmux und starten darin den zweiten Server diesmal auf Port 8081.

tmux new -s embedder

In der Session angekommen, dann das Starten des Embedder-Modells:

numactl --cpunodebind=4,5,6,7 --interleave=4,5,6,7 --   ~/llama.cpp/build/bin/llama-server   --hf-repo groonga/bge-m3-Q4_K_M-GGUF   --hf-file bge-m3-q4_k_m.gguf   --alias bge-m3 -t 64 --embedding --pooling cls   -c 8192 -b 8192 -ub 8192   --numa numactl   --host 0.0.0.0 --port 8081

--embedding schaltet den Server in den Embedding-Modus, er generiert also keinen Text mehr, sondern gibt Vektoren zurück.

Und --pooling cls bestimmt, wie die Vektoren aus dem Text gebildet werden, das ist die Methode, die bge-m3 erwartet.


AnythingLLM

Unser Ziel ist es, unsere Homelab-Dokumentation befragbar zu machen und im besten Fall auch noch einen zweiten Blick darauf zu bekommen. Dafür habe ich AnythingLLM entdeckt, ein Tool, das genau das bietet.

Wir haben damit die Möglichkeit, komplett lokal unsere gesamte Dokumentation hochzuladen und sie anschließend über einen Chat abzufragen. Und es bleibt nicht beim reinen Nachschlagen: Wir können genauso gut nach Schwachstellen oder Verbesserungsideen fragen und bekommen einen Blick auf unser Setup, den wir selbst vielleicht gar nicht mehr haben, weil wir zu tief drinstecken.

Das Ganze lässt sich glücklicherweise ebenfalls wieder unkompliziert mit Docker Compose hosten.

Docker

Da wir Anything LLM mit Docker betreiben möchten, installieren wir als Erstes natürlich erstmal Docker selbst, falls noch nicht geschehen. Das können wir ganz einfach mit dem offiziellen Convenience Script von Docker machen.

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

Compose-File

services:
  anythingllm:
    image: mintplexlabs/anythingllm:1.10.0
    container_name: anythingllm
    network_mode: host
    environment:
      - STORAGE_DIR=/app/server/storage
      - DISABLE_TELEMETRY=true
      - LLM_PROVIDER=generic-openai
      - GENERIC_OPEN_AI_BASE_PATH=http://127.0.0.1:8080/v1
      - GENERIC_OPEN_AI_MODEL_PREF=chat-model
      - GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT=16384
      - GENERIC_OPEN_AI_API_KEY=sk-local-noauth
      - EMBEDDING_ENGINE=generic-openai
      - GENERIC_OPEN_AI_EMBEDDING_BASE_PATH=http://127.0.0.1:8081/v1
      - GENERIC_OPEN_AI_EMBEDDING_API_KEY=sk-local-noauth
      - EMBEDDING_MODEL_PREF=bge-m3
      - EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192
      - VECTOR_DB=lancedb
    volumes:
      - anythingllm_storage:/app/server/storage
    restart: unless-stopped
volumes:
  anythingllm_storage:
sudo docker compose up -d

Erste Schritte

Nachdem AnythingLLM gestartet ist, erreichen wir das über die Server-IP und den Port 3001. Falls ihr es via Reverse Proxy erreichbar macht, dann über eure Domain.

Bei Anything LLM angekommen, machen wir noch ein paar kleine Anpassungen und dann können wir auch schon loslegen.

LLM-Server

Als Erstes schauen wir uns in den Einstellungen den LLM-Server an. Hier können wir nämlich noch die Max Tokens hochsetzen auf 4096, damit werden längere Antworten nicht einfach abgeschnitten. Zusätzlich können wir das Model Context Window erhöhen bei Bedarf.

Embedder-Server

Die URL für den Embedder-Server wird manchmal nicht aus der Docker-Compose übernommen, daher tragen wir sie noch einmal nach.

Textsplitting & Chunking-Präferenzen

Als Letztes können wir noch die Textsplittung und Chunks etwas hochsetzen. Mit der Textchunk-Größe geben wir an, wie lang ein Stück Text beim Upload maximal sein darf. Damit keine Absätze auseinandergerissen werden, erhöhen wir den Wert.

Textchunk-Überlappung bestimmt, wie viele Zeichen sich zwei benachbarte Stücke teilen. Ohne Überlappung geht eine Information verloren, die genau auf einer Schnittkante liegt: Sie wäre in beiden Stücken nur halb vorhanden. Mit Überlappung taucht der Randbereich in beiden auf und wird zuverlässiger gefunden.

Workspace erstellen

Jetzt können wir unseren ersten Workspace erstellen. Dafür gehen wir zurück auf die Übersicht und klicken links auf das Plus.

Jetzt haben wir unseren neuen Workspace. Hier können wir auf dem Upload-Icon unsere Dokumente hochladen.

Dokumente in den Workspace laden.

AnythingLLM befragen

Das war es auch schon. Jetzt können wir in diesem Workspace eine erste Konversation starten und fragen zu unserer Dokumentation stellen.


Für alle ohne 128 Kerne im Keller

Kommen wir also zum leicht abgewandelten Setup mit normaler "Consumer Hardware". Wir können das Ganze nämlich etwas abgespeckter ebenfalls auf einem normalen PC mit halbwegs guter Grafikkarte betreiben. Alles, was wir dafür brauchen, ist ein kleineres, nicht so schlaues Modell.

Als Beispiel habe ich meinen Windows-PC genommen. Dieser hat eine AMD 9700XT GPU mit 20GB VRAM.

Für das LLM können wir es uns hier etwas leichter machen. In meinem Fall habe ich einfach Ollama installiert und das Modell heruntergeladen. Für Ollama hat AnythingLLM einen eingebauten Provider, sodass wir dort einfach das gewünschte Modell auswählen können.

Angepasste Docker Compose

Da unter Windows der Container in einer Linux VM läuft, müssen wir den Port wieder richtig mappen.

services:
  anythingllm:
    image: mintplexlabs/anythingllm:1.10.0
    container_name: anythingllm
    ports:
      - "3001:3001"
    environment:
      - STORAGE_DIR=/app/server/storage
      - DISABLE_TELEMETRY=true
      - VECTOR_DB=lancedb
    volumes:
      - anythingllm_storage:/app/server/storage
    restart: unless-stopped
volumes:
  anythingllm_storage:

Ollama

Ollama kann ganz einfach als normales Programm installiert werden. Anschließend können wir uns auf der Seite das gewünschte Modell raussuchen und mit einem Befehl herunterladen. Bei der Modell wahl, solltet ihr drauf achten, dass es nicht so viele Parameter hat. Ich habe 2 Modelle die aktuell beliebt sind ausprobiert.

gemma4:14b: Das Modell ist sehr schlank, hat allerdings auf einige Fragen nicht wirklich korrekt geantwortet.

qwen3.6:27b: Aktuell auch ein sehr beliebtes Modell. Eigentlich braucht man hierfür mindestens 24GB VRAM, aber es hat bei mir akzeptabel funktioniert.

Das Modell könnt ihr dann bspw. mit dem Befehl hier herunterladen.

ollama run qwen3.6:27b

AnythingLLM - Ollama hinterlegen

Ollama hat in AnythingLLM einen Provider, den wir auswählen können. Damit wird die Ollama-Instanz automatisch erkannt und ihr könnt aus den heruntergeladenen Modellen wählen.

Einen Embedder brauchen wir hier ebenfalls nicht unbedingt, da AnythingLLM einen kleinen mitbringt, der in dem Setup ausreicht.

Erste Fragen

Nachdem ihr dann alles soweit eingestellt habt und eure Dokumente hochgeladen habt, kann die Befragung auch schon beginnen.

In dem Setup habe ich leider das Nachdenken des LLMs nicht deaktiviert bekommen, es antwortet aber trotzdem zügig.


Fazit

Der gezeigte Server ist ohne Frage eine absolute Ausnahme. 128 Kerne, 1,5 TB RAM und 24 Speicherkanäle wird wohl kaum jemand im eigenen Homelab stehen haben. Trotzdem war genau das der Grund, warum dieses Projekt so spannend war.

Besonders interessant war für mich nicht die reine Rechenleistung, sondern zu sehen, wie stark Speicherbandbreite und NUMA das Verhalten großer Sprachmodelle beeinflussen. Mehr Hardware bedeutet eben nicht automatisch mehr Geschwindigkeit. Im Gegenteil: Im gezeigten Benchmark war ein einzelner Sockel beim Generieren der Antworten sogar deutlich schneller als beide Prozessoren zusammen.

Gleichzeitig zeigt das Projekt aber auch, dass die eigentliche Idee keineswegs an Enterprise-Hardware gebunden ist. Mit kleineren Modellen, Ollama und einer aktuellen Consumer-GPU lässt sich derselbe Workflow problemlos zu Hause nachbauen. Vielleicht nicht mit einem 122-Milliarden-Parameter-Modell, aber trotzdem schnell genug, um Dokumentationen, Notizen oder Wikis lokal durchsuchbar zu machen.

Genau das nehme ich aus diesem Test mit: Nicht die Größe des Modells ist entscheidend, sondern dass die KI zuverlässig bei den eigenen Daten helfen kann, komplett lokal, ohne Cloud und mit voller Kontrolle über die eigenen Informationen.