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,187 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ // Minimal GGUF header reader: metadata key/values and tensor infos.
4
+ // Spec: https://github.com/ggml-org/ggml/blob/master/docs/gguf.md
5
+
6
+ const T = { UINT8: 0, INT8: 1, UINT16: 2, INT16: 3, UINT32: 4, INT32: 5, FLOAT32: 6, BOOL: 7, STRING: 8, ARRAY: 9, UINT64: 10, INT64: 11, FLOAT64: 12 };
7
+
8
+ // ggml tensor types -> [block size (elements), bytes per block]
9
+ const GGML_TYPE_SIZE = {
10
+ 0: [1, 4], 1: [1, 2], 2: [32, 18], 3: [32, 20], 6: [32, 22], 7: [32, 24], 8: [32, 34], 9: [32, 36],
11
+ 10: [256, 84], 11: [256, 110], 12: [256, 144], 13: [256, 176], 14: [256, 210], 15: [256, 292],
12
+ 16: [256, 66], 17: [256, 74], 18: [256, 98], 19: [256, 50], 20: [32, 18], 21: [256, 110], 22: [256, 82],
13
+ 23: [256, 136], 24: [1, 1], 25: [1, 2], 26: [1, 4], 27: [1, 8], 28: [1, 8], 29: [256, 56], 30: [1, 2],
14
+ 34: [256, 54], 35: [256, 66], 39: [32, 17],
15
+ };
16
+
17
+ class Reader {
18
+ constructor(fh) { this.fh = fh; this.buf = Buffer.alloc(0); this.bufStart = 0; this.pos = 0; }
19
+ async ensure(n) {
20
+ const end = this.pos + n;
21
+ if (this.pos >= this.bufStart && end <= this.bufStart + this.buf.length) return;
22
+ const size = Math.max(n, 4 * 1024 * 1024);
23
+ const buf = Buffer.alloc(size);
24
+ const { bytesRead } = await this.fh.read(buf, 0, size, this.pos);
25
+ if (bytesRead < n) throw new Error('GGUF: unexpected end of file');
26
+ this.buf = buf.subarray(0, bytesRead);
27
+ this.bufStart = this.pos;
28
+ }
29
+ async take(n) { await this.ensure(n); const o = this.pos - this.bufStart; this.pos += n; return this.buf.subarray(o, o + n); }
30
+ async u32() { return (await this.take(4)).readUInt32LE(0); }
31
+ async u64() { return Number((await this.take(8)).readBigUInt64LE(0)); }
32
+ async str() { const n = await this.u64(); return (await this.take(n)).toString('utf8'); }
33
+ async value(type, keepArrays) {
34
+ switch (type) {
35
+ case T.UINT8: return (await this.take(1)).readUInt8(0);
36
+ case T.INT8: return (await this.take(1)).readInt8(0);
37
+ case T.UINT16: return (await this.take(2)).readUInt16LE(0);
38
+ case T.INT16: return (await this.take(2)).readInt16LE(0);
39
+ case T.UINT32: return (await this.take(4)).readUInt32LE(0);
40
+ case T.INT32: return (await this.take(4)).readInt32LE(0);
41
+ case T.FLOAT32: return (await this.take(4)).readFloatLE(0);
42
+ case T.BOOL: return (await this.take(1)).readUInt8(0) !== 0;
43
+ case T.STRING: return this.str();
44
+ case T.UINT64: return Number((await this.take(8)).readBigUInt64LE(0));
45
+ case T.INT64: return Number((await this.take(8)).readBigInt64LE(0));
46
+ case T.FLOAT64: return (await this.take(8)).readDoubleLE(0);
47
+ case T.ARRAY: {
48
+ const itemType = await this.u32();
49
+ const n = await this.u64();
50
+ // Large arrays (tokenizer vocab) are skipped; we only keep their length.
51
+ if (!keepArrays || n > 4096) {
52
+ for (let i = 0; i < n; i++) await this.value(itemType, false);
53
+ return { length: n };
54
+ }
55
+ const out = [];
56
+ for (let i = 0; i < n; i++) out.push(await this.value(itemType, false));
57
+ return out;
58
+ }
59
+ default: throw new Error(`GGUF: unknown value type ${type}`);
60
+ }
61
+ }
62
+ }
63
+
64
+ export async function readGguf(file) {
65
+ const fh = await fs.open(file, 'r');
66
+ try {
67
+ const stat = await fh.stat();
68
+ const r = new Reader(fh);
69
+ const magic = (await r.take(4)).toString('ascii');
70
+ if (magic !== 'GGUF') throw new Error(`${file} is not a GGUF file`);
71
+ const version = await r.u32();
72
+ const nTensors = await r.u64();
73
+ const nKv = await r.u64();
74
+ const kv = {};
75
+ for (let i = 0; i < nKv; i++) {
76
+ const key = await r.str();
77
+ const type = await r.u32();
78
+ kv[key] = await r.value(type, true);
79
+ }
80
+ const tensors = [];
81
+ for (let i = 0; i < nTensors; i++) {
82
+ const name = await r.str();
83
+ const nDims = await r.u32();
84
+ const dims = [];
85
+ for (let d = 0; d < nDims; d++) dims.push(await r.u64());
86
+ const type = await r.u32();
87
+ await r.u64(); // offset
88
+ const elems = dims.reduce((a, b) => a * b, 1);
89
+ const [bs, bb] = GGML_TYPE_SIZE[type] ?? [1, 2];
90
+ tensors.push({ name, dims, type, bytes: Math.ceil(elems / bs) * bb });
91
+ }
92
+ return { version, kv, tensors, fileBytes: stat.size };
93
+ } finally {
94
+ await fh.close();
95
+ }
96
+ }
97
+
98
+ const first = (v) => (Array.isArray(v) ? Math.max(...v) : v);
99
+
100
+ /**
101
+ * Turn raw GGUF data into the numbers the estimator needs: per-layer weight
102
+ * bytes, which layers have attention (KV cache), KV head dims, experts, vocab.
103
+ */
104
+ export function summarizeGguf({ kv, tensors, fileBytes }) {
105
+ const arch = kv['general.architecture'];
106
+ const g = (k) => kv[`${arch}.${k}`];
107
+ const nLayers = g('block_count');
108
+ const nEmbd = g('embedding_length');
109
+ const nHead = first(g('attention.head_count'));
110
+ const headCountKv = g('attention.head_count_kv');
111
+ const nHeadKv = first(headCountKv ?? nHead);
112
+ const keyLen = g('attention.key_length') ?? (nEmbd && nHead ? nEmbd / nHead : 128);
113
+ const valLen = g('attention.value_length') ?? keyLen;
114
+ const vocab = kv['tokenizer.ggml.tokens']?.length ?? g('vocab_size') ?? 32000;
115
+
116
+ const layerBytes = new Array(nLayers).fill(0);
117
+ const layerExpertBytes = new Array(nLayers).fill(0);
118
+ const attnLayers = new Set();
119
+ let outputBytes = 0, embdBytes = 0;
120
+ for (const t of tensors) {
121
+ const m = /^blk\.(\d+)\.(.+)$/.exec(t.name);
122
+ if (m) {
123
+ const i = Number(m[1]);
124
+ if (i >= nLayers) continue; // e.g. MTP / nextn layers not used for inference
125
+ layerBytes[i] += t.bytes;
126
+ if (/_exps\./.test(m[2])) layerExpertBytes[i] += t.bytes;
127
+ if (/^attn_(k|q|qkv|kv_a_mqa|k_b)\b/.test(m[2])) attnLayers.add(i);
128
+ } else if (/^token_embd/.test(t.name)) embdBytes += t.bytes;
129
+ else outputBytes += t.bytes; // output.weight, output_norm, rope freqs...
130
+ }
131
+ // Tied embeddings: output shares token_embd, llama.cpp still puts a copy on GPU.
132
+ if (!tensors.some((t) => t.name === 'output.weight')) outputBytes += embdBytes;
133
+
134
+ // Per-layer KV heads can be an array (0 for non-attention layers).
135
+ const kvHeadsPerLayer = Array.from({ length: nLayers }, (_, i) =>
136
+ Array.isArray(headCountKv) ? headCountKv[i] : (attnLayers.has(i) || attnLayers.size === 0 ? nHeadKv : 0));
137
+
138
+ const slidingWindow = g('attention.sliding_window') || null;
139
+ // Some SWA models declare which layers use it; fall back to "every layer but each Nth".
140
+ let swaLayers = [];
141
+ const pattern = g('attention.sliding_window_pattern');
142
+ if (slidingWindow) {
143
+ if (Array.isArray(pattern)) swaLayers = pattern.map((v, i) => (v ? i : -1)).filter((i) => i >= 0);
144
+ else if (typeof pattern === 'number' && pattern > 1) swaLayers = [...Array(nLayers).keys()].filter((i) => (i + 1) % pattern !== 0);
145
+ }
146
+
147
+ const nExperts = g('expert_count') || 0;
148
+ const nExpertsUsed = g('expert_used_count') || 0;
149
+
150
+ let totalBytes = 0, totalElements = 0;
151
+ for (const t of tensors) {
152
+ totalBytes += t.bytes;
153
+ let elements = 1;
154
+ for (const d of t.dims) elements *= d;
155
+ totalElements += elements;
156
+ }
157
+ const bitsPerWeight = totalElements === 0 ? null : +(totalBytes * 8 / totalElements).toFixed(2);
158
+
159
+ return {
160
+ arch,
161
+ name: kv['general.name'] || null,
162
+ nLayers,
163
+ nEmbd,
164
+ nHead,
165
+ nHeadKv,
166
+ keyLen,
167
+ valLen,
168
+ vocab,
169
+ trainContext: g('context_length') || null,
170
+ fileBytes,
171
+ bitsPerWeight,
172
+ layerBytes,
173
+ layerExpertBytes,
174
+ outputBytes,
175
+ embdBytes,
176
+ kvHeadsPerLayer,
177
+ slidingWindow,
178
+ swaLayers,
179
+ nExperts,
180
+ nExpertsUsed,
181
+ isMoE: nExperts > 1,
182
+ };
183
+ }
184
+
185
+ export async function modelMetaFromFile(file) {
186
+ return summarizeGguf(await readGguf(file));
187
+ }
@@ -0,0 +1,162 @@
1
+ import os from 'node:os';
2
+ import si from 'systeminformation';
3
+ import { run, which, PLATFORM, MiB, GiB } from './util.js';
4
+
5
+ // Approximate memory bandwidth (GB/s) for common GPUs. Used only to rank
6
+ // candidates before benchmarking, so rough numbers are fine.
7
+ const GPU_BANDWIDTH = [
8
+ [/RTX 5090/i, 1792], [/RTX 5080/i, 960], [/RTX 5070 Ti/i, 896], [/RTX 5070/i, 672], [/RTX 5060 Ti/i, 448], [/RTX 5060/i, 448],
9
+ [/RTX 4090/i, 1008], [/RTX 4080/i, 717], [/RTX 4070 Ti/i, 504], [/RTX 4070/i, 504], [/RTX 4060 Ti/i, 288], [/RTX 4060/i, 272],
10
+ [/RTX 3090/i, 936], [/RTX 3080/i, 760], [/RTX 3070/i, 448], [/RTX 3060 Ti/i, 448], [/RTX 3060/i, 360],
11
+ [/RTX 20[0-9]0/i, 448], [/GTX 16[0-9]0/i, 192], [/GTX 10[0-9]0/i, 256],
12
+ [/A100/i, 1935], [/H100/i, 3350], [/L40/i, 864], [/A6000/i, 768], [/A4000/i, 448],
13
+ [/RX 9070 XT/i, 640], [/RX 9070/i, 640], [/RX 7900 XTX/i, 960], [/RX 7900/i, 800], [/RX 7800/i, 624], [/RX 7700/i, 432], [/RX 7600/i, 288],
14
+ [/RX 6900|RX 6800/i, 512], [/RX 6700/i, 384],
15
+ [/Arc A770/i, 560], [/Arc B580/i, 456],
16
+ ];
17
+
18
+ // Apple Silicon unified memory bandwidth (GB/s).
19
+ const APPLE_BANDWIDTH = [
20
+ [/M[1-5] Ultra/i, 800], [/M4 Max/i, 546], [/M[1-3] Max/i, 400], [/M4 Pro/i, 273], [/M[1-3] Pro/i, 200], [/M4/i, 120], [/M[1-3]/i, 100],
21
+ ];
22
+
23
+ // PCIe per-lane throughput in GB/s by generation.
24
+ const PCIE_LANE_GBPS = { 1: 0.25, 2: 0.5, 3: 0.985, 4: 1.97, 5: 3.94, 6: 7.56 };
25
+
26
+ const lookup = (table, name, dflt) => (table.find(([re]) => re.test(name))?.[1] ?? dflt);
27
+
28
+ async function nvidiaGpus() {
29
+ const smi = await which('nvidia-smi', PLATFORM === 'win32' ? ['C:\\Windows\\System32\\nvidia-smi.exe'] : []);
30
+ if (!smi) return [];
31
+ const fields = 'index,name,uuid,pci.bus_id,memory.total,memory.used,memory.free,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,display_active';
32
+ const r = await run(smi, [`--query-gpu=${fields}`, '--format=csv,noheader,nounits']);
33
+ if (r.code !== 0) return [];
34
+ return r.stdout.trim().split('\n').filter(Boolean).map((line) => {
35
+ const [index, name, uuid, busId, total, used, free, gen, widthCur, widthMax, display] = line.split(',').map((s) => s.trim());
36
+ const pcieGen = Number(gen) || null;
37
+ // width.max reports the GPU's capability; width.current reflects the real slot.
38
+ const pcieWidth = Number(widthCur) || Number(widthMax) || null;
39
+ return {
40
+ vendor: 'nvidia',
41
+ backend: 'cuda',
42
+ index: Number(index),
43
+ name,
44
+ uuid,
45
+ busId,
46
+ totalBytes: Number(total) * MiB,
47
+ usedBytes: Number(used) * MiB,
48
+ freeBytes: Number(free) * MiB,
49
+ pcieGen,
50
+ pcieWidth,
51
+ pcieGBps: pcieGen && pcieWidth ? +(PCIE_LANE_GBPS[pcieGen] * pcieWidth).toFixed(1) : null,
52
+ displayAttached: /enabled/i.test(display || ''),
53
+ bandwidthGBps: lookup(GPU_BANDWIDTH, name, 400),
54
+ };
55
+ });
56
+ }
57
+
58
+ async function amdGpus() {
59
+ const smi = await which('rocm-smi');
60
+ if (!smi) return [];
61
+ const r = await run(smi, ['--showmeminfo', 'vram', '--showproductname', '--json']);
62
+ if (r.code !== 0) return [];
63
+ let data;
64
+ try { data = JSON.parse(r.stdout); } catch { return []; }
65
+ return Object.entries(data)
66
+ .filter(([k]) => /^card\d+/.test(k))
67
+ .map(([k, v], i) => {
68
+ const total = Number(v['VRAM Total Memory (B)'] || 0);
69
+ const used = Number(v['VRAM Total Used Memory (B)'] || 0);
70
+ const name = v['Card Series'] || v['Card series'] || v['Card model'] || k;
71
+ return {
72
+ vendor: 'amd', backend: 'rocm', index: i, name, uuid: k, busId: null,
73
+ totalBytes: total, usedBytes: used, freeBytes: total - used,
74
+ pcieGen: null, pcieWidth: null, pcieGBps: null, displayAttached: false,
75
+ bandwidthGBps: lookup(GPU_BANDWIDTH, name, 400),
76
+ };
77
+ });
78
+ }
79
+
80
+ async function appleGpu(cpuBrand, totalMem) {
81
+ if (PLATFORM !== 'darwin' || os.arch() !== 'arm64') return [];
82
+ // macOS lets Metal wire roughly 2/3 of RAM on small machines, ~3/4 on larger ones.
83
+ const usable = totalMem <= 36 * GiB ? totalMem * 0.67 : totalMem * 0.75;
84
+ return [{
85
+ vendor: 'apple', backend: 'metal', index: 0, name: `${cpuBrand} GPU`, uuid: 'apple-gpu', busId: null,
86
+ totalBytes: usable, usedBytes: 0, freeBytes: usable, unifiedMemory: true,
87
+ pcieGen: null, pcieWidth: null, pcieGBps: null, displayAttached: true,
88
+ bandwidthGBps: lookup(APPLE_BANDWIDTH, cpuBrand, 100),
89
+ }];
90
+ }
91
+
92
+ /** Fallback for GPUs without a vendor tool (Intel, AMD on Windows, ...). */
93
+ function genericGpus(controllers) {
94
+ return controllers
95
+ .filter((c) => c.vram && c.vram > 512 && !/microsoft basic|llvmpipe|virtual/i.test(c.model || ''))
96
+ .map((c, i) => ({
97
+ vendor: /nvidia/i.test(c.vendor) ? 'nvidia' : /amd|ati|advanced micro/i.test(c.vendor) ? 'amd' : /intel/i.test(c.vendor) ? 'intel' : 'other',
98
+ backend: 'vulkan', index: i, name: c.model, uuid: c.busAddress || `gpu${i}`, busId: c.busAddress || null,
99
+ totalBytes: c.vram * MiB, usedBytes: (c.memoryUsed ?? 0) * MiB, freeBytes: (c.memoryFree ?? c.vram) * MiB,
100
+ pcieGen: null, pcieWidth: null, pcieGBps: null, displayAttached: false,
101
+ bandwidthGBps: lookup(GPU_BANDWIDTH, c.model || '', 300),
102
+ vramApproximate: true,
103
+ }));
104
+ }
105
+
106
+ /**
107
+ * Snapshot of the machine: CPU, RAM and GPUs (with free VRAM and PCIe link).
108
+ * GPUs are sorted by suggested priority: fastest link / no display first.
109
+ */
110
+ export async function detectHardware() {
111
+ const [cpu, mem, osInfo, graphics] = await Promise.all([si.cpu(), si.mem(), si.osInfo(), si.graphics().catch(() => ({ controllers: [] }))]);
112
+ const cpuBrand = `${cpu.manufacturer} ${cpu.brand}`.trim();
113
+
114
+ let gpus = await nvidiaGpus();
115
+ if (!gpus.length) gpus = await amdGpus();
116
+ if (!gpus.length) gpus = await appleGpu(cpuBrand, mem.total);
117
+ if (!gpus.length) gpus = genericGpus(graphics.controllers || []);
118
+
119
+ return {
120
+ os: { platform: PLATFORM, distro: osInfo.distro, release: osInfo.release, arch: os.arch() },
121
+ cpu: {
122
+ brand: cpuBrand,
123
+ physicalCores: cpu.physicalCores || Math.max(1, Math.floor(os.cpus().length / 2)),
124
+ threads: cpu.cores || os.cpus().length,
125
+ avx2: /avx2/i.test(cpu.flags || ''),
126
+ },
127
+ ram: { totalBytes: mem.total, availableBytes: mem.available },
128
+ // Dual-channel DDR4 ~40 GB/s, DDR5 ~70 GB/s; Apple uses unified memory.
129
+ ramBandwidthGBps: gpus[0]?.unifiedMemory ? gpus[0].bandwidthGBps : 45,
130
+ gpus: rankGpus(gpus),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Priority order for placing model layers: prefer the GPU with the widest
136
+ * PCIe link, then the one not driving a display, then the most free VRAM.
137
+ */
138
+ export function rankGpus(gpus) {
139
+ return [...gpus].sort((a, b) =>
140
+ (b.pcieGBps ?? 0) - (a.pcieGBps ?? 0) ||
141
+ Number(a.displayAttached) - Number(b.displayAttached) ||
142
+ b.freeBytes - a.freeBytes ||
143
+ a.index - b.index);
144
+ }
145
+
146
+ /** Poll per-GPU VRAM usage (NVIDIA only) until stop() is called. */
147
+ export function startVramSampler(intervalMs = 500) {
148
+ const peaks = new Map();
149
+ let timer = null;
150
+ let stopped = false;
151
+ const tick = async () => {
152
+ const gpus = await nvidiaGpus();
153
+ for (const g of gpus) peaks.set(g.index, Math.max(peaks.get(g.index) ?? 0, g.usedBytes));
154
+ if (!stopped) timer = setTimeout(tick, intervalMs);
155
+ };
156
+ tick();
157
+ return {
158
+ stop() { stopped = true; clearTimeout(timer); return Object.fromEntries(peaks); },
159
+ };
160
+ }
161
+
162
+ export { nvidiaGpus };
@@ -0,0 +1,10 @@
1
+ export { detectHardware, rankGpus } from './hardware.js';
2
+ export { readGguf, summarizeGguf, modelMetaFromFile } from './gguf.js';
3
+ export { planCandidates, placeLayers, maxFullOffloadContext, kvTotalBytes, KV_TYPES } from './estimator.js';
4
+ export { Tuner, ENGINES, detectEngines, pickBest, slim } from './tuner.js';
5
+ export { PROFILES, DEFAULT_PROFILE, normalizeProfile, QUALITY_KV_TYPES, kvTypesFor, meetsQuality, fitsFullyOnGpu, pickVariant, recommendProfile, HINT_TARGETS, variantHints, VRAM_BUSY_FRACTION, vramInUse } from './profiles.js';
6
+ export { installPlans, runInstall, openUrl } from './installer.js';
7
+ export { fmtBytes, configDir } from './util.js';
8
+ export { findPreset, savePreset, listPresets, presetsDir, presetPath, variantsSignature, hardwareFingerprint } from './presets.js';
9
+ export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme } from './settings.js';
10
+ export { TunerError, benchError } from './errors.js';
@@ -0,0 +1,59 @@
1
+ import os from 'node:os';
2
+ import { spawn } from 'node:child_process';
3
+ import { which, PLATFORM } from './util.js';
4
+
5
+ /**
6
+ * Official install routes per engine and OS. Every plan is shown to the user
7
+ * before running; nothing is installed silently.
8
+ */
9
+ export async function installPlans(engineId) {
10
+ const brew = PLATFORM === 'darwin' ? await which('brew') : null;
11
+ const winget = PLATFORM === 'win32' ? await which('winget') : null;
12
+ const arch = os.arch() === 'arm64' ? 'arm64' : 'x64';
13
+ const plans = [];
14
+
15
+ if (engineId === 'ollama') {
16
+ if (PLATFORM === 'linux') plans.push({ id: 'script', labelCode: 'install.ollamaScript', shell: 'curl -fsSL https://ollama.com/install.sh | sh', needsSudo: true });
17
+ if (PLATFORM === 'darwin') {
18
+ if (brew) plans.push({ id: 'brew', labelCode: 'install.ollamaBrew', cmd: 'brew', args: ['install', '--cask', 'ollama'] });
19
+ plans.push({ id: 'dmg', labelCode: 'install.ollamaDmg', url: 'https://ollama.com/download/Ollama.dmg', manual: true });
20
+ }
21
+ if (PLATFORM === 'win32') {
22
+ if (winget) plans.push({ id: 'winget', labelCode: 'install.ollamaWinget', cmd: 'winget', args: ['install', '-e', '--id', 'Ollama.Ollama', '--accept-source-agreements', '--accept-package-agreements'] });
23
+ plans.push({ id: 'ps1', labelCode: 'install.ollamaPs1', cmd: 'powershell', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', 'irm https://ollama.com/install.ps1 | iex'] });
24
+ }
25
+ }
26
+
27
+ if (engineId === 'lmstudio') {
28
+ if (PLATFORM === 'linux' || PLATFORM === 'darwin') {
29
+ plans.push({ id: 'llmster', labelCode: 'install.lmsHeadless', shell: 'curl -fsSL https://lmstudio.ai/install.sh | bash' });
30
+ }
31
+ if (PLATFORM === 'darwin' && brew) plans.push({ id: 'brew', labelCode: 'install.lmsBrew', cmd: 'brew', args: ['install', '--cask', 'lm-studio'] });
32
+ if (PLATFORM === 'win32') {
33
+ if (winget) plans.push({ id: 'winget', labelCode: 'install.lmsWinget', cmd: 'winget', args: ['install', '-e', '--id', 'ElementLabs.LMStudio', '--accept-source-agreements', '--accept-package-agreements'] });
34
+ plans.push({ id: 'ps1', labelCode: 'install.lmsHeadlessPs1', cmd: 'powershell', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', 'irm https://lmstudio.ai/install.ps1 | iex'] });
35
+ }
36
+ const plat = { linux: 'linux', darwin: 'darwin', win32: 'win32' }[PLATFORM];
37
+ plans.push({ id: 'download', labelCode: 'install.lmsDownload', url: `https://lmstudio.ai/download/latest/${plat}/${arch}`, manual: true });
38
+ }
39
+ return plans;
40
+ }
41
+
42
+ /** Run an install plan, streaming output lines to `onLine`. Resolves exit code. */
43
+ export function runInstall(plan, onLine = () => {}) {
44
+ if (plan.manual) {
45
+ openUrl(plan.url);
46
+ return Promise.resolve({ code: 0, manual: true });
47
+ }
48
+ const child = plan.shell
49
+ ? spawn(PLATFORM === 'win32' ? 'cmd' : 'sh', PLATFORM === 'win32' ? ['/c', plan.shell] : ['-c', plan.shell], { stdio: ['inherit', 'pipe', 'pipe'] })
50
+ : spawn(plan.cmd, plan.args, { stdio: ['inherit', 'pipe', 'pipe'], windowsHide: true });
51
+ child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(onLine));
52
+ child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(onLine));
53
+ return new Promise((resolve) => child.on('exit', (code) => resolve({ code })));
54
+ }
55
+
56
+ export function openUrl(url) {
57
+ const [cmd, args] = PLATFORM === 'win32' ? ['cmd', ['/c', 'start', '', url]] : PLATFORM === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
58
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
59
+ }
@@ -0,0 +1,107 @@
1
+ import path from 'node:path';
2
+ import { spawnLogged, killProcess, freePort, fetchJson, sleep, PLATFORM } from './util.js';
3
+ import { makeCodePrompt, withSampling } from './benchmark.js';
4
+ import { benchError } from './errors.js';
5
+
6
+ const LIB_ENV = { linux: 'LD_LIBRARY_PATH', darwin: 'DYLD_LIBRARY_PATH', win32: 'PATH' };
7
+
8
+ /** Translate an engine-agnostic candidate into llama-server flags. */
9
+ export function llamaServerArgs(modelFile, c, port) {
10
+ // llama.cpp gives the *last* layers (and the output layer) to the last device,
11
+ // so devices are listed in reverse priority: the preferred GPU ends up last.
12
+ const split = [...c.tensorSplit].reverse();
13
+ const args = [
14
+ '--model', modelFile,
15
+ '--host', '127.0.0.1', '--port', String(port),
16
+ '--ctx-size', String(c.ctx),
17
+ '--n-gpu-layers', String(c.fullOffload ? 999 : c.gpuLayers),
18
+ '--split-mode', 'layer',
19
+ '--batch-size', String(c.batch), '--ubatch-size', String(c.ubatch),
20
+ '--threads', String(c.threads),
21
+ '--parallel', String(c.parallel),
22
+ '--cache-type-k', c.kvType, '--cache-type-v', c.kvType,
23
+ '--flash-attn', c.flashAttention ? 'on' : 'off',
24
+ '--kv-offload', '--no-kv-unified',
25
+ '--no-webui',
26
+ ];
27
+ if (split.length > 1) args.push('--tensor-split', split.join(','), '--main-gpu', String(split.length - 1));
28
+ if (c.cpuMoeLayers) args.push('--n-cpu-moe', String(c.cpuMoeLayers));
29
+ return args;
30
+ }
31
+
32
+ export function llamaServerEnv(libDirs, c) {
33
+ const key = LIB_ENV[PLATFORM] || 'LD_LIBRARY_PATH';
34
+ const env = { ...process.env, [key]: [...libDirs, process.env[key]].filter(Boolean).join(path.delimiter) };
35
+ if (c.gpuOrder?.length) {
36
+ // Match nvidia-smi numbering, then enumerate in reverse priority (see above).
37
+ env.CUDA_DEVICE_ORDER = 'PCI_BUS_ID';
38
+ env.CUDA_VISIBLE_DEVICES = [...c.gpuOrder].reverse().join(',');
39
+ }
40
+ return env;
41
+ }
42
+
43
+ /**
44
+ * Start the engine's own llama-server with exactly the candidate settings,
45
+ * measure a short and a deep prompt, then stop it. Same binary LM Studio uses,
46
+ * so results transfer 1:1 to the app.
47
+ */
48
+ export async function runLlamaServerBenchmark({ server, libDirs, cwd, modelFile, candidate, depthTokens = 4000, genTokens = 200, onProgress = () => {} }) {
49
+ const port = await freePort();
50
+ const args = llamaServerArgs(modelFile, candidate, port);
51
+ const t0 = Date.now();
52
+ onProgress({ phase: 'load' });
53
+ const proc = spawnLogged(server, args, { cwd, env: llamaServerEnv(libDirs, candidate) });
54
+ const base = `http://127.0.0.1:${port}`;
55
+ try {
56
+ let healthy = false;
57
+ let exited = null;
58
+ proc.exited.then((e) => { exited = e; });
59
+ for (let i = 0; i < 1200 && !exited; i++) {
60
+ const h = await fetchJson(`${base}/health`, { timeout: 1000 });
61
+ if (h?.ok) { healthy = true; break; }
62
+ await sleep(500);
63
+ }
64
+ if (!healthy) {
65
+ const log = proc.lines.join('\n');
66
+ const oom = /out of memory|cudaMalloc failed|failed to allocate|ErrorOutOfDeviceMemory/i.test(log);
67
+ return oom
68
+ ? { ...benchError('bench.oomOnLoad'), oom, args, logTail: proc.lines.slice(-15) }
69
+ : { ok: false, oom, error: lastError(proc.lines), args, logTail: proc.lines.slice(-15) };
70
+ }
71
+ const loadSeconds = +((Date.now() - t0) / 1000).toFixed(1);
72
+
73
+ // Calibrate chars/token with the model's own tokenizer.
74
+ let cpt = 3.3;
75
+ const sample = makeCodePrompt(500);
76
+ const tok = await fetchJson(`${base}/tokenize`, { method: 'POST', body: { content: sample }, timeout: 10000 });
77
+ if (tok?.json?.tokens?.length) cpt = sample.length / tok.json.tokens.length;
78
+
79
+ const one = async (label, tokens) => {
80
+ onProgress({ phase: label, promptTokens: tokens });
81
+ const res = await fetchJson(`${base}/completion`, {
82
+ method: 'POST', timeout: 1_800_000,
83
+ body: { prompt: makeCodePrompt(tokens, cpt), n_predict: genTokens, ignore_eos: true, cache_prompt: false, temperature: 0.2 },
84
+ });
85
+ const t = res?.json?.timings;
86
+ if (!t) return { ok: false, error: res?.json?.error?.message || lastError(proc.lines) };
87
+ return { ok: true, promptTokens: t.prompt_n, genTokens: t.predicted_n, genTps: +t.predicted_per_second.toFixed(1), ppTps: Math.round(t.prompt_per_second) };
88
+ };
89
+
90
+ const maxDepth = Math.max(0, candidate.ctx - genTokens - 256);
91
+ const { result, cpu, vramPeakBytes } = await withSampling(async () => {
92
+ const short = await one('short', 200);
93
+ const deep = short.ok && depthTokens > 400 ? await one('deep', Math.min(depthTokens, maxDepth)) : null;
94
+ return { short, deep };
95
+ });
96
+ const ok = result.short.ok && (!result.deep || result.deep.ok);
97
+ return { ok, error: ok ? null : (result.deep?.error || result.short.error), loadSeconds, short: result.short, deep: result.deep, cpu, vramPeakBytes, args };
98
+ } finally {
99
+ await killProcess(proc.child);
100
+ await sleep(1000); // let the driver release VRAM before the next candidate
101
+ }
102
+ }
103
+
104
+ function lastError(lines) {
105
+ const errs = lines.filter((l) => /\b(E|error|failed)\b/i.test(l));
106
+ return (errs.slice(-2).join(' | ') || lines.slice(-2).join(' | ') || 'llama-server exited without responding').slice(0, 400);
107
+ }
@@ -0,0 +1,112 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { configDir, readJson, GiB } from './util.js';
5
+ import { DEFAULT_PROFILE, normalizeProfile } from './profiles.js';
6
+
7
+ // A preset stores the benchmark winner for (engine, model, context, hardware),
8
+ // so the tuning scripts only run again when one of those changes.
9
+
10
+ export function presetsDir() {
11
+ return path.join(configDir(), 'presets');
12
+ }
13
+
14
+ /** Identifies the hardware a preset was measured on (GPU models + VRAM + CPU). */
15
+ export function hardwareFingerprint(hw) {
16
+ const parts = [
17
+ hw.cpu.brand,
18
+ ...hw.gpus.map((g) => `${g.name}|${Math.round(g.totalBytes / GiB)}G|pcie${g.pcieGen ?? '?'}x${g.pcieWidth ?? '?'}`).sort(),
19
+ ];
20
+ return createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
21
+ }
22
+
23
+ const slug = (s) => s.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120);
24
+
25
+ /** Returns the path to the preset file. */
26
+ export function presetPath(engine, modelKey, ctx, profile = DEFAULT_PROFILE) {
27
+ // The profile is part of the file name: anything that is not a known profile is balanced.
28
+ const safe = normalizeProfile(profile);
29
+ const suffix = safe === DEFAULT_PROFILE ? '' : `__${safe}`;
30
+ return path.join(presetsDir(), `${engine}__${slug(modelKey)}__${ctx}${suffix}.json`);
31
+ }
32
+
33
+ /** Identifies the downloaded variants of a model (keys + file sizes + which one is selected), order independent. */
34
+ export function variantsSignature(variants) {
35
+ if (!Array.isArray(variants) || !variants.length) return '';
36
+ return variants.map((v) => v.selected ? `${v.key}:${v.sizeBytes}:selected` : `${v.key}:${v.sizeBytes}`).sort().join('|');
37
+ }
38
+
39
+ /** The variant list a profile preset stores: key and file size, sorted by key, with the selected one flagged. */
40
+ function storedVariants(variants) {
41
+ if (!Array.isArray(variants)) return null;
42
+ return variants.map((v) => {
43
+ const obj = { key: v.key, sizeBytes: v.sizeBytes };
44
+ if (v.selected) obj.selected = true;
45
+ return obj;
46
+ }).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
47
+ }
48
+
49
+ /** Return the preset if it exists and was measured on this hardware/model/variants file. */
50
+ export async function findPreset({ engine, modelKey, ctx, hw, modelBytes, profile: rawProfile = DEFAULT_PROFILE, variants }) {
51
+ const profile = normalizeProfile(rawProfile);
52
+ // A preset without a profile field is a balanced preset.
53
+ const p = await readJson(presetPath(engine, modelKey, ctx, profile));
54
+ if (!p) return { preset: null, reason: 'none' };
55
+ if (p.fingerprint !== hardwareFingerprint(hw)) return { preset: null, stale: p, reason: 'hardware' };
56
+ if (profile !== DEFAULT_PROFILE && Array.isArray(variants)) {
57
+ const signature = variantsSignature(variants);
58
+ if (signature !== variantsSignature(p.variants)) {
59
+ return { preset: null, stale: p, reason: 'variants' };
60
+ }
61
+ // Engines report [] when listing fails: an empty signature proves nothing, so the model file size decides.
62
+ if (signature) return { preset: p, reason: 'hit' };
63
+ }
64
+ if (modelBytes && p.modelBytes && p.modelBytes !== modelBytes) {
65
+ return { preset: null, stale: p, reason: 'model' };
66
+ }
67
+ return { preset: p, reason: 'hit' };
68
+ }
69
+
70
+ /** Save the winner of a measurement as the preset of its profile. */
71
+ export async function savePreset({ engine, modelKey, ctx, hw, modelBytes, best, results, profile: rawProfile = DEFAULT_PROFILE, variant = null, variants = null }) {
72
+ // An unknown profile is stored as balanced, never as part of a file name.
73
+ const profile = normalizeProfile(rawProfile);
74
+ const { placement, score, ...candidate } = best.candidate;
75
+ const preset = {
76
+ version: 1,
77
+ engine,
78
+ model: modelKey,
79
+ ctx,
80
+ profile,
81
+ modelBytes: modelBytes || null,
82
+ fingerprint: hardwareFingerprint(hw),
83
+ hardware: { cpu: hw.cpu.brand, gpus: hw.gpus.map((g) => ({ index: g.index, name: g.name, totalBytes: g.totalBytes, pcieGen: g.pcieGen, pcieWidth: g.pcieWidth })) },
84
+ createdAt: new Date().toISOString(),
85
+ candidate,
86
+ bench: best.bench,
87
+ tried: (results || []).map((r) => ({ id: r.candidate.id, ok: r.bench.ok, error: r.bench.error || null, shortTps: r.bench.short?.genTps ?? null, deepTps: r.bench.deep?.genTps ?? null })),
88
+ };
89
+ if (profile !== DEFAULT_PROFILE) {
90
+ Object.assign(preset, { variant, variants: storedVariants(variants) });
91
+ }
92
+ const file = presetPath(engine, modelKey, ctx, profile);
93
+ await fs.mkdir(path.dirname(file), { recursive: true });
94
+ await fs.writeFile(file, JSON.stringify(preset, null, 2));
95
+ return { file, preset };
96
+ }
97
+
98
+ /** All presets for a model (to show which contexts and profiles are already tuned). */
99
+ export async function listPresets({ engine, modelKey } = {}) {
100
+ const dir = presetsDir();
101
+ const files = await fs.readdir(dir).catch(() => []);
102
+ const out = [];
103
+ for (const f of files) {
104
+ if (!f.endsWith('.json')) continue;
105
+ const p = await readJson(path.join(dir, f));
106
+ if (!p) continue;
107
+ if (engine && p.engine !== engine) continue;
108
+ if (modelKey && p.model !== modelKey) continue;
109
+ out.push({ file: path.join(dir, f), engine: p.engine, model: p.model, ctx: p.ctx, profile: p.profile || 'balanced', variant: p.variant?.key || null, createdAt: p.createdAt, fingerprint: p.fingerprint, kvType: p.candidate.kvType, fullOffload: p.candidate.fullOffload, shortTps: p.bench?.short?.genTps, deepTps: p.bench?.deep?.genTps });
110
+ }
111
+ return out.sort((a, b) => a.ctx - b.ctx || a.profile.localeCompare(b.profile));
112
+ }