feat(llm): Speicherplatz vor dem Download pruefen, klarer Aufteilen-Dialog
llm-adapter: Vor jeder Aenderung an Registry/Belegung rechnet die Box aus,
was vom Modell noch fehlt (fertige Blobs zaehlen nicht), und vergleicht es
mit dem freien Platz minus Puffer (LLM_DISK_RESERVE_GB, Standard 2). Reicht
es nicht, werden halbe Downloads, an denen seit 30 min niemand schreibt,
geloescht und neu gerechnet; reicht es dann immer noch nicht, bricht das
Laden mit klarer Meldung im Download-Fenster ab ("braucht ca. X GB, frei
sind Y GB"). Frueher brach llama.cpp mitten im Download ab, und der Grund
kam ueber llama-swap nicht an.
Diagnostic: Die MoE-Frage war ein confirm(), bei dem "Abbrechen" "ganz auf
die Karte laden" hiess. Jetzt ein Dialog mit drei Knoepfen: Aufteilen
(empfohlen) / Ganz auf die Karte / Abbrechen (laedt nichts). Esc und Klick
daneben brechen ab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+35
-11
@@ -9740,7 +9740,7 @@
|
||||
openLlmDownloads();
|
||||
}
|
||||
|
||||
function onLlmCardSelect(sel) {
|
||||
async function onLlmCardSelect(sel) {
|
||||
const target = sel.dataset.box, gpu = sel.dataset.gpu, current = sel.dataset.current, next = sel.value;
|
||||
sel.blur(); // Fokus weg → aufgeschobenes Neuzeichnen holt den Stand nach
|
||||
const node = _boxName(target);
|
||||
@@ -9755,7 +9755,8 @@
|
||||
if (!m || !m.hfRepo) { alert(`${next} steht nicht im Modell-Katalog — Herkunft unbekannt.`); sel.value = current; return; }
|
||||
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
|
||||
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
|
||||
const cpuMoe = askCpuMoe(m, sl.vramMB || 0);
|
||||
const cpuMoe = await askCpuMoe(m, sl.vramMB || 0);
|
||||
if (cpuMoe === null) { sel.value = current; return; }
|
||||
const warn = (!cpuMoe && vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92)
|
||||
? `\n\n⚠ ${next} ist etwa ${m.sizeGB} GB groß, die Karte hat ${vramGB.toFixed(0)} GB — wahrscheinlich passt es nicht.` : '';
|
||||
const repl = current ? `\n\nErsetzt ${current} — Programme, die es nutzen, verlieren es.` : '';
|
||||
@@ -9829,17 +9830,39 @@
|
||||
// langsam wird. Erkennung am Namen (A3B, A4B, MoE …); sicher weiss es die Box
|
||||
// erst nach dem Laden (slot.moe).
|
||||
function looksMoe(m) { return /(\bA\d+(\.\d+)?B\b|moe|mixtral)/i.test((m.id || '') + ' ' + (m.hfRepo || '')); }
|
||||
/** 0 = normal laden, -2 = Experten automatisch aufteilen, null = abbrechen */
|
||||
/** Promise: 0 = normal laden, -2 = Experten automatisch aufteilen, null = abbrechen.
|
||||
* Eigener Dialog mit drei Knoepfen — bei confirm() hiess "Abbrechen" frueher
|
||||
* "normal laden", und das liest jeder als "gar nicht laden". */
|
||||
function askCpuMoe(m, vramMB) {
|
||||
if (!looksMoe(m) || !vramMB || !m.sizeGB || m.sizeGB * 1024 <= vramMB * 0.85) return 0;
|
||||
return confirm(`${m.id} ist ein MoE-Modell (pro Wort rechnet nur ein kleiner Teil) und mit ~${m.sizeGB} GB `
|
||||
+ `größer als die Karte (${(vramMB / 1024).toFixed(0)} GB).\n\n`
|
||||
+ `Die Experten automatisch zwischen Karte und Arbeitsspeicher aufteilen? So viele wie passen bleiben `
|
||||
+ `auf der Karte, der Rest geht in den RAM der Box. Langsamer als ganz auf der Karte, aber es läuft.\n\n`
|
||||
+ `OK = aufteilen · Abbrechen = normal laden (passt dann vermutlich nicht)`) ? -2 : 0;
|
||||
if (!looksMoe(m) || !vramMB || !m.sizeGB || m.sizeGB * 1024 <= vramMB * 0.85) return Promise.resolve(0);
|
||||
return new Promise(resolve => {
|
||||
const bg = document.createElement('div');
|
||||
bg.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;';
|
||||
const btn = (val, label, sub, primary) => `<button type="button" data-v="${val}" class="btn ${primary ? '' : 'secondary'}"
|
||||
style="display:block;width:100%;text-align:left;padding:8px 12px;margin-top:8px;font-size:12px;">
|
||||
<b>${label}</b><div style="font-weight:normal;opacity:.75;font-size:11px;margin-top:2px;">${sub}</div></button>`;
|
||||
bg.innerHTML = `<div style="background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:460px;width:100%;color:#E0E0F0;font-size:13px;line-height:1.5;">
|
||||
<div style="font-weight:700;margin-bottom:6px;">${escapeHtml(m.id)} ist größer als die Karte</div>
|
||||
<div style="color:#9a9ab5;font-size:12px;">Das Modell braucht ~${m.sizeGB} GB, die Karte hat ${(vramMB / 1024).toFixed(0)} GB.
|
||||
Es ist ein MoE-Modell — pro Wort rechnet nur ein kleiner Teil, deshalb dürfen die „Experten" in den Arbeitsspeicher der Box.</div>
|
||||
${btn(-2, 'Aufteilen (empfohlen)', 'So viele Experten wie passen auf die Karte, der Rest in den RAM. Langsamer, aber es läuft.', true)}
|
||||
${btn(0, 'Ganz auf die Karte', 'Passt vermutlich nicht — die Box setzt die Karte dann von selbst zurück.', false)}
|
||||
${btn('x', 'Abbrechen', 'Nichts laden.', false)}
|
||||
</div>`;
|
||||
const done = v => { document.removeEventListener('keydown', onKey, true); bg.remove(); resolve(v); };
|
||||
const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); done(null); } };
|
||||
bg.addEventListener('click', e => {
|
||||
const b = e.target.closest('[data-v]');
|
||||
if (b) done(b.dataset.v === 'x' ? null : Number(b.dataset.v));
|
||||
else if (e.target === bg) done(null);
|
||||
});
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.body.appendChild(bg);
|
||||
bg.querySelector('[data-v="-2"]').focus();
|
||||
});
|
||||
}
|
||||
|
||||
function provisionModel(i) {
|
||||
async function provisionModel(i) {
|
||||
const m = _llmCatalog[i];
|
||||
if (!m) return;
|
||||
const sel = document.getElementById('llm-cat-box-' + i);
|
||||
@@ -9853,7 +9876,8 @@
|
||||
!confirm(`Karte ${gpu} auf ${tgt.box.node} hält gerade ${tgt.slot.model}.\n\n`
|
||||
+ `${m.id} ersetzt es. Clients, die ${tgt.slot.model} nutzen, verlieren es dann.\n`
|
||||
+ `Die anderen Karten dieser Box starten dabei kurz neu.\n\nErsetzen?`)) return;
|
||||
const cpuMoe = askCpuMoe(m, (tgt && tgt.slot && tgt.slot.vramMB) || 0);
|
||||
const cpuMoe = await askCpuMoe(m, (tgt && tgt.slot && tgt.slot.vramMB) || 0);
|
||||
if (cpuMoe === null) return;
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
|
||||
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx, cpuMoe });
|
||||
|
||||
@@ -29,6 +29,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import httpx
|
||||
@@ -1141,6 +1142,63 @@ def _scan_download(oids: set) -> tuple:
|
||||
return done, active, found
|
||||
|
||||
|
||||
# Freier Platz vor dem Download: llama.cpp bricht bei voller Platte mitten
|
||||
# im Download ab, und der Grund kommt ueber llama-swap nicht an. Vorher
|
||||
# rechnen: was fehlt noch (fertige Blobs zaehlen nicht) gegen den freien
|
||||
# Platz minus Puffer. Halbe Downloads, an denen seit STALE_PARTIAL_S niemand
|
||||
# mehr schreibt, sind Reste abgebrochener Versuche — die duerfen weg.
|
||||
DISK_RESERVE_BYTES = int(float(os.getenv("LLM_DISK_RESERVE_GB", "2")) * 1024**3)
|
||||
STALE_PARTIAL_S = 30 * 60
|
||||
|
||||
|
||||
def _blob_state(oids: set) -> tuple:
|
||||
"""({oid: Bytes fertig}, [(pfad, bytes, mtime)] aller halben Downloads)."""
|
||||
done, partial = {}, []
|
||||
for root, _dirs, fns in os.walk(MODELS_DIR):
|
||||
for fn in fns:
|
||||
path = os.path.join(root, fn)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if fn.endswith(_INPROGRESS):
|
||||
partial.append((path, st.st_size, st.st_mtime))
|
||||
elif fn in oids:
|
||||
done[fn] = st.st_size
|
||||
return done, partial
|
||||
|
||||
|
||||
def _check_disk(files: list) -> tuple:
|
||||
"""(ok, Meldung). Ohne HF-Dateiliste (files leer) wird nicht geprueft."""
|
||||
if not files:
|
||||
return True, ""
|
||||
done, partial = _blob_state({f["oid"] for f in files if f.get("oid")})
|
||||
need = sum(f["size"] for f in files if f.get("oid") not in done)
|
||||
if not need:
|
||||
return True, ""
|
||||
free = shutil.disk_usage(MODELS_DIR).free
|
||||
note = ""
|
||||
if need + DISK_RESERVE_BYTES > free:
|
||||
stale = [(p, b) for p, b, mt in partial if time.time() - mt > STALE_PARTIAL_S]
|
||||
for path, _b in stale:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
if stale:
|
||||
freed = sum(b for _p, b in stale)
|
||||
note = f"Reste abgebrochener Downloads geloescht ({len(stale)} Datei(en), {freed / 1024**3:.1f} GB)"
|
||||
logger.info("Speicherplatz: %s", note)
|
||||
free = shutil.disk_usage(MODELS_DIR).free
|
||||
if need + DISK_RESERVE_BYTES > free:
|
||||
gb = lambda n: f"{n / 1024**3:.1f}".replace(".", ",")
|
||||
return False, (f"Zu wenig Speicherplatz auf {NODE_NAME}: das Modell braucht ca. {gb(need)} GB, "
|
||||
f"frei sind {gb(free)} GB (dazu {gb(DISK_RESERVE_BYTES)} GB Puffer). "
|
||||
f"Nicht mehr gebrauchte Modelle im Katalog entfernen oder Platz auf der Box schaffen."
|
||||
+ (f" ({note})" if note else ""))
|
||||
return True, note
|
||||
|
||||
|
||||
async def _gpu_info(card: str) -> dict:
|
||||
"""Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen')."""
|
||||
if not card:
|
||||
@@ -1258,6 +1316,13 @@ 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)
|
||||
# Vor jeder Aenderung an Registry/Belegung: passt es auf die Platte?
|
||||
disk_ok, disk_note = await asyncio.to_thread(_check_disk, _files_for_quant(repo_files, quant))
|
||||
if not disk_ok:
|
||||
await _emit_progress(ws, key, "error", gpu=card, error=disk_note)
|
||||
return await _provision_result(ws, key, False, gpu=card, error=disk_note)
|
||||
if disk_note:
|
||||
quant_note = "; ".join(x for x in (quant_note, disk_note) if x)
|
||||
ctx_req = int(payload.get("ctx") or 8192)
|
||||
try:
|
||||
cpu_moe = int(payload.get("cpuMoe") or 0)
|
||||
|
||||
Reference in New Issue
Block a user