Qwen3.8 27B auf zwei Mittelklasse-GPUs: Was MTP, Quantisierung und Tensor-Split wirklich bringen
Qwen3.8 27B auf zwei Mittelklasse-GPUs
Was MTP, Quantisierung und Tensor-Split wirklich bringen
- Der größte Gewinn kam nicht von MTP, sondern von einer einzigen Zeile:
-sm tensor. Unser Produktionsmodell wurde damit von 29 auf 47,5 Token/s schneller, ohne jeden Qualitätsverlust. - MTP lohnt sich nur mit K-Quants (Q4_K_M & Co.). Mit I-Quants (IQ3_S) bringt es auf Mittelklasse-Karten nichts. Die schnellste Variante erreichte 79 Token/s.
- Schnell ist nicht gleich zuverlässig: Die 79-Token/s-Variante scheiterte in beiden langen Agentensitzungen mit der neuen Anleitung. Die langsamere Produktionsvariante rekonstruierte mit derselben Anleitung in drei von drei Läufen ein optimiertes Windows-Programm ohne Debug-Symbole aus dem Maschinencode: 119, 120 und 120 von 120 Prüffällen.
- Den größten Sprung im Agententest brachte die Anleitung für den Agenten, nicht das Modell.
- Update 18. Oktober: Bestandene Tests sind nicht alles. Auch innen sauber (Smart Pointer, passende Datentypen, dokumentiertes Ghidra-Projekt) wurde der Nachbau erst, als wir die Arbeit auf zwei getrennte Sitzungen aufteilten und die zweite eine vorbereitete Liste abarbeiten ließen: Strukturbewertung 20 von 21 statt 9–16. Mehr Regeln in einer einzigen Anleitung machten es dagegen jedes Mal schlechter (Teil 3).
Inhalt
Alle unsere Dienste (Chatbot, n8n, Agenten, OpenCode) laufen auf einem einzigen lokalen Sprachmodell: Qwen3.8 27B (dicht, nicht MoE). Die Frage, mit der alles anfing, war harmlos: Nutzt unser Modell eigentlich schon MTP? Die Antwort war Nein. Daraus wurden zwei Tage Messreihen mit sechs Modelldateien, über zwanzig Konfigurationen, vier verschiedenen Testarten und mehreren Stunden selbstständiger Agentenarbeit.
Die Hardware
| Baustein | Details |
|---|---|
| GPUs | NVIDIA RTX 5060 Ti 16 GB + RTX 4070 Ti 12 GB = 28 GB VRAM |
| RAM | 64 GB |
| Software | llama.cpp b10964, nativ unter Windows 11, hinter llama-swap (Modellwechsel) und LiteLLM (API-Gateway) |
| Kontext | 64k Token, ein Nutzer gleichzeitig (--parallel 1) |
Also kein Rechenzentrum, sondern zwei Gaming-Karten unterschiedlicher Generation. Genau das macht die Ergebnisse interessant: Viele Benchmarks im Netz stammen von RTX 4090/5090, und dort gelten andere Regeln.
Die getesteten Modelldateien
Alle Dateien sind öffentlich auf Hugging Face verfügbar:
| Kurzname | Repository / Datei | Größe | Quant-Typ | MTP-Kopf |
|---|---|---|---|---|
| GSQ | ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF → Qwen3.8-27B-GSQ-RCO-IQ3_S.gguf | 11,8 GB | I-Quant (gemischt, ~3,5 bpw) | nein |
| GSQ-mtp | dasselbe Repo → Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf | 12,1 GB | I-Quant | ja |
| Q4_K_M | Jackrong/Qwen3.8-27B-MTP-GGUF → Qwen3.8-27B-MTP-Q4_K_M.gguf | 16,8 GB | K-Quant | ja |
| Q3_K_M | dasselbe Repo → Qwen3.8-27B-MTP-Q3_K_M.gguf | 13,5 GB | K-Quant | ja |
| Q5_K_M | dasselbe Repo → Qwen3.8-27B-MTP-Q5_K_M.gguf | 19,5 GB | K-Quant | ja |
| UD-Q4_K_XL | unsloth/Qwen3.8-27B-GGUF → Qwen3.8-27B-UD-Q4_K_XL.gguf | 17,6 GB | K-Quant (Unsloth Dynamic) | ja |
Warum GSQ unser Ausgangspunkt war: In einem früheren Test hielt der GSQ-Quant von ISTA-DASLab die Tool-Call-Disziplin auch bei 51k Kontext, wo ein normaler Q4_K_M unter Ollama ab etwa 50k anfing, Werkzeugaufrufe nur noch anzukündigen statt auszuführen.
Kurz erklärt: MTP und Tensor-Split
MTP (Multi-Token Prediction) ist ein kleiner Zusatzkopf im Modell, der die nächsten 2–4 Token rät. Das große Modell prüft diese Entwürfe in einem einzigen Rechenschritt und übernimmt alle, die stimmen. Das Ergebnis ist identisch mit dem ohne MTP, nur schneller. Voraussetzung: Das Prüfen von drei Token darf kaum länger dauern als das Erzeugen von einem.
Split-Modus: Läuft ein Modell auf zwei GPUs, verteilt llama.cpp es
standardmäßig schichtweise (-sm layer). Karte 1 rechnet die
erste Hälfte der Schichten, dann Karte 2 die zweite. Beim Schreiben arbeitet also immer nur
eine Karte, die andere wartet. Mit -sm tensor
wird jede Schicht auf beide Karten aufgeteilt, und beide rechnen gleichzeitig. Die Funktion
ist in llama.cpp noch als experimentell markiert
(PR #19378).
Teil 1: Der Testaufbau
Wir haben vier Testarten kombiniert. Jede beantwortet eine andere Frage:
| Test | Frage | Dauer pro Variante |
|---|---|---|
| A. Geschwindigkeit | Wie schnell schreibt das Modell kurze und lange Antworten? | ~5 min |
| B. llama-bench | Lohnt sich MTP mit diesem Quant überhaupt? | ~2 min |
| C. Replay | Ruft das Modell in echten Agentensituationen das richtige Werkzeug auf? | ~20 min |
| D. re-lab | Hält das Modell eine Stunde selbstständiger Arbeit durch und liefert ein prüfbares Ergebnis? | 60 min |
A bis C laufen über ein PowerShell-Skript (mtp-matrix.ps1), D über ein eigenes Testlabor (re-lab).
Gemeinsame Regeln für alle Messungen
- Eine Variante zur Zeit, auf leeren GPUs. Vor jeder einzelnen Messung entlädt das Skript alle anderen Modelle, beendet fremde
llama-server-Prozesse und prüft mitnvidia-smi, dass nur noch der Desktop Speicher belegt (Grenzwerte: 4070 Ti unter 0,7 GB, 5060 Ti unter 1,9 GB). Ist der Speicher nach drei Minuten nicht frei, wird die Messung als „unsauber“ markiert. - Der Produktionsdienst wird sauber abgeschaltet, nicht abgeschossen: llama-swap wird über eine Modusdatei auf „aus“ gestellt und nach dem Test wieder auf „Produktion“.
- Gleiche Basiseinstellungen für alle: beide GPUs, 64k Kontext, ein Slot, Denkmodus aus, Qwen-Sampling für Non-Thinking (
--temp 0.7 --top-k 20 --top-p 0.8 --min-p 0 --presence-penalty 1.5). Variiert wurden nur Modelldatei, MTP-Tiefe (--spec-draft-n-max 2/3/4) und Split-Modus. - Jeder Server startet auf einem eigenen Testport, und das Skript wartet auf
/health, bevor es misst.
Eine Lektion vorab: der unsichtbare Störenfried
Mitten in der ersten Messreihe fiel auf, dass auf der 4070 Ti zeitweise 3,9 GB belegt waren, die keinem Test gehörten. Die Ursache war unser Spracherkennungsdienst (Whisper large-v3 im Container). Ein Watchdog startete ihn jede Stunde neu, und beim Neustart lud er das Modell für 15 Minuten auf die GPU. Einige Messungen lagen genau in diesen Fenstern.
Test A: Geschwindigkeit
Drei kurze Aufgaben, je zweimal, jeweils bis zu 600 Token Antwort:
| Aufgabe | Prompt (gekürzt) | Warum |
|---|---|---|
| Code | „Schreibe eine Python-Funktion, die eine CSV-Datei einliest, Zeilen mit leeren Feldern verwirft und pro Kategorie die Beträge summiert. Mit Docstring und Typ-Hints.“ | gut vorhersagbarer Text, gut für MTP |
| Fließtext | „Erkläre in etwa 300 Wörtern, wie eine Wärmepumpe funktioniert.“ | freie Sprache, schwer vorherzusagen |
| JSON | „Gib ein JSON-Array mit 12 fiktiven Mitarbeitern aus (id, name, abteilung, eintrittsjahr, gehalt).“ | sehr regelmäßige Struktur |
Dazu kommt eine Langkontext-Messung: Die gesamte Markdown-Dokumentation unseres Repositorys (gekürzt auf 80.000 Zeichen, rund 26.000 Token) wird als Lesestoff vorangestellt, dann soll das Modell die wichtigsten Dienste und Ports zusammenfassen. Das misst zwei Dinge: wie schnell der lange Prompt eingelesen wird (Prefill) und wie schnell das Modell schreibt, wenn der Kontext voll ist.
Gemessen werden die Zahlen, die llama-server selbst in jeder Antwort mitliefert
(timings): Token pro Sekunde beim Schreiben, Prefill-Geschwindigkeit und bei MTP die
Zahl der vorgeschlagenen und angenommenen Entwurfs-Token (daraus die Akzeptanzrate).
Dazu der belegte Grafikspeicher pro Karte.
Test B: llama-bench – Was kostet ein Prüfschritt?
MTP lohnt sich nur, wenn das große Modell mehrere Token fast so schnell prüfen kann wie eines. Das lässt sich direkt messen:
llama-bench -m <modell>.gguf -ngl 99 -fa 1 -dev CUDA0/CUDA1 -p 1,2,3,4 -n 32 -r 3
-p 1,2,3,4 misst die Zeit für einen Rechenschritt mit 1, 2, 3 und 4 Token,
-r 3 wiederholt dreimal. Der entscheidende Wert ist der Faktor 3 Token /
1 Token. Liegt er nahe 1, sind drei geratene Token fast gratis zu prüfen. Liegt er bei 2,
frisst die Prüfung den Gewinn auf.
Test C: Replay echter Agentenanfragen
Kurze Benchmarks sagen nichts darüber, ob ein Modell in einer echten Agentensitzung richtig handelt. Deshalb haben wir im September einen Mitschnitt-Proxy zwischen OpenCode und das Modell gehängt. Er speicherte jede echte Anfrage einer Arbeitssitzung als JSON-Datei: Systemprompt, alle bisherigen Nachrichten, Tool-Ergebnisse und die Liste der verfügbaren Werkzeuge.
Für den Test haben wir fünf Mitschnitte zwischen 25.000 und 57.000 Token Kontext ausgewählt. Drei davon sind Situationen, in denen das damalige Modell versagt hatte (es kündigte nur an: „Ich schaue mir jetzt die Datei an …“, rief aber kein Werkzeug auf). Jede Anfrage wird dem Testmodell sechsmal mit verschiedenen Zufalls-Seeds vorgelegt, also 30 Versuche pro Variante.
Bewertet wird die Antwort automatisch:
| Kategorie | Bedeutung |
|---|---|
| ok | gültiger, strukturierter Tool-Call mit existierendem Werkzeug und allen Pflichtargumenten |
| prose | nur eine Absichtserklärung, kein Aufruf (das bekannte Fehlerbild) |
| text_toolcall | Aufruf als Klartext statt im API-Format |
| bad_name / bad_args | erfundenes Werkzeug oder fehlende Argumente |
Test D: re-lab – ein Programm aus dem Maschinencode zurückgewinnen
Der härteste Test. Ein Agent soll ohne Zugriff auf den Quellcode ein C++-Programm aus der fertigen Windows-EXE rekonstruieren, so genau, dass das Nachbauprogramm sich Byte für Byte gleich verhält. Das prüft alles, was ein Agentenmodell können muss: Werkzeuge über viele Runden korrekt bedienen, Zwischenergebnisse festhalten, Fehler erkennen, selbst korrigieren und eine Stunde lang den Faden nicht verlieren.
Das Zielprogramm: minipack
minipack ist ein kleines Kommandozeilenprogramm (360 Zeilen C++), das wir eigens für
diesen Test geschrieben haben. Es enthält bewusst die Bausteine, an denen Decompiler und Modelle
typischerweise scheitern:
| C++-Baustein | Wo im Programm | Warum schwierig |
|---|---|---|
| Klassenhierarchie mit virtuellen Methoden | abstrakte Basisklasse Codec mit vier Ableitungen (RleCodec, DeltaCodec, XorCodec, ChainCodec), je name(), encode(), decode() | im Maschinencode nur Funktionszeiger-Tabellen (vtables); Klassen und Vererbung müssen erschlossen werden |
| Composite-Muster mit Smart Pointern | ChainCodec hält std::vector<std::unique_ptr<Codec>>, kodiert vorwärts, dekodiert rückwärts | Templates werden vom Compiler komplett aufgelöst und eingebettet |
| std::string / std::vector | überall | Inlining, Small-String-Optimierung: aus einer Zeile Quelltext werden dutzende Maschinenbefehle |
| Kompressions-/Kodieralgorithmen | Run-Length (Paare Anzahl/Byte, max. 255), Delta-Kodierung, rollierendes XOR mit positionsabhängiger Maske (key[i] ^ (i*31+7)) | exakte Konstanten und Randfälle müssen stimmen |
| Prüfsummen | CRC32 mit Tabelle, die beim ersten Aufruf statisch initialisiert wird (Polynom 0xEDB88320), FNV-1a 64 Bit | magische Konstanten erkennen, Initialisierungslogik nachbauen |
| Parser | Spezifikationen wie xor:KEY+delta+rle werden an + zerlegt, Einzelteile per Fabrikfunktion erzeugt | Fehlerfälle (rle+, +rle, leere Spec) |
| Statistik mit Gleitkommazahlen | Bytes, Zeilen, Wörter, häufigstes Byte, Shannon-Entropie mit eigenem log2 (Reihenentwicklung statt <cmath>) | Gleitkomma im Assembler, Ausgabe auf vier Nachkommastellen genau |
| Datei-I/O und Kommandozeile | fopen/fread in 4-KB-Blöcken, fünf Befehle, Fehlercodes 1/2/3 | Rückgabecodes und exakte Fehlermeldungen |
Die fünf Befehle des Programms:
minipack enc <spec> <in> <out> Datei kodieren
minipack dec <spec> <in> <out> Datei dekodieren
minipack hash <in> CRC32, FNV-1a 64, Größe
minipack stats <in> Bytes, Zeilen, Wörter, häufigstes Byte, Entropie
minipack selftest 5 Codec-Ketten an einem eingebauten Muster prüfen
Die vier Schwierigkeitsstufen
Aus demselben Quellcode erzeugt ein Build-Skript mit dem Microsoft-Compiler (Visual Studio 2026, C++20) vier Varianten:
| Stufe | Compiler-Optionen | Was der Agent im Binary findet |
|---|---|---|
| A | /Od /Zi + PDB (Debug) | Funktionsnamen, Typen, Variablennamen. ~2.600 Funktionen (Debug-STL) |
| B | /O2 /Zi + PDB | Namen, aber optimierter Code mit Inlining |
| C | /O2 ohne PDB | keine Namen, nur Laufzeit-Typinformation (RTTI) der Klassen und Textkonstanten. ~200 Funktionen |
| D | /O2 /GR- ohne PDB | gar nichts außer Strings |
Stufe C ist das realistische Szenario: ein ganz normales Release-Programm, wie es
ausgeliefert wird. Alle Funktionen heißen dort nur FUN_1400041e0. Der Agent muss
allein aus Pseudocode und Assembler erschließen, was eine Funktion tut. Stufe A hat unser Modell
schon früher gelöst (120/120, allerdings in rund 1,5 Stunden Analyse plus Thinking). Stufen B und
D haben wir noch nicht getestet. Alle Ergebnisse in diesem Artikel beziehen sich auf
Stufe C.
Die 120 Prüffälle
Eine Prüfsoftware (Python) führt Original und Nachbau mit identischen Argumenten aus und vergleicht drei Dinge: den Exit-Code, die komplette Bildschirmausgabe und, wo eine Datei entsteht, deren SHA-256-Prüfsumme. Ein Fall gilt nur als bestanden, wenn alles exakt übereinstimmt. Die Referenz wurde einmalig vom Original aufgenommen.
Sieben Testdateien decken die Randfälle ab:
| Datei | Inhalt | Prüft |
|---|---|---|
empty.bin | 0 Byte | leere Eingaben, Division durch null in der Entropie |
one.bin | ein Byte „A“ | Minimalfall |
text.txt | 4.600 Byte deutscher/lateinischer Text mit Tabs und Windows-Zeilenenden | Zeilen-/Wortzählung, Text-Kodierung |
random.bin | 5.000 Byte Pseudozufall (LCG) | hohe Entropie, RLE vergrößert die Datei |
runs.bin | 300ׄa“, 600×0x00, 256×0xFF … | RLE-Grenze bei 255 Wiederholungen |
ramp.bin | 2.048 Byte 0,1,2,…,255,0,1,… | Delta-Kodierung (alle Differenzen = 1), Byte-Überlauf |
words.txt | " eins zwei\n\ndrei\t vier \n" | Wortgrenzen mit führenden/mehrfachen Leerzeichen |
Daraus ergeben sich die 120 Fälle:
| Gruppe | Anzahl | Was genau |
|---|---|---|
| Fehlerpfade | 4 | ohne Argumente (Usage-Text, Code 1), unbekannter Befehl, fehlende Datei (Code 3), selftest |
| Ungültige Codec-Spezifikationen | 4 | zip, rle+, +rle, leerer String → Fehlermeldung, Code 2 |
hash | 7 | je Testdatei: CRC32, FNV-1a 64, Größe |
stats | 7 | je Testdatei: Bytes, Zeilen, Wörter, häufigstes Byte, Entropie auf 4 Stellen |
enc | 49 | 7 Codec-Ketten × 7 Dateien: Ausgabezeile und SHA-256 der kodierten Datei |
Roundtrip (enc → dec) | 49 | 7 × 7: die Kodierung des Nachbaus wird wieder dekodiert, Ergebnis muss mit dem Original übereinstimmen |
| Summe | 120 |
Die sieben Codec-Ketten: rle, delta, xor (Standardschlüssel),
xor:secret, delta+rle, xor:k+delta+rle und rle+rle
(doppelte Lauflängenkodierung).
Ein erstes Grundgerüst mit main und Fehlermeldungen brachte in unseren Läufen 2 bis 26
von 120. Für 120 müssen alle Klassen, Algorithmen, Konstanten und Ausgabeformate exakt stimmen.
Die Werkzeuge des Agenten
| Werkzeug | Rolle |
|---|---|
OpenCode (opencode run --auto) | Agenten-Rahmen: Gesprächsschleife, Dateiwerkzeuge, Kontextverwaltung |
| Ghidra 12.1.4 | Disassembler/Decompiler der NSA, analysiert die EXE |
| pyghidra-mcp 0.2.7 | stellt Ghidra als MCP-Server (Model Context Protocol) über HTTP bereit |
| check.ps1 | baut den Nachbau mit demselben Compiler und lässt die 120 Fälle laufen |
pyghidra-mcp bietet 20 Werkzeuge an, darunter list_project_binaries,
search_strings, list_xrefs (wer verweist auf diese Adresse?),
decompile_function (Pseudo-C), disassemble, read_bytes,
gen_callgraph, search_symbols_by_name sowie schreibende wie
rename_function, set_function_prototype und set_comment.
OpenCode bindet sie im Code-Modus ein: Das Modell schreibt kleine
JavaScript-Schnipsel, die die Werkzeuge aufrufen, etwa
const s = await tools.ghidra.search_strings({ binary_name: "/minipack.exe-9344e7", query: "usage" });
return s;
Daneben hat der Agent nur die eingebauten OpenCode-Werkzeuge read, write,
edit, grep, glob und eine Shell, in der genau
ein Befehl erlaubt ist: pwsh -File check.ps1. check.ps1
zeigt bei jedem fehlgeschlagenen Fall die erwartete und die erhaltene Ausgabe, verrät aber nicht
den Quellcode.
Abschottung: Der Agent darf nicht schummeln
Seitdem gilt:
- Jeder Lauf bekommt ein frisches Arbeitsverzeichnis außerhalb des Repositorys (nur
AGENTS.md,check.ps1,opencode.jsonund ein leerer Ordnerrecon/). - In
opencode.json: Zugriff auf fremde Verzeichnisse, Web und Unteragenten verboten, Shell verboten bis aufpwsh -File check.ps1. - Ein Isolationstest versucht vor jeder Testserie gezielt, Quellcode und Referenzdaten zu lesen. Alle Versuche müssen abgelehnt werden.
- Jeder Lauf bekommt ein eigenes, frisches Ghidra-Projekt, damit Umbenennungen aus früheren Läufen nicht helfen.
- Die Endbewertung baut der Testrahmen nach Ablauf selbst aus
recon/minipack.cppund prüft unabhängig vom Agenten. Was der Agent über sich selbst behauptet, zählt nicht.
Ablauf eines Laufs
- llama-swap wechselt in den Coding-Modus und lädt die gewünschte Modellvariante.
- Eine kurze Kontrollfrage prüft, ob der Denkmodus wirklich so eingestellt ist wie gewollt.
- pyghidra-mcp startet, analysiert die EXE vollständig und öffnet erst danach seinen Port.
- OpenCode startet ohne Benutzeroberfläche mit dem Auftrag: „Arbeite AGENTS.md vollständig ab. Stelle keine Rückfragen. Höre erst auf, wenn check.ps1 alle Fälle besteht oder du nachweislich nicht weiterkommst.“
- Alle 15 Sekunden: Ist der Agent fertig? Ist die Stunde um? Höchster Grafikspeicherverbrauch?
- Nach Ende oder Timeout: Ghidra beenden, Nachbau unabhängig bauen und prüfen, Produktion zurückschalten.
Das komplette Protokoll jeder Sitzung (jede Modellantwort, jeder Werkzeugaufruf mit Zeitstempel) wird gespeichert. Daraus stammen die Verlaufszahlen unten.
Die Arbeitsanweisung (AGENTS.md)
Die AGENTS.md beschreibt den Ablauf: main über die Usage-Zeichenkette
finden, Aufrufgraph holen, Funktionen einzeln dekompilieren, in Ghidra benennen und in C++
übertragen. Dazu kommen Regeln, die aus früheren Fehlschlägen entstanden sind: Binary-Namen exakt
übernehmen, Adressen kopieren statt abtippen (ein Modell suchte zehn Minuten nach einer Adresse
mit fehlender Ziffer), Compiler-Bibliothek erkennen, aber nicht nachbauen, Unklares als
// UNKLAR: markieren statt erfinden.
Die entscheidende Ergänzung für diese Testserie war ein neuer Abschnitt „CODE ZUERST“:
Analyse ohne Code bringt 0 Punkte. Sobaldmaindekompiliert ist (spätestens nach 10 Tool-Aufrufen): SOFORT ein kompilierbares Grundgerüst schreiben, mit allen Befehlen, Argumentprüfung, Usage-/Fehlermeldungen und Rückgabecodes, Unverstandenes als Stub. Danncheck.ps1. Danach immer im Wechsel: höchstens 3 Ghidra-Aufrufe, dann Code ändern und prüfen.check.ps1zeigt die erwartete Ausgabe: Formate dort ablesen, statt sie im Disassembly zu suchen.
Teil 2: Die Ergebnisse
Ergebnis A: Geschwindigkeit
Decode-Geschwindigkeit (Token/s, Mittel aus Code, Text und JSON), 64k Kontext:
| Variante | Split | Token/s | VRAM 5060 Ti + 4070 Ti |
|---|---|---|---|
| GSQ IQ3_S (vorherige Produktion) | layer | 29 | 11,3 + 6,7 GB |
| GSQ IQ3_S + MTP | layer | 26–29 | – |
| GSQ IQ3_S | tensor | 47,5 | 9,6 + 8,1 GB |
| GSQ IQ3_S + MTP 3 | tensor | 40 | – |
| Q4_K_M ohne MTP | layer | 24 | – |
| Q4_K_M + MTP 3 | layer | 49 | 14,6 + 8,9 GB |
| Q4_K_M + MTP 3 | tensor | 75 | 12,2 + 10,8 GB |
| Q4_K_M + MTP 4 | tensor | 79,4 (bei 26k Kontext: 65,5) | 12,2 + 10,9 GB |
| UD-Q4_K_XL + MTP 3 | tensor | 75,9 | 12,4 + 11,2 GB |
| Q3_K_M + MTP | – | nicht schneller als Q4_K_M | – |
| Q5_K_M + MTP 3 | tensor | passt nicht (Speicher voll auf der 12-GB-Karte) | – |
Drei Beobachtungen:
-sm tensorbringt jedem Modell 50–65 %. Es ist der einzige Hebel, der unabhängig vom Quant wirkt.- MTP verdoppelt K-Quants ungefähr, bei GSQ bringt es nichts oder schadet sogar.
- Prefill wird mit tensor etwa 20 % langsamer (1.070 statt 1.370 Token/s), weil sich die Karten nach jeder Schicht abstimmen. Bei 30k Kontext kostet das einmalig rund 6 Sekunden pro neuer Anfrage. Die Ersparnis beim Schreiben ist deutlich größer.
Die Akzeptanzrate der MTP-Entwürfe hängt stark von der Textart ab: 70–90 % bei Code und JSON, nur 40–60 % bei Fließtext. Agentenarbeit profitiert also mehr als ein Plauder-Chat.
Ergebnis B: Warum MTP mit IQ3_S nichts bringt
| Quant | Faktor 3 Token / 1 Token |
|---|---|
| GSQ IQ3_S (I-Quant) | 2,08× |
| K-Quants (Q4_K_M, UD-Q4_K_XL, Q5_K_M) | 1,09–1,22× |
I-Quants speichern Gewichte über Codebücher. Für ein einzelnes Token gibt es einen schnellen Rechenweg. Ab zwei Token wechselt llama.cpp auf einen anderen Kernel, der auf diesen Mittelklasse-Karten doppelt so lange braucht. Ein MTP-Schritt mit drei Token kostet dann so viel wie zwei normale Schritte, und der Gewinn ist weg. K-Quants entpacken einfacher, dort kosten drei Token fast dasselbe wie eines.
llama-bench -p 1,2,3,4 laufen lassen.
Liegt der Faktor für 3 Token deutlich über 1,5, lohnt sich MTP nicht.
Ergebnis C: Tool-Calls im Replay
| Variante | Treffer („ok“) |
|---|---|
| GSQ, layer (llama.cpp) | 30/30 |
| GSQ, tensor | 30/30 |
| Q4_K_M + MTP 3, layer | 30/30 |
| Q4_K_M + MTP 3, tensor | 29/30 |
| Zum Vergleich: Q4_K_M unter Ollama (09/2026) | 25/30 |
| Zum Vergleich: GSQ unter Ollama (09/2026) | 19/30 |
Unter llama.cpp sind alle Varianten praktisch gleichwertig. Der Wechsel von Ollama zu llama.cpp hat das Tool-Call-Problem gelöst, nicht die Wahl des Quants. Weder MTP noch tensor verschlechtern die Zuverlässigkeit messbar.
finish_reason prüfen.
Ergebnis D: re-lab Stufe C
| Modell / Einstellung | AGENTS.md | Ergebnis | Was passierte |
|---|---|---|---|
| GSQ, layer | alt | 0/120 | 60 min nur analysiert, kein Code |
| Q4_K_M + MTP | alt | 0/120 | 60 min nur analysiert, kein Code |
| Q4_K_M + MTP 4, tensor | neu | 0/120 | nach 11 min abgebrochen: Tool-Aufruf kam als Text |
| Q4_K_M + MTP 4, tensor, Denken „low“ | neu | 0/120 | nach 12 min abgebrochen: Kontext-Zusammenfassung zu lang |
| GSQ, tensor – Lauf 1 | neu | 119/120 | Timeout nach 60 min, Stand 119 schon nach 32 min |
| GSQ, tensor – Lauf 2 | neu | 120/120 | selbst beendet nach 58 min |
| GSQ, tensor – Lauf 3 | neu | 120/120 | selbst beendet nach 51 min |
| Claude Sonnet (Cloud), zum Vergleich | – | 120/120 | in 11 min |
So verliefen die drei GSQ-Läufe
Ausgewertet aus den Sitzungsprotokollen (Minute nach Start → bestandene Fälle bei check.ps1):
| Lauf 1 | Lauf 2 | Lauf 3 | |
|---|---|---|---|
| Erstes Grundgerüst geschrieben | Min. 12 | Min. 10 | Min. 7 |
| Verlauf der Prüfungen | 26 → 117 (Min. 26) → 119 (Min. 32) | 11 → 113 (Min. 36) → 119 (Min. 48) → 120 (Min. 55) | 2 → 82 (Min. 18) → 104 (Min. 35) → 119 (Min. 45) → 120 (Min. 50) |
| Modellrunden | 77 | 83 | 65 |
| Ghidra-Aufrufe gesamt | 60 | 40 | 30 |
davon decompile_function | 25 | 18 | 18 |
davon read_bytes (Konstanten, Tabellen) | 24 | 10 | 5 |
davon disassemble | 6 | 6 | – |
Code-Änderungen (write/edit) | 13 | 13 | 14 |
| Nachbau | 379 Zeilen | 423 Zeilen | 474 Zeilen |
Der typische Ablauf war in allen drei Läufen gleich: list_project_binaries →
search_strings nach „usage“ → list_xrefs auf die gefundene Adresse führt
zu main → decompile_function(main) → Grundgerüst schreiben →
check.ps1 → dann Codec für Codec dekompilieren und nachbauen. Den letzten Feinschliff
(Ausgabeformate) holte sich das Modell aus den Fehlermeldungen von check.ps1, genau wie
in der Anleitung vorgesehen.
Strukturell sind alle drei Nachbauten nah am Original: alle fünf Klassen
(Codec plus vier Ableitungen) mit vier virtuellen Methoden in der Basisklasse und
12–13 override. Das Modell hat die Klassenhierarchie also allein aus den vtables und
der RTTI erschlossen, nicht nur ein Programm geschrieben, das zufällig dieselbe Ausgabe erzeugt.
Der eine fehlende Fall in Lauf 1 war selftest: Das Modell hatte die Formel, mit der
das Programm sein internes Testmuster aus 1.000 Byte erzeugt, nicht exakt getroffen. Die Codecs
selbst waren richtig, das zeigen alle 98 enc- und Roundtrip-Fälle. Lauf 2 und 3 haben
auch das gelöst.
Zwei Erkenntnisse
Was wir umgestellt haben
Produktiv läuft jetzt GSQ mit Tensor-Split. Gegenüber vorher ist das eine einzige zusätzliche Zeile:
llama-server.exe
-m Qwen3.8-27B-GSQ-RCO-IQ3_S.gguf
-ngl 99
--device CUDA0,CUDA1
-sm tensor
-c 65536
--parallel 1
--jinja
--reasoning-format deepseek
--temp 0.7 --top-k 20 --top-p 0.8 --min-p 0
--presence-penalty 1.5 --repeat-penalty 1.0
Wer maximale Geschwindigkeit für Chat will und auf lange Agentenläufe verzichten kann, nimmt die schnellste Variante:
llama-server.exe
-m Qwen3.8-27B-MTP-Q4_K_M.gguf # oder Qwen3.8-27B-UD-Q4_K_XL.gguf
-ngl 99
--device CUDA0,CUDA1
-sm tensor
-c 65536
--parallel 1
--spec-type draft-mtp
--spec-draft-n-max 4 # 4 für Code/JSON, 2–3 für Fließtext
--jinja
--reasoning-format deepseek
--temp 0.7 --top-k 20 --top-p 0.8 --min-p 0
--presence-penalty 1.5 --repeat-penalty 1.0
Teil 3 (Update 18. Oktober): Vom bestandenen Test zum sauberen Nachbau
120 von 120 Prüffällen heißt: Das Programm verhält sich wie das Original. Ob es auch
innen dem Original entspricht, sagen die Fälle nicht. Genau das will man aber beim
Reverse Engineering: Code, mit dem ein Mensch weiterarbeiten kann, und ein Ghidra-Projekt, in dem
die Funktionen nicht mehr FUN_1400041e0 heißen.
Die Strukturbewertung
Ein zweites Prüfskript (structure.py) bewertet den fertigen Nachbau mit
21 Kriterien. Der Agent sieht diese Bewertung nicht, sie würde Details des
Originals verraten.
| Gruppe | Kriterien (Auswahl) |
|---|---|
| Klassen | Basisklasse mit ≥ 4 Ableitungen, abstrakt, virtueller Destruktor, ≥ 12 override, std::string name() virtuell |
| Speicher | std::unique_ptr für Codecs, Kette als vector<unique_ptr<…>>, kein rohes new/delete |
| Daten | Bytes als std::vector<uint8_t> statt std::string, Statistik als struct, eigenes log2, nur erlaubte Header |
| Abschluss | keine TODO/UNKLAR, keine Hilfsdateien, plan.md ohne offene Punkte, Umfang 0,7–1,5× Original, keine Compiler-Warnungen |
| Ghidra | ≥ 15 Funktionen umbenannt, ≥ 10 kommentiert, ≥ 5 Prototypen gesetzt |
Die Ghidra-Kriterien werden live aus dem Ghidra-Projekt gelesen, bevor der Server beendet wird, nicht aus dem Gesprächsprotokoll. Das Protokoll zählte Umbenennungen in Schleifen nicht mit.
Die drei erfolgreichen Läufe aus Teil 2 erreichten hier nur 10, 13 und 10 von 21:
korrekte Klassenhierarchie, aber rohe Zeiger, Binärdaten in std::string, offene
UNKLAR-Kommentare und ein Ghidra-Projekt, in dem kaum eine Funktion einen Namen hatte.
Versuch 1: mehr Regeln in die Anleitung – jedes Mal schlechter
Der naheliegende Weg war, die fehlenden Pflichten in die AGENTS.md zu schreiben. Das
Ergebnis war eindeutig, nur nicht so wie erhofft:
| Lauf | Änderung an der Anleitung | Prüffälle | Struktur |
|---|---|---|---|
| Referenz (Teil 2) | schlanke „Code zuerst“-Fassung | 119–120 | 10–13 |
| 1 | + Abschlussschritt (Besitz, Typen, Aufräumen, Ghidra) | 120 | 16 |
| 2 | + „jede fertige Funktion sofort in Ghidra dokumentieren“ | 14 | 9 |
| 3 | + Regeln gegen Festbeißen an Einzelfragen | 2 | 7 |
| 4 | + automatisches Fortsetzen, „kurz denken“ | 0 (kompiliert nicht) | 10 |
Jede zusätzliche Pflicht lenkte das Modell von der Hauptaufgabe ab. In Lauf 3 schrieb es einen 66.000 Zeichen langen Gedankenmonolog, lief damit ins Ausgabelimit und beendete die Sitzung, weil eine Antwort ohne Werkzeugaufruf für OpenCode „fertig“ bedeutet. Lauf 1 zeigte aber, dass ein Abschlussschritt nach der eigentlichen Arbeit grundsätzlich etwas bringt.
Versuch 2: zwei Sitzungen
Daraus entstand die Aufteilung:
- Sitzung 1 (bis 120 min) bekommt die unveränderte schlanke Anleitung und nur ein Ziel: alle Fälle bestehen.
- Sobald der Testrahmen 120/120 sieht, startet er eine frische Sitzung 2 (45 min) mit eigener, kurzer Anleitung: Ghidra dokumentieren, Besitz und Typen wie im Original, aufräumen. Jede Änderung, nach der ein Fall fällt, muss sofort zurück.
Sitzung 2 beginnt mit leerem Kontext. Ihr Wissen stammt allein aus dem Code und der
plan.md, die Sitzung 1 geschrieben hat. Endet eine Sitzung vorzeitig (etwa durch einen
Monolog), setzt der Testrahmen sie bis zu fünfmal mit „Weiter nach AGENTS.md“ fort.
Der erste Lauf dieser Art brauchte für Sitzung 1 rund 98 Minuten. Sitzung 2 blieb nur der Rest des gemeinsamen Zeitlimits (20 min), und ihre 19 Umbenennungen gingen verloren, weil das Ghidra-Projekt nicht gespeichert wurde. Danach bekam Sitzung 2 eigene 45 Minuten, und der Testrahmen speichert das Projekt vor dem Beenden.
Im nächsten Lauf war Sitzung 1 schnell wie nie: 120/120 nach 48 Minuten, der schnellste Lauf bisher.
Sitzung 2 dagegen brachte nichts. Das Modell ordnete die Funktionen richtig zu,
verbiss sich dann aber 20 Minuten in eine Nebenfrage (hat der printf-Wrapper vier oder
sechs Parameter?), las dafür Rohbytes und den PE-Header und benannte bis zum Timeout keine einzige
Funktion. Struktur: 9 von 21.
Versuch 3: Sitzung 2 mit vorbereiteter Liste
Die Zuordnung „Adresse → Funktion“ hatte Sitzung 1 längst erarbeitet. Sie stand als Tabelle in
plan.md. Ein kleines Skript (ghidra_list.py) macht daraus beim
Sitzungswechsel eine Arbeitsliste ghidra-liste.md:
| Adresse | Name | Zweck | Signatur |
|-------------|----------------|--------------------------------|-----------------------------------|
| 0x1400041e0 | main | Command-Dispatch, usage, stats | ... |
| 0x1400037c0 | build_pipeline | '+'-Split, ChainCodec | build_pipeline(spec) |
| 0x140002bd0 | crc32_str | CRC-32 (Standard-Tabelle) | crc32_str(data) |
| ... | | | |
Laufzeit- und STL-Funktionen filtert das Skript heraus. Die Anleitung für Sitzung 2 sagt nur noch: Liste sofort abarbeiten, je Aufruf fünf Einträge, nichts vorher prüfen, Unklarheiten nicht untersuchen – ein „vermutlich …“ im Kommentar genügt. Danach die übrigen Codec-Methoden über die vtables ergänzen.
Die Liste nimmt dem Modell keine Analyse ab. Sie enthält nur seine eigenen Ergebnisse, in eine Form gebracht, die zum Abarbeiten einlädt statt zum Weiterforschen.
Ergebnis
| Zwei Sitzungen ohne Liste | Zwei Sitzungen mit Liste | |
|---|---|---|
| Sitzung 1 | 120/120 nach 48 min | 120/120 nach 68 min |
| Sitzung 2 | Timeout nach 45 min | selbst beendet nach ~39 min |
| Ghidra: benannt / kommentiert | 0 / 0 | 23 / 23 |
| Ghidra: Prototypen | 0 | 1 |
| Endprüfung | 120/120 | 120/120 |
| Struktur | 9/21 | 20/21 |
Was Sitzung 2 im Einzelnen erledigt hat:
- Ghidra: alle 16 Einträge der Liste benannt und kommentiert. Dazu kamen 7 weitere Codec-Methoden, die das Modell selbst über die vtables gefunden hat, etwa
RleCodec_encode/_decodeundDeltaCodec_encode/_decode. - Code:
std::unique_ptrundstd::vector<std::unique_ptr<Codec>>, kein rohesnew/deletemehr,std::vector<uint8_t>für Binärdaten,std::string name()als virtuelle Methode. KeineTODO/UNKLARmehr, Hilfsdatei entfernt, keine Warnungen, und alle 120 Fälle bestehen weiterhin. - Das einzige Nein: nur ein Prototyp statt fünf. Das Modell rief
set_function_prototypenur zweimal auf. Die Anleitung sagte „wo möglich“, und das hat es sehr sparsam ausgelegt.
Was wir daraus mitnehmen
- Eine Aufgabe pro Sitzung. GSQ löst die Rekonstruktion zuverlässig, solange die Anleitung schlank bleibt. Pflichten für „danach“ gehören in eine eigene Sitzung mit frischem Kontext.
- Fleißarbeit als Liste vorgeben. Mit offener Aufgabe („dokumentiere die Funktionen“) forschte das Modell lieber weiter. Mit einer Liste arbeitete es die Liste ab und fand danach sogar selbst weitere Funktionen.
- Werkzeugzustand sichern. Ghidra-Änderungen über pyghidra-mcp gehen verloren, wenn der Server ohne
savebeendet wird. Einen Lauf mit 19 Umbenennungen haben wir so verloren, bevor wir es bemerkten.
Konsequenz für den Betrieb
-sm tensor. Bisher stand dort noch Qwen3-Coder 30B. Die Coding-Einstellung entspricht
der aus den re-lab-Läufen: Produktions-Quant, Tensor-Split, Denken mit begrenztem Budget
(--reasoning-budget 2048). Damit läuft jetzt tatsächlich ein Modell für alle
Dienste.
Stolpersteine, die uns Zeit gekostet haben
- Gerätenummern: llama.cpp nummeriert die GPUs anders als
nvidia-smi. Bei uns istCUDA0die 4070 Ti, innvidia-smiist sie GPU 1. Ein Einzel-GPU-Test landete deshalb auf der falschen Karte und lief in einen Speicherfehler. Vorherllama-server --list-devicesprüfen. -sm tensorteilt gleichmäßig auf. Die kleinere Karte bestimmt, was passt. Mit 16 + 12 GB zählen effektiv nur 2 × 12 GB. Daran scheiterte Q5_K_M.-sm rowwird von unserem Build nicht unterstützt („device does not support split buffers“).--parallelbei 1 lassen: Laut Community schrumpft der MTP-Vorteil mit mehreren parallelen Slots stark.- Kontext nicht über 64k erhöhen: Für Qwen3.8 sind in llama.cpp Einbrüche über ~80k und Abbrüche über ~130k gemeldet (#27623, #27756).
- ngram-Spekulation zusätzlich zu MTP (
ngram-mod) machte es bei uns etwas langsamer. - Agententests abschotten: Ein Agent, der den Quellcode finden kann, findet ihn auch.
- pyghidra-mcp findet PDB-Dateien nicht neben der EXE, sondern nur im eigenen Symbolspeicher. Pfad explizit mit
--sym-file-pathübergeben bzw.--no-symbolsfür Stufe C/D. - pyghidra-mcp speichert nicht von selbst. Vor dem Beenden
saveaufrufen, sonst sind alle Umbenennungen und Kommentare weg. - OpenCode nimmt das Projektverzeichnis aus
$PWD. Aus Git Bash gestartet, lief ein Agent deshalb im falschen Repository mit der falschen Konfiguration. Im Startskript$env:PWDexplizit setzen. - Eine Antwort ohne Werkzeugaufruf beendet die OpenCode-Sitzung. Lange Gedankenmonologe, die ins Ausgabelimit laufen, enden genau so. Der Testrahmen setzt solche Sitzungen jetzt automatisch fort.
Und die Hardware?
Der Engpass ist jetzt die 12-GB-Karte. Wir haben zwei bezahlbare Wege geprüft:
- Zweite RTX 5060 Ti 16 GB statt der 4070 Ti: zwei gleiche Karten, tensor nutzt effektiv 32 statt 24 GB, und alles bleibt CUDA. Damit hätte Q4_K_M + MTP Reserve, und Q5_K_M + MTP sollte passen. Das ist der gezielteste Hebel.
- AMD Radeon AI PRO R9700 (32 GB): viel Speicher, aber neben einer NVIDIA-Karte geht in llama.cpp nur schichtweises Aufteilen. Der Tensor-Split-Gewinn wäre weg. Gemessene Werte für dichte 27B-Modelle liegen bei 25–43 Token/s auf einer Karte, der Prefill ist deutlich langsamer als bei NVIDIA. Sinnvoll ist die Karte, wenn man Speicher für größere Modelle braucht, nicht für mehr Tempo.
Fazit
Was wir jedem mit zwei GPUs empfehlen:
-sm tensorausprobieren. Eine Zeile, bei uns gut +60 %, sofort rückgängig zu machen.- Vor MTP
llama-bench -p 1,2,3,4messen. Bei I-Quants auf Mittelklasse-Karten lohnt es sich meist nicht, bei K-Quants fast immer. - Agenten mit langen Läufen testen, nicht nur mit Einzelanfragen, und mehrfach. Unsere schnellste Variante bestand jeden kurzen Test und scheiterte trotzdem im Ernstfall.
- Dem Agenten eine Arbeitsweise vorgeben. „Code zuerst, dann iterieren“ machte aus 0 von 120 dreimal hintereinander 119–120 von 120.
- Ein objektives Prüfkriterium bauen. OpenCode meldete in einem früheren Lauf „erfolgreich beendet“, obwohl keine Zeile Code existierte. Erst die 120 Fälle machen das sichtbar.
- Vor jeder Messung den VRAM prüfen. Ein Dienst, der stündlich neu startet, verfälscht still.
- Lange Agentenaufgaben aufteilen. Eine Sitzung für das Ergebnis, eine zweite für Aufräumen und Dokumentation, und die zweite mit einer vorbereiteten Liste statt einer offenen Aufgabe. Zusätzliche Regeln in einer einzigen Anleitung haben bei uns jedes Mal geschadet.
Unsere Produktion heute: ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF
(IQ3_S) mit -sm tensor, 47,5 Token/s, gleiche Tool-Call-Qualität wie vorher. Ein
Modell für alle Dienste, das jetzt auch ein Release-Binary ohne Symbole in unter einer Stunde
zurück in C++ verwandelt.
Nächste Schritte: den Zwei-Sitzungs-Ablauf mit Liste mehrfach wiederholen (Zuverlässigkeit,
Prototypen als feste Vorgabe), Stufen B und D des re-lab testen, die Q4_K_M-Variante ohne MTP im
Agentenlauf gegenprüfen (Quant oder MTP als Ursache?), --cache-reuse messen und nach
llama.cpp-Updates die Matrix erneut laufen lassen. Messskript (mtp-matrix.ps1) und
Testlabor liegen bereit.