Optimiseur : loki tune et bouton « Optimiser », essais sans perte sur un moteur privé

Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset
demandait de dupliquer, basculer et comparer des bench complets un par un.
L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni
à ce que calcule le modèle, et n'écrit rien sans un clic.

- Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie
  de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur
  127.0.0.1 et un port libre, groupe de processus tué en sortie
- Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage +
  binaire) vérifié par serviceAction start/restart et par le chat, les tâches,
  bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé
  d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur
- Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit
  et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande
- EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de
  chaque essai composée à blanc et comparée à la référence (« dénature »),
  contexte, slots et cache KV recontrôlés une fois chargé
- Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour
  type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages)
- Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit
  clé par clé, vérifié par une sonde et rétabli en cas d'échec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 11:56:11 +02:00
1 parent f87c7b615b
commit 9bffff78c2
36 files changed
+4580 -16

No files matched your search

+24
View File
@@ -1350,6 +1350,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div class="pe-row stack" id="s-loadmode-warn-row" style="display:none">
<div class="pe-note" id="s-loadmode-warn" style="color:var(--warn)"></div>
</div>
<!-- Optimiseur sans perte (backend_tune*.go) : essais sur un moteur
privé, mesurés au bench complet. Rien n'est écrit sans un clic. -->
<div class="pe-row" id="m-tune-row">
<span class="pe-row-l">Optimiseur<span class="pe-sub" id="m-tune-sub">lots, threads, marges --fit… mesurés sur un moteur privé · rien n'est écrit sans ton accord</span></span>
<span class="pe-row-c"><button class="pe-link" onclick="openTuneModal()">Optimiser…</button></span>
</div>
</div>
</div>
@@ -1417,6 +1423,24 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
</div>
<!-- Optimiseur sans perte (29-tune.js) : au-dessus de l'éditeur de preset, d'où
il s'ouvre. Les options cochées ici ne s'enregistrent nulle part. -->
<div id="tune-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:40;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeTuneModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:640px;display:flex;flex-direction:column;max-height:90vh">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong>Optimiser le preset</strong>
<button onclick="closeTuneModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div id="tune-body" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;overflow:auto"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap">
<button id="tune-cancel" onclick="cancelTuneUI()" style="display:none">annuler l'optimisation</button>
<button id="tune-copy" onclick="applyTuneUI('copy')" style="display:none">enregistrer dans une copie</button>
<button id="tune-apply" onclick="applyTuneUI('preset')" style="display:none;border-color:var(--accent);color:var(--accent)">appliquer à ce preset…</button>
<button id="tune-start" onclick="startTuneUI()" style="border-color:var(--accent);color:var(--accent)">lancer</button>
<button onclick="closeTuneModal()">fermer</button>
</div>
</div>
</div>
<script src="marked.min.js"></script>
<script>
@@JS@@
+177
View File
@@ -0,0 +1,177 @@
// Optimiseur sans perte (backend_tune*.go). Il tourne côté serveur, en tâche de
// fond : la modale lance, suit, montre les mesures — et n'écrit RIEN sans un
// clic (copie du preset, ou ce preset après confirmation du diff).
let tunePoll = null, tuneState = 'form';
// Étapes de base (tuneStages côté serveur) ; placement et opt-in ont leur case.
const TUNE_STAGES = [['lots','micro-lot et lot'], ['threads','threads'], ['delestage','délestage'], ['marges','marges --fit'], ['files','files CUDA']];
function closeTuneModal(){ clearTimeout(tunePoll); hideModal('tune-modal'); }
function tuneBtns(state){
tuneState = state;
const show = (id, on) => { const b = document.getElementById(id); if(b) b.style.display = on ? '' : 'none'; };
show('tune-start', state !== 'running');
const sb = document.getElementById('tune-start');
if(sb) sb.textContent = state === 'form' ? 'lancer' : 'nouvelle mesure…';
show('tune-cancel', state === 'running');
show('tune-copy', state === 'result');
show('tune-apply', state === 'result');
}
function tuneSec(s){
s = Math.max(0, Math.round(s||0));
return s >= 60 ? Math.floor(s/60) + ' min ' + String(s%60).padStart(2,'0') + ' s' : s + ' s';
}
async function openTuneModal(){
showModal('tune-modal');
const body = document.getElementById('tune-body');
body.innerHTML = '<div class="muted">…</div>';
let st = {};
try{ st = await jget('/api/tune/status'); }catch(_){}
// Une mesure finie (modale fermée pendant qu'elle tournait) : son résultat
// d'abord, avec ses boutons — « nouvelle mesure… » ramène au formulaire.
if(st.running || st.applying || st.apply || (st.result && st.result.preset_id === editingKey)){ tuneWatch(); return; }
let last = null;
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
tuneForm(last && last.ok ? last : null, st.elsewhere);
}
function tuneForm(last, elsewhere){
const body = document.getElementById('tune-body');
let h = '<div class="pe-note">Des essais sur un moteur <b>privé</b> (127.0.0.1), chacun mesuré au bench complet : '
+ 'micro-lot et lot, threads (poids sur CPU), seuil de délestage, marges <code>--fit</code> et files CUDA (2 cartes ou plus). '
+ 'Jamais touchés : modèle, contexte, cache KV, raisonnement, échantillonnage, slots. Le moteur principal est arrêté pendant la mesure '
+ '(chat et tâches en pause), puis relancé. Rien n\'est écrit sans ton accord.</div>'
+ '<div class="pe-note muted">Mesure la version <b>enregistrée</b> du preset en service.</div>'
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:12px"><span class="muted">Étapes :</span>'
+ TUNE_STAGES.map(s => '<label style="display:flex;gap:4px;align-items:center"><input type="checkbox" class="tune-stage" value="' + s[0] + '" checked> ' + s[1] + '</label>').join('')
+ '</div>'
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-placement"> <span>Inclure le placement'
+ '<span class="muted" style="display:block;font-size:11px">--fit au lieu des experts MoE ou du --tensor-split placés à la main : réécrit EXTRA_ARGS, confirmé à part</span></span></label>'
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-optin"> <span>Inclure les options opt-in'
+ '<span class="muted" style="display:block;font-size:11px">SPEC (sortie inchangée, jugée sur prose et code), SPEC_N_MAX, CUDA_GRAPH_OPT (expérimental), --backend-sampling</span></span></label>'
+ '<label style="display:flex;gap:8px;align-items:center">Budget <input type="number" id="tune-budget" class="pe-val" min="5" max="360" step="5" value="30" style="width:80px"> minutes'
+ '<span class="muted" style="font-size:11px">— au-delà, résultat partiel</span></label>';
if(elsewhere) h += '<div class="pe-note" style="color:var(--warn)">Une optimisation tourne déjà (' + escHtml(elsewhere) + ').</div>';
if(last && last.result){
const r = last.result;
const when = new Date(r.at*1000).toLocaleString();
h += '<div class="pe-note">Dernière optimisation : ' + escHtml(when) + (r.best ? ' — ' + escHtml(r.best) + ', −' + (r.gain*100).toFixed(1) + ' % par tour' : ' — rien de mieux trouvé') + '</div>';
if(last.engine_changed) h += '<div class="pe-note" style="color:var(--warn)">Le moteur a changé depuis (' + escHtml(last.engine_changed) + ') : les meilleurs réglages ont pu bouger — relance l\'optimisation.</div>';
if(last.preset_changed) h += '<div class="pe-note muted">Le preset a été modifié depuis cette mesure.</div>';
}
body.innerHTML = h;
tuneBtns('form');
}
async function startTuneUI(){
if(tuneState !== 'form'){
let last = null;
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
tuneForm(last && last.ok ? last : null);
return;
}
const placement = !!(document.getElementById('tune-placement')||{}).checked;
const optin = !!(document.getElementById('tune-optin')||{}).checked;
const budget = parseInt((document.getElementById('tune-budget')||{}).value, 10) || 30;
// Toutes cochées = toutes (liste vide) ; aucune = seulement placement / opt-in.
const boxes = [...document.querySelectorAll('.tune-stage')];
const picked = boxes.filter(b => b.checked).map(b => b.value);
const stages = picked.length === boxes.length ? [] : (picked.length ? picked : ['aucune']);
const msg = 'Le moteur principal va être ARRÊTÉ pendant jusqu\'à ' + budget + ' minutes : chat, tâches et clients /v1 attendent. '
+ 'Chaque essai recharge le modèle. Lancer l\'optimisation ?';
if(!await askConfirm(msg, {title:'Optimiser', okText:'Lancer'})) return;
let r;
try{ r = await jpost('/api/tune', {placement, optin, budget_min: budget, stages}); }catch(e){ r = {ok:false, error:e.message}; }
if(!r.ok){ document.getElementById('tune-body').insertAdjacentHTML('beforeend', '<div style="color:var(--err)">' + escHtml(r.error||'?') + '</div>'); return; }
tuneWatch();
}
async function cancelTuneUI(){ try{ await jpost('/api/tune/cancel', {}); }catch(_){} }
function tuneTrialsHTML(trials){
if(!trials || !trials.length) return '';
let h = '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr class="muted"><td>étape</td><td>essai</td>'
+ '<td style="text-align:right">tour</td><td style="text-align:right">VRAM libre</td><td>statut</td></tr>';
for(const t of trials){
const color = t.status === 'retenu' ? 'var(--ok)' : (t.status === 'échec' || t.status === 'écarté') ? 'var(--warn)' : 'inherit';
const runs = (t.runs||[]);
let tip = '';
if(runs.length){
const r0 = runs[0];
tip = 'prose ' + (r0.prose_pp||0).toFixed(0) + ' / ' + (r0.prose_tg||0).toFixed(1) + ' t/s';
if(r0.depth) tip += ' · à ' + r0.depth + ' jetons : froid ' + (r0.cold_pp||0).toFixed(0) + ', tours ' + (r0.cached_pp||0).toFixed(0) + ', decode ' + (r0.tg||0).toFixed(1) + ' t/s';
tip += ' · ' + runs.length + ' passage(s)';
}
if(t.offloaded) tip += ' · couches ' + t.offloaded;
h += '<tr title="' + escHtml(tip) + '"><td class="muted" style="padding:3px 6px 3px 0">' + escHtml(t.stage) + '</td>'
+ '<td>' + escHtml(t.label) + '</td>'
+ '<td style="text-align:right">' + (t.turn_sec ? t.turn_sec.toFixed(1) + ' s' : '—') + '</td>'
+ '<td style="text-align:right">' + (t.headroom_mib >= 0 ? t.headroom_mib + ' Mio' : '—') + '</td>'
+ '<td style="color:' + color + ';padding-left:6px">' + escHtml(t.status) + (t.why ? ' <span class="muted">· ' + escHtml(t.why) + '</span>' : '') + '</td></tr>';
}
return h + '</table>';
}
function tuneResultHTML(r){
let h = '<div class="pe-note muted">Score : durée d\'un tour type — ' + (r.weights.k).toFixed(0) + ' jetons lus (cache repris), '
+ (r.weights.g).toFixed(0) + ' écrits, ' + (r.weights.f_cold*100).toFixed(0) + ' % des tours relus à froid à ' + r.depth + ' jetons ('
+ escHtml(r.weights.from) + '). Un gain compte au-delà de 3 % et de l\'écart entre passages. Moteur ' + escHtml(r.engine||'?') + '.</div>';
h += tuneTrialsHTML([r.baseline].concat(r.trials||[]));
for(const s of (r.skipped||[])) h += '<div class="muted" style="font-size:11px">sauté — ' + escHtml(s) + '</div>';
for(const n of (r.notes||[])) h += '<div style="font-size:11px;color:var(--warn)">' + escHtml(n) + '</div>';
if(r.partial) h += '<div style="font-size:11px;color:var(--warn)">résultat partiel : ' + escHtml(r.partial) + '</div>';
if(r.best){
h += '<div style="margin-top:6px"><b style="color:var(--ok)">Meilleur : ' + escHtml(r.best) + '</b> — −' + (r.gain*100).toFixed(1) + ' % par tour</div>'
+ '<div class="pe-note" style="white-space:pre-wrap;font-family:var(--mono,monospace);font-size:11px">' + escHtml((r.changes||[]).join('\n')) + '</div>';
if(r.placement) h += '<div class="pe-note" style="color:var(--warn)">Ce résultat réécrit EXTRA_ARGS (placement) : à confirmer à part.</div>';
} else {
h += '<div style="margin-top:6px">La configuration actuelle reste la meilleure : rien à changer.</div>';
}
return h;
}
function tuneWatch(){
clearTimeout(tunePoll);
const body = document.getElementById('tune-body');
const tick = async () => {
let st;
try{ st = await jget('/api/tune/status'); }
catch(_){ tunePoll = setTimeout(tick, 3000); return; }
if(st.running){
tuneBtns('running');
const eta = st.eta_sec >= 0 ? ' · reste ~' + tuneSec(st.eta_sec) : '';
body.innerHTML = '<div class="muted" style="text-align:center">⏳ ' + escHtml(st.phase||'…') + '<br>' + tuneSec(st.elapsed_sec) + eta
+ '<br><span style="font-size:11px">moteur principal arrêté · chat et tâches en pause</span></div>' + tuneTrialsHTML(st.trials);
tunePoll = setTimeout(tick, 2000);
return;
}
let h = '';
if(st.error) h += '<div style="color:var(--err)">' + escHtml(st.error) + '</div>';
if(st.result) h += tuneResultHTML(st.result);
const a = st.apply;
if(a){
h += '<div class="pe-note">' + (a.log||[]).map(escHtml).join('<br>') + '</div>';
if(a.message) h += '<div style="color:var(--ok)">' + escHtml(a.message) + '</div>';
if(a.error) h += '<div style="color:var(--err)">' + escHtml(a.error) + '</div>';
}
body.innerHTML = h || '<div class="muted">rien à montrer</div>';
const canApply = !!(st.result && st.result.best) && !st.applying && !(a && a.message);
tuneBtns(canApply ? 'result' : 'done');
if(st.applying){ tunePoll = setTimeout(tick, 2000); }
};
tick();
}
async function applyTuneUI(target){
let st = {};
try{ st = await jget('/api/tune/status'); }catch(_){}
const r = st.result;
if(!r || !r.best) return;
const diff = (r.changes||[]).map(c => '• ' + c).join('\n');
if(target === 'copy'){
if(!await askConfirm('Créer une COPIE de « ' + r.preset_name + ' » avec :\n\n' + diff + '\n\nLe preset d\'origine ne bouge pas.',
{title:'Enregistrer dans une copie', okText:'Créer la copie'})) return;
} else {
if(!await askConfirm('Réécrire « ' + r.preset_name + ' » (sauvegardé avant) avec :\n\n' + diff
+ '\n\nLe moteur redémarre, puis une sonde vérifie un prompt à ' + r.depth + ' jetons ; en cas d\'échec, l\'ancienne version est rétablie.',
{title:'Appliquer à ce preset', okText:'Appliquer'})) return;
if(r.placement && !await askConfirm('Ce résultat RÉÉCRIT EXTRA_ARGS (placement des experts ou --tensor-split retirés pour --fit). Confirmer ?',
{title:'Placement', okText:'Confirmer'})) return;
}
let res;
try{ res = await jpost('/api/tune/apply', {target}); }catch(e){ res = {ok:false, error:e.message}; }
if(!res.ok){ toast('erreur : ' + (res.error||'')); return; }
if(target === 'copy'){ toast(res.message || 'copie créée'); KINDS.preset.reload(); return; }
tuneWatch();
}