diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..7eeaa6f --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,17 @@ +# Medium-Leistungsvergleich, 28.09.2026 + +Synthetische Textanfragen direkt an llama-server, je eine Aufwärmrunde plus drei Messungen: 87 Eingabe-Token, 256 Ausgabe-Token, Seed 1234, Temperature 1, Top-p 0,95, Top-k 20, Prompt-Cache aus. Keine Benutzerinhalte gelesen oder gespeichert. + +| Aufbau | Ausgabe Token/s (Mittel) | +|---|---:| +| Alter Medium-Container | 64,44 | +| Deck bisher, GPU-Limit 65 | 38,38 | +| Deck volle GPU, CPU-Budget 2 | 64,99 | +| Deck volle GPU, CPU-Budget 6 | 65,06 | +| Deck volle GPU, zurück auf CPU-Budget 2 | 65,03 | + +Deck-Varianten mit identischem Build, gespeichertem Medium-Profil (160000 Kontext, 1 Slot, MTP2 / p-min 0,05, Split85:15) und identischen Requests. Änderung nur Speicherreserve/Offloading und danach CPU-Budget. Bei voller GPU-Ausführung keine CPU-Drosselereignisse während der Messungen. Der erste Versuch mit `docker update --cpus 0` war kein verlässlicher Nachweis eines aufgehobenen Limits (weiterhin Drosselereignisse) und wird nicht als Vergleich verwendet. Der zweite Vergleich prüfte cpu.max ausdrücklich: 200000/100000 und 600000/100000. + +Ursache: Die frühere Reserve max(1024 MiB, 5 Prozent) verhinderte volle GPU-Belegung knapp und reduzierte das Limit auf 65. Die Modellmetadaten enthalten 65 Blöcke einschließlich eines MTP-Blocks. Der geprüfte llama-model.cpp-Code legt bei begrenztem Offloading frühe Modellblöcke auf die CPU; die Ausgabeschicht liegt am Ende der GPU-Auswahl. Die erste Vermutung einer CPU-Ausgabeschicht war daher falsch. Mit max(512 MiB, 2,5 Prozent) besteht die Prognose einschließlich MTP-Kontext und alle Schichten passen. Das RAM-Limit und die übrigen Startparameter bleiben unverändert. + +Kurzer Einzelnutzer-Test, keine Aussage zur Stabilität bei maximal gefülltem Kontext oder paralleler Last. Alter Router: 2 Slots, Vision-Projektor, anderer Build; kein ansonsten identischer Vergleich. Ursachennachweis daher durch die kontrollierten Deck-Varianten. Nach Abschluss: alter Medium-Container gestoppt, Deck läuft mit bisherigem Zwei-Kern-Limit, Testmodell entladen. diff --git a/STUDIO.md b/STUDIO.md index 68f8ea9..fabea86 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -161,3 +161,5 @@ Sprachmodelle besitzen jetzt den Reiter **Testen**: interner Textchat mit Stream ### MTP bei Sprachmodellprofilen Im Profileditor kann MTP aktiviert werden, mit 1–8 Draft-Token und Mindestwahrscheinlichkeit 0–1. Medium-Referenz: 2 und 0,05; Draft-KV ist f16. Bestehende Profile bleiben standardmäßig ohne MTP. Das Modell muss eingebettete MTP-Gewichte enthalten. Der CUDA-Build benötigt den Deck-MTP-Fit-Adapter; neue GUI-Builds installieren ihn automatisch. Die Prognose zählt die gemeinsamen Gewichte einmal sowie Haupt- und MTP-Kontext und deren Compute-Puffer. Ein inkompatibles Modell oder ein alter Build wird nicht still ohne MTP gestartet. MTP garantiert keinen Geschwindigkeitsgewinn. + +GPU-Ausführung: „Automatisch“ darf bei Platzmangel Schichten auf die CPU verlagern. „Vollständig auf GPU“ verlangt alle Schichten einschließlich Ausgabe und bricht sonst vor dem Laden ab. Die Prognose lässt je GPU mindestens 512 MiB bzw. 2,5 % Gesamtspeicher frei (der größere Wert gilt); keine Garantie für beliebige Last. Die Chat-Diagnose kennzeichnet eine begrenzte Layerzahl. diff --git a/chat-test-ui.js b/chat-test-ui.js index bbca1ce..904541d 100644 --- a/chat-test-ui.js +++ b/chat-test-ui.js @@ -15,7 +15,7 @@ window.ChatTestUI=(()=>{ function selection(){const p=profiles.find(p=>p.id===el('chat-profile').value),running=status?.job?.state==='running';el('chat-profile-info').textContent=p?`${p.parameters.context.toLocaleString('de-DE')} Token gesamt · ${p.parameters.slots} Slots · Batch ${p.parameters.batch} / Microbatch ${p.parameters.ubatch}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'Zuerst ein Sprachmodellprofil anlegen.';el('chat-send').disabled=pending||running||!p?.runnable;el('chat-profile').disabled=pending||running;el('chat-clear').disabled=pending||running;el('chat-cancel').disabled=!running;el('chat-unload').disabled=!!status?.scheduler.active_requests;} async function refresh(){try{const [s,h]=await Promise.all([api('chat-tests'),api('hardware').catch(()=>({gpus:[]}))]);if(!root.isConnected)return;status=s;const j=s.job,w=s.worker,plan=w.memory_plan; put(el('chat-state'),`

${e(j?.phase||'Noch kein Chat-Test gestartet.')}

Worker: ${e(w.phase||w.state)} · ${e(w.profile_name||'kein Modell geladen')}
${s.scheduler.active_requests} laufende / ${s.scheduler.waiting_requests} wartende Anfragen

${j?.error?`

${e(j.error)}

`:''}${w.error?`

${e(w.error)}

`:''}${metrics(j)}${j?`

Dauer: ${Math.max(0,Math.round((j.finished_at||Date.now()/1000)-j.started_at))} s

`:''}`); - put(el('chat-memory'),(plan?`

Letzte Prognose für ${e(plan.profile_name||w.profile_name||'das Modell')}

${plan.fits?'Prognose passt einschließlich Reserve':'Prognose passt nicht in den verfügbaren Speicher'} · ${plan.context.toLocaleString('de-DE')} Token / ${plan.slots} Slots · GPU-Layerlimit ${plan.gpu_layers===999?'alle':plan.gpu_layers}

${plan.gpus.map(g=>`

${e(g.name)}: geschätzt ${gib(g.required_mib)} + ${gib(g.reserve_mib)} Reserve / ${gib(g.free_mib)} beim Prüfen frei

`).join('')}

RAM-Budget: ${gib(plan.host_required_mib)} benötigt / ${gib(plan.host_available_mib)} verfügbar

`:'

Noch keine Speicherprognose für einen Modellstart vorhanden.

')+`

Aktuelle GPU-Belegung

${(h.gpus||[]).map(g=>`

${e(g.name)}: ${gib(g.used_mib)} / ${gib(g.total_mib)} belegt · ${g.percent??'—'} % Auslastung

`).join('')||'

GPU-Messwerte nicht verfügbar.

'}`); + put(el('chat-memory'),(plan?`

Letzte Prognose für ${e(plan.profile_name||w.profile_name||'das Modell')}

${plan.fits?'Prognose passt einschließlich Reserve':'Prognose passt nicht in den verfügbaren Speicher'} · ${plan.context.toLocaleString('de-DE')} Token / ${plan.slots} Slots · ${plan.gpu_layers===999?'Alle Schichten einschließlich Ausgabe auf GPU':`GPU-Layerlimit ${plan.gpu_layers} · CPU-Auslagerung möglich (kann stark bremsen)`}

${plan.gpus.map(g=>`

${e(g.name)}: geschätzt ${gib(g.required_mib)} + ${gib(g.reserve_mib)} Reserve / ${gib(g.free_mib)} beim Prüfen frei

`).join('')}

RAM-Budget: ${gib(plan.host_required_mib)} benötigt / ${gib(plan.host_available_mib)} verfügbar

`:'

Noch keine Speicherprognose für einen Modellstart vorhanden.

')+`

Aktuelle GPU-Belegung

${(h.gpus||[]).map(g=>`

${e(g.name)}: ${gib(g.used_mib)} / ${gib(g.total_mib)} belegt · ${g.percent??'—'} % Auslastung

`).join('')||'

GPU-Messwerte nicht verfügbar.

'}`); if(j&&j.id===ownJob){let response=turns[turns.length-1];if(response?.role!=='assistant'){response={role:'assistant',content:'',reasoning:''};turns.push(response);}response.content=j.answer;response.reasoning=j.reasoning;transcript(); if(j.state!=='running'&&finished!==j.id){finished=j.id;if(j.state==='complete'&&j.answer)messages.push({role:'assistant',content:j.answer});else{messages=[];notice('Der nächste Prompt beginnt einen neuen Kontext; der letzte Test wurde nicht vollständig beantwortet.');}if(j.finish_reason==='length')notice('Antwort am Tokenlimit beendet. Bei Bedarf das Antwortlimit erhöhen.');} }selection(); diff --git a/inference.py b/inference.py index aec17f4..0534530 100644 --- a/inference.py +++ b/inference.py @@ -145,11 +145,11 @@ 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(1024,round(g['total_mib']*.05))) 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(str(max(512,round(g['total_mib']*.025))) for g in selected)] 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(1024,round(g['total_mib']*.05)) for g in selected] + 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) @@ -161,10 +161,13 @@ 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,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,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['tensor_split']: + if params.get('gpu_offload')=='full': + layers=999;fits,memory=estimate(layers) + if not fits:raise InferenceError('Vollständige GPU-Ausführung passt einschließlich Reserve nicht. Kontext reduzieren oder automatische CPU-Auslagerung wählen.') + elif params['tensor_split']: # Upstream automatic fitting refuses user tensor_split. Predict the # fixed split instead; bounded layer search preserves ratio/context. layers=999;fits,memory=estimate(layers) diff --git a/profiles-ui.js b/profiles-ui.js index a6188a7..42b82e4 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -1,6 +1,6 @@ window.ProfilesUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const labels={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_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=()=>'

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; 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){ @@ -29,7 +29,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)=>``+available.map(g=>``).join(''); - return `

GPU-Auswahl und Verteilung

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.

`; + return `

GPU-Auswahl und Verteilung

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.

`; } function editor(p=null,id=null){ panelSequence++; @@ -38,7 +38,7 @@ window.ProfilesUI=(()=>{ if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));} 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');button.disabled=true; - try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='chat'){parameters.mtp=values.has('mtp');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.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.');} catch(error){if(root.isConnected)el('profile-error').textContent=error.message;}finally{button.disabled=false;} }; el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'}); diff --git a/profiles.py b/profiles.py index a009d7f..c9a7f0e 100644 --- a/profiles.py +++ b/profiles.py @@ -12,11 +12,12 @@ 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} +CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto'} 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_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.') devices=params['gpu_devices'];split=params['tensor_split'] if not isinstance(devices,list) or len(devices)>16 or any(not isinstance(v,str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',v) for v in devices) or len(set(devices))!=len(devices):raise ValueError('GPUs müssen als eindeutige, geordnete GPU-UUIDs angegeben werden.') diff --git a/test_inference.py b/test_inference.py index 35036e9..d2fc7f8 100644 --- a/test_inference.py +++ b/test_inference.py @@ -46,6 +46,11 @@ class WorkerTests(unittest.TestCase): 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 50000 1000 1000\nCUDA1 2000 1000 1000\nHost 512 0 256\n') as fit,patch('inference.subprocess.Popen') as launch: with self.assertRaises(InferenceError):self.worker.ensure(self.profile) launch.assert_not_called();self.assertLessEqual(fit.call_count,11) + def test_full_gpu_refuses_cpu_fallback(self): + self.profile['parameters']['gpu_offload']='full' + 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 50000 1000 1000\nCUDA1 2000 1000 1000\nHost 512 0 256\n') as fit,patch('inference.subprocess.Popen') as launch: + with self.assertRaisesRegex(InferenceError,'Vollständige GPU'):self.worker.ensure(self.profile) + launch.assert_not_called();self.assertEqual(fit.call_count,1) def test_build_symlink_cannot_escape_deck_state(self): self.runtime.status=lambda:dict(active='outside',builds=[dict(id='outside',backend='CUDA',fit_tool=True)]) (self.root/'runtime'/'outside').symlink_to('/usr') diff --git a/test_model_management.py b/test_model_management.py index 0923669..29d3aa8 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -26,8 +26,8 @@ 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)) - for change in [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'])]: + self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto')) + 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))) def test_profiles_persist_and_update_conflicts(self):