Feat: MTP-Speculative-Decoding-Support + Warm-Set-/Budget-Korrektur (Strix-Halo-Optimierung)

Backend:
- MTP-Drafter-Support (Multi-Token-Prediction): erkennt MTP-Köpfe neben dem Modell
  (mtp-*.gguf / *-assistant, arch gemma4-assistant), schreibt
  --model-draft … --spec-type draft-mtp --spec-draft-n-max statt draft-simple.
  _parse_model erkennt --model-draft/-md; drafts_for/set_spec_draft/find_compatible_draft
  MTP-bewusst. Backward-kompatibel (klassische Drafts unverändert). (config.SPEC_DRAFT_N_MAX)
- register_model: cache-reuse/-cram als Default (Drift-Fix — neue Installs wie der
  hand-getunte Box-Stand), --parallel 2 nur noch für coder (kein ctx-Halbierungs-Footgun),
  Spec-Auto-Attach für alle Rollen self-guarding.
- budget.reserved_gb auf die VERIFIZIERTE llama-swap-Gruppen-Swap-Semantik angeglichen:
  on-demand-Modelle verdrängen die brains-Gruppe und laufen allein (reservieren 0, voller
  GTT); brains-Member reservieren nur die übrigen Member. Tote _persist_members entfernt.

Frontend:
- SpecDraftModal zeigt MTP-Drafter mit MTP-Badge (DraftInfo.mtp).

Docs:
- docs/OPTIMIZATION_PLAN.md: vollständiges Audit + Umsetzungs-Log (W1/W2 Warm-Set,
  gemma ctx/fa/cache-reuse/MTP 52→70,8 t/s, fast --parallel 1, scout=GLM-4.6V-Flash),
  alles live gegen die Box verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-30 18:13:07 +02:00
parent dd99401f3e
commit f655f09ce1
6 changed files with 418 additions and 46 deletions
+79 -23
View File
@@ -15,7 +15,7 @@ from ruamel.yaml.scalarstring import LiteralScalarString
from config import (
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
SPEC_DRAFT_MODEL_PATH, SPEC_TYPE,
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
)
log = logging.getLogger(__name__)
@@ -84,15 +84,15 @@ def _parse_model(name: str, spec: dict) -> dict:
role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)
prompt_cache = "--prompt-cache " in cmd or cmd.endswith("--prompt-cache") or "--prompt-cache-all" in cmd
# Draft-Modell erkennen — klassisch (--spec-draft-model) ODER MTP (--model-draft / -md).
spec_draft = None
if "--spec-draft-model" in cmd:
m_draft = re.search(r"--spec-draft-model\s+([^\s]+)", cmd)
if m_draft:
spec_draft = os.path.basename(m_draft.group(1).replace("'", "").replace('"', ""))
if (m_draft := re.search(r"--(?:spec-draft-model|model-draft)\s+([^\s]+)", cmd)) \
or (m_draft := re.search(r"(?<![\w-])-md\s+([^\s]+)", cmd)):
spec_draft = os.path.basename(m_draft.group(1).replace("'", "").replace('"', ""))
spec_type = None
if (m_st := re.search(r"--spec-type\s+([^\s]+)", cmd)):
spec_type = m_st.group(1)
# Spec ist nur AKTIV, wenn BEIDES gesetzt ist (--spec-draft-model UND --spec-type).
# Spec ist nur AKTIV, wenn BEIDES gesetzt ist (Draft-Modell UND --spec-type).
spec_active = bool(spec_draft and spec_type)
parallel_match = re.search(r"--parallel\s+(\d+)", cmd)
parallel_slots = int(parallel_match.group(1)) if parallel_match else 1
@@ -218,13 +218,20 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
cmd += " --jinja"
role_lower = (role or "").strip().lower()
if role_lower in ("fast", "coder"):
if "--parallel" not in cmd:
cmd += " --parallel 2"
# Nur einen VOCAB-KOMPATIBLEN Draft anhängen (sonst scheitert llama.cpp).
# Bei frischem Install existiert die GGUF noch nicht → kein Draft (später im UI setzbar).
if "--spec-draft-model" not in cmd:
cmd += spec_draft_flags(model_path)
# KV-Cache-Reuse über Turns (Prompt-Cache wiederverwenden) — hilft allen Chat-Modellen
# (Agent-Hirn, Coding, Multi-Turn). Spiegelt die auf der Box bewährten Flags wider, damit
# neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix).
if "--cache-reuse" not in cmd:
cmd += " --cache-reuse 256 -cram 16384"
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
if role_lower == "coder" and "--parallel" not in cmd:
cmd += " --parallel 2"
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
# dem Modell. Self-guarding: ohne kompatiblen/vorhandenen Draft passiert nichts (später im UI
# setzbar). Bei frischem Install existiert die Modell-GGUF noch nicht → ebenfalls kein Draft.
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
cmd += spec_draft_flags(model_path)
cfg.setdefault("models", {})[model_id] = {
"cmd": LiteralScalarString(cmd + "\n"),
@@ -250,15 +257,54 @@ def list_drafts() -> list[dict]:
return out
def _is_mtp_draft(draft_path: str) -> bool:
"""Ist dieser Draft ein MTP-Kopf (Multi-Token-Prediction) statt eines klassischen
Draft-Modells? MTP-Köpfe (z.B. gemma-4) laden mit `--model-draft … --spec-type
draft-mtp` statt `--spec-draft-model … --spec-type draft-simple`. Erkennung am Arch
('…-assistant' / 'mtp') oder Dateinamen ('mtp-*', '*-MTP', '*-assistant')."""
base = os.path.basename(draft_path).lower()
if base.startswith("mtp-") or "-mtp" in base or "assistant" in base:
return True
from services import gguf_meta
arch = ((gguf_meta.fingerprint(draft_path) or {}).get("arch") or "").lower()
return arch.endswith("-assistant") or "mtp" in arch
def _spec_flags_for_draft(draft_path: str) -> str:
"""Korrekte llama-server-Spec-Flags für einen (vocab-kompatiblen) Draft. MTP-Kopf →
`--model-draft … --spec-type draft-mtp --spec-draft-n-max N`; klassischer Draft →
`--spec-draft-model … --spec-type draft-simple`. (Beides nötig, sonst Spec inaktiv.)"""
if _is_mtp_draft(draft_path):
return (f" --model-draft {draft_path} --spec-type draft-mtp"
f" --spec-draft-n-max {SPEC_DRAFT_N_MAX}")
return f" --spec-draft-model {draft_path} --spec-type {SPEC_TYPE}"
def _sibling_mtp_drafters(target_path: str) -> list[str]:
"""MTP-Kopf-GGUFs NEBEN dem Zielmodell (gleicher Ordner): 'mtp-*.gguf', '*-MTP.gguf',
'*-assistant*.gguf'. Per Konstruktion vocab-identisch zum Modell → idealer Draft."""
out: list[str] = []
d = os.path.dirname(target_path)
if os.path.isdir(d):
for f in sorted(os.listdir(d)):
fl = f.lower()
if fl.endswith(".gguf") and (fl.startswith("mtp-") or "-mtp" in fl or "assistant" in fl):
p = os.path.join(d, f)
if p != target_path:
out.append(p)
return out
def find_compatible_draft(target_path: str) -> str | None:
"""Pfad eines vocab-kompatiblen Drafts für target_path, oder None.
Bevorzugt MC_SPEC_DRAFT_MODEL (falls gesetzt+kompatibel), sonst der erste
kompatible Draft in DRAFTS_DIR. None auch, wenn target (noch) fehlt (dann
nicht verifizierbar → bewusst KEIN Draft anhängen)."""
Bevorzugt einen MTP-Kopf NEBEN dem Modell (höchste Qualität, by-construction),
dann MC_SPEC_DRAFT_MODEL (falls gesetzt+kompatibel), sonst der erste kompatible
Draft in DRAFTS_DIR. None auch, wenn target (noch) fehlt (nicht verifizierbar →
bewusst KEIN Draft anhängen)."""
if not target_path or not os.path.exists(target_path):
return None
from services import gguf_meta
candidates: list[str] = []
candidates: list[str] = list(_sibling_mtp_drafters(target_path))
if SPEC_DRAFT_MODEL_PATH and os.path.exists(SPEC_DRAFT_MODEL_PATH):
candidates.append(SPEC_DRAFT_MODEL_PATH)
for d in list_drafts():
@@ -272,19 +318,27 @@ def find_compatible_draft(target_path: str) -> str | None:
def spec_draft_flags(target_path: str) -> str:
"""llama-server-Flags für Speculative Decoding (Draft + --spec-type), oder ''
wenn kein kompatibler Draft existiert. Beides nötig, sonst ist Spec inaktiv."""
wenn kein kompatibler Draft existiert. MTP-bewusst (s. _spec_flags_for_draft)."""
d = find_compatible_draft(target_path)
return f" --spec-draft-model {d} --spec-type {SPEC_TYPE}" if d else ""
return _spec_flags_for_draft(d) if d else ""
def drafts_for(target_path: str) -> dict:
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell.
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
from services import gguf_meta
exists = bool(target_path and os.path.exists(target_path))
drafts = list_drafts()
seen = {d["path"] for d in drafts}
for p in _sibling_mtp_drafters(target_path):
if p not in seen:
drafts.append({"path": p, "filename": os.path.basename(p),
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
"vocab": gguf_meta.fingerprint(p)})
for d in drafts:
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
d["mtp"] = _is_mtp_draft(d["path"])
return {
"target_path": target_path,
"target_exists": exists,
@@ -302,9 +356,11 @@ def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
if not isinstance(spec, dict):
return {"ok": False, "reason": "Modell nicht gefunden"}
cmd = str(spec.get("cmd", ""))
# vorhandene Spec-Flags entfernen (idempotent)
cmd = re.sub(r"\s+--spec-draft-model\s+\S+", "", cmd)
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
if draft_path:
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
@@ -323,7 +379,7 @@ def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
if comp is False else
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
return {"ok": False, "reason": reason}
cmd = cmd.rstrip() + f" --spec-draft-model {draft_path} --spec-type {SPEC_TYPE}"
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
write_config(cfg)