Add bounded context ranges and complete GPU split coverage to auto test

This commit is contained in:
Mikei386
2026-09-30 08:58:27 +02:00
parent 638202ee86
commit fac4a12d52
4 changed files with 91 additions and 39 deletions
+27
View File
@@ -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.
+3 -3
View File
@@ -1,11 +1,11 @@
window.AutoTestUI=(()=>{ window.AutoTestUI=(()=>{
const e=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const e=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;} 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 `<section id="auto-test"><h2>Automatische Einpassung & Leistungstest</h2><p>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.</p><form id="auto-form" class="card"><label>Heruntergeladenes Sprachmodell<select id="auto-model" required></select></label><label>Bis zu diesem Kontext testen<select id="auto-context">${[4096,8192,16384,32768,65536,131072,160000,192000,262144].map(x=>`<option ${x===16384?'selected':''}>${x}</option>`).join('')}</select></label><label><input id="auto-mtp" type="checkbox"> MTP testen (2 Draft-Token, p-min 0,05; kompatible Gewichte erforderlich)</label><p>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.</p><button id="auto-start">Auto-Test starten</button><button id="auto-stop" type="button" class="secondary">Abbrechen</button></form><p id="auto-message" role="alert"></p><p id="auto-phase" role="status"></p><p>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.</p><label>Name für das zu speichernde Profil<input id="auto-name" value="auto-llm" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" maxlength="64"></label><div id="auto-results"></div></section>`;} function html(){return `<section id="auto-test"><h2>Automatische Einpassung & Leistungstest</h2><p>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.</p><form id="auto-form" class="card"><label>Heruntergeladenes Sprachmodell<select id="auto-model" required></select></label><label>Start-Kontext<select id="auto-start-context">${[2048,4096,8192,16384,32768,65536,131072,160000,192000,262144].map(x=>`<option ${x===8192?'selected':''}>${x}</option>`).join('')}</select></label><label>End-Kontext<select id="auto-context">${[2048,4096,8192,16384,32768,65536,131072,160000,192000,262144].map(x=>`<option ${x===32768?'selected':''}>${x}</option>`).join('')}</select></label><label><input id="auto-mtp" type="checkbox"> MTP testen (2 Draft-Token, p-min 0,05; kompatible Gewichte erforderlich)</label><p>Ein Slot, Batch 2048, Microbatch 128 / 64 / 256. Nur Kontextstufen im gewählten Bereich; pro Kandidat werden bis nahe an das volle Kontextbudget Eingabetoken geprüft und 64 Token erzeugt. Alle Grundverteilungen 5080 allein sowie 98:2, 95:5, 90:10, 85:15, 75:25 und 50:50 werden je Stufe vor der Layer-Feinsuche geprüft. Höchstens 300 Kandidaten oder zwei Stunden; bei Abbruch wird die Stufe als unvollständig markiert. Kein absichtliches Übergehen der Speicherreserve.</p><button id="auto-start">Auto-Test starten</button><button id="auto-stop" type="button" class="secondary">Abbrechen</button></form><p id="auto-message" role="alert"></p><p id="auto-phase" role="status"></p><p>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.</p><label>Name für das zu speichernde Profil<input id="auto-name" value="auto-llm" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" maxlength="64"></label><div id="auto-results"></div></section>`;}
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;}; 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=>`<option value="${e(x.id)}">${e(x.file)}</option>`).join('');}).catch(x=>msg(x.message)); 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=>`<option value="${e(x.id)}">${e(x.file)}</option>`).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)=>`<article class="card"><h3>${r.context.toLocaleString('de-DE')} Kontext · ${e(r.tier)} ${best?.i===i?'· schnellster gemessener Kandidat':''}</h3><p>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)`:""}</p>${r.success?`<p>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</p><p>GPU-Layerlimit: ${r.memory_plan.gpu_layers===999?'alle':r.memory_plan.gpu_layers}</p><button data-save="${i}">Als neues Profil speichern</button>`:`<p>${e(r.error)}</p>`}</article>`).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}/${job.max_candidates||96} Kandidaten · vollständig geprüfte Stufen: ${(job.completed_contexts||[]).map(x=>x.toLocaleString('de-DE')).join(', ')||'keine'}${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)=>`<article class="card"><h3>${r.context.toLocaleString('de-DE')} Kontext · ${e(r.tier)} ${best?.i===i?'· schnellster gemessener Kandidat':''}</h3><p>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)`:""}</p>${r.success?`<p>Prefill ${Number(r.timings.prompt_per_second).toFixed(1)} Token/s · Ausgabe ${Number(r.timings.predicted_per_second).toFixed(1)} Token/s · ${Number(r.timings.prompt_n||0).toLocaleString('de-DE')} / ${r.context.toLocaleString('de-DE')} Eingabe-Token geprüft (${(100*(r.tested_context_fraction||0)).toFixed(1)} %)</p><p>GPU-Layerlimit: ${r.memory_plan.gpu_layers===999?'alle':r.memory_plan.gpu_layers}</p><button data-save="${i}">Als neues Profil speichern</button>`:`<p>${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)}</p>`}</article>`).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-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(); el('auto-stop').onclick=()=>api('auto-tests/cancel',{}).then(()=>msg('Abbruch angefordert.')).catch(x=>msg(x.message));refresh();
}return {html,bind}; }return {html,bind};
})(); })();
+39 -22
View File
@@ -5,6 +5,11 @@ from profiles import SCHEMAS,CHAT_GPU_DEFAULTS
from image_test import probe from image_test import probe
from inference import InferenceError 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): def model_layers(path,mtp):
"""Read bounded GGUF metadata only; never load tensors or tokenizer strings.""" """Read bounded GGUF metadata only; never load tensors or tokenizer strings."""
import struct import struct
@@ -68,14 +73,16 @@ class AutoTests:
def update(self,**kw): def update(self,**kw):
with self.lock:self.job.update(kw);self.persist() with self.lock:self.job.update(kw);self.persist()
def start(self,data): 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']) 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.') 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) 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.') 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: with self.lock:
if self.thread and self.thread.is_alive():raise ValueError('Ein Auto-Test läuft bereits.') 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() self.thread=threading.Thread(target=self.run,args=(primary['uuid'],secondary['uuid']),daemon=True);self.thread.start()
return self.status() return self.status()
def stop(self): def stop(self):
@@ -99,33 +106,39 @@ class AutoTests:
with self.lock:self.conn=None with self.lock:self.conn=None
def benchmark(self,context): def benchmark(self,context):
# Tokenize synthetic text with this model; do not estimate token count from characters. # 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'] text=unit*(target//4+1);tokens=self.request('/tokenize',{'content':text,'add_special':True})['tokens']
if len(tokens)<target:raise InferenceError('Synthetischer Kontext konnte nicht gefüllt werden.') if len(tokens)<target:raise InferenceError('Synthetischer Kontext konnte nicht gefüllt werden.')
result=self.request('/completion',dict(prompt=tokens[:target],n_predict=64,temperature=0,seed=1234,cache_prompt=False,stream=False)) result=self.request('/completion',dict(prompt=tokens[:target],n_predict=64,temperature=0,seed=1234,cache_prompt=False,stream=False))
timing=result.get('timings',{}) timing=result.get('timings',{})
if not timing.get('predicted_n') or timing.get('prompt_n',0)<target*.95:raise InferenceError('Keine vollständige Kontext-/Ausgabemessung zurückgegeben.') if not timing.get('predicted_n') or timing.get('prompt_n',0)<target*.99:raise InferenceError('Keine vollständige Kontext-/Ausgabemessung zurückgegeben.')
return {k:timing[k] for k in ('prompt_n','prompt_ms','prompt_per_second','predicted_n','predicted_ms','predicted_per_second') if k in timing} return {k:timing[k] for k in ('prompt_n','prompt_ms','prompt_per_second','predicted_n','predicted_ms','predicted_per_second') if k in timing}
def test_candidate(self,context,tier,devices,split,ratio,offload,layer_count=None): def test_candidate(self,context,tier,devices,split,ratio,offload,layer_count=None):
successes=[] successes=[]
for micro in (128,64,256): for micro in (128,64,256):
if self.cancel.is_set():raise InterruptedError() if self.cancel.is_set():raise InterruptedError()
if self.job['attempts']>=96 or time.time()-self.job['started_at']>7200: if self.job['attempts']>=self.job.get('max_candidates',MAX_CANDIDATES):raise TestBudget('Kandidatenlimit erreicht')
raise TestBudget() 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']} 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) 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}') 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) row=dict(context=context,tier=tier,parameters=params,success=False,primary_gpu_layers=layer_count)
self.worker.stop() self.worker.stop()
stage='prediction'
try: try:
self.worker.plan(p,cancel=self.cancel.is_set) self.worker.plan(p,cancel=self.cancel.is_set)
stage='model_start'
self.worker.ensure(p,cancel=self.cancel.is_set) self.worker.ensure(p,cancel=self.cancel.is_set)
# Small warm-up, then populated-context measurement, bounded output. # 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)) 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) successes.append(row)
except Exception as exc: except Exception as exc:
if self.cancel.is_set():raise InterruptedError() 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.' row['error']=str(exc) if isinstance(exc,ValueError) else 'Testprozess oder Verbindung fehlgeschlagen; kein eindeutiger OOM-Nachweis.'
finally:self.worker.stop() finally:self.worker.stop()
with self.lock:self.job['results'].append(row);self.persist() with self.lock:self.job['results'].append(row);self.persist()
@@ -135,29 +148,33 @@ class AutoTests:
try: try:
with self.scheduler.lease(('auto-test',self.job['id']),timeout=0,allowed=lambda:not self.cancel.is_set()): with self.scheduler.lease(('auto-test',self.job['id']),timeout=0,allowed=lambda:not self.cancel.is_set()):
try: 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: 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')] self.update(phase=f'{context} Kontext · Grundverteilungen',current_context=context)
found=False;upper=100 primary_success=self.test_candidate(context,'5080',[primary],'none',[],'full')
for tier,devices,split,ratio,offload in candidates: dual_success=[]
successes=self.test_candidate(context,tier,devices,split,ratio,offload) for ratio in GPU_SPLITS:
if successes: rows=self.test_candidate(context,'5080 + 3060',[primary,secondary],'layer',ratio,'full')
found=True if rows:dual_success.append(ratio)
if tier=='5080 + 3060': 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: try:
entry=self.worker.catalog.entry(self.job['model_id']) 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']) total=model_layers(self.worker.catalog.root/entry['id']/('model'+Path(entry['file']).suffix),self.job['mtp'])
except (OSError,ValueError,TypeError) as exc: except (OSError,ValueError,TypeError) as exc:
self.update(fine_search_note='Layer-Feinsuche nicht verfügbar: '+str(exc));break self.update(fine_search_note='Layer-Feinsuche nicht verfügbar: '+str(exc));continue
for layers,finer in fine_splits(total,ratio,upper): for layers,finer in fine_splits(total,ratio,100):
result=self.test_candidate(context,'5080 + 3060 · Layer-Feinsuche',devices,split,finer,offload,layers) result=self.test_candidate(context,'5080 + 3060 · Layer-Feinsuche',[primary,secondary],'layer',finer,'full',layers)
if not result:break 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()) self.update(state='complete',phase='Testreihe abgeschlossen · Ergebnisse sind keine Garantie für beliebige Last',finished_at=time.time())
finally:self.worker.stop() 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()) 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): def save(self,data):
with self.lock: with self.lock:
+17 -9
View File
@@ -1,8 +1,8 @@
import tempfile,unittest,json import tempfile,unittest,json,time
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock,patch 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 from inference import Scheduler,InferenceError
class AutoTestsTests(unittest.TestCase): 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')] self.gpus=[dict(name='RTX 5080',uuid='first'),dict(name='RTX 3060',uuid='second')]
def tearDown(self):self.tmp.cleanup() def tearDown(self):self.tmp.cleanup()
def start(self): 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()) self.auto.thread.join(3);self.assertFalse(self.auto.thread.is_alive())
def test_primary_first_results_persist_and_save_explicitly(self): 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.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.start();job=self.auto.status()['job'];self.assertEqual(job['state'],'complete');self.assertEqual(len(job['results']),3*(1+len(GPU_SPLITS)))
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.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.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') self.assertEqual(AutoTests(self.auto.path,self.profiles,self.worker,self.scheduler).job['state'],'complete')
def test_secondary_before_cpu(self): def test_secondary_before_cpu(self):
def plan(p,cancel): def plan(p,cancel):
if p['parameters']['gpu_offload']=='full':raise InferenceError('does not fit') 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() 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): def test_busy_lease_does_not_stop_another_worker(self):
with self.scheduler.lease(('other',)): with self.scheduler.lease(('other',)):
self.worker.reset_mock();self.start();self.assertEqual(self.auto.job['state'],'failed');self.worker.stop.assert_not_called() 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() 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): 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',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}]} 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')) with self.assertRaises(ValueError):self.auto.save(dict(job_id='a',index=0,name='x'))
def test_prompt_filled_by_token_count(self): 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): class FineTests(unittest.TestCase):
def test_single_layer_steps_between_coarse_candidates(self): def test_single_layer_steps_between_coarse_candidates(self):
import math import math
@@ -55,12 +63,12 @@ class FineTests(unittest.TestCase):
def test_refinement_integrated_after_first_fit(self): def test_refinement_integrated_after_first_fit(self):
fixture=AutoTestsTests();fixture.setUp() fixture=AutoTestsTests();fixture.setUp()
try: 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=[] seen=[]
def candidate(context,tier,devices,split,ratio,offload,layer_count=None): 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 [] 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') a.test_candidate=candidate;fixture.worker.catalog.root=Path('/unused')
with patch('auto_test.model_layers',return_value=66):a.run('first','second') 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() finally:fixture.tearDown()
if __name__=='__main__':unittest.main() if __name__=='__main__':unittest.main()