From 37a757fd9f30fafd05f712cfa9eb9c447a6bdee5 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:53:53 +0200 Subject: [PATCH] Refine dual GPU allocation in individual layer steps --- STUDIO.md | 6 ++- auto-test-ui.js | 4 +- auto_test.py | 117 ++++++++++++++++++++++++++++++++++++---------- test_auto_test.py | 29 +++++++++++- 4 files changed, 127 insertions(+), 29 deletions(-) diff --git a/STUDIO.md b/STUDIO.md index 1cd5bd8..f2c46f0 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -168,8 +168,12 @@ GPU-Ausführung: „Automatisch“ darf bei Platzmangel Schichten auf die CPU ve Unter Sprachmodelle → Auto-Test ein GGUF und eine obere Kontextgrenze auswählen. Feste Gerätepriorität RTX 5080, dann RTX 3060; andere Hardware wird ausdrücklich abgelehnt. Kontextstufen: 2048, 4096, 8192, 16384, 32768, 65536, 131072, 160000, 192000, 262144 bis zur gewählten Grenze. Pro Kontext zuerst nur 5080, dann Layer-Splits 95:5, 90:10, 85:15, 75:25, 50:50. Nur wenn kein GPU-Kandidat besteht, wird automatische CPU-Auslagerung mit 85:15 getestet. Die erste erfolgreiche Gerätestufe hat Priorität; keine Behauptung eines globalen Geschwindigkeitsoptimums. -Microbatch 128, 64 und 256, Batch 2048, ein Slot; optional MTP2/p-min0,05. Erst Speicherprognose, dann echter Start, Aufwärmrunde und synthetischer, per Modelltokenizer auf 75 Prozent des Kontexts gefüllter Prompt mit bis zu 64 Ausgabetoken. Speicherung nur von Parametern, Prognose, Messwerten und Fehlerstatus; keine Antworten. Keine absichtliche Überschreitung der Speicherreserve. Höchstens 48 Kandidaten bzw. zwei Stunden zwischen Kandidaten; laufende Einzeloperationen haben eigene Zeitlimits. Ein kurzer erfolgreicher Lauf ist kein Vollkontext-/Parallelitäts-Stabilitätsnachweis. +Microbatch 128, 64 und 256, Batch 2048, ein Slot; optional MTP2/p-min0,05. Erst Speicherprognose, dann echter Start, Aufwärmrunde und synthetischer, per Modelltokenizer auf 75 Prozent des Kontexts gefüllter Prompt mit bis zu 64 Ausgabetoken. Speicherung nur von Parametern, Prognose, Messwerten und Fehlerstatus; keine Antworten. Keine absichtliche Überschreitung der Speicherreserve. Höchstens 96 Kandidaten bzw. zwei Stunden zwischen Kandidaten; laufende Einzeloperationen haben eigene Zeitlimits. Ein kurzer erfolgreicher Lauf ist kein Vollkontext-/Parallelitäts-Stabilitätsnachweis. Der gemeinsame Scheduler sperrt die Deck-Modelle während der Testreihe exklusiv. Bereits laufende Anfragen verhindern den Start; fremde GPU-Prozesse werden nicht beendet. Abbrechen schließt die eigene Anfrage, beendet nur die eigenen Testprozesse und gibt die Reservierung frei. Beim App-Neustart wird eine offene Reihe als unterbrochen markiert. Ergebnisse bleiben in auto-tests.json; ein neuer Test ersetzt diese Ergebnisliste. Erfolgreiche Kandidaten lassen sich explizit als neues Profil speichern, ohne Veröffentlichung am Endpunkt. Admin-API: GET /api/v1/auto-tests; POST /start mit model_id, max_context, mtp; POST /cancel mit {}; POST /save mit job_id, index, name (jeweils unter /api/v1/auto-tests). Session und CSRF-Schutz wie Profilverwaltung; API-Bearer gewährt keinen Zugriff. + +### Layer-Feinsuche + +Nach der ersten erfolgreichen Dual-GPU-Stufe erhöht Deck den geplanten Anteil der 5080 jeweils um eine Schicht bis vor die zuvor abgelehnte Verteilung. Die Anzahl wird aus GGUF block_count/nextn_predict_layers einschließlich Ausgabeschicht berechnet; Tensor-Split wird zwischen Rundungsgrenzen gesetzt. Jede Stufe prüft erneut Speicher und Microbatch128/64/256. Wenn alle drei scheitern, endet die Feinsuche. Erfolgreiche Ergebnisse bleiben einzeln speicherbar; mehr 5080-Layer bedeutet nicht zwingend mehr Geschwindigkeit. Die angezeigte Layerzahl ist eine aus Metadaten und llama.cpp-Layer-Split-Regel berechnete Zuordnung, keine Log-Auswertung. Fehlende/ungültige GGUF-Metadaten überspringen die Feinsuche mit sichtbarem Hinweis. Laufende Testreihen werden nicht nachträglich verändert. diff --git a/auto-test-ui.js b/auto-test-ui.js index cce4095..0be401e 100644 --- a/auto-test-ui.js +++ b/auto-test-ui.js @@ -1,10 +1,10 @@ window.AutoTestUI=(()=>{ const e=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{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 html(){return `

Automatische Einpassung & Leistungstest

Priorität: RTX 5080 allein → möglichst kleiner Anteil auf RTX 3060 → erst zuletzt CPU-Auslagerung. Synthetische Tests belegen die Deck-Laufzeit exklusiv; API-Anfragen warten. Fremde GPU-Dienste werden nicht beendet.

Ein Slot, Batch 2048, Microbatch 128 / 64 / 256. Kontextstufen ab 2048; pro Kandidat werden 75 % des Kontextes tatsächlich gefüllt und 64 Token erzeugt. Höchstens 48 Kandidaten bzw. zwei Stunden zwischen Kandidaten. Kein absichtliches Übergehen der Speicherreserve.

Dies ist eine begrenzte Suche, kein Beweis des absoluten Optimums oder der Stabilität bei vollem Kontext und paralleler Last. Erfolgreiche Profile werden nur auf Wunsch gespeichert, nicht am Endpunkt freigegeben.

`;} + function html(){return `

Automatische Einpassung & Leistungstest

Priorität: RTX 5080 allein → möglichst kleiner Anteil auf RTX 3060 → erst zuletzt CPU-Auslagerung. Nach der ersten passenden Dual-GPU-Verteilung wird die 5080 in Ein-Layer-Schritten weiter gefüllt. Synthetische Tests belegen die Deck-Laufzeit exklusiv; API-Anfragen warten. Fremde GPU-Dienste werden nicht beendet.

Ein Slot, Batch 2048, Microbatch 128 / 64 / 256. Kontextstufen ab 2048; pro Kandidat werden 75 % des Kontextes tatsächlich gefüllt und 64 Token erzeugt. Höchstens 96 Kandidaten bzw. zwei Stunden zwischen Kandidaten. Kein absichtliches Übergehen der Speicherreserve.

Dies ist eine begrenzte Suche, kein Beweis des absoluten Optimums oder der Stabilität bei vollem Kontext und paralleler Last. Erfolgreiche Profile werden nur auf Wunsch gespeichert, nicht am Endpunkt freigegeben.

`;} function bind(){const root=document.querySelector('#auto-test'),el=x=>root.querySelector('#'+x);let job=null,last='';const msg=x=>{if(root.isConnected)el('auto-message').textContent=x;}; api('catalog').then(s=>{if(root.isConnected)el('auto-model').innerHTML=s.entries.filter(x=>x.kind==='chat'&&x.profile_eligible&&x.file.endsWith('.gguf')).map(x=>``).join('');}).catch(x=>msg(x.message)); - async function refresh(){try{const s=await api('auto-tests');if(!root.isConnected)return;job=s.job;const running=job?.state==='running';el('auto-start').disabled=running;el('auto-stop').disabled=!running;el('auto-phase').textContent=job?`${job.state} · ${job.phase} · ${job.attempts} Kandidaten${job.error?' · '+job.error:''}`:'Noch kein Auto-Test.';const rows=job?.results||[],signature=JSON.stringify(rows);if(last!==signature){last=signature;const best=rows.reduce((a,r,i)=>r.success&&(!a||r.timings.predicted_per_second>a.speed)?{i,speed:r.timings.predicted_per_second}:a,null);el('auto-results').innerHTML=rows.map((r,i)=>`

${r.context.toLocaleString('de-DE')} Kontext · ${e(r.tier)} ${best?.i===i?'· schnellster gemessener Kandidat':''}

Verteilung ${e(r.parameters.tensor_split.join(':')||'nur 5080')} · Microbatch ${r.parameters.ubatch}

${r.success?`

Prefill ${Number(r.timings.prompt_per_second).toFixed(1)} Token/s · Ausgabe ${Number(r.timings.predicted_per_second).toFixed(1)} Token/s · ${r.timings.prompt_n} Eingabe-Token geprüft

GPU-Layerlimit: ${r.memory_plan.gpu_layers===999?'alle':r.memory_plan.gpu_layers}

`:`

${e(r.error)}

`}
`).join('');root.querySelectorAll('[data-save]').forEach(b=>b.onclick=async()=>{try{await api('auto-tests/save',{job_id:job.id,index:Number(b.dataset.save),name:el('auto-name').value});msg('Profil gespeichert.');}catch(x){msg(x.message);}});}}catch(x){msg(x.message);}finally{if(root.isConnected)setTimeout(refresh,2000);}} + async function refresh(){try{const s=await api('auto-tests');if(!root.isConnected)return;job=s.job;const running=job?.state==='running';el('auto-start').disabled=running;el('auto-stop').disabled=!running;el('auto-phase').textContent=job?`${job.state} · ${job.phase} · ${job.attempts} Kandidaten${job.error?' · '+job.error:''}${job.fine_search_note?' · '+job.fine_search_note:''}`:'Noch kein Auto-Test.';const rows=job?.results||[],signature=JSON.stringify(rows);if(last!==signature){last=signature;const best=rows.reduce((a,r,i)=>r.success&&(!a||r.timings.predicted_per_second>a.speed)?{i,speed:r.timings.predicted_per_second}:a,null);el('auto-results').innerHTML=rows.map((r,i)=>`

${r.context.toLocaleString('de-DE')} Kontext · ${e(r.tier)} ${best?.i===i?'· schnellster gemessener Kandidat':''}

Verteilung ${e(r.parameters.tensor_split.map(v=>Number(v.toFixed(3))).join(':')||'nur 5080')} · Microbatch ${r.parameters.ubatch}${r.primary_gpu_layers?` · geplant ${r.primary_gpu_layers} GPU-Layer auf der 5080 (inkl. Ausgabe/MTP gemäß GGUF)`:""}

${r.success?`

Prefill ${Number(r.timings.prompt_per_second).toFixed(1)} Token/s · Ausgabe ${Number(r.timings.predicted_per_second).toFixed(1)} Token/s · ${r.timings.prompt_n} Eingabe-Token geprüft

GPU-Layerlimit: ${r.memory_plan.gpu_layers===999?'alle':r.memory_plan.gpu_layers}

`:`

${e(r.error)}

`}
`).join('');root.querySelectorAll('[data-save]').forEach(b=>b.onclick=async()=>{try{await api('auto-tests/save',{job_id:job.id,index:Number(b.dataset.save),name:el('auto-name').value});msg('Profil gespeichert.');}catch(x){msg(x.message);}});}}catch(x){msg(x.message);}finally{if(root.isConnected)setTimeout(refresh,2000);}} el('auto-form').onsubmit=async ev=>{ev.preventDefault();try{await api('auto-tests/start',{model_id:el('auto-model').value,max_context:Number(el('auto-context').value),mtp:el('auto-mtp').checked});msg('Auto-Test gestartet.');}catch(x){msg(x.message);}}; el('auto-stop').onclick=()=>api('auto-tests/cancel',{}).then(()=>msg('Abbruch angefordert.')).catch(x=>msg(x.message));refresh(); }return {html,bind}; diff --git a/auto_test.py b/auto_test.py index 932e8fb..faabe86 100644 --- a/auto_test.py +++ b/auto_test.py @@ -5,6 +5,57 @@ from profiles import SCHEMAS,CHAT_GPU_DEFAULTS from image_test import probe from inference import InferenceError +def model_layers(path,mtp): + """Read bounded GGUF metadata only; never load tensors or tokenizer strings.""" + import struct + formats={0:'B',1:'b',2:'H',3:'h',4:'I',5:'i',6:'f',7:'?',10:'Q',11:'q',12:'d'} + with open(path,'rb') as f: + size=Path(path).stat().st_size + def read(n): + b=f.read(n) + if len(b)!=n:raise ValueError('Unvollständige GGUF-Metadaten.') + return b + def num(fmt):return struct.unpack('<'+fmt,read(struct.calcsize('<'+fmt)))[0] + def string(keep=False): + n=num('Q') + if n>size-f.tell() or (keep and n>65536):raise ValueError('Ungültige GGUF-Zeichenfolge.') + if keep:return read(n).decode('utf-8') + f.seek(n,1) + def value(t,keep=False,depth=0): + if t in formats:return num(formats[t]) + if t==8:return string(keep) + if t==9 and depth<2: + subtype=num('I');count=num('Q') + if count>10000000:raise ValueError('GGUF-Array zu groß.') + if subtype in formats: + length=struct.calcsize('<'+formats[subtype])*count + if length>size-f.tell():raise ValueError('Ungültiges GGUF-Array.') + f.seek(length,1) + else: + for _ in range(count):value(subtype,False,depth+1) + return None + raise ValueError('Unbekannter GGUF-Metadatentyp.') + if read(4)!=b'GGUF' or num('I') not in (2,3):raise ValueError('GGUF-Version nicht unterstützt.') + num('Q');count=num('Q');fields={} + if count>100000:raise ValueError('Zu viele GGUF-Felder.') + for _ in range(count): + key=string(True);t=num('I');wanted=key=='general.architecture' or key.endswith(('.block_count','.nextn_predict_layers')) + v=value(t,wanted) + if wanted:fields[key]=v + arch=fields.get('general.architecture');blocks=fields.get(str(arch)+'.block_count');draft=fields.get(str(arch)+'.nextn_predict_layers',0) + if type(blocks)!=int or type(draft)!=int or not 1<=blocks<=4096 or not 0<=draft=96 or time.time()-self.job['started_at']>7200: + raise TestBudget() + params={**{k:v[2] for k,v in SCHEMAS['chat'].items()},**CHAT_GPU_DEFAULTS,'context':context,'slots':1,'batch':2048,'ubatch':micro,'gpu_devices':devices,'split_mode':split,'tensor_split':ratio,'gpu_offload':offload,'mtp':self.job['mtp']} + p=dict(id='auto-'+self.job['id'],revision=self.job['attempts']+1,name='deck-auto-test',kind='chat',model_id=self.job['model_id'],parameters=params) + self.update(attempts=p['revision'],phase=f'{context} Kontext · {tier} · Microbatch {micro}') + row=dict(context=context,tier=tier,parameters=params,success=False,primary_gpu_layers=layer_count) + self.worker.stop() + try: + self.worker.plan(p,cancel=self.cancel.is_set) + self.worker.ensure(p,cancel=self.cancel.is_set) + # Small warm-up, then populated-context measurement, bounded output. + self.request('/completion',dict(prompt='Synthetic warmup.',n_predict=16,temperature=0,cache_prompt=False)) + row.update(success=True,timings=self.benchmark(context),memory_plan=copy.deepcopy(self.worker.memory_plan),tested_context_fraction=.75) + successes.append(row) + except Exception as exc: + if self.cancel.is_set():raise InterruptedError() + row['error']=str(exc) if isinstance(exc,ValueError) else 'Testprozess oder Verbindung fehlgeschlagen; kein eindeutiger OOM-Nachweis.' + finally:self.worker.stop() + with self.lock:self.job['results'].append(row);self.persist() + # Smaller microbatch is useful after rejection; do not skip it. + return successes def run(self,primary,secondary): try: with self.scheduler.lease(('auto-test',self.job['id']),timeout=0,allowed=lambda:not self.cancel.is_set()): @@ -62,35 +138,26 @@ class AutoTests: contexts=[x for x in (2048,4096,8192,16384,32768,65536,131072,160000,192000,262144) if x<=self.job['max_context']] for context in contexts: candidates=[('5080',[primary],'none',[],'full')]+[('5080 + 3060',[primary,secondary],'layer',s,'full') for s in ([95,5],[90,10],[85,15],[75,25],[50,50])]+[('5080 + 3060 + CPU',[primary,secondary],'layer',[85,15],'auto')] - found=False + found=False;upper=100 for tier,devices,split,ratio,offload in candidates: - successes=[] - for micro in (128,64,256): - if self.cancel.is_set():raise InterruptedError() - if self.job['attempts']>=48 or time.time()-self.job['started_at']>7200: - self.update(state='complete',phase='Testbudget erreicht · Teilresultate verfügbar',finished_at=time.time());return - params={**{k:v[2] for k,v in SCHEMAS['chat'].items()},**CHAT_GPU_DEFAULTS,'context':context,'slots':1,'batch':2048,'ubatch':micro,'gpu_devices':devices,'split_mode':split,'tensor_split':ratio,'gpu_offload':offload,'mtp':self.job['mtp']} - p=dict(id='auto-'+self.job['id'],revision=self.job['attempts']+1,name='deck-auto-test',kind='chat',model_id=self.job['model_id'],parameters=params) - self.update(attempts=p['revision'],phase=f'{context} Kontext · {tier} · Microbatch {micro}') - row=dict(context=context,tier=tier,parameters=params,success=False) - self.worker.stop() - try: - self.worker.plan(p,cancel=self.cancel.is_set) - self.worker.ensure(p,cancel=self.cancel.is_set) - # Small warm-up, then populated-context measurement, bounded output. - self.request('/completion',dict(prompt='Synthetic warmup.',n_predict=16,temperature=0,cache_prompt=False)) - row.update(success=True,timings=self.benchmark(context),memory_plan=copy.deepcopy(self.worker.memory_plan),tested_context_fraction=.75) - successes.append(row) - except Exception as exc: - if self.cancel.is_set():raise InterruptedError() - row['error']=str(exc) if isinstance(exc,ValueError) else 'Testprozess oder Verbindung fehlgeschlagen; kein eindeutiger OOM-Nachweis.' - finally:self.worker.stop() - with self.lock:self.job['results'].append(row);self.persist() - # Smaller microbatch is useful after rejection; do not skip it. - if successes:found=True;break + successes=self.test_candidate(context,tier,devices,split,ratio,offload) + if successes: + found=True + if tier=='5080 + 3060': + try: + entry=self.worker.catalog.entry(self.job['model_id']) + total=model_layers(self.worker.catalog.root/entry['id']/('model'+Path(entry['file']).suffix),self.job['mtp']) + except (OSError,ValueError,TypeError) as exc: + self.update(fine_search_note='Layer-Feinsuche nicht verfügbar: '+str(exc));break + for layers,finer in fine_splits(total,ratio,upper): + result=self.test_candidate(context,'5080 + 3060 · Layer-Feinsuche',devices,split,finer,offload,layers) + if not result:break + break + if tier=='5080 + 3060':upper=ratio[0] if not found:break self.update(state='complete',phase='Testreihe abgeschlossen · Ergebnisse sind keine Garantie für beliebige Last',finished_at=time.time()) finally:self.worker.stop() + except TestBudget:self.update(state='complete',phase='Testbudget erreicht · Teilresultate verfügbar',finished_at=time.time()) except Exception as exc:self.update(state='cancelled' if self.cancel.is_set() else 'failed',phase='Abgebrochen' if self.cancel.is_set() else 'Test beendet',error=None if self.cancel.is_set() else str(exc),finished_at=time.time()) def save(self,data): with self.lock: diff --git a/test_auto_test.py b/test_auto_test.py index 3c5167a..cd021fc 100644 --- a/test_auto_test.py +++ b/test_auto_test.py @@ -2,7 +2,7 @@ import tempfile,unittest,json from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock,patch -from auto_test import AutoTests +from auto_test import AutoTests,model_layers,fine_splits from inference import Scheduler,InferenceError class AutoTestsTests(unittest.TestCase): @@ -36,4 +36,31 @@ class AutoTestsTests(unittest.TestCase): with self.assertRaises(ValueError):self.auto.save(dict(job_id='a',index=0,name='x')) def test_prompt_filled_by_token_count(self): self.auto.request=Mock(side_effect=[{'tokens':list(range(4000))},{'timings':{'prompt_n':3072,'predicted_n':64,'predicted_per_second':12}}]);r=self.auto.benchmark(4096);self.assertEqual(r['prompt_n'],3072);self.assertEqual(len(self.auto.request.call_args.args[1]['prompt']),3072) +class FineTests(unittest.TestCase): + def test_single_layer_steps_between_coarse_candidates(self): + import math + values=fine_splits(66,[85,15],90) + self.assertEqual([k for k,_ in values],[58,59]) + for k,split in values:self.assertEqual(math.ceil(66*split[0]/sum(split)),k) + self.assertEqual(fine_splits(66,[95,5],100)[-1][0],65) + def test_gguf_metadata_mtp_and_output(self): + import struct + def text(s):b=s.encode();return struct.pack('