llm-tuner 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +187 -64
- package/README.md +190 -67
- package/package.json +1 -1
- package/src/cli/index.js +78 -23
- package/src/cli/profile.js +14 -1
- package/src/core/engines/lmstudio.js +2 -1
- package/src/core/engines/ollama.js +2 -1
- package/src/core/index.js +2 -0
- package/src/core/telemetry.js +73 -0
- package/src/core/tuner.js +113 -78
- package/src/i18n/en.js +7 -2
- package/src/i18n/es.js +7 -2
- package/src/web/public/app.js +78 -0
- package/src/web/public/index.html +20 -0
- package/src/web/public/style.css +16 -0
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { select, input, confirm, search } from '@inquirer/prompts';
|
|
3
3
|
import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError } from '../core/index.js';
|
|
4
4
|
import { t as translate, LOCALES, errorText } from '../i18n/index.js';
|
|
5
|
-
import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile } from './profile.js';
|
|
5
|
+
import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile, parseContext } from './profile.js';
|
|
6
6
|
|
|
7
7
|
const args = parseArgs(process.argv.slice(2));
|
|
8
8
|
let { lang, theme } = await readSettings();
|
|
@@ -114,7 +114,45 @@ async function settingsFlow() {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
async function main() {
|
|
117
|
-
const
|
|
117
|
+
const isTTY = Boolean(process.stdout.isTTY && !args.json);
|
|
118
|
+
let lastProgress = null;
|
|
119
|
+
let lastTelemetry = null;
|
|
120
|
+
let liveActive = false;
|
|
121
|
+
|
|
122
|
+
const clearLive = () => {
|
|
123
|
+
if (isTTY && liveActive) {
|
|
124
|
+
process.stdout.write('\r\x1b[K');
|
|
125
|
+
liveActive = false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const renderLive = () => {
|
|
130
|
+
if (!isTTY || !lastProgress) return;
|
|
131
|
+
const pct = Math.min(100, Math.max(0, lastProgress.pct ?? 0));
|
|
132
|
+
const barLen = 16;
|
|
133
|
+
const filled = Math.round((pct / 100) * barLen);
|
|
134
|
+
const bar = c.cyan('━'.repeat(filled)) + c.dim('─'.repeat(barLen - filled));
|
|
135
|
+
|
|
136
|
+
let telem = '';
|
|
137
|
+
if (lastTelemetry) {
|
|
138
|
+
if (lastTelemetry.cpu) telem += ` · CPU ${lastTelemetry.cpu.percent}%`;
|
|
139
|
+
if (lastTelemetry.ram) {
|
|
140
|
+
telem += ` · RAM ${(lastTelemetry.ram.usedBytes / 1024 ** 3).toFixed(1)}/${(lastTelemetry.ram.totalBytes / 1024 ** 3).toFixed(1)} GB`;
|
|
141
|
+
}
|
|
142
|
+
if (lastTelemetry.gpus?.length) {
|
|
143
|
+
telem += ' · ' + lastTelemetry.gpus.map((g) => `GPU${g.index} ${(g.usedBytes / 1024 ** 3).toFixed(1)}/${(g.totalBytes / 1024 ** 3).toFixed(1)} GB`).join(' ');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
process.stdout.write(`\r\x1b[K [${bar}] ${String(pct).padStart(3)}%${telem}`);
|
|
148
|
+
liveActive = true;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const out = (...a) => {
|
|
152
|
+
if (args.json) return;
|
|
153
|
+
clearLive();
|
|
154
|
+
console.log(...a);
|
|
155
|
+
};
|
|
118
156
|
out([
|
|
119
157
|
'',
|
|
120
158
|
` ${c.cyan(' ╭───')}${c.dim('╷ ╷ ╷')}${c.cyan('───╮')}`,
|
|
@@ -168,7 +206,7 @@ async function main() {
|
|
|
168
206
|
});
|
|
169
207
|
|
|
170
208
|
// 3. Context
|
|
171
|
-
let ctx =
|
|
209
|
+
let ctx = parseContext(args.ctx);
|
|
172
210
|
if (!ctx) {
|
|
173
211
|
const presets = await listPresets({ engine: engineId, modelKey });
|
|
174
212
|
const preview = await tuner.plan(modelKey, 8192);
|
|
@@ -176,11 +214,12 @@ async function main() {
|
|
|
176
214
|
const list = Object.entries(preview.maxContext).map(([k, v]) => `KV ${k} ${v ? Math.floor(v / 1024) + 'K' : '—'}`).join(' · ');
|
|
177
215
|
out(c.dim(` ${t('cli.maxFullGpu', { list, train: Math.floor(limit / 1024) })}`));
|
|
178
216
|
if (presets.length) out(c.dim(` ${t('cli.tunedContexts', { list: presets.map((p) => `${p.ctx / 1024}K (${p.shortTps} t/s)`).join(', ') })}`));
|
|
179
|
-
|
|
217
|
+
const answer = await input({
|
|
180
218
|
message: t('cli.context'),
|
|
181
219
|
default: String(presets.at(-1)?.ctx || Math.min(16384, limit)),
|
|
182
|
-
validate: (v) => (
|
|
183
|
-
})
|
|
220
|
+
validate: (v) => parseContext(v, limit) !== null || t('cli.contextRange', { limit }),
|
|
221
|
+
});
|
|
222
|
+
ctx = parseContext(answer, limit);
|
|
184
223
|
}
|
|
185
224
|
|
|
186
225
|
// 4. Profile
|
|
@@ -214,6 +253,14 @@ async function main() {
|
|
|
214
253
|
let previous = null;
|
|
215
254
|
tuner.on('progress', (e) => {
|
|
216
255
|
if (args.json) return;
|
|
256
|
+
if (e.type === 'telemetry') {
|
|
257
|
+
lastTelemetry = e;
|
|
258
|
+
renderLive();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (e.progress) {
|
|
262
|
+
lastProgress = e.progress;
|
|
263
|
+
}
|
|
217
264
|
if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
|
|
218
265
|
for (const line of profileEventLines(e, previous, t, advisedByWizard ? null : tuner.engine.name)) {
|
|
219
266
|
out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
|
|
@@ -229,26 +276,34 @@ async function main() {
|
|
|
229
276
|
out(b.ok ? ` ${c.g('✓')} ${t('cli.benchShort', { tps: c.b(b.short.genTps + ' t/s') })}${b.deep ? t('cli.benchDeep', { tps: c.b(b.deep.genTps + ' t/s'), tokens: b.deep.promptTokens }) : ''} · CPU ${b.cpu.avg}% · VRAM ${vramStr(b.vramPeakBytes)}` : c.r(` ✗ ${benchErrorText(b)}`));
|
|
230
277
|
}
|
|
231
278
|
if (e.type === 'preset-saved') out(c.g(`\n✓ ${t('common.presetSaved', { file: e.file })}`));
|
|
232
|
-
});
|
|
233
279
|
|
|
234
|
-
|
|
235
|
-
profile,
|
|
236
|
-
force: !!args.force,
|
|
237
|
-
dryRun: !!args['dry-run'],
|
|
238
|
-
maxCandidates: Number(args.candidates) || 3,
|
|
239
|
-
confirmApply: async (preview) => {
|
|
240
|
-
out(c.b(`\n${t('cli.changes')}`));
|
|
241
|
-
if (engineId === 'lmstudio') {
|
|
242
|
-
preview.files.forEach((f) => out(` ${f}`));
|
|
243
|
-
if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
|
|
244
|
-
} else {
|
|
245
|
-
out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
|
|
246
|
-
out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
|
|
247
|
-
}
|
|
248
|
-
return args.yes || confirm({ message: t('cli.confirmApply'), default: true });
|
|
249
|
-
},
|
|
280
|
+
renderLive();
|
|
250
281
|
});
|
|
251
282
|
|
|
283
|
+
let result;
|
|
284
|
+
try {
|
|
285
|
+
result = await tuner.load(modelKey, ctx, {
|
|
286
|
+
profile,
|
|
287
|
+
force: !!args.force,
|
|
288
|
+
dryRun: !!args['dry-run'],
|
|
289
|
+
maxCandidates: Number(args.candidates) || 3,
|
|
290
|
+
confirmApply: async (preview) => {
|
|
291
|
+
clearLive();
|
|
292
|
+
out(c.b(`\n${t('cli.changes')}`));
|
|
293
|
+
if (engineId === 'lmstudio') {
|
|
294
|
+
preview.files.forEach((f) => out(` ${f}`));
|
|
295
|
+
if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
|
|
296
|
+
} else {
|
|
297
|
+
out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
|
|
298
|
+
out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
|
|
299
|
+
}
|
|
300
|
+
return args.yes || confirm({ message: t('cli.confirmApply'), default: true });
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
} finally {
|
|
304
|
+
clearLive();
|
|
305
|
+
}
|
|
306
|
+
|
|
252
307
|
if (args['dry-run']) out(c.y(`\n${t('cli.dryRun')}`));
|
|
253
308
|
if (result.applied?.backups?.length) out(c.dim(` ${t('common.backups', { list: result.applied.backups.join(', ') })}`));
|
|
254
309
|
if (result.applied?.pendingCommands?.length) {
|
package/src/cli/profile.js
CHANGED
|
@@ -4,11 +4,24 @@ import { PROFILES, fmtBytes } from '../core/index.js';
|
|
|
4
4
|
// Reasons of a `variant-picked` event that mean the profile could not be honoured: the core flags them with a `profile-fallback` event right after.
|
|
5
5
|
export const FALLBACK_REASONS = new Set(['profile.variant.noFullGpu']);
|
|
6
6
|
|
|
7
|
+
/** Parses a context string (e.g. "8192", "8k", "16K") into tokens, or null if invalid or outside [512, limit]. */
|
|
8
|
+
export function parseContext(v, limit = Infinity) {
|
|
9
|
+
if (v == null) return null;
|
|
10
|
+
const str = String(v).trim().toLowerCase();
|
|
11
|
+
const m = str.match(/^(\d+(?:\.\d+)?)\s*(k|m)?$/);
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
const num = Number(m[1]);
|
|
14
|
+
if (!Number.isFinite(num)) return null;
|
|
15
|
+
const multiplier = m[2] === 'k' ? 1024 : m[2] === 'm' ? 1024 * 1024 : 1;
|
|
16
|
+
const val = Math.round(num * multiplier);
|
|
17
|
+
return val >= 512 && val <= limit ? val : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
/** True when the CLI has to ask for the load profile; `args` are the parsed flags. */
|
|
8
21
|
export function shouldAskProfile(args) {
|
|
9
22
|
if (args.profile || args.yes || args.json) return false;
|
|
10
23
|
// Engine, model and context all from flags: a scripted run that asked nothing before the profiles existed, so it stays prompt-free.
|
|
11
|
-
const flagged = Boolean(args.engine && args.model &&
|
|
24
|
+
const flagged = Boolean(args.engine && args.model && parseContext(args.ctx ?? args.context));
|
|
12
25
|
return !flagged;
|
|
13
26
|
}
|
|
14
27
|
|
|
@@ -78,7 +78,8 @@ export const lmstudio = {
|
|
|
78
78
|
try { list = JSON.parse(r.stdout); } catch { return []; }
|
|
79
79
|
return list
|
|
80
80
|
.filter((m) => m.type === 'llm' && m.format === 'gguf')
|
|
81
|
-
.map((m) => ({ key: m.modelKey || m.path, name: m.displayName, sizeBytes: m.sizeBytes, arch: m.architecture, quant: m.quantization?.name, maxContext: m.maxContextLength, moe: /A\d+B/i.test(m.paramsString || '') }))
|
|
81
|
+
.map((m) => ({ key: m.modelKey || m.path, name: m.displayName, sizeBytes: m.sizeBytes, arch: m.architecture, quant: m.quantization?.name, maxContext: m.maxContextLength, moe: /A\d+B/i.test(m.paramsString || '') }))
|
|
82
|
+
.sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) || (a.name || '').localeCompare(b.name || ''));
|
|
82
83
|
},
|
|
83
84
|
|
|
84
85
|
/** Resolve the GGUF file behind a model key using LM Studio's model index. */
|
|
@@ -149,7 +149,8 @@ export const ollama = {
|
|
|
149
149
|
const r = await fetchJson(`${ctx.detection.apiUrl}/api/tags`);
|
|
150
150
|
return (r?.json?.models || [])
|
|
151
151
|
.filter((m) => !/embed/i.test(m.name) && !/bert/i.test(m.details?.family || ''))
|
|
152
|
-
.map((m) => ({ key: m.name, name: m.name, sizeBytes: m.size, arch: m.details?.family, quant: m.details?.quantization_level, maxContext: null }))
|
|
152
|
+
.map((m) => ({ key: m.name, name: m.name, sizeBytes: m.size, arch: m.details?.family, quant: m.details?.quantization_level, maxContext: null }))
|
|
153
|
+
.sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) || (a.name || '').localeCompare(b.name || ''));
|
|
153
154
|
},
|
|
154
155
|
|
|
155
156
|
/** Downloaded variants (tags) of a model: [{ key, quant, sizeBytes, selected }]. */
|
package/src/core/index.js
CHANGED
|
@@ -8,3 +8,5 @@ export { fmtBytes, configDir } from './util.js';
|
|
|
8
8
|
export { findPreset, savePreset, listPresets, presetsDir, presetPath, variantsSignature, hardwareFingerprint } from './presets.js';
|
|
9
9
|
export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme } from './settings.js';
|
|
10
10
|
export { TunerError, benchError } from './errors.js';
|
|
11
|
+
export { sampleRam, sampleGpus, startTelemetrySampler } from './telemetry.js';
|
|
12
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { nvidiaGpus } from './hardware.js';
|
|
3
|
+
import { cpuSnapshot, cpuBusyPercent } from './util.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sample RAM usage information.
|
|
7
|
+
* @returns {{ totalBytes: number, freeBytes: number, usedBytes: number, percent: number }}
|
|
8
|
+
*/
|
|
9
|
+
export function sampleRam() {
|
|
10
|
+
const totalBytes = os.totalmem();
|
|
11
|
+
const freeBytes = os.freemem();
|
|
12
|
+
const usedBytes = totalBytes - freeBytes;
|
|
13
|
+
const percent = totalBytes > 0 ? +(100 * (1 - freeBytes / totalBytes)).toFixed(1) : 0;
|
|
14
|
+
return { totalBytes, freeBytes, usedBytes, percent };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sample GPU usage information.
|
|
19
|
+
* @returns {Promise<Array<{ index: number, name: string, totalBytes: number, usedBytes: number, freeBytes: number, percent: number }>>}
|
|
20
|
+
*/
|
|
21
|
+
export async function sampleGpus() {
|
|
22
|
+
const gpus = await nvidiaGpus();
|
|
23
|
+
return gpus.map((g) => ({
|
|
24
|
+
index: g.index,
|
|
25
|
+
name: g.name,
|
|
26
|
+
totalBytes: g.totalBytes,
|
|
27
|
+
usedBytes: g.usedBytes,
|
|
28
|
+
freeBytes: g.freeBytes,
|
|
29
|
+
percent: g.totalBytes > 0 ? +(100 * (g.usedBytes / g.totalBytes)).toFixed(1) : 0,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start telemetry sampling at regular intervals.
|
|
35
|
+
* @param {Function} onSample - Callback function to receive sample data
|
|
36
|
+
* @param {number} intervalMs - Sampling interval in milliseconds (default: 500)
|
|
37
|
+
* @returns {{ stop: () => void }} Object with stop method to cancel the sampler
|
|
38
|
+
*/
|
|
39
|
+
export function startTelemetrySampler(onSample, intervalMs = 500) {
|
|
40
|
+
let timerId = null;
|
|
41
|
+
let prevCpu = cpuSnapshot();
|
|
42
|
+
let sampling = false;
|
|
43
|
+
let stopped = false;
|
|
44
|
+
|
|
45
|
+
const tick = async () => {
|
|
46
|
+
if (sampling || stopped) return;
|
|
47
|
+
sampling = true;
|
|
48
|
+
try {
|
|
49
|
+
const nowCpu = cpuSnapshot();
|
|
50
|
+
const cpuPct = +(cpuBusyPercent(prevCpu, nowCpu)).toFixed(1);
|
|
51
|
+
prevCpu = nowCpu;
|
|
52
|
+
const ram = sampleRam();
|
|
53
|
+
const gpus = await sampleGpus();
|
|
54
|
+
if (!stopped) onSample({ cpu: { percent: cpuPct }, ram, gpus });
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore transient sampling errors
|
|
57
|
+
} finally {
|
|
58
|
+
sampling = false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
timerId = setInterval(tick, intervalMs);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
stop() {
|
|
66
|
+
stopped = true;
|
|
67
|
+
if (timerId) {
|
|
68
|
+
clearInterval(timerId);
|
|
69
|
+
timerId = null;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/core/tuner.js
CHANGED
|
@@ -6,6 +6,7 @@ import { PROFILES, DEFAULT_PROFILE, normalizeProfile, kvTypesFor, meetsQuality,
|
|
|
6
6
|
import { lmstudio } from './engines/lmstudio.js';
|
|
7
7
|
import { ollama } from './engines/ollama.js';
|
|
8
8
|
import { TunerError, benchError } from './errors.js';
|
|
9
|
+
import { startTelemetrySampler } from './telemetry.js';
|
|
9
10
|
|
|
10
11
|
export { pickBest };
|
|
11
12
|
export const ENGINES = { lmstudio, ollama };
|
|
@@ -57,7 +58,23 @@ export class Tuner extends EventEmitter {
|
|
|
57
58
|
|
|
58
59
|
log(type, data = {}) { this.emit('progress', { type, ...data, at: Date.now() }); }
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
withTelemetry(fn) {
|
|
62
|
+
if (this._telemetryActive) return fn();
|
|
63
|
+
this._telemetryActive = true;
|
|
64
|
+
const sampler = startTelemetrySampler((sample) => this.log('telemetry', sample));
|
|
65
|
+
return Promise.resolve()
|
|
66
|
+
.then(fn)
|
|
67
|
+
.finally(() => {
|
|
68
|
+
this._telemetryActive = false;
|
|
69
|
+
sampler.stop();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async listModels() {
|
|
75
|
+
const list = (await this.engine.listModels?.(this.ctx)) || [];
|
|
76
|
+
return list.slice().sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) || (a.name || '').localeCompare(b.name || ''));
|
|
77
|
+
}
|
|
61
78
|
|
|
62
79
|
async modelInfo(key) {
|
|
63
80
|
const meta = await this.engine.modelMeta(this.ctx, key);
|
|
@@ -200,34 +217,50 @@ export class Tuner extends EventEmitter {
|
|
|
200
217
|
|
|
201
218
|
/** Estimate, benchmark the top candidates for real, and return a report. The profile decides the winner. */
|
|
202
219
|
async run(key, ctx, { maxCandidates = 3, depthFraction = 0.5, genTokens = 200, profile: requested = DEFAULT_PROFILE } = {}) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
return this.withTelemetry(async () => {
|
|
221
|
+
const profile = normalizeProfile(requested);
|
|
222
|
+
this.log('status', { code: 'status.engine', params: { name: this.engine.name }, progress: { pct: 5, phase: 'plan' } });
|
|
223
|
+
const { model, candidates, maxContext } = await this.plan(key, ctx, { unload: true, profile });
|
|
224
|
+
const model2 = { key, meta: model.meta };
|
|
225
|
+
const toTest = candidates.slice(0, maxCandidates);
|
|
226
|
+
this.log('plan', { candidates: toTest.map(slim), maxContext, progress: { pct: 10, phase: 'plan' } });
|
|
209
227
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
228
|
+
const depthTokens = Math.floor(ctx * depthFraction);
|
|
229
|
+
const results = [];
|
|
230
|
+
for (const [i, c] of toTest.entries()) {
|
|
231
|
+
this.log('candidate-start', {
|
|
232
|
+
index: i,
|
|
233
|
+
total: toTest.length,
|
|
234
|
+
candidate: slim(c),
|
|
235
|
+
progress: { pct: 10 + Math.round((i / toTest.length) * 70), phase: 'bench', current: i + 1, total: toTest.length },
|
|
236
|
+
});
|
|
237
|
+
let bench;
|
|
238
|
+
try {
|
|
239
|
+
bench = await this.engine.benchmark(this.ctx, model2, c, {
|
|
240
|
+
depthTokens, genTokens,
|
|
241
|
+
onProgress: (p) => this.log('bench-progress', {
|
|
242
|
+
index: i,
|
|
243
|
+
...p,
|
|
244
|
+
progress: { pct: 10 + Math.round(((i + (p.phase === 'deep' ? 0.6 : 0.2)) / toTest.length) * 70), phase: 'bench', current: i + 1, total: toTest.length },
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
bench = err?.name === 'TunerError' ? benchError(err.code, err.params) : { ok: false, error: String(err.message || err) };
|
|
249
|
+
}
|
|
250
|
+
results.push({ candidate: c, bench });
|
|
251
|
+
this.log('candidate-done', {
|
|
252
|
+
index: i,
|
|
253
|
+
candidate: slim(c),
|
|
254
|
+
bench,
|
|
255
|
+
progress: { pct: 10 + Math.round(((i + 1) / toTest.length) * 70), phase: 'bench', current: i + 1, total: toTest.length },
|
|
219
256
|
});
|
|
220
|
-
} catch (err) {
|
|
221
|
-
bench = err?.name === 'TunerError' ? benchError(err.code, err.params) : { ok: false, error: String(err.message || err) };
|
|
222
257
|
}
|
|
223
|
-
|
|
224
|
-
this.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
this.lastReport = { ...report, modelObj: model2 };
|
|
230
|
-
return report;
|
|
258
|
+
const best = pickBest(results, profile);
|
|
259
|
+
const report = { engine: this.engine.id, model: key, ctx, profile, hw: this.hw, maxContext, results: results.map((r) => ({ candidate: slim(r.candidate), bench: r.bench })), best: best ? { candidate: best.candidate, bench: best.bench } : null };
|
|
260
|
+
this.log('done', { best: report.best && { candidate: slim(best.candidate), bench: best.bench }, progress: { pct: 82, phase: 'bench' } });
|
|
261
|
+
this.lastReport = { ...report, modelObj: model2 };
|
|
262
|
+
return report;
|
|
263
|
+
});
|
|
231
264
|
}
|
|
232
265
|
|
|
233
266
|
/**
|
|
@@ -238,63 +271,65 @@ export class Tuner extends EventEmitter {
|
|
|
238
271
|
* the preset, or pick one once the VRAM is free.
|
|
239
272
|
*/
|
|
240
273
|
async load(key, ctx, { force = false, dryRun = false, maxCandidates = 3, depthFraction = 0.5, measure = true, confirmApply = async () => true, profile: requested = DEFAULT_PROFILE } = {}) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
274
|
+
return this.withTelemetry(async () => {
|
|
275
|
+
const profile = normalizeProfile(requested);
|
|
276
|
+
const model = await this.modelInfo(key);
|
|
277
|
+
const modelBytes = model.meta.fileBytes;
|
|
278
|
+
// Listing the variants of a non-balanced profile needs no hardware; picking one does, so the pick waits.
|
|
279
|
+
const variants = profile === DEFAULT_PROFILE ? null : await this.variants(key);
|
|
280
|
+
const listed = variants?.map((v) => ({ key: v.key, sizeBytes: v.sizeBytes, selected: v.selected }));
|
|
281
|
+
// A profile preset is keyed by the key the user chose plus the profile, and goes stale with the variant list or its selected entry.
|
|
282
|
+
const keyed = variants ? { profile, variants: listed } : {};
|
|
283
|
+
let target = null;
|
|
284
|
+
let source = 'preset';
|
|
285
|
+
let candidate;
|
|
286
|
+
let report = null;
|
|
287
|
+
let found = force ? { preset: null, reason: 'forced' } : await findPreset({ engine: this.engine.id, modelKey: key, ctx, hw: this.hw, modelBytes, ...keyed });
|
|
254
288
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
289
|
+
if (found.preset) {
|
|
290
|
+
candidate = found.preset.candidate;
|
|
291
|
+
this.log('preset-hit', { preset: found.preset, file: presetPath(this.engine.id, key, ctx, profile), progress: { pct: 30, phase: 'preset' } });
|
|
292
|
+
// The preset remembers which variant it measured: nothing is picked and no VRAM is freed.
|
|
293
|
+
if (variants) target = this.presetTarget(key, profile, model, found.preset);
|
|
294
|
+
} else {
|
|
295
|
+
source = 'benchmark';
|
|
296
|
+
const code = { none: 'status.searchNone', hardware: 'status.searchHardware', model: 'status.searchModel', forced: 'status.searchForced', variants: 'status.searchVariants' }[found.reason];
|
|
297
|
+
this.log('status', { code, progress: { pct: 5, phase: 'plan' } });
|
|
298
|
+
if (variants) {
|
|
299
|
+
// The pick depends on the free VRAM, so the engine's models are unloaded before it.
|
|
300
|
+
await this.freeVram();
|
|
301
|
+
target = this.profileTarget(key, ctx, profile, model, variants);
|
|
302
|
+
}
|
|
303
|
+
report = await this.run(target?.key ?? key, ctx, { maxCandidates, depthFraction, profile });
|
|
304
|
+
if (!report.best) throw new TunerError('errors.noConfigWorked');
|
|
305
|
+
candidate = report.best.candidate;
|
|
306
|
+
// Quality found no configuration that keeps its promise: say so once.
|
|
307
|
+
if (profile === 'quality' && !target.fallback && !meetsQuality(candidate)) this.log('profile-fallback', { profile, reasonCode: 'profile.fallback.noFullGpuConfig', variant: target.picked });
|
|
308
|
+
if (!dryRun) {
|
|
309
|
+
const saved = await savePreset({ engine: this.engine.id, modelKey: key, ctx, hw: this.hw, modelBytes, best: report.best, results: this.lastReport.results, ...(target ? { profile, variant: target.variant, variants: listed } : {}) });
|
|
310
|
+
this.log('preset-saved', { file: saved.file, preset: saved.preset, progress: { pct: 85, phase: 'save' } });
|
|
311
|
+
}
|
|
268
312
|
}
|
|
269
|
-
report = await this.run(target?.key ?? key, ctx, { maxCandidates, depthFraction, profile });
|
|
270
|
-
if (!report.best) throw new TunerError('errors.noConfigWorked');
|
|
271
|
-
candidate = report.best.candidate;
|
|
272
|
-
// Quality found no configuration that keeps its promise: say so once.
|
|
273
|
-
if (profile === 'quality' && !target.fallback && !meetsQuality(candidate)) this.log('profile-fallback', { profile, reasonCode: 'profile.fallback.noFullGpuConfig', variant: target.picked });
|
|
274
|
-
if (!dryRun) {
|
|
275
|
-
const saved = await savePreset({ engine: this.engine.id, modelKey: key, ctx, hw: this.hw, modelBytes, best: report.best, results: this.lastReport.results, ...(target ? { profile, variant: target.variant, variants: listed } : {}) });
|
|
276
|
-
this.log('preset-saved', { file: saved.file, preset: saved.preset });
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
313
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
314
|
+
// The variant a profile picked is applied and loaded with its own metadata.
|
|
315
|
+
const loadKey = target?.key ?? key;
|
|
316
|
+
const meta = loadKey === key ? model.meta : variants?.find((v) => v.key === loadKey)?.meta ?? (await this.modelInfo(loadKey)).meta;
|
|
317
|
+
const modelObj = { key: loadKey, meta };
|
|
318
|
+
const preview = await this.engine.apply(this.ctx, modelObj, candidate, { dryRun: true });
|
|
319
|
+
if (dryRun) return { source, candidate: slim(candidate), report, preview, profile, variant: loadKey };
|
|
320
|
+
if (!(await confirmApply(preview))) return { source, candidate: slim(candidate), report, preview, applied: false, profile, variant: loadKey };
|
|
287
321
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
322
|
+
this.log('status', { code: 'status.applying', progress: { pct: 88, phase: 'apply' } });
|
|
323
|
+
const applied = await this.engine.apply(this.ctx, modelObj, candidate, { useSudo: true, skipIfApplied: source === 'preset' });
|
|
324
|
+
this.log('applied', { result: applied, progress: { pct: 92, phase: 'apply' } });
|
|
325
|
+
if (applied.pendingCommands?.length) return { source, candidate: slim(candidate), report, applied, loaded: null, profile, variant: loadKey };
|
|
292
326
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
327
|
+
this.log('status', { code: 'status.loading', progress: { pct: 95, phase: 'load' } });
|
|
328
|
+
this.ctx.detection = await this.engine.detect();
|
|
329
|
+
const loaded = await this.engine.load(this.ctx, modelObj, { candidate, depthTokens: 0, genTokens: measure ? 200 : 1, onProgress: (p) => this.log('bench-progress', { ...p, progress: { pct: 98, phase: 'load' } }) });
|
|
330
|
+
this.log('loaded', { bench: loaded, progress: { pct: 100, phase: 'done' } });
|
|
331
|
+
return { source, candidate: slim(candidate), report, applied, loaded, profile, variant: loadKey };
|
|
332
|
+
});
|
|
298
333
|
}
|
|
299
334
|
}
|
|
300
335
|
|
package/src/i18n/en.js
CHANGED
|
@@ -92,7 +92,7 @@ export default {
|
|
|
92
92
|
// Shared by CLI and web
|
|
93
93
|
'common.error': 'Error: {message}',
|
|
94
94
|
'common.na': 'n/a',
|
|
95
|
-
'common.longPrompt': 'long prompt (~{tokens} tokens)…',
|
|
95
|
+
'common.longPrompt': 'long prompt (~{tokens} tokens, 50% context)…',
|
|
96
96
|
'common.presetSaved': 'Preset saved: {file}',
|
|
97
97
|
'common.backups': 'Backups: {list}',
|
|
98
98
|
|
|
@@ -154,7 +154,8 @@ Presets are saved in {presetsDir}`,
|
|
|
154
154
|
'cli.confirmApply': 'Apply and load the model?',
|
|
155
155
|
'cli.dryRun': '--dry-run: nothing was applied and no preset was saved.',
|
|
156
156
|
'cli.pending': 'To finish, run (asks for the administrator password):',
|
|
157
|
-
'cli.
|
|
157
|
+
'cli.tuningModel': 'Tuning {model} with {ctx} context ({profile})…',
|
|
158
|
+
'cli.loaded': 'Model loaded in {engine} with {ctx} context: {tps}',
|
|
158
159
|
'cli.usesModel': ' (uses the model {model})',
|
|
159
160
|
'cli.loadFailed': 'Could not load: {error}',
|
|
160
161
|
'cli.installMethod': 'Install method:',
|
|
@@ -239,5 +240,9 @@ Presets are saved in {presetsDir}`,
|
|
|
239
240
|
'web.themeDark': 'Dark',
|
|
240
241
|
'web.settings': 'Settings',
|
|
241
242
|
'web.close': 'Close',
|
|
243
|
+
'web.telemetryCpu': 'CPU',
|
|
244
|
+
'web.telemetryRam': 'RAM',
|
|
245
|
+
'web.telemetryVram': 'VRAM',
|
|
246
|
+
'web.telemetryGpu': 'GPU {index}',
|
|
242
247
|
};
|
|
243
248
|
|
package/src/i18n/es.js
CHANGED
|
@@ -92,7 +92,7 @@ export default {
|
|
|
92
92
|
// Shared by CLI and web
|
|
93
93
|
'common.error': 'Error: {message}',
|
|
94
94
|
'common.na': 'n/d',
|
|
95
|
-
'common.longPrompt': 'prompt largo (~{tokens} tokens)…',
|
|
95
|
+
'common.longPrompt': 'prompt largo (~{tokens} tokens, 50% del contexto)…',
|
|
96
96
|
'common.presetSaved': 'Preset guardado: {file}',
|
|
97
97
|
'common.backups': 'Copias de seguridad: {list}',
|
|
98
98
|
|
|
@@ -154,7 +154,8 @@ Los presets se guardan en {presetsDir}`,
|
|
|
154
154
|
'cli.confirmApply': '¿Aplicar y cargar el modelo?',
|
|
155
155
|
'cli.dryRun': '--dry-run: no se aplicó nada ni se guardó preset.',
|
|
156
156
|
'cli.pending': 'Para terminar ejecuta (pide contraseña de administrador):',
|
|
157
|
-
'cli.
|
|
157
|
+
'cli.tuningModel': 'Ajustando {model} con {ctx} de contexto ({profile})…',
|
|
158
|
+
'cli.loaded': 'Modelo cargado en {engine} con {ctx} de contexto: {tps}',
|
|
158
159
|
'cli.usesModel': ' (usa el modelo {model})',
|
|
159
160
|
'cli.loadFailed': 'No se pudo cargar: {error}',
|
|
160
161
|
'cli.installMethod': 'Método de instalación:',
|
|
@@ -239,5 +240,9 @@ Los presets se guardan en {presetsDir}`,
|
|
|
239
240
|
'web.themeDark': 'Oscuro',
|
|
240
241
|
'web.settings': 'Configuración',
|
|
241
242
|
'web.close': 'Cerrar',
|
|
243
|
+
'web.telemetryCpu': 'CPU',
|
|
244
|
+
'web.telemetryRam': 'RAM',
|
|
245
|
+
'web.telemetryVram': 'VRAM',
|
|
246
|
+
'web.telemetryGpu': 'GPU {index}',
|
|
242
247
|
};
|
|
243
248
|
|