mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-09 17:36:14 -10:00
317 lines
14 KiB
JavaScript
317 lines
14 KiB
JavaScript
/** Engine configuration: beginner goals, expert parameters, predictions, restart. */
|
|
import { CONTEXT_TOKENS, expertParams, footprint, variant } from './catalog.mjs';
|
|
import { cachedHardware, detect } from './hardware.mjs';
|
|
import { get as getStore, update } from './store.mjs';
|
|
import * as supervisor from './supervisor.mjs';
|
|
import { nowIso } from './util.mjs';
|
|
|
|
const engineState = {
|
|
running: false,
|
|
startedAt: null,
|
|
uptimeSec: 0,
|
|
version: 'strata-engine 0.1.0',
|
|
backend: 'metal',
|
|
timer: null,
|
|
};
|
|
|
|
let hubRef = null;
|
|
|
|
export function init(hub) {
|
|
hubRef = hub;
|
|
supervisor.init(hub);
|
|
if (engineState.timer) clearInterval(engineState.timer);
|
|
engineState.timer = setInterval(() => {
|
|
if (!engineState.running || !engineState.startedAt) return;
|
|
engineState.uptimeSec = Math.round((Date.now() - new Date(engineState.startedAt).getTime()) / 1000);
|
|
}, 1000);
|
|
}
|
|
|
|
export function markRunning(backend) {
|
|
engineState.running = true;
|
|
engineState.startedAt = nowIso();
|
|
engineState.uptimeSec = 0;
|
|
if (backend) engineState.backend = backend;
|
|
}
|
|
|
|
export function markStopped() {
|
|
engineState.running = false;
|
|
engineState.startedAt = null;
|
|
engineState.uptimeSec = 0;
|
|
}
|
|
|
|
/** Merge stored overrides on top of the catalog defaults. */
|
|
export function params() {
|
|
const overrides = getStore().expert ?? {};
|
|
const catalog = expertParams(cachedHardware()?.cpu?.cores ?? null);
|
|
const known = new Set(catalog.map(p => p.id));
|
|
const mapped = new Set(['gpu.layers','ctx.length','ctx.kvMode','mem.mmap','mem.lock','perf.batch','perf.threads','perf.flashAttn','server.port','server.parallel']);
|
|
return [...catalog.map((p) => ({
|
|
...p,
|
|
applies: p.id.startsWith('sample.') ? 'Next request' : mapped.has(p.id) ? 'Next model load' : 'Preserved only · adapter does not apply this value',
|
|
value: Object.hasOwn(overrides, p.id) ? overrides[p.id] : p.value,
|
|
})), ...Object.entries(overrides).filter(([id]) => !known.has(id)).map(([id, value]) => ({
|
|
id, value, recommended: value, group: 'Additional settings', label: id,
|
|
effect: 'Preserved; no verified engine mapping in this build.', applies: 'Preserved only',
|
|
type: typeof value === 'boolean' ? 'toggle' : typeof value === 'number' ? 'number' : 'text',
|
|
}))];
|
|
}
|
|
|
|
function loadArgs(args) {
|
|
const sampling = new Set(['--temp', '--top-p', '--top-k', '--repeat-penalty', '--seed']);
|
|
const result = [];
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (sampling.has(args[i])) { i++; continue; }
|
|
result.push(args[i]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function config() {
|
|
const s = getStore();
|
|
const id = supervisor.status().modelId ?? s.loaded ?? s.current;
|
|
const v = id ? variant(id) : null;
|
|
const hw = await detect();
|
|
const sup = supervisor.status();
|
|
const ctxLabel = String(s.beginner.contextLength).toUpperCase();
|
|
const ctxTokens = CONTEXT_TOKENS[ctxLabel.toLowerCase()] ?? 32768;
|
|
|
|
const { pathFor } = await import('./models.mjs');
|
|
const previewArgs = v ? supervisor.buildArgs({
|
|
modelPath: sup.running ? pathFor(v) : `<models>/${v.id}/${v.source.file}`,
|
|
config: { beginner: s.beginner, expert: params(), serving: s.serving, layerCount: v.layerCount },
|
|
hardware: hw, port: Number(s.expert?.['server.port'] ?? 8080),
|
|
}) : [];
|
|
|
|
return {
|
|
extra: s.configExtra ?? {},
|
|
variantId: v?.id ?? null,
|
|
beginner: s.beginner,
|
|
expert: params(),
|
|
predicted: predict(s.beginner, hw, v),
|
|
footprint: v ? footprint(v, ctxTokens) : null,
|
|
dirty: sup.running && JSON.stringify(loadArgs(previewArgs)) !== JSON.stringify(loadArgs(sup.args)),
|
|
appliedAt: sup.startedAt ?? undefined,
|
|
engine: {
|
|
running: sup.running,
|
|
status: sup.status,
|
|
pid: sup.pid ?? undefined,
|
|
uptimeSec: sup.startedAt ? Math.round((Date.now() - new Date(sup.startedAt).getTime()) / 1000) : 0,
|
|
version: sup.binary ? `llama.cpp · ${sup.binary.source}` : engineState.version,
|
|
backend: hw.accelerator.backend,
|
|
startedAt: sup.startedAt ?? undefined,
|
|
port: sup.port,
|
|
command: v ? [sup.binary?.path ?? 'llama-server', ...previewArgs].join(' ') : '',
|
|
args: previewArgs,
|
|
error: sup.error,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function predict(beginner, hw, v, expert = getStore().expert ?? {}) {
|
|
const notes = [];
|
|
const ctx = CONTEXT_TOKENS[beginner.contextLength] ?? 32768;
|
|
|
|
const speed = beginner.priority === 'speed' ? 'Larger prompt batches' : beginner.priority === 'quality' ? 'Lower randomness' : beginner.priority === 'coding' ? 'Focused sampling' : 'Balanced';
|
|
let memory = 'Recommended';
|
|
let restartNeeded = true;
|
|
|
|
if (beginner.priority === 'speed' && ctx <= 16384) notes.push('Short context keeps the KV cache small.');
|
|
if (beginner.priority === 'quality') notes.push('Lower temperature makes responses less varied; it does not guarantee accuracy.');
|
|
if (beginner.priority === 'coding') notes.push('Lower temperature favors repeatable responses.');
|
|
if (beginner.readImages) {
|
|
memory = 'Higher — vision layer adds ~1.8 GB';
|
|
notes.push('Vision needs the multimodal projector loaded alongside the model.');
|
|
}
|
|
if (ctx >= 65536) {
|
|
memory = 'Much higher — long context dominates memory';
|
|
notes.push('Longer context allocates a larger KV cache. Actual usage varies by architecture and cache format.');
|
|
}
|
|
if (beginner.gpuIdle !== 'never') {
|
|
notes.push(`Freeing the GPU after ${beginner.gpuIdle.replace('m', ' minutes').replace('1h', 'an hour')} means the next prompt reloads the model.`);
|
|
} else {
|
|
notes.push('Keeping the model loaded avoids a model reload between requests. Prompt reading still takes time.');
|
|
}
|
|
if (v && hw) {
|
|
const headroom = hw.accelerator.unified ? hw.memory.totalGb * 0.72 : hw.accelerator.usableVramGb;
|
|
const fp = { ...footprint(v, ctx) };
|
|
const kvFactor = ['FP16'].includes(expert['ctx.kvMode']) ? 2 : expert['ctx.kvMode'] === 'Q4_0' ? .5 : 1;
|
|
fp.kvGb *= kvFactor; fp.totalGb = fp.weightsGb + fp.kvGb + fp.overheadGb;
|
|
if (headroom) {
|
|
const verdict = fp.totalGb <= headroom * 0.85 ? 'Comfortable' : fp.totalGb <= headroom ? 'Tight' : 'Above estimated memory budget';
|
|
/* Non-breaking spaces inside the measurement: in the narrow effect
|
|
panel this wrapped after "of", leaving an orphaned "12 GB" on a line
|
|
of its own. The break now falls before the figure, so the second line
|
|
is the whole measurement rather than its tail. */
|
|
const nb = '\u00a0';
|
|
memory = `${verdict} · about${nb}${fp.totalGb.toFixed(1)}${nb}GB${nb}of${nb}${headroom.toFixed(0)}${nb}GB`;
|
|
notes.push(
|
|
`${v.label} at ${(ctx / 1024).toFixed(0)}K: ${fp.weightsGb.toFixed(1)} GB weights + ${fp.kvGb.toFixed(2)} GB KV cache + ${fp.overheadGb.toFixed(1)} GB runtime.`,
|
|
);
|
|
if (fp.totalGb > headroom) notes.push('This estimate exceeds the selected memory budget. Use less context or a smaller variant.');
|
|
}
|
|
}
|
|
if (ctx > (v?.contextMax ?? 32768)) {
|
|
notes.push(`Beyond the model's advertised context. Output reliability is not verified past ${((v?.contextMax ?? 32768) / 1024).toFixed(0)}K.`);
|
|
}
|
|
|
|
return { responseSpeed: speed, memory, restartNeeded, notes };
|
|
}
|
|
|
|
export async function patch(body) {
|
|
const s = getStore();
|
|
const next = { ...s };
|
|
|
|
if (body.beginner) {
|
|
next.beginner = { ...s.beginner, ...body.beginner };
|
|
if (body.beginner.contextLength) next.expert = { ...(s.expert ?? {}), 'ctx.length': body.beginner.contextLength === 'recommended' ? '128K' : body.beginner.contextLength.toUpperCase() };
|
|
}
|
|
if (Array.isArray(body.expert)) {
|
|
const overrides = body.replaceExpert ? {} : { ...(next.expert ?? {}) };
|
|
for (const item of body.expert) {
|
|
if (!item || typeof item.id !== 'string') continue;
|
|
overrides[item.id] = item.value;
|
|
}
|
|
next.expert = overrides;
|
|
}
|
|
if (body.expertReset) next.expert = {};
|
|
if (body.extra && typeof body.extra === 'object' && !Array.isArray(body.extra)) next.configExtra = body.extra;
|
|
|
|
update(next);
|
|
const cfg = await config();
|
|
hubRef?.send('engine', cfg);
|
|
return cfg;
|
|
}
|
|
|
|
export async function reset() {
|
|
update({
|
|
beginner: { priority: 'balanced', contextLength: 'recommended', readImages: false, gpuIdle: 'never' },
|
|
expert: {},
|
|
});
|
|
const cfg = await config();
|
|
hubRef?.send('engine', cfg);
|
|
return cfg;
|
|
}
|
|
|
|
export async function restart() {
|
|
if (process.env.STRATA_REAL_ENGINE !== '1') throw new Error('Restart is unavailable with the example engine. Save settings for the next load.');
|
|
const s = getStore();
|
|
const id = supervisor.status().modelId ?? s.loaded;
|
|
const v = id ? variant(id) : null;
|
|
const hw = await detect();
|
|
|
|
if (!v) {
|
|
hubRef?.send('log', { level: 'warn', message: 'No model loaded — nothing to restart', at: nowIso() });
|
|
const cfg = await config();
|
|
hubRef?.send('engine', cfg);
|
|
return cfg;
|
|
}
|
|
|
|
hubRef?.send('log', { level: 'info', message: `Restarting engine with ${v.label}`, at: nowIso() });
|
|
try {
|
|
const { pathFor } = await import('./models.mjs');
|
|
await supervisor.restart({
|
|
modelId: v.id,
|
|
modelPath: pathFor(v),
|
|
port: Number(s.expert?.['server.port'] ?? 8080),
|
|
config: { beginner: s.beginner, expert: params(), serving: s.serving, layerCount: v.layerCount },
|
|
hardware: hw,
|
|
});
|
|
markRunning(hw.accelerator.backend);
|
|
} catch (err) {
|
|
hubRef?.send('log', { level: 'error', message: `Restart failed: ${err.message}`, at: nowIso() });
|
|
markStopped();
|
|
}
|
|
|
|
const cfg = await config();
|
|
hubRef?.send('engine', cfg);
|
|
return cfg;
|
|
}
|
|
|
|
/**
|
|
* @param draft optional beginner config to predict *instead of* the saved one.
|
|
* Without it the screen can only show the consequence of settings that have
|
|
* already been applied, which makes an "impact" line a restatement of the
|
|
* status quo rather than a preview of the choice in front of you.
|
|
*/
|
|
export async function validate(draft = {}) {
|
|
const hw = await detect();
|
|
const s = getStore();
|
|
const beginner = { ...s.beginner, ...(draft?.beginner ?? {}) };
|
|
const v = variant(draft?.variantId ?? s.loaded ?? s.current);
|
|
const issues = [];
|
|
const values = Object.fromEntries(params().map(p => [p.id, p.value]));
|
|
for (const p of draft?.expert ?? []) values[p.id] = p.value;
|
|
const addError = (key, message) => issues.push({ level: 'error', key, message: `${key}: ${message}` });
|
|
const enums = { priority: ['speed', 'balanced', 'quality', 'coding'], contextLength: Object.keys(CONTEXT_TOKENS), gpuIdle: ['never'] };
|
|
for (const [key, options] of Object.entries(enums)) if (!options.includes(beginner[key])) addError(key, 'choose a supported value.');
|
|
if (typeof beginner.readImages !== 'boolean' || beginner.readImages) addError('readImages', 'image input is not supported by this host.');
|
|
const limits = {
|
|
'sample.temp': [0, 2], 'sample.topP': [0, 1], 'sample.topK': [0, 10000], 'sample.repeat': [0.5, 3],
|
|
'sample.seed': [-1, 2147483647], 'sample.maxTokens': [0, 1048576], 'perf.threads': [1, 1024], 'perf.batch': [1, 65536],
|
|
'perf.draftDepth': [1, 64], 'mem.expertCache': [0, 10000], 'server.port': [1, 65535],
|
|
'server.parallel': [1, 64], 'server.queue': [0, 10000], 'server.timeout': [1, 86400],
|
|
'adv.rope': [0.01, 100], 'adv.ropeBase': [1, 1000000000],
|
|
};
|
|
const fractional = new Set(['sample.temp', 'sample.topP', 'sample.repeat', 'adv.rope']);
|
|
for (const p of expertParams(hw.cpu.cores)) {
|
|
const value = values[p.id];
|
|
if (p.type === 'number') {
|
|
const [min, max] = limits[p.id] ?? [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER];
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (!fractional.has(p.id) && !Number.isInteger(value))) addError(p.id, `enter ${fractional.has(p.id) ? 'a number' : 'a whole number'} from ${min} to ${max}.`);
|
|
} else if (p.type === 'toggle' && typeof value !== 'boolean') addError(p.id, 'use true or false.');
|
|
else if (p.type === 'select' && !p.options?.includes(value)) addError(p.id, 'choose a supported value.');
|
|
else if (p.type === 'text' && typeof value !== 'string') addError(p.id, 'enter text.');
|
|
}
|
|
if (!CONTEXT_TOKENS[String(values['ctx.length']).toLowerCase()] || String(values['ctx.length']).toLowerCase() === 'recommended') addError('ctx.length', 'enter a whole context size from 4K to 1024K.');
|
|
const kv = values['ctx.kvMode'];
|
|
if (draft?.beginner && draft?.expert?.some(p => p.id === 'ctx.length')) {
|
|
const effective = beginner.contextLength === 'recommended' ? '128K' : String(beginner.contextLength).toUpperCase();
|
|
if (values['ctx.length'] !== effective) addError('ctx.length', 'match beginner.contextLength (Recommended means 128K).');
|
|
}
|
|
const ctxLabel = beginner.contextLength;
|
|
const ctx = CONTEXT_TOKENS[String(ctxLabel).toLowerCase()] ?? 32768;
|
|
const headroom = hw.accelerator.unified ? hw.memory.totalGb * 0.72 : hw.accelerator.usableVramGb;
|
|
|
|
if (v && ctx > v.contextMax) {
|
|
issues.push({ level: 'warn', message: `${ctx / 1024}K exceeds ${v.label}'s trained context — quality beyond that limit is not verified.` });
|
|
}
|
|
if (v && headroom) {
|
|
const fp = footprint(v, ctx); const factor = kv === 'FP16' ? 2 : kv === 'Q4_0' ? .5 : 1;
|
|
const estimate = fp.weightsGb + fp.kvGb * factor + fp.overheadGb;
|
|
if (estimate > hw.memory.totalGb) issues.push({ level: 'warn', message: `Estimated working memory (${estimate.toFixed(1)} GB) exceeds physical memory. Choose a smaller model or shorter context.` });
|
|
}
|
|
if (v && headroom && v.diskGb > headroom) {
|
|
issues.push({ level: 'warn', message: `${v.label} needs ${v.diskGb} GB; about ${headroom.toFixed(0)} GB is fast memory. Actual fit and offload behavior depend on the adapter.` });
|
|
}
|
|
const threads = Number(values['perf.threads'] ?? 0);
|
|
if (threads > hw.cpu.cores) {
|
|
issues.push({ level: 'warn', message: `CPU threads (${threads}) exceed physical cores (${hw.cpu.cores}).` });
|
|
}
|
|
const parallel = Number(values['server.parallel'] ?? 1);
|
|
if (parallel > 1) {
|
|
issues.push({ level: 'warn', message: 'Parallel requests multiply KV cache memory — keep it at 1 on unified memory.' });
|
|
}
|
|
// The prediction for the config being asked about, not the one in force.
|
|
return {
|
|
ok: issues.every((i) => i.level !== 'error'),
|
|
issues,
|
|
predicted: predict(beginner, hw, v, values),
|
|
};
|
|
}
|
|
|
|
export async function exportConfig() {
|
|
const s = getStore();
|
|
return {
|
|
...(s.configExtra ?? {}),
|
|
schema: 'strata.engine/1',
|
|
exportedAt: nowIso(),
|
|
model: s.loaded ?? s.current,
|
|
beginner: s.beginner,
|
|
expert: Object.fromEntries(params().map((p) => [p.id, p.value])),
|
|
};
|
|
}
|
|
|
|
export function engineInfo() {
|
|
return { ...engineState, timer: undefined };
|
|
}
|