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.
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.
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)=>`
`}`).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('