Make per-profile GPU reserve configurable without changing existing defaults

This commit is contained in:
Mikei386
2026-09-28 22:29:24 +02:00
parent 682211e6a9
commit 18a5183719
7 changed files with 36 additions and 10 deletions
+6
View File
@@ -195,3 +195,9 @@ Eine Modellfamilie oder ein vollständiger Hugging-Face-Repository-Link sucht un
### Offizielle llama.cpp-Versionen
Die Update-Abfrage zeigt das aktuelle reguläre Release (`releases/latest`) zuerst und ergänzt die jüngsten Nightlies. Reguläre Tags `vX.Y.Z`, Nightly-Tags `bNNNN` und vollständige Commit-IDs sind zulässig. Reguläre Releases werden mit `LLAMA_BUILD_IS_DEV=OFF`, Nightlies mit `ON` gebaut. Ein reguläres Release ist keine automatische Kompatibilitätszusage für Decks Fit-/MTP-Adapter; Standardwechsel bleiben explizit. Versionsschema: https://github.com/ggml-org/ggml/discussions/1579
### GPU-Reserve pro LLM-Profil
Unter GPU-Auswahl: Automatisch (maximaler Wert aus 512 MiB und 2,5 Prozent je GPU), Manuell (0–32768 MiB je ausgewählter GPU) oder Keine zusätzliche Reserve. Manuelle Werte werden per GPU-UUID gespeichert. Alte Profile verwenden weiter Automatisch. Die Reserve beeinflusst Fit-Ziel und Speicherentscheidung; Modell/KV/MTP sowie die konservative Projektor-Schätzung (Dateigröße plus 2 GiB Arbeitsbedarf) bleiben enthalten. Host-RAM-Grenzen bleiben bestehen. Ohne Reserve ist ein erfolgreicher Modellstart nicht garantiert.
Für einen Versuch ohne CPU-Offloading zusätzlich „Vollständig auf GPU · sonst Start abbrechen“ wählen. Dann gibt es bei negativer Prognose keinen automatischen Layer-Rückfall. „Automatisch“ unter GPU-Ausführung erlaubt ihn weiterhin ausdrücklich. Der Testchat zeigt Layerlimit und Reserve je GPU. Gespeicherte Profile werden durch dieses Update nicht umgestellt.
+4 -3
View File
@@ -161,11 +161,12 @@ class LlamaWorker:
# Never inherit LLAMA_ARG_* or user HF credentials into the model server.
env={k:v for k,v in os.environ.items() if not k.startswith(('LLAMA_','HF_','DECK_'))}
env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in selected),HF_HUB_OFFLINE='1',OMP_NUM_THREADS=str(params['threads']))
common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k','q4_0','--cache-type-v','q4_0','--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(str(max(512,round(g['total_mib']*.025))) for g in selected)]
reserve_mode=params.get('gpu_reserve_mode','auto')
margins=[0 if reserve_mode=='none' else params.get('gpu_reserve_mib',{}).get(g['uuid'],512) if reserve_mode=='manual' else max(512,round(g['total_mib']*.025)) for g in selected]
common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k','q4_0','--cache-type-v','q4_0','--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(map(str,margins))]
if params['tensor_split']:common+=['--tensor-split',','.join(map(str,params['tensor_split']))]
tool=str(directory/'build/bin/llama-fit-params')
mtp_fit=['--deck-mtp'] if mtp else []
margins=[max(512,round(g['total_mib']*.025)) for g in selected]
def estimate(layers,extra=()):
if cancel():raise InferenceError('Modellstart abgebrochen.')
output=self._fit_command([tool]+mtp_fit+common+['--gpu-layers',str(layers),'--fit-print','on']+list(extra),env,generation,cancel)
@@ -180,7 +181,7 @@ class LlamaWorker:
gpu_fits=all(rows['CUDA'+str(i)]+margins[i]<=g['free_mib'] for i,g in enumerate(selected))
need=max(staging,rows['Host']*1024**2+4*GIB)
host_fits=mem.get('MemAvailable',0)>=need+4*GIB and (headroom is None or headroom>=need)
with self.lock:self.memory_plan=dict(profile_name=profile['name'],estimated=True,gpu_layers=layers,full_gpu=layers==999,context=params['context'],slots=params['slots'],fits=gpu_fits and host_fits,host_required_mib=round(need/1024**2),host_available_mib=round(min(mem.get('MemAvailable',0)-4*GIB,headroom if headroom is not None else mem.get('MemAvailable',0))/1024**2),gpus=[dict(name=g.get('name',g['uuid']),uuid=g['uuid'],free_mib=g['free_mib'],required_mib=rows['CUDA'+str(i)],reserve_mib=margins[i]) for i,g in enumerate(selected)])
with self.lock:self.memory_plan=dict(profile_name=profile['name'],estimated=True,reserve_mode=reserve_mode,gpu_layers=layers,full_gpu=layers==999,context=params['context'],slots=params['slots'],fits=gpu_fits and host_fits,host_required_mib=round(need/1024**2),host_available_mib=round(min(mem.get('MemAvailable',0)-4*GIB,headroom if headroom is not None else mem.get('MemAvailable',0))/1024**2),gpus=[dict(name=g.get('name',g['uuid']),uuid=g['uuid'],free_mib=g['free_mib'],required_mib=rows['CUDA'+str(i)],reserve_mib=margins[i]) for i,g in enumerate(selected)])
return gpu_fits and host_fits,rows
extra=[]
if params.get('gpu_offload')=='full':
+5 -5
View File
@@ -1,13 +1,13 @@
window.ProfilesUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const labels={vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
const labels={gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
const html=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
function bind(kind,modelId){
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p><details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p><details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
@@ -30,7 +30,7 @@ window.ProfilesUI=(()=>{
function gpuEditor(params){
const selected=params.gpu_devices||[],available=[...gpus];for(const uuid of selected)if(!available.some(g=>g.uuid===uuid))available.push({uuid,name:'Derzeit nicht verfügbar'});
const options=(value,empty)=>`<option value="">${empty}</option>`+available.map(g=>`<option value="${e(g.uuid)}" ${value===g.uuid?'selected':''}>${e(g.name)} · ${e(g.uuid)}</option>`).join('');
return `<h3>GPU-Auswahl und Verteilung</h3><div class="form-grid"><label>GPU-Ausführung<select name="gpu_offload"><option value="auto" ${(params.gpu_offload||'auto')==='auto'?'selected':''}>Automatisch · CPU-Auslagerung bei Platzmangel</option><option value="full" ${params.gpu_offload==='full'?'selected':''}>Vollständig auf GPU · sonst Start abbrechen</option></select></label><label>Erste GPU · CUDA0<select name="gpu_first">${options(selected[0],'Automatische Auswahl')}</select></label><label>Zweite GPU · CUDA1<select name="gpu_second">${options(selected[1],'Keine zweite GPU')}</select></label><label>GPU-Split<select name="split_mode">${[['none','Keine Aufteilung'],['layer','Layer-Split'],['row','Row-Split']].map(([v,t])=>`<option value="${v}" ${(params.split_mode||'none')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>GPU-Verteilung (erste, zweite GPU)<input name="tensor_split" placeholder="z. B. 85,15" value="${e((params.tensor_split||[]).join(','))}"></label></div><p class="small">Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.</p>`;
return `<h3>GPU-Auswahl und Verteilung</h3><div class="form-grid"><label>GPU-Ausführung<select name="gpu_offload"><option value="auto" ${(params.gpu_offload||'auto')==='auto'?'selected':''}>Automatisch · CPU-Auslagerung bei Platzmangel</option><option value="full" ${params.gpu_offload==='full'?'selected':''}>Vollständig auf GPU · sonst Start abbrechen</option></select></label><label>Zusätzliche GPU-Reserve<select name="gpu_reserve_mode">${[['auto','Automatisch · mindestens 512 MiB je GPU'],['manual','Manuell je GPU'],['none','Keine zusätzliche Reserve']].map(([v,t])=>`<option value="${v}" ${(params.gpu_reserve_mode||'auto')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>Reserve erste GPU (MiB)<input name="reserve_first" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[0]]??512}"></label><label>Reserve zweite GPU (MiB)<input name="reserve_second" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[1]]??512}"></label><label>Erste GPU · CUDA0<select name="gpu_first">${options(selected[0],'Automatische Auswahl')}</select></label><label>Zweite GPU · CUDA1<select name="gpu_second">${options(selected[1],'Keine zweite GPU')}</select></label><label>GPU-Split<select name="split_mode">${[['none','Keine Aufteilung'],['layer','Layer-Split'],['row','Row-Split']].map(([v,t])=>`<option value="${v}" ${(params.split_mode||'none')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>GPU-Verteilung (erste, zweite GPU)<input name="tensor_split" placeholder="z. B. 85,15" value="${e((params.tensor_split||[]).join(','))}"></label></div><p class="small">Manuelle Reserven gelten für die jeweils ausgewählte GPU. Ohne Reserve entfällt nur der zusätzliche GPU-Puffer; Modell, KV-Cache und die konservative Projektor-Prognose bleiben berücksichtigt. OOM bleibt möglich. Für einen Abbruch statt CPU-Auslagerung „Vollständig auf GPU“ wählen. Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.</p>`;
}
function bindProjectorDiscovery(){
const form=el('profile-form'),box=el('projector-discovery'),select=form.elements.vision_projector;
@@ -89,10 +89,10 @@ window.ProfilesUI=(()=>{
if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))};
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" placeholder="z. B. qwen-image-test" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='chat'?gpuEditor(data.parameters)+`<h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.repo)} / ${e(x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
if(kind==='chat')bindProjectorDiscovery();
if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();}
el('profile-close').onclick=()=>el('profile-editor').replaceChildren();
el('profile-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button:not([type=button])');button.disabled=true;
try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.gpu_offload=values.get('gpu_offload');if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');}
try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.gpu_offload=values.get('gpu_offload');if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');}
catch(error){if(root.isConnected)el('profile-error').textContent=error.message;}finally{button.disabled=false;}
};
el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
+5 -1
View File
@@ -12,11 +12,15 @@ SCHEMAS={
'audio':{'speed':(.25,4,1)},
'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)}
}
CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','vision_projector':None,'vision_device':'cpu'}
CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu'}
CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05}
def chat_parameters(params):
params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params}
if params['gpu_reserve_mode'] not in ('auto','manual','none'):raise ValueError('Ungültiger GPU-Reservemodus.')
reserve=params['gpu_reserve_mib']
if not isinstance(reserve,dict) or any(k not in params['gpu_devices'] or type(v) is not int or not 0<=v<=32768 for k,v in reserve.items()):raise ValueError('GPU-Reserve muss je ausgewählter GPU zwischen 0 und 32768 MiB liegen.')
if params['gpu_reserve_mode']=='manual' and (not params['gpu_devices'] or set(reserve)!=set(params['gpu_devices'])):raise ValueError('Manuelle Reserve erfordert einen Wert für jede ausgewählte GPU.')
if params['gpu_offload'] not in ('auto','full'):raise ValueError('Ungültiger GPU-Auslagerungsmodus.')
if type(params['mtp']) is not bool:raise ValueError('MTP muss an oder aus sein.')
if params['vision_projector'] is not None and (not isinstance(params['vision_projector'],str) or not re.fullmatch(r'[a-f0-9]{64}',params['vision_projector'])):raise ValueError('Ungültige Projektor-ID.')
+10
View File
@@ -25,6 +25,16 @@ class WorkerTests(unittest.TestCase):
self.worker.ensure(self.profile)
args=launch.call_args.args[0];env=launch.call_args.kwargs['env']
self.assertEqual(env['CUDA_VISIBLE_DEVICES'],'gpu-first,gpu-second');self.assertEqual(args[args.index('--tensor-split')+1],'85,15');self.assertIn('--kv-unified',args);self.assertIn('--fit-print',fit.call_args.args[0]);self.assertEqual(self.worker.status()['state'],'ready')
def test_reserve_modes_control_full_gpu_boundary(self):
for mode,reserve,passes in [('auto',{},False),('none',{},True),('manual',{'gpu-first':50,'gpu-second':200},True),('manual',{'gpu-first':200,'gpu-second':200},False)]:
self.profile['parameters'].update(gpu_offload='full',gpu_reserve_mode=mode,gpu_reserve_mib=reserve)
with self.patches(),patch('inference.probe',return_value=self.gpus),patch('inference.cgroup_headroom',return_value=32*1024**3),patch.object(self.worker,'_fit_command',return_value='CUDA0 14900 0 0\nCUDA1 4000 0 0\nHost 512 0 256\n') as fit,patch('inference.subprocess.Popen') as launch:
if passes:self.worker.plan(self.profile)
else:
with self.assertRaises(InferenceError):self.worker.plan(self.profile)
launch.assert_not_called()
self.assertEqual(self.worker.memory_plan['gpus'][0]['reserve_mib'],0 if mode=='none' else 512 if mode=='auto' else reserve['gpu-first'])
self.assertEqual(fit.call_count,1)
def test_mtp_requires_adapter_before_loading(self):
self.profile['parameters']['mtp']=True
with patch('inference.subprocess.Popen') as launch:
+1 -1
View File
@@ -26,7 +26,7 @@ class ManagementTests(unittest.TestCase):
req.update(id=saved['id'],revision=1)
self.profiles.save(req)
restarted=Profiles(self.root/'profiles.json',self.catalog)
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',vision_projector=None,vision_device='cpu'))
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',gpu_reserve_mode='auto',gpu_reserve_mib={},vision_projector=None,vision_device='cpu'))
for change in [dict(gpu_offload='unknown'),dict(mtp='yes'),dict(mtp_tokens=0),dict(mtp_tokens=True),dict(mtp_min_p=1.1),dict(gpu_devices=devices*2),dict(tensor_split=[85]),dict(split_mode='none'),dict(top_p=1.1),dict(temperature=float('nan')),dict(top_k=True),dict(tensor_split=[0,100]),dict(gpu_devices=['CUDA0','CUDA1'])]:
with self.subTest(change=change),self.assertRaises(ValueError):self.profiles.save(dict(req,revision=2,parameters=dict(params,**change)))
+5
View File
@@ -12,6 +12,11 @@ class CapabilitiesTests(unittest.TestCase):
self.assertEqual(capabilities({'siblings':[{'rfilename':'mmproj-model.gguf'}]})['vision'],'Projektordatei vorhanden')
def test_projector_is_not_standalone_or_text_encoder(self):
self.assertEqual(file_role('mmproj-qwen3vl.gguf')['role'],'vision_projector');self.assertFalse(file_role('mmproj-qwen3vl.gguf')['profile_eligible'])
def test_reserve_validation(self):
self.assertEqual(chat_parameters({})['gpu_reserve_mode'],'auto')
for value in (-1,32769,True,1.5):
with self.assertRaises(ValueError):chat_parameters({'gpu_devices':['GPU-a'],'gpu_reserve_mode':'manual','gpu_reserve_mib':{'GPU-a':value}})
with self.assertRaises(ValueError):chat_parameters({'gpu_reserve_mode':'manual'})
def test_projector_device_requires_selected_gpu(self):
with self.assertRaises(ValueError):chat_parameters({'vision_device':'GPU-missing'})