feat(llm): grosse MoE-Modelle auf kleinen Karten — Experten in den Arbeitsspeicher
Bei MoE-Modellen (z.B. Qwen3-Coder-30B-A3B: 18,6 GB, davon 16,35 GB Experten) rechnet pro Wort nur ein kleiner Teil. llama.cpp kann die Experten in den RAM legen (--cpu-moe / --n-cpu-moe N), der Rest bleibt auf der Karte. Adapter: - Registry-Feld cpuMoe: 0 = Karte, -1 = alle Experten in den RAM, N = die der ersten N Schichten; -2 beim Laden = automatisch aufteilen. - Automatisch: liest nur den Kopf der GGUF bei HuggingFace (HTTP-Range, Tensor-Verzeichnis), rechnet Experten je Schicht + KV-Cache gegen den VRAM der Karte und legt so viele Experten auf die Karte wie passen (12 GB/32k: 16 von 48 Schichten auf der Karte, 32 im RAM). - Kontext-Auswahl: GB-Schaetzung fuer die Karte ohne ausgelagerte Experten; slot meldet moe, cpuMoe, ramMB. KV-Rechnung als _kv_bytes() geteilt. Diagnostic: MoE-Modell zu gross fuer die Karte → Rueckfrage "Experten automatisch aufteilen?"; pro Karte Auswahl Experten: Karte / automatisch / Arbeitsspeicher. Kontextwechsel rechnet die Aufteilung neu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+154
-28
@@ -226,6 +226,31 @@ def _base_config() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _cpu_moe_flag(v) -> str:
|
||||
"""MoE-Modelle: Experten in den Arbeitsspeicher. -1 = alle (--cpu-moe),
|
||||
N > 0 = die der ersten N Schichten (--n-cpu-moe N), 0/leer = alles auf die Karte.
|
||||
Der Rest (Attention, gemeinsame Teile, KV-Cache) bleibt auf der Karte — pro
|
||||
Wort rechnet ein MoE nur wenige Experten, darum bleibt es brauchbar schnell."""
|
||||
try:
|
||||
n = int(v or 0)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if n < 0:
|
||||
return " --cpu-moe"
|
||||
return f" --n-cpu-moe {n}" if n > 0 else ""
|
||||
|
||||
|
||||
def _model_cpu_moe(key: str) -> int:
|
||||
for e in _load_registry():
|
||||
if (e.get("key") or "").strip() == key:
|
||||
try:
|
||||
return int(e.get("cpuMoe") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
m = re.search(r"--n-cpu-moe\s+(\d+)", _model_cmd(key) or "")
|
||||
return int(m.group(1)) if m else (-1 if "--cpu-moe" in (_model_cmd(key) or "") else 0)
|
||||
|
||||
|
||||
def _model_cmd(key: str) -> str:
|
||||
"""llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die
|
||||
Diagnostic geladen) oder aus dem Basis-Template (qwen3-8b & Co.)."""
|
||||
@@ -234,7 +259,8 @@ def _model_cmd(key: str) -> str:
|
||||
quant = (e.get("quant") or "Q4_K_M").strip()
|
||||
return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
|
||||
f"-hf {e['hfRepo'].strip()}:{quant}\n"
|
||||
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja")
|
||||
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja"
|
||||
+ _cpu_moe_flag(e.get("cpuMoe")))
|
||||
spec = (_base_config().get("models") or {}).get(key) or {}
|
||||
return str(spec.get("cmd") or "")
|
||||
|
||||
@@ -534,15 +560,16 @@ VRAM_OVERHEAD_MB = 1400
|
||||
UBATCH = 512
|
||||
|
||||
|
||||
def _gguf_meta(path: str) -> dict:
|
||||
def _gguf_meta(path: str, fsize: int = 0) -> dict:
|
||||
"""Metadaten-Block einer GGUF-Datei (gecacht). Lange String-Listen (der
|
||||
Tokenizer) werden uebersprungen, nicht gespeichert."""
|
||||
Tokenizer) werden uebersprungen, nicht gespeichert. fsize: wahre Groesse,
|
||||
wenn path nur der Anfang der Datei ist (Kopf per HTTP-Range geholt)."""
|
||||
try:
|
||||
mt = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return {}
|
||||
hit = _gguf_cache.get(path)
|
||||
if hit and hit[0] == mt:
|
||||
if hit and hit[0] == mt and not fsize:
|
||||
return hit[1]
|
||||
import struct
|
||||
meta: dict = {}
|
||||
@@ -575,12 +602,35 @@ def _gguf_meta(path: str) -> dict:
|
||||
with open(path, "rb") as f:
|
||||
if f.read(4) != b"GGUF":
|
||||
return {}
|
||||
rd(f, "I"); rd(f, "Q")
|
||||
rd(f, "I")
|
||||
n_tensors = rd(f, "Q")
|
||||
for _ in range(rd(f, "Q")):
|
||||
k = rstr(f)
|
||||
v = rval(f, rd(f, "I"))
|
||||
if v is not None:
|
||||
meta[k] = v
|
||||
# Tensor-Verzeichnis: Groesse der MoE-Experten (…_exps) je Schicht —
|
||||
# fuer die Schaetzung "was bleibt auf der Karte, was geht in den RAM"
|
||||
infos = []
|
||||
for _ in range(n_tensors):
|
||||
name = rstr(f)
|
||||
nd = rd(f, "I")
|
||||
for _ in range(nd):
|
||||
rd(f, "Q")
|
||||
rd(f, "I")
|
||||
infos.append((rd(f, "Q"), name))
|
||||
align = int(meta.get("general.alignment") or 32)
|
||||
data_start = (f.tell() + align - 1) // align * align
|
||||
fsize = fsize or os.path.getsize(path)
|
||||
infos.sort()
|
||||
exp_by_layer: dict = {}
|
||||
for i, (off, name) in enumerate(infos):
|
||||
nxt = infos[i + 1][0] if i + 1 < len(infos) else fsize - data_start
|
||||
if "_exps" in name:
|
||||
mm = re.match(r"blk\.(\d+)\.", name)
|
||||
lay = int(mm.group(1)) if mm else -1
|
||||
exp_by_layer[lay] = exp_by_layer.get(lay, 0) + max(0, nxt - off)
|
||||
meta["_expert_bytes_by_layer"] = exp_by_layer
|
||||
except Exception as e:
|
||||
logger.debug("GGUF %s nicht lesbar: %s", path, e)
|
||||
meta = {}
|
||||
@@ -588,6 +638,74 @@ def _gguf_meta(path: str) -> dict:
|
||||
return meta
|
||||
|
||||
|
||||
def _kv_bytes(meta: dict, ctx: int) -> int:
|
||||
"""KV-Cache (f16) fuer ctx Tokens laut GGUF-Metadaten — Sliding-Window-
|
||||
Schichten (Gemma) halten nur ihr Fenster."""
|
||||
arch = meta.get("general.architecture")
|
||||
if not arch:
|
||||
return 0
|
||||
g = lambda k, d=None: meta.get(f"{arch}.{k}", d)
|
||||
n_layer = int(g("block_count", 0) or 0)
|
||||
if not n_layer:
|
||||
return 0
|
||||
|
||||
def per_layer(v, d=0):
|
||||
return list(v) if isinstance(v, list) else [v if v is not None else d] * n_layer
|
||||
kv_heads = per_layer(g("attention.head_count_kv", g("attention.head_count", 0)))
|
||||
k_len = int(g("attention.key_length", 0) or 0)
|
||||
v_len = int(g("attention.value_length", 0) or 0)
|
||||
if not (k_len and v_len):
|
||||
emb, heads = int(g("embedding_length", 0) or 0), int(g("attention.head_count", 0) or 0)
|
||||
k_len = v_len = emb // heads if heads else 0
|
||||
k_swa = int(g("attention.key_length_swa", k_len) or k_len)
|
||||
v_swa = int(g("attention.value_length_swa", v_len) or v_len)
|
||||
window = int(g("attention.sliding_window", 0) or 0)
|
||||
pattern = g("attention.sliding_window_pattern")
|
||||
swa = per_layer(pattern, False) if isinstance(pattern, list) and window else [False] * n_layer
|
||||
per_tok = sum(int(kv_heads[i]) * (k_len + v_len) * 2 for i in range(n_layer) if not swa[i])
|
||||
swa_tok = sum(int(kv_heads[i]) * (k_swa + v_swa) * 2 for i in range(n_layer) if swa[i])
|
||||
return per_tok * ctx + swa_tok * min(ctx, window + UBATCH)
|
||||
|
||||
|
||||
async def _auto_cpu_moe(repo: str, file: dict, vram_mb: int, ctx: int) -> int:
|
||||
"""Wie viele Schichten mit Experten muessen in den RAM, damit der Rest auf
|
||||
die Karte passt? Holt nur den Kopf der GGUF (Metadaten + Tensor-Verzeichnis,
|
||||
wenige MB per HTTP-Range) — die Datei selbst laedt erst llama.cpp.
|
||||
0 = passt komplett, -1 = alle Experten in den RAM (oder unbekannt)."""
|
||||
import tempfile
|
||||
url = f"https://huggingface.co/{repo}/resolve/main/{file['path']}"
|
||||
headers = {"Range": "bytes=0-41943039"}
|
||||
if HF_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as c:
|
||||
r = await c.get(url, headers=headers)
|
||||
r.raise_for_status()
|
||||
with tempfile.NamedTemporaryFile(suffix=".gguf", delete=False) as tf:
|
||||
tf.write(r.content)
|
||||
tmp = tf.name
|
||||
try:
|
||||
meta = _gguf_meta(tmp, fsize=int(file.get("size") or 0))
|
||||
finally:
|
||||
_gguf_cache.pop(tmp, None)
|
||||
os.unlink(tmp)
|
||||
except Exception as e:
|
||||
logger.warning("MoE-Aufteilung fuer %s nicht berechenbar (%s) — alle Experten in den RAM", repo, e)
|
||||
return -1
|
||||
exp = meta.get("_expert_bytes_by_layer") or {}
|
||||
if not exp:
|
||||
return 0 # kein MoE
|
||||
total_mb = int(file.get("size") or 0) / 1048576
|
||||
exp_mb = sum(exp.values()) / 1048576
|
||||
budget = vram_mb * 0.92 - VRAM_OVERHEAD_MB - _kv_bytes(meta, ctx) / 1048576 - (total_mb - exp_mb)
|
||||
per_layer = exp_mb / len(exp)
|
||||
on_gpu = max(0, int(budget // per_layer)) if per_layer else 0
|
||||
n = max(0, len(exp) - on_gpu)
|
||||
logger.info("MoE %s: %d von %d Schichten mit Experten in den RAM (Karte %d MB, ctx %d)",
|
||||
repo, n, len(exp), vram_mb, ctx)
|
||||
return -1 if n >= len(exp) else n
|
||||
|
||||
|
||||
def _model_files(key: str) -> tuple:
|
||||
"""(Gewichte.gguf, mmproj.gguf|None) eines Modells im HF-Cache unter /models
|
||||
— aus "-hf org/repo:QUANT" bzw. "-m pfad" im llama-server-Aufruf."""
|
||||
@@ -624,35 +742,29 @@ def _ctx_profile(key: str) -> dict:
|
||||
if not n_layer:
|
||||
return {}
|
||||
|
||||
def per_layer(v, d=0):
|
||||
return list(v) if isinstance(v, list) else [v if v is not None else d] * n_layer
|
||||
|
||||
kv_heads = per_layer(g("attention.head_count_kv", g("attention.head_count", 0)))
|
||||
k_len = int(g("attention.key_length", 0) or 0)
|
||||
v_len = int(g("attention.value_length", 0) or 0)
|
||||
if not (k_len and v_len):
|
||||
emb, heads = int(g("embedding_length", 0) or 0), int(g("attention.head_count", 0) or 0)
|
||||
k_len = v_len = emb // heads if heads else 0
|
||||
k_swa = int(g("attention.key_length_swa", k_len) or k_len)
|
||||
v_swa = int(g("attention.value_length_swa", v_len) or v_len)
|
||||
window = int(g("attention.sliding_window", 0) or 0)
|
||||
pattern = g("attention.sliding_window_pattern")
|
||||
swa = per_layer(pattern, False) if isinstance(pattern, list) else [False] * n_layer
|
||||
if not window:
|
||||
swa = [False] * n_layer
|
||||
|
||||
per_tok = sum(int(kv_heads[i]) * (k_len + v_len) * 2 for i in range(n_layer) if not swa[i])
|
||||
swa_tok = sum(int(kv_heads[i]) * (k_swa + v_swa) * 2 for i in range(n_layer) if swa[i])
|
||||
files_mb = (os.path.getsize(weights) + (os.path.getsize(mmproj) if mmproj else 0)) / 1048576
|
||||
ctx_max = int(g("context_length", 0) or 0)
|
||||
exp_by_layer = meta.get("_expert_bytes_by_layer") or {}
|
||||
moe = bool(exp_by_layer) or int(g("expert_count", 0) or 0) > 0
|
||||
cpu_moe = _model_cpu_moe(key) if moe else 0
|
||||
if cpu_moe:
|
||||
layers = sorted(exp_by_layer) if cpu_moe < 0 else sorted(exp_by_layer)[:cpu_moe]
|
||||
ram_mb = sum(exp_by_layer[l] for l in layers) / 1048576
|
||||
else:
|
||||
ram_mb = 0.0
|
||||
gpu_files_mb = files_mb - ram_mb
|
||||
steps = [c for c in CTX_STEPS if not ctx_max or c <= ctx_max]
|
||||
if ctx_max and ctx_max not in steps:
|
||||
steps.append(ctx_max) # krummes Maximum (z.B. 40960) mit anbieten
|
||||
opts = []
|
||||
for c in steps:
|
||||
kv_mb = (per_tok * c + swa_tok * min(c, window + UBATCH)) / 1048576
|
||||
opts.append({"ctx": c, "estMB": int(files_mb + kv_mb + VRAM_OVERHEAD_MB)})
|
||||
return {"ctxMax": ctx_max, "options": opts}
|
||||
kv_mb = _kv_bytes(meta, c) / 1048576
|
||||
opts.append({"ctx": c, "estMB": int(gpu_files_mb + kv_mb + VRAM_OVERHEAD_MB)})
|
||||
out = {"ctxMax": ctx_max, "options": opts, "moe": moe, "cpuMoe": cpu_moe}
|
||||
if moe:
|
||||
out["expertsMB"] = int(sum(exp_by_layer.values()) / 1048576)
|
||||
out["ramMB"] = int(ram_mb)
|
||||
return out
|
||||
|
||||
|
||||
async def _vram_used() -> dict:
|
||||
@@ -1112,10 +1224,24 @@ async def _handle_provision(ws, payload: dict) -> None:
|
||||
if quant != wanted.upper():
|
||||
quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}"
|
||||
logger.info("Karte %s: %s", card, quant_note)
|
||||
ctx_req = int(payload.get("ctx") or 8192)
|
||||
try:
|
||||
cpu_moe = int(payload.get("cpuMoe") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cpu_moe = 0
|
||||
if cpu_moe == -2:
|
||||
# "automatisch verteilen": so viele Experten auf die Karte wie passen
|
||||
qfiles = _files_for_quant(repo_files, quant) if repo_files else []
|
||||
vram = int(((await _gpu_names()).get(card) or {}).get("vramMB") or 0)
|
||||
if len(qfiles) == 1 and vram:
|
||||
cpu_moe = await _auto_cpu_moe(repo, qfiles[0], vram, ctx_req)
|
||||
else:
|
||||
cpu_moe = -1 # gesplittete Datei / Karte unbekannt: alle in den RAM
|
||||
entry = {
|
||||
"key": key, "hfRepo": repo, "quant": quant,
|
||||
"ctx": int(payload.get("ctx") or 8192),
|
||||
"ctx": ctx_req,
|
||||
"ngl": int(payload.get("ngl") or 99),
|
||||
"cpuMoe": cpu_moe,
|
||||
}
|
||||
_save_registry([e for e in _load_registry() if e.get("key") != key] + [entry])
|
||||
elif not _model_cmd(key):
|
||||
|
||||
Reference in New Issue
Block a user