llm-tuner 0.1.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.
@@ -0,0 +1,335 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { run, which, exists, fetchJson, freePort, spawnLogged, killProcess, sleep, PLATFORM, homeDir, timestamp } from '../util.js';
5
+ import { modelMetaFromFile } from '../gguf.js';
6
+ import { runApiBenchmark } from '../benchmark.js';
7
+ import { TunerError, benchError } from '../errors.js';
8
+
9
+ const DEFAULT_HOST = '127.0.0.1:11434';
10
+
11
+ function baseUrl() {
12
+ let h = process.env.OLLAMA_HOST || DEFAULT_HOST;
13
+ if (!/^https?:\/\//.test(h)) h = `http://${h}`;
14
+ return h.replace('0.0.0.0', '127.0.0.1').replace(/\/$/, '');
15
+ }
16
+
17
+ async function ollamaBin() {
18
+ const extra = PLATFORM === 'win32'
19
+ ? [path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Ollama', 'ollama.exe')]
20
+ : PLATFORM === 'darwin' ? ['/Applications/Ollama.app/Contents/Resources/ollama', '/usr/local/bin/ollama', '/opt/homebrew/bin/ollama'] : ['/usr/local/bin/ollama', '/usr/bin/ollama'];
21
+ return which('ollama', extra);
22
+ }
23
+
24
+ async function hasSystemdService() {
25
+ if (PLATFORM !== 'linux') return false;
26
+ const r = await run('systemctl', ['cat', 'ollama.service']);
27
+ return r.code === 0;
28
+ }
29
+
30
+ /** Where the models live (needed to start a private benchmark server). */
31
+ async function modelsDir() {
32
+ if (process.env.OLLAMA_MODELS) return process.env.OLLAMA_MODELS;
33
+ const candidates = [path.join(homeDir(), '.ollama', 'models')];
34
+ if (PLATFORM === 'linux') candidates.unshift('/usr/share/ollama/.ollama/models', '/var/lib/ollama/.ollama/models');
35
+ for (const c of candidates) {
36
+ try { await fs.access(path.join(c, 'manifests'), fs.constants.R_OK); return c; } catch { /* next */ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // Synthesize estimator metadata from /api/show when the GGUF blob isn't readable.
42
+ function metaFromModelInfo(info, sizeBytes) {
43
+ const arch = info['general.architecture'];
44
+ const g = (k) => info[`${arch}.${k}`];
45
+ const nLayers = g('block_count');
46
+ const nEmbd = g('embedding_length');
47
+ const nHead = g('attention.head_count');
48
+ const nHeadKv = g('attention.head_count_kv') ?? nHead;
49
+ const keyLen = g('attention.key_length') ?? nEmbd / nHead;
50
+ const vocab = info['tokenizer.ggml.tokens']?.length ?? 32000;
51
+ const outputBytes = Math.round(sizeBytes * 0.03);
52
+ const per = (sizeBytes - outputBytes) / nLayers;
53
+ const nExperts = g('expert_count') || 0;
54
+ return {
55
+ arch, nLayers, nEmbd, nHead, nHeadKv, keyLen, valLen: g('attention.value_length') ?? keyLen, vocab,
56
+ trainContext: g('context_length'), fileBytes: sizeBytes, layerBytes: Array(nLayers).fill(per),
57
+ layerExpertBytes: Array(nLayers).fill(nExperts ? per * 0.9 : 0), outputBytes, embdBytes: outputBytes,
58
+ kvHeadsPerLayer: Array(nLayers).fill(Array.isArray(nHeadKv) ? Math.max(...nHeadKv) : nHeadKv),
59
+ slidingWindow: g('attention.sliding_window') || null, swaLayers: [],
60
+ nExperts, nExpertsUsed: g('expert_used_count') || 0, isMoE: nExperts > 1, approximate: true,
61
+ };
62
+ }
63
+
64
+ /** Tag of an Ollama model name: the text after the first colon, `latest` when there is none. */
65
+ const tagOf = (name) => {
66
+ const i = name.indexOf(':');
67
+ return i === -1 ? 'latest' : name.slice(i + 1);
68
+ };
69
+
70
+ /** Tag of a model name without its quantization level, lower-cased: what tells models apart. */
71
+ export function tagStem(name, quant) {
72
+ const parts = tagOf(name).toLowerCase().split('-');
73
+ if (quant) {
74
+ const target = String(quant).toLowerCase();
75
+ const lastIdx = parts.lastIndexOf(target);
76
+ if (lastIdx !== -1) {
77
+ parts.splice(lastIdx, 1);
78
+ }
79
+ }
80
+ return parts.join('-');
81
+ }
82
+
83
+ /** Tags of the same model as `key` in an /api/tags list: [{ key, quant, sizeBytes, selected }]. */
84
+ export function ollamaVariants(models, key) {
85
+ const isTuned = (name) => tagOf(name).includes('-tuned-');
86
+ // A tuned copy is not a base model: callers must pass the base key.
87
+ if (isTuned(key)) return [];
88
+ const named = models.filter((m) => typeof m?.name === 'string');
89
+ const self = named.find((m) => m.name === key);
90
+ if (!self) return [];
91
+ const toVariant = (m) => ({
92
+ key: m.name,
93
+ quant: m.details?.quantization_level ?? null,
94
+ sizeBytes: m.size,
95
+ selected: m.name === key,
96
+ });
97
+ const size = self.details?.parameter_size;
98
+ if (!size) return [toVariant(self)];
99
+ const base = key.split(':')[0];
100
+ const family = self.details?.family;
101
+ const stem = tagStem(key, self.details?.quantization_level);
102
+ // Deliberately conservative: two tags are the same model only when base name, parameter size,
103
+ // family and tag stem all match. A default tag such as `14b` is not grouped with
104
+ // `14b-instruct-q8_0`, because the names cannot prove they are the same model.
105
+ const group = named.filter((m) =>
106
+ m.name.split(':')[0] === base &&
107
+ !isTuned(m.name) &&
108
+ m.details?.parameter_size === size &&
109
+ m.details?.family === family &&
110
+ tagStem(m.name, m.details?.quantization_level) === stem
111
+ );
112
+ // Alias tags share a digest: keep one of them, preferring the requested key.
113
+ return group.filter((m, i) =>
114
+ !m.digest ||
115
+ m.name === key ||
116
+ (m.digest !== self.digest && group.findIndex((x) => x.digest === m.digest) === i)
117
+ ).map(toVariant);
118
+ }
119
+
120
+ export const ollama = {
121
+ id: 'ollama',
122
+ name: 'Ollama',
123
+ // KV type and flash attention are server-wide settings in Ollama; GPU order via CUDA_VISIBLE_DEVICES.
124
+ capabilities: { kvTypes: ['f16', 'q8_0', 'q4_0'], perModelKv: false, gpuOrder: true, cpuMoe: false, exactBenchmark: true, variantSelect: true },
125
+
126
+ async detect() {
127
+ const bin = await ollamaBin();
128
+ const url = baseUrl();
129
+ const v = await fetchJson(`${url}/api/version`, { timeout: 1500 });
130
+ let version = v?.json?.version || null;
131
+ if (!version && bin) version = (await run(bin, ['--version'])).stdout.match(/\d+\.\d+\.\d+/)?.[0] || null;
132
+ return { installed: !!bin || !!v?.ok, bin, version, serverRunning: !!v?.ok, apiUrl: url, systemd: await hasSystemdService(), modelsDir: await modelsDir() };
133
+ },
134
+
135
+ async ensureServer(ctx) {
136
+ if ((await fetchJson(`${ctx.detection.apiUrl}/api/version`, { timeout: 1500 }))?.ok) return true;
137
+ if (!ctx.detection.bin) return false;
138
+ const { spawn } = await import('node:child_process');
139
+ spawn(ctx.detection.bin, ['serve'], { detached: true, stdio: 'ignore' }).unref();
140
+ for (let i = 0; i < 20; i++) {
141
+ await sleep(500);
142
+ if ((await fetchJson(`${ctx.detection.apiUrl}/api/version`, { timeout: 1000 }))?.ok) return true;
143
+ }
144
+ return false;
145
+ },
146
+
147
+ async listModels(ctx) {
148
+ await this.ensureServer(ctx);
149
+ const r = await fetchJson(`${ctx.detection.apiUrl}/api/tags`);
150
+ return (r?.json?.models || [])
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 }));
153
+ },
154
+
155
+ /** Downloaded variants (tags) of a model: [{ key, quant, sizeBytes, selected }]. */
156
+ async listVariants(ctx, key) {
157
+ await this.ensureServer(ctx);
158
+ const r = await fetchJson(`${ctx.detection.apiUrl}/api/tags`);
159
+ return ollamaVariants(r?.json?.models || [], key);
160
+ },
161
+
162
+ async modelMeta(ctx, key) {
163
+ const r = await fetchJson(`${ctx.detection.apiUrl}/api/show`, { method: 'POST', body: { model: key, verbose: true }, timeout: 30000 });
164
+ if (!r?.ok) throw new TunerError('errors.ollamaUnknownModel', { key });
165
+ const from = r.json.modelfile?.match(/^FROM\s+(.+)$/m)?.[1]?.trim();
166
+ if (from && (await exists(from))) {
167
+ try { return { file: from, ...(await modelMetaFromFile(from)) }; } catch { /* unreadable blob */ }
168
+ }
169
+ const list = await this.listModels(ctx);
170
+ const size = list.find((m) => m.key === key)?.sizeBytes || 0;
171
+ return { file: null, ...metaFromModelInfo(r.json.model_info || {}, size) };
172
+ },
173
+
174
+ async unloadAll(url) {
175
+ const ps = await fetchJson(`${url}/api/ps`);
176
+ for (const m of ps?.json?.models || []) {
177
+ await fetchJson(`${url}/api/generate`, { method: 'POST', body: { model: m.name, keep_alive: 0 }, timeout: 30000 });
178
+ }
179
+ },
180
+
181
+ async prepare(ctx) {
182
+ await this.unloadAll(ctx.detection.apiUrl);
183
+ await sleep(1500);
184
+ },
185
+
186
+ serverEnv(c) {
187
+ const env = {
188
+ OLLAMA_FLASH_ATTENTION: c.flashAttention ? '1' : '0',
189
+ OLLAMA_KV_CACHE_TYPE: c.kvType,
190
+ OLLAMA_NUM_PARALLEL: String(c.parallel),
191
+ };
192
+ if (c.gpuOrder?.length) {
193
+ env.CUDA_DEVICE_ORDER = 'PCI_BUS_ID';
194
+ env.CUDA_VISIBLE_DEVICES = c.gpuOrder.join(',');
195
+ }
196
+ return env;
197
+ },
198
+
199
+ requestOptions(c) {
200
+ return { num_ctx: c.ctx, num_gpu: c.fullOffload ? 999 : c.gpuLayers, num_thread: c.threads, num_batch: c.ubatch };
201
+ },
202
+
203
+ /**
204
+ * Benchmark in a private `ollama serve` on another port so KV type / flash
205
+ * attention can change per candidate without touching the user's server.
206
+ */
207
+ async benchmark(ctx, model, candidate, opts) {
208
+ const dir = ctx.detection.modelsDir;
209
+ if (!dir || !ctx.detection.bin) {
210
+ // Can't read the models: measure on the main server (its KV type/FA stay as configured).
211
+ const res = await runApiBenchmark({ kind: 'ollama', baseUrl: ctx.detection.apiUrl, model: model.key, ollamaOptions: this.requestOptions(candidate), ...opts });
212
+ await this.unloadAll(ctx.detection.apiUrl);
213
+ return { ...res, noteCode: 'bench.mainServerNote' };
214
+ }
215
+ const port = await freePort();
216
+ const url = `http://127.0.0.1:${port}`;
217
+ const env = { ...process.env, ...this.serverEnv(candidate), OLLAMA_HOST: `127.0.0.1:${port}`, OLLAMA_MODELS: dir, OLLAMA_MAX_LOADED_MODELS: '1', OLLAMA_KEEP_ALIVE: '10m' };
218
+ const proc = spawnLogged(ctx.detection.bin, ['serve'], { env });
219
+ try {
220
+ let up = false;
221
+ for (let i = 0; i < 40; i++) {
222
+ if ((await fetchJson(`${url}/api/version`, { timeout: 1000 }))?.ok) { up = true; break; }
223
+ await sleep(500);
224
+ }
225
+ if (!up) return benchError('bench.ollamaTestServerFailed', { detail: proc.lines.slice(-2).join(' | ') });
226
+ const res = await runApiBenchmark({ kind: 'ollama', baseUrl: url, model: model.key, ollamaOptions: this.requestOptions(candidate), ...opts });
227
+ // How much really landed on GPU (Ollama may silently fall back to CPU).
228
+ const ps = await fetchJson(`${url}/api/ps`);
229
+ const loaded = ps?.json?.models?.[0];
230
+ if (loaded) res.offload = { sizeBytes: loaded.size, vramBytes: loaded.size_vram, full: loaded.size_vram >= loaded.size * 0.99 };
231
+ if (/out of memory|cudaMalloc failed/i.test(proc.lines.join('\n'))) Object.assign(res, benchError('bench.oom'), { oom: true });
232
+ return res;
233
+ } finally {
234
+ await killProcess(proc.child);
235
+ await sleep(1000);
236
+ }
237
+ },
238
+
239
+ tunedName(key, c) {
240
+ const [base, tag = 'latest'] = key.split(':');
241
+ return `${base}:${tag}-tuned-${Math.round(c.ctx / 1024)}k`;
242
+ },
243
+
244
+ modelfile(key, c) {
245
+ const o = this.requestOptions(c);
246
+ return [`FROM ${key}`, ...Object.entries(o).map(([k, v]) => `PARAMETER ${k} ${v}`), ''].join('\n');
247
+ },
248
+
249
+ /**
250
+ * 1) Create a tuned model variant with per-model parameters (num_ctx, num_gpu...).
251
+ * 2) Set server-wide env (KV type, flash attention, GPU order) for the Ollama service.
252
+ */
253
+ async apply(ctx, model, c, { dryRun = false, useSudo = false, skipIfApplied = false } = {}) {
254
+ const name = this.tunedName(model.key, c);
255
+ const env = this.serverEnv(c);
256
+ const result = { tunedModel: name, modelfile: this.modelfile(model.key, c), env, changes: [], pendingCommands: [] };
257
+ if (dryRun) return { dryRun: true, ...result };
258
+
259
+ const dir = path.join(os.tmpdir(), `llm-tuner-${timestamp()}`);
260
+ await fs.mkdir(dir, { recursive: true });
261
+ if (skipIfApplied && (await this.isApplied(ctx, model, c))) {
262
+ result.changes.push({ code: 'changes.ollamaExists', params: { name } });
263
+ return result;
264
+ }
265
+ const mf = path.join(dir, 'Modelfile');
266
+ await fs.writeFile(mf, result.modelfile);
267
+ const r = await run(ctx.detection.bin, ['create', name, '-f', mf], { timeout: 300_000 });
268
+ if (r.code !== 0) throw new Error(`ollama create failed: ${(r.stderr || r.stdout).slice(-300)}`);
269
+ result.changes.push({ code: 'changes.ollamaCreated', params: { name } });
270
+
271
+ if (PLATFORM === 'win32') {
272
+ for (const [k, v] of Object.entries(env)) await run('setx', [k, v]);
273
+ result.changes.push({ code: 'changes.ollamaSetx' });
274
+ await run('taskkill', ['/F', '/IM', 'ollama app.exe']);
275
+ await run('taskkill', ['/F', '/IM', 'ollama.exe']);
276
+ const app = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Ollama', 'ollama app.exe');
277
+ if (await exists(app)) { const { spawn } = await import('node:child_process'); spawn(app, [], { detached: true, stdio: 'ignore' }).unref(); }
278
+ } else if (PLATFORM === 'darwin') {
279
+ for (const [k, v] of Object.entries(env)) await run('launchctl', ['setenv', k, v]);
280
+ result.changes.push({ code: 'changes.ollamaLaunchctl' });
281
+ await run('osascript', ['-e', 'quit app "Ollama"']);
282
+ await sleep(2000);
283
+ await run('open', ['-a', 'Ollama']);
284
+ } else if (ctx.detection.systemd) {
285
+ const conf = ['[Service]', ...Object.entries(env).map(([k, v]) => `Environment="${k}=${v}"`), ''].join('\n');
286
+ const confFile = path.join(dir, 'llm-tuner.conf');
287
+ await fs.writeFile(confFile, conf);
288
+ const cmds = [
289
+ 'sudo mkdir -p /etc/systemd/system/ollama.service.d',
290
+ `sudo cp ${confFile} /etc/systemd/system/ollama.service.d/llm-tuner.conf`,
291
+ 'sudo systemctl daemon-reload',
292
+ 'sudo systemctl restart ollama',
293
+ ];
294
+ if (useSudo) {
295
+ const ok = (await run('sudo', ['-n', 'true'])).code === 0;
296
+ if (ok) {
297
+ for (const cmd of cmds) { const [, ...rest] = cmd.split(' '); await run('sudo', ['-n', ...rest]); }
298
+ result.changes.push({ code: 'changes.ollamaSystemd' });
299
+ } else result.pendingCommands.push(...cmds);
300
+ } else result.pendingCommands.push(...cmds);
301
+ } else {
302
+ result.pendingCommands.push(...Object.entries(env).map(([k, v]) => `export ${k}=${v}`), 'ollama serve');
303
+ }
304
+ return result;
305
+ },
306
+
307
+ async isApplied(ctx, model, c) {
308
+ const r = await fetchJson(`${ctx.detection.apiUrl}/api/show`, { method: 'POST', body: { model: this.tunedName(model.key, c) }, timeout: 10000 });
309
+ return !!r?.ok;
310
+ },
311
+
312
+ /** Load the tuned variant (kept in memory 30 min) and optionally measure it. */
313
+ async load(ctx, model, { candidate, depthTokens = 0, genTokens = 200, onProgress } = {}) {
314
+ await this.ensureServer(ctx);
315
+ await this.unloadAll(ctx.detection.apiUrl);
316
+ const name = this.tunedName(model.key, candidate);
317
+ onProgress?.({ phase: 'load' });
318
+ const warm = await fetchJson(`${ctx.detection.apiUrl}/api/generate`, { method: 'POST', body: { model: name, keep_alive: '30m' }, timeout: 600_000 });
319
+ if (!warm?.ok) return warm?.json?.error ? { ok: false, error: warm.json.error } : benchError('bench.loadFailed');
320
+ const res = await runApiBenchmark({ kind: 'ollama', baseUrl: ctx.detection.apiUrl, model: name, depthTokens, genTokens, onProgress, ollamaOptions: {} });
321
+ return { ...res, model: name };
322
+ },
323
+
324
+ async chat(ctx, model, messages) {
325
+ const url = ctx.detection?.apiUrl || baseUrl();
326
+ const r = await fetchJson(`${url}/api/chat`, {
327
+ method: 'POST',
328
+ body: { model, messages, stream: false },
329
+ timeout: 120_000,
330
+ });
331
+ if (!r?.ok) throw new Error(r?.json?.error || 'Failed to chat with Ollama');
332
+ return { message: r.json.message };
333
+ },
334
+ };
335
+
@@ -0,0 +1,16 @@
1
+ import { t } from '../i18n/index.js';
2
+
3
+ /** A user-facing error: interfaces translate `code`; `message` is English for logs. */
4
+ export class TunerError extends Error {
5
+ constructor(code, params = {}) {
6
+ super(t('en', code, params));
7
+ this.name = 'TunerError';
8
+ this.code = code;
9
+ this.params = params;
10
+ }
11
+ }
12
+
13
+ /** Failed benchmark/load result with a translatable code. */
14
+ export function benchError(code, params = {}) {
15
+ return { ok: false, errorCode: code, errorParams: params, error: t('en', code, params) };
16
+ }
@@ -0,0 +1,237 @@
1
+ import { MiB } from './util.js';
2
+
3
+ // Bytes per element of each KV cache type (ggml block sizes).
4
+ export const KV_TYPES = {
5
+ // readCost: relative cost of reading the cache during decode. Q4_0 dequantization
6
+ // in flash attention is much slower in practice (measured ~12x on RTX 50xx).
7
+ f16: { bytes: 2, quality: 1.0, readCost: 1, label: 'F16 (maximum quality)' },
8
+ q8_0: { bytes: 34 / 32, quality: 0.99, readCost: 1, label: 'Q8_0 (almost lossless)' },
9
+ q4_0: { bytes: 18 / 32, quality: 0.93, readCost: 12, label: 'Q4_0 (saves memory, loses precision)' },
10
+ };
11
+
12
+ // Calibrated on RTX 5070 x2 / Qwen2.5-Coder-32B: CUDA context + scratch per GPU.
13
+ // nvidia-smi "free" already excludes the driver's own reserve, so only a small margin.
14
+ const PER_GPU_OVERHEAD = 350 * MiB;
15
+ const PER_GPU_RESERVE = 64 * MiB;
16
+ // Estimates within this distance of fitting are still tried as a full offload.
17
+ const PROBE_MARGIN = 1024 * MiB;
18
+ const COMPUTE_BYTES_PER_CTX_TOKEN = 3.5 * 1024;
19
+ // Fraction of theoretical bandwidth llama.cpp typically achieves in decode.
20
+ const DECODE_EFFICIENCY = 0.7;
21
+
22
+ /** KV cache bytes for one layer at a given context length. */
23
+ export function kvLayerBytes(meta, layer, ctx, kvType) {
24
+ const heads = meta.kvHeadsPerLayer[layer];
25
+ if (!heads) return 0;
26
+ const tokens = meta.slidingWindow && meta.swaLayers.includes(layer) ? Math.min(ctx, meta.slidingWindow) : ctx;
27
+ const b = KV_TYPES[kvType].bytes;
28
+ return tokens * heads * (meta.keyLen * b + meta.valLen * b);
29
+ }
30
+
31
+ export function kvTotalBytes(meta, ctx, kvType) {
32
+ let total = 0;
33
+ for (let i = 0; i < meta.nLayers; i++) total += kvLayerBytes(meta, i, ctx, kvType);
34
+ return total;
35
+ }
36
+
37
+ /** Memory each GPU can give to the model, after what's already in use. */
38
+ export function gpuBudgets(gpus) {
39
+ return gpus.map((g) => ({
40
+ index: g.index,
41
+ name: g.name,
42
+ budget: Math.max(0, g.freeBytes - PER_GPU_OVERHEAD - PER_GPU_RESERVE),
43
+ bandwidthGBps: g.bandwidthGBps,
44
+ }));
45
+ }
46
+
47
+ /**
48
+ * Pack layers onto GPUs in priority order (llama.cpp split-mode "layer" keeps
49
+ * layers contiguous per device; the output layer lands on the last device).
50
+ * MoE: when `cpuMoeLayers` > 0, the expert tensors of the first N layers stay
51
+ * in system RAM (llama.cpp --n-cpu-moe), which frees VRAM cheaply.
52
+ */
53
+ export function placeLayers(meta, gpus, { ctx, kvType, ubatch = 512, cpuMoeLayers = 0 }) {
54
+ const budgets = gpuBudgets(gpus);
55
+ const logitsBytes = meta.vocab * ubatch * 4;
56
+ const computePerGpu = ctx * COMPUTE_BYTES_PER_CTX_TOKEN / Math.max(1, budgets.length);
57
+ let used = budgets.map(() => computePerGpu);
58
+ let layersPerGpu = budgets.map(() => 0);
59
+ const layerCost = (i) => meta.layerBytes[i] - (i < cpuMoeLayers ? meta.layerExpertBytes[i] : 0) + kvLayerBytes(meta, i, ctx, kvType);
60
+
61
+ let g = 0;
62
+ let placed = 0;
63
+ // Layers that do not fit stay on CPU; llama.cpp offloads the *last* n layers,
64
+ // so we count how many fit and treat the remaining first layers as CPU layers.
65
+ const order = [...Array(meta.nLayers).keys()].reverse();
66
+ const fits = [];
67
+ for (const i of order) {
68
+ const cost = layerCost(i);
69
+ while (g < budgets.length && used[g] + cost > budgets[g].budget) g++;
70
+ if (g >= budgets.length) break;
71
+ used[g] += cost;
72
+ layersPerGpu[g]++;
73
+ fits.push(i);
74
+ placed++;
75
+ }
76
+ // Output layer (+ logits buffer) goes to the GPU that holds the last layers,
77
+ // i.e. the first one we filled.
78
+ let outputOnGpu = false;
79
+ if (placed === meta.nLayers && budgets.length) {
80
+ const outCost = meta.outputBytes + logitsBytes;
81
+ const target = used.findIndex((u, k) => u + outCost <= budgets[k].budget);
82
+ if (target >= 0) { used[target] += outCost; outputOnGpu = true; }
83
+
84
+ // Greedy packing leaves the first GPU at its limit; llama.cpp then fails to
85
+ // allocate compute buffers there. Spread layers so every GPU keeps the same
86
+ // fraction of headroom.
87
+ if (outputOnGpu && budgets.length > 1) {
88
+ const frac = used.reduce((a, b) => a + b, 0) / budgets.reduce((a, b) => a + b.budget, 0);
89
+ const u2 = budgets.map(() => computePerGpu);
90
+ const l2 = budgets.map(() => 0);
91
+ u2[target] += outCost;
92
+ let k = 0;
93
+ for (const i of order) {
94
+ const cost = layerCost(i);
95
+ while (k < budgets.length - 1 && u2[k] + cost > budgets[k].budget * frac) k++;
96
+ u2[k] += cost;
97
+ l2[k]++;
98
+ }
99
+ if (u2.every((u, j) => u <= budgets[j].budget)) { used = u2; layersPerGpu = l2; }
100
+ }
101
+ }
102
+
103
+ // Rough RAM bytes read per token by the CPU for layers left behind.
104
+ const cpuLayers = meta.nLayers - placed;
105
+ const activeFrac = meta.isMoE && meta.nExperts ? meta.nExpertsUsed / meta.nExperts : 1;
106
+ let cpuBytesPerToken = 0;
107
+ for (let i = 0; i < meta.nLayers; i++) {
108
+ const onCpu = !fits.includes(i);
109
+ const expert = meta.layerExpertBytes[i];
110
+ const dense = meta.layerBytes[i] - expert;
111
+ if (onCpu) cpuBytesPerToken += dense + expert * activeFrac;
112
+ else if (i < cpuMoeLayers) cpuBytesPerToken += expert * activeFrac;
113
+ }
114
+ if (!outputOnGpu) cpuBytesPerToken += meta.outputBytes;
115
+
116
+ return {
117
+ gpuLayers: placed + (outputOnGpu ? 1 : 0), // llama.cpp counts output as one extra layer
118
+ totalLayers: meta.nLayers + 1,
119
+ cpuLayers,
120
+ fullOffload: placed === meta.nLayers && outputOnGpu,
121
+ kvType,
122
+ deficit: budgets.length ? Math.max(0, totalNeed(meta, ctx, kvType, ubatch, cpuMoeLayers, budgets.length) - budgets.reduce((a, b) => a + b.budget, 0)) : Infinity,
123
+ layersPerGpu: budgets.map((b, k) => ({ index: b.index, layers: layersPerGpu[k], bytes: used[k] + PER_GPU_OVERHEAD, budget: b.budget + PER_GPU_OVERHEAD })),
124
+ kvBytes: kvTotalBytes(meta, ctx, kvType),
125
+ cpuBytesPerToken,
126
+ cpuMoeLayers,
127
+ };
128
+ }
129
+
130
+ /** Total bytes a full offload needs across all GPUs. */
131
+ function totalNeed(meta, ctx, kvType, ubatch, cpuMoeLayers, nGpus) {
132
+ let t = meta.outputBytes + meta.vocab * ubatch * 4 + ctx * COMPUTE_BYTES_PER_CTX_TOKEN * (nGpus ? 1 : 0);
133
+ for (let i = 0; i < meta.nLayers; i++) t += meta.layerBytes[i] - (i < cpuMoeLayers ? meta.layerExpertBytes[i] : 0) + kvLayerBytes(meta, i, ctx, kvType);
134
+ return t;
135
+ }
136
+
137
+ /** Predicted decode tokens/s from memory bandwidth (for ranking only). */
138
+ export function predictTps(meta, placement, gpus, hw, ctxDepth) {
139
+ const activeFrac = meta.isMoE && meta.nExperts ? meta.nExpertsUsed / meta.nExperts : 1;
140
+ let seconds = 0;
141
+ const byIndex = new Map(gpus.map((g) => [g.index, g]));
142
+ let layerCursor = meta.nLayers - 1;
143
+ for (const lp of placement.layersPerGpu) {
144
+ const gpu = byIndex.get(lp.index);
145
+ let bytes = 0;
146
+ for (let n = 0; n < lp.layers; n++, layerCursor--) {
147
+ const i = layerCursor;
148
+ const expert = i < placement.cpuMoeLayers ? 0 : meta.layerExpertBytes[i];
149
+ bytes += (meta.layerBytes[i] - meta.layerExpertBytes[i]) + expert * activeFrac;
150
+ }
151
+ bytes += placement.kvBytes * (lp.layers / meta.nLayers) * (ctxDepth / Math.max(1, placement.ctx || ctxDepth)) * KV_TYPES[placement.kvType].readCost;
152
+ seconds += bytes / (gpu.bandwidthGBps * 1e9 * DECODE_EFFICIENCY);
153
+ }
154
+ if (placement.fullOffload) seconds += meta.outputBytes / (gpus[0].bandwidthGBps * 1e9 * DECODE_EFFICIENCY);
155
+ seconds += placement.cpuBytesPerToken / (hw.ramBandwidthGBps * 1e9 * 0.6);
156
+ return seconds > 0 ? 1 / seconds : 0;
157
+ }
158
+
159
+ /**
160
+ * Build ranked candidate configurations for a target context length.
161
+ * Each candidate is engine-agnostic; engines translate it to their own flags.
162
+ */
163
+ export function planCandidates(meta, hw, { ctx, kvTypes = Object.keys(KV_TYPES), allowCpuMoe = true } = {}) {
164
+ const gpus = hw.gpus;
165
+ const out = [];
166
+
167
+ for (const kvType of kvTypes) {
168
+ const base = { ctx, kvType, flashAttention: true, ubatch: 512, batch: 512, parallel: 1, gpuOrder: gpus.map((g) => g.index) };
169
+ let placement = placeLayers(meta, gpus, base);
170
+
171
+ // MoE: if dense layers don't all fit, try keeping experts of the first N layers in RAM.
172
+ if (!placement.fullOffload && meta.isMoE && allowCpuMoe) {
173
+ for (let n = 1; n <= meta.nLayers; n++) {
174
+ const p = placeLayers(meta, gpus, { ...base, cpuMoeLayers: n });
175
+ if (p.fullOffload) { placement = p; break; }
176
+ }
177
+ }
178
+
179
+ placement.ctx = ctx;
180
+ // Close to fitting: also try the full offload for real (a failed load is cheap).
181
+ if (!placement.fullOffload && placement.deficit <= PROBE_MARGIN && gpus.length) {
182
+ // Spread all layers proportionally to each GPU's budget.
183
+ const total = placement.layersPerGpu.reduce((a, l) => a + l.budget, 0);
184
+ let left = meta.nLayers;
185
+ const layersPerGpu = placement.layersPerGpu.map((l, k, arr) => {
186
+ const n = k === arr.length - 1 ? left : Math.round(meta.nLayers * l.budget / total);
187
+ left -= n;
188
+ return { ...l, layers: n };
189
+ });
190
+ const probe = { ...placement, layersPerGpu, fullOffload: true, gpuLayers: placement.totalLayers, cpuLayers: 0, cpuBytesPerToken: 0, probe: true };
191
+ out.push(makeCandidate(meta, hw, base, probe));
192
+ }
193
+ out.push(makeCandidate(meta, hw, base, placement));
194
+ }
195
+
196
+ if (!gpus.length) {
197
+ return out.slice(0, 1).map((c) => ({ ...c, gpuLayers: 0, fullOffload: false }));
198
+ }
199
+ return out.sort((a, b) => Number(b.fullOffload && !b.probe) - Number(a.fullOffload && !a.probe) || b.score - a.score);
200
+ }
201
+
202
+ function makeCandidate(meta, hw, base, placement) {
203
+ const gpus = hw.gpus;
204
+ const physical = hw.cpu.physicalCores;
205
+ const { ctx, kvType } = base;
206
+ const onGpuOnly = placement.fullOffload && placement.cpuMoeLayers === 0;
207
+ // Fully on GPU the CPU only feeds tokens; with CPU layers use all physical cores.
208
+ const threads = onGpuOnly ? Math.min(4, physical) : Math.max(1, physical - (physical > 4 ? 1 : 0));
209
+ const tpsShort = predictTps(meta, placement, gpus, hw, 512);
210
+ const tpsDeep = predictTps(meta, placement, gpus, hw, ctx * 0.6);
211
+
212
+ return {
213
+ id: `${kvType}-${placement.gpuLayers}${placement.cpuMoeLayers ? `-moe${placement.cpuMoeLayers}` : ''}${placement.probe ? '-probe' : ''}`,
214
+ ...base,
215
+ threads,
216
+ gpuLayers: placement.gpuLayers,
217
+ totalLayers: placement.totalLayers,
218
+ fullOffload: placement.fullOffload,
219
+ cpuMoeLayers: placement.cpuMoeLayers,
220
+ tensorSplit: placement.layersPerGpu.map((l) => l.layers),
221
+ placement,
222
+ predicted: { tpsShort: +tpsShort.toFixed(1), tpsDeep: +tpsDeep.toFixed(1) },
223
+ probe: !!placement.probe,
224
+ score: tpsDeep * KV_TYPES[kvType].quality,
225
+ };
226
+ }
227
+
228
+ /** Largest context (multiple of 1024) that fits fully on GPU for a KV type. */
229
+ export function maxFullOffloadContext(meta, hw, kvType, limit = meta.trainContext || 131072) {
230
+ let lo = 0, hi = Math.floor(limit / 1024);
231
+ while (lo < hi) {
232
+ const mid = Math.ceil((lo + hi) / 2);
233
+ const p = placeLayers(meta, hw.gpus, { ctx: mid * 1024, kvType });
234
+ if (p.fullOffload) lo = mid; else hi = mid - 1;
235
+ }
236
+ return lo * 1024;
237
+ }