From fac4a12d528c286bda072a2ecd199bdb43b39664 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Wed, 30 Sep 2026 08:58:27 +0200
Subject: [PATCH] Add bounded context ranges and complete GPU split coverage to
auto test
---
AUTO_TEST.md | 27 ++++++++++++++++++
auto-test-ui.js | 6 ++--
auto_test.py | 71 +++++++++++++++++++++++++++++------------------
test_auto_test.py | 26 +++++++++++------
4 files changed, 91 insertions(+), 39 deletions(-)
create mode 100644 AUTO_TEST.md
diff --git a/AUTO_TEST.md b/AUTO_TEST.md
new file mode 100644
index 0000000..7cab7e7
--- /dev/null
+++ b/AUTO_TEST.md
@@ -0,0 +1,27 @@
+# Automatische Einpassung von Sprachmodellen
+
+Unter **Sprachmodelle / Chat → Auto-Test** wählst du ein heruntergeladenes
+GGUF-Modell sowie Start- und End-Kontext. Nur die diskreten Kontextstufen
+innerhalb dieses Bereichs werden geprüft. Der Test verwendet exklusiv die
+Deck-Laufzeit; fremde GPU-Dienste werden weder gestoppt noch verändert.
+
+Pro Stufe folgen zuerst 5080 allein und dann die Dual-GPU-Verteilungen 98:2,
+95:5, 90:10, 85:15, 75:25 und 50:50 mit den Microbatches 128, 64 und 256.
+CPU-Auslagerung wird nur versucht, wenn kein vollständiger GPU-Kandidat
+funktioniert. Erst wenn die Grundverteilungen aller gewählten Stufen geprüft
+sind, sucht Deck bei erfolgreichen Dual-GPU-Kandidaten in Ein-Layer-Schritten
+nach mehr Layern auf der 5080.
+
+Eine abgelehnte Speicherprognose wird als **Prognose, kein Modellstart**
+gekennzeichnet. Fehler beim Modellstart oder bei der Messung beweisen für sich
+allein keinen GPU-Out-of-Memory-Absturz. Ein erfolgreicher Kandidat muss einen
+synthetischen Prompt bis nahe an das Kontextlimit plus 64 Ausgabetoken
+verarbeiten. Die Oberfläche nennt die tatsächlich geprüften Eingabe-Tokens
+und ihren Anteil am Kontext; 100 % sind wegen der Ausgabetoken und
+Template-Reserve nicht möglich.
+
+Der Lauf endet spätestens nach 300 Kandidaten oder zwei Stunden. Wenn dabei
+eine Stufe nicht vollständig geprüft wurde, steht der Zustand auf
+**unvollständig** statt abgeschlossen. Die vollständig geprüften Kontextstufen
+werden angezeigt. Ein gespeichertes Ergebnis wird nicht automatisch am
+API-Endpunkt freigegeben.
diff --git a/auto-test-ui.js b/auto-test-ui.js
index 0be401e..fa49f18 100644
--- a/auto-test-ui.js
+++ b/auto-test-ui.js
@@ -1,11 +1,11 @@
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. 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 html(){return `
Automatische Einpassung & Leistungstest
Priorität: RTX 5080 allein → möglichst kleiner Anteil auf RTX 3060 → erst zuletzt CPU-Auslagerung. Nach den Grundverteilungen aller gewählten Kontextstufen wird die 5080 für passende Dual-GPU-Kandidaten 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:''}${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.failure_stage==='prediction'?'Speicherprognose abgelehnt · kein Modellstart':r.failure_stage==='model_start'?'Modellstart fehlgeschlagen · Ursache nicht als GPU-OOM bewiesen':'Messung fehlgeschlagen · Ursache nicht als GPU-OOM bewiesen'}: ${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,start_context:Number(el('auto-start-context').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 faabe86..2a911e8 100644
--- a/auto_test.py
+++ b/auto_test.py
@@ -5,6 +5,11 @@ from profiles import SCHEMAS,CHAT_GPU_DEFAULTS
from image_test import probe
from inference import InferenceError
+CONTEXT_STEPS=(2048,4096,8192,16384,32768,65536,131072,160000,192000,262144)
+MAX_CANDIDATES=300
+MAX_SECONDS=7200
+GPU_SPLITS=([98,2],[95,5],[90,10],[85,15],[75,25],[50,50])
+
def model_layers(path,mtp):
"""Read bounded GGUF metadata only; never load tensors or tokenizer strings."""
import struct
@@ -68,14 +73,16 @@ class AutoTests:
def update(self,**kw):
with self.lock:self.job.update(kw);self.persist()
def start(self,data):
- if set(data)!={'model_id','max_context','mtp'} or type(data['max_context'])!=int or data['max_context'] not in (4096,8192,16384,32768,65536,131072,160000,192000,262144) or type(data['mtp'])!=bool:raise ValueError('Modell, Kontextgrenze und MTP auswählen.')
+ if set(data) not in ({'model_id','max_context','mtp'},{'model_id','start_context','max_context','mtp'}):raise ValueError('Modell, Start- und Endkontext sowie MTP auswählen.')
+ start_context=data.get('start_context',2048)
+ if type(start_context)!=int or start_context not in CONTEXT_STEPS or type(data['max_context'])!=int or data['max_context'] not in CONTEXT_STEPS or start_context>data['max_context'] or type(data['mtp'])!=bool:raise ValueError('Start- und Endkontext müssen gültige Stufen in aufsteigender Reihenfolge sein.')
model=self.worker.catalog.entry(data['model_id'])
if model['kind']!='chat' or not model['file'].endswith('.gguf') or not model['profile_eligible']:raise ValueError('Ein heruntergeladenes Chat-GGUF auswählen.')
devices=probe();primary=next((g for g in devices if '5080' in g['name']),None);secondary=next((g for g in devices if '3060' in g['name']),None)
if not primary or not secondary:raise ValueError('Dieser Auto-Test benötigt RTX 5080 und RTX 3060. Keine stillschweigende andere GPU-Zuordnung.')
with self.lock:
if self.thread and self.thread.is_alive():raise ValueError('Ein Auto-Test läuft bereits.')
- self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf exklusive Modellreservierung',model_id=model['id'],model_file=model['file'],max_context=data['max_context'],mtp=data['mtp'],results=[],attempts=0,started_at=time.time(),error=None);self.persist()
+ self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf exklusive Modellreservierung',model_id=model['id'],model_file=model['file'],start_context=start_context,max_context=data['max_context'],mtp=data['mtp'],results=[],attempts=0,max_candidates=MAX_CANDIDATES,completed_contexts=[],started_at=time.time(),error=None);self.persist()
self.thread=threading.Thread(target=self.run,args=(primary['uuid'],secondary['uuid']),daemon=True);self.thread.start()
return self.status()
def stop(self):
@@ -99,33 +106,39 @@ class AutoTests:
with self.lock:self.conn=None
def benchmark(self,context):
# Tokenize synthetic text with this model; do not estimate token count from characters.
- target=int(context*.75);unit='alpha beta gamma delta epsilon zeta eta theta 0123456789. '
+ # Keep room for 64 output tokens and template overhead, but verify nearly the full context.
+ target=context-max(128,context//100);unit='alpha beta gamma delta epsilon zeta eta theta 0123456789. '
text=unit*(target//4+1);tokens=self.request('/tokenize',{'content':text,'add_special':True})['tokens']
if len(tokens)=96 or time.time()-self.job['started_at']>7200:
- raise TestBudget()
+ if self.job['attempts']>=self.job.get('max_candidates',MAX_CANDIDATES):raise TestBudget('Kandidatenlimit erreicht')
+ if time.time()-self.job['started_at']>MAX_SECONDS:raise TestBudget('Zeitlimit erreicht')
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()
+ stage='prediction'
try:
self.worker.plan(p,cancel=self.cancel.is_set)
+ stage='model_start'
self.worker.ensure(p,cancel=self.cancel.is_set)
# Small warm-up, then populated-context measurement, bounded output.
+ stage='measurement'
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)
+ timings=self.benchmark(context)
+ row.update(success=True,timings=timings,memory_plan=copy.deepcopy(self.worker.memory_plan),tested_context_fraction=timings.get('prompt_n',0)/context)
successes.append(row)
except Exception as exc:
if self.cancel.is_set():raise InterruptedError()
+ row['failure_stage']=stage
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()
@@ -135,29 +148,33 @@ class AutoTests:
try:
with self.scheduler.lease(('auto-test',self.job['id']),timeout=0,allowed=lambda:not self.cancel.is_set()):
try:
- contexts=[x for x in (2048,4096,8192,16384,32768,65536,131072,160000,192000,262144) if x<=self.job['max_context']]
+ contexts=[x for x in CONTEXT_STEPS if self.job.get('start_context',2048)<=x<=self.job['max_context']]
+ fine_search=[]
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;upper=100
- for tier,devices,split,ratio,offload in candidates:
- 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(phase=f'{context} Kontext · Grundverteilungen',current_context=context)
+ primary_success=self.test_candidate(context,'5080',[primary],'none',[],'full')
+ dual_success=[]
+ for ratio in GPU_SPLITS:
+ rows=self.test_candidate(context,'5080 + 3060',[primary,secondary],'layer',ratio,'full')
+ if rows:dual_success.append(ratio)
+ if not primary_success and not dual_success:
+ self.test_candidate(context,'5080 + 3060 + CPU',[primary,secondary],'layer',[85,15],'auto')
+ self.update(completed_contexts=self.job.get('completed_contexts',[])+[context])
+ if dual_success and not primary_success:fine_search.append((context,dual_success[0]))
+ # Run one-layer refinement only after every requested context has its
+ # coarse 5080/3060 splits, so tuning cannot starve later context levels.
+ for context,ratio in reversed(fine_search):
+ 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));continue
+ for layers,finer in fine_splits(total,ratio,100):
+ result=self.test_candidate(context,'5080 + 3060 · Layer-Feinsuche',[primary,secondary],'layer',finer,'full',layers)
+ if not result: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 TestBudget as exc:self.update(state='partial',phase=f'{exc} · Testreihe unvollständig, 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 cd021fc..f98f5a8 100644
--- a/test_auto_test.py
+++ b/test_auto_test.py
@@ -1,8 +1,8 @@
-import tempfile,unittest,json
+import tempfile,unittest,json,time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock,patch
-from auto_test import AutoTests,model_layers,fine_splits
+from auto_test import AutoTests,model_layers,fine_splits,MAX_CANDIDATES,GPU_SPLITS
from inference import Scheduler,InferenceError
class AutoTestsTests(unittest.TestCase):
@@ -12,19 +12,22 @@ class AutoTestsTests(unittest.TestCase):
self.gpus=[dict(name='RTX 5080',uuid='first'),dict(name='RTX 3060',uuid='second')]
def tearDown(self):self.tmp.cleanup()
def start(self):
- with patch('auto_test.probe',return_value=self.gpus):self.auto.start(dict(model_id='m',max_context=4096,mtp=False))
+ with patch('auto_test.probe',return_value=self.gpus):self.auto.start(dict(model_id='m',start_context=4096,max_context=4096,mtp=False))
self.auto.thread.join(3);self.assertFalse(self.auto.thread.is_alive())
def test_primary_first_results_persist_and_save_explicitly(self):
self.auto.request=Mock(return_value={});self.auto.benchmark=Mock(return_value={'predicted_per_second':40})
- self.start();job=self.auto.status()['job'];self.assertEqual(job['state'],'complete');self.assertEqual(len(job['results']),6)
- self.assertTrue(all(r['parameters']['gpu_devices']==['first'] for r in job['results']));self.profiles.save.assert_not_called();self.assertEqual(self.scheduler.active,0)
+ self.start();job=self.auto.status()['job'];self.assertEqual(job['state'],'complete');self.assertEqual(len(job['results']),3*(1+len(GPU_SPLITS)))
+ self.assertEqual(job['completed_contexts'],[4096]);self.assertEqual(job['results'][0]['parameters']['gpu_devices'],['first']);self.profiles.save.assert_not_called();self.assertEqual(self.scheduler.active,0)
+ tested_splits={tuple(r['parameters']['tensor_split']) for r in job['results']}
+ self.assertIn((75,25),tested_splits);self.assertIn((50,50),tested_splits)
self.auto.save(dict(job_id=job['id'],index=0,name='saved'));self.profiles.save.assert_called_once()
self.assertEqual(AutoTests(self.auto.path,self.profiles,self.worker,self.scheduler).job['state'],'complete')
def test_secondary_before_cpu(self):
def plan(p,cancel):
if p['parameters']['gpu_offload']=='full':raise InferenceError('does not fit')
self.worker.plan.side_effect=plan;self.auto.request=Mock(return_value={});self.auto.benchmark=Mock(return_value={'predicted_per_second':10});self.start()
- rows=self.auto.job['results'];first_success=next(i for i,r in enumerate(rows) if r['success']);self.assertEqual(first_success,18);self.assertEqual(rows[first_success]['parameters']['gpu_offload'],'auto')
+ rows=self.auto.job['results'];first_success=next(i for i,r in enumerate(rows) if r['success']);self.assertEqual(first_success,3*(1+len(GPU_SPLITS)));self.assertEqual(rows[first_success]['parameters']['gpu_offload'],'auto')
+ self.assertEqual(rows[0]['failure_stage'],'prediction')
def test_busy_lease_does_not_stop_another_worker(self):
with self.scheduler.lease(('other',)):
self.worker.reset_mock();self.start();self.assertEqual(self.auto.job['state'],'failed');self.worker.stop.assert_not_called()
@@ -32,10 +35,15 @@ class AutoTestsTests(unittest.TestCase):
self.worker.plan.side_effect=lambda *a,**k:self.auto.cancel.set();self.auto.request=Mock(side_effect=InterruptedError());self.start();self.assertEqual(self.auto.job['state'],'cancelled');self.assertEqual(self.scheduler.active,0);self.profiles.save.assert_not_called()
def test_validation_and_failed_result_save(self):
with self.assertRaises(ValueError):self.auto.start(dict(model_id='m',max_context=True,mtp=False))
+ with self.assertRaises(ValueError):self.auto.start(dict(model_id='m',start_context=65536,max_context=8192,mtp=False))
self.auto.job={'id':'a','model_id':'m','results':[{'success':False}]}
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)
+ self.auto.request=Mock(side_effect=[{'tokens':list(range(4000))},{'timings':{'prompt_n':3968,'predicted_n':64,'predicted_per_second':12}}]);r=self.auto.benchmark(4096);self.assertEqual(r['prompt_n'],3968);self.assertEqual(len(self.auto.request.call_args.args[1]['prompt']),3968)
+ def test_budget_exhaustion_is_not_reported_as_complete(self):
+ self.auto.job=dict(id='j',model_id='m',start_context=65536,max_context=65536,mtp=False,attempts=MAX_CANDIDATES,results=[],completed_contexts=[],started_at=time.time(),max_candidates=MAX_CANDIDATES)
+ self.auto.run('first','second')
+ self.assertEqual(self.auto.job['state'],'partial');self.assertEqual(self.auto.job['completed_contexts'],[])
class FineTests(unittest.TestCase):
def test_single_layer_steps_between_coarse_candidates(self):
import math
@@ -55,12 +63,12 @@ class FineTests(unittest.TestCase):
def test_refinement_integrated_after_first_fit(self):
fixture=AutoTestsTests();fixture.setUp()
try:
- a=fixture.auto;a.job=dict(id='j',model_id='m',max_context=4096,mtp=True,attempts=0,results=[],started_at=0)
+ a=fixture.auto;a.job=dict(id='j',model_id='m',start_context=4096,max_context=4096,mtp=True,attempts=0,results=[],completed_contexts=[],started_at=time.time())
seen=[]
def candidate(context,tier,devices,split,ratio,offload,layer_count=None):
seen.append((context,ratio,layer_count));return [True] if ratio and ratio[0]<=90 else []
a.test_candidate=candidate;fixture.worker.catalog.root=Path('/unused')
with patch('auto_test.model_layers',return_value=66):a.run('first','second')
- self.assertEqual([v[2] for v in seen if v[2] is not None],[61,61])
+ self.assertEqual([v[2] for v in seen if v[2] is not None],[61])
finally:fixture.tearDown()
if __name__=='__main__':unittest.main()