ollanet 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.md +38 -2
- package/dist/bench-store.js +20 -0
- package/dist/bench-suite.js +149 -0
- package/dist/bench.js +781 -0
- package/dist/cli.js +9 -0
- package/dist/config.js +22 -0
- package/dist/hosts.js +56 -16
- package/dist/ollama-chat.js +307 -0
- package/dist/paths.js +4 -0
- package/dist/prompt.js +133 -126
- package/dist/scan.js +3 -3
- package/package.json +5 -2
package/dist/bench.js
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ollanet bench — speed + lightweight quality suite against Ollama models.
|
|
4
|
+
*/
|
|
5
|
+
import { defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
|
|
6
|
+
import { comparabilityKey, getSuiteCases, median, suiteRevision, } from "./bench-suite.js";
|
|
7
|
+
import { benchmarksDir, newBenchId, saveBenchmark } from "./bench-store.js";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { discoverHosts, envInt, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
|
|
10
|
+
import { projectPath } from "./paths.js";
|
|
11
|
+
import { contextLengthForModel, isCompletionCapable, isVisionCapable, ollamaChat, ollamaShow, ollamaTags, ollamaUnload, ollamaVersion, ollamaPs, psHasModel, shouldWarnNoThinking, vramForModel, waitUntilUnloaded, } from "./ollama-chat.js";
|
|
12
|
+
const MODEL_COL = 34;
|
|
13
|
+
const BENCH_TIMEOUT_MS = envInt("OLLAMA_BENCH_TIMEOUT_MS", 60_000);
|
|
14
|
+
const UNLOAD_WAIT_MS = envInt("OLLAMA_BENCH_UNLOAD_WAIT_MS", 60_000);
|
|
15
|
+
function usage() {
|
|
16
|
+
console.error(`Usage:
|
|
17
|
+
ollanet bench <machine|ip> [model...] [options]
|
|
18
|
+
ollanet bench <machine|ip> --all [options]
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--all Every completion-capable model from /api/tags
|
|
22
|
+
(skips vision models; use --include-vision)
|
|
23
|
+
--include-vision With --all, also bench vision-capable models
|
|
24
|
+
--suite quick|full Prompt suite (default quick)
|
|
25
|
+
--runs <n> Throughput repeats (default 3)
|
|
26
|
+
--warmup / --no-warmup
|
|
27
|
+
--cold-load Measure cold load via unload + /api/ps
|
|
28
|
+
--num-predict <n> Pin throughput length (default 256)
|
|
29
|
+
--num-ctx <n>
|
|
30
|
+
--keep-alive <value>
|
|
31
|
+
--think / --no-think
|
|
32
|
+
--judge --judge-model <name>
|
|
33
|
+
--json Machine-readable stdout
|
|
34
|
+
--save / --no-save
|
|
35
|
+
--fail-fast
|
|
36
|
+
--fail-on-error Exit 2 if any model/case errored
|
|
37
|
+
|
|
38
|
+
See docs/bench-spec.md for measurement rules.`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
function takeValue(args, flag) {
|
|
42
|
+
const value = args.shift();
|
|
43
|
+
if (!value) {
|
|
44
|
+
console.error(`${flag} requires a value`);
|
|
45
|
+
usage();
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function parseNumberFlag(raw, flag) {
|
|
50
|
+
const n = Number(raw);
|
|
51
|
+
if (!Number.isFinite(n)) {
|
|
52
|
+
console.error(`${flag} must be a number (got "${raw}")`);
|
|
53
|
+
usage();
|
|
54
|
+
}
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
function isLoopback(host) {
|
|
58
|
+
return (host.source === "localhost" ||
|
|
59
|
+
host.ip === "127.0.0.1" ||
|
|
60
|
+
host.ip === "::1" ||
|
|
61
|
+
shortName(host).toLowerCase() === "localhost");
|
|
62
|
+
}
|
|
63
|
+
function resolveModelName(tags, query) {
|
|
64
|
+
const q = query.toLowerCase();
|
|
65
|
+
const exact = tags.find((t) => t.name.toLowerCase() === q);
|
|
66
|
+
if (exact)
|
|
67
|
+
return exact.name;
|
|
68
|
+
const prefix = tags.find((t) => t.name.toLowerCase().startsWith(`${q}:`) || q.startsWith(`${t.name.toLowerCase()}:`));
|
|
69
|
+
return prefix?.name;
|
|
70
|
+
}
|
|
71
|
+
function tokPerSec(chunk) {
|
|
72
|
+
if (chunk.eval_count == null || chunk.eval_duration == null || chunk.eval_duration <= 0) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return chunk.eval_count / (chunk.eval_duration / 1e9);
|
|
76
|
+
}
|
|
77
|
+
function parseJudgeScore(content) {
|
|
78
|
+
const m = content.trim().match(/\b([1-5])\b/);
|
|
79
|
+
if (!m)
|
|
80
|
+
return undefined;
|
|
81
|
+
return Number(m[1]);
|
|
82
|
+
}
|
|
83
|
+
function parseArgs(argv) {
|
|
84
|
+
const args = [...argv];
|
|
85
|
+
let suite = "quick";
|
|
86
|
+
let runs = 3;
|
|
87
|
+
let all = false;
|
|
88
|
+
let includeVision = false;
|
|
89
|
+
let warmup = true;
|
|
90
|
+
let coldLoad = false;
|
|
91
|
+
let json = false;
|
|
92
|
+
let save = true;
|
|
93
|
+
let failFast = false;
|
|
94
|
+
let failOnError = false;
|
|
95
|
+
let judge = false;
|
|
96
|
+
let judgeModel;
|
|
97
|
+
let throughputNumPredict = 256;
|
|
98
|
+
const settings = {};
|
|
99
|
+
const positional = [];
|
|
100
|
+
while (args.length > 0) {
|
|
101
|
+
const arg = args.shift();
|
|
102
|
+
if (arg === "--" || arg === "--help" || arg === "-h")
|
|
103
|
+
usage();
|
|
104
|
+
if (arg === "--all") {
|
|
105
|
+
all = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (arg === "--include-vision") {
|
|
109
|
+
includeVision = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (arg === "--json") {
|
|
113
|
+
json = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (arg === "--save") {
|
|
117
|
+
save = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (arg === "--no-save") {
|
|
121
|
+
save = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (arg === "--warmup") {
|
|
125
|
+
warmup = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (arg === "--no-warmup") {
|
|
129
|
+
warmup = false;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (arg === "--cold-load") {
|
|
133
|
+
coldLoad = true;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (arg === "--fail-fast") {
|
|
137
|
+
failFast = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (arg === "--fail-on-error") {
|
|
141
|
+
failOnError = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (arg === "--think") {
|
|
145
|
+
settings.think = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (arg === "--no-think") {
|
|
149
|
+
settings.think = false;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (arg === "--judge") {
|
|
153
|
+
judge = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (arg === "--suite" || arg.startsWith("--suite=")) {
|
|
157
|
+
const raw = arg.includes("=") ? arg.slice("--suite=".length) : takeValue(args, "--suite");
|
|
158
|
+
if (raw !== "quick" && raw !== "full") {
|
|
159
|
+
console.error(`Unknown suite "${raw}" (use quick|full)`);
|
|
160
|
+
usage();
|
|
161
|
+
}
|
|
162
|
+
suite = raw;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (arg === "--runs" || arg.startsWith("--runs=")) {
|
|
166
|
+
const raw = arg.includes("=") ? arg.slice("--runs=".length) : takeValue(args, "--runs");
|
|
167
|
+
runs = Math.max(1, Math.trunc(parseNumberFlag(raw, "--runs")));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (arg === "--num-predict" || arg.startsWith("--num-predict=")) {
|
|
171
|
+
const raw = arg.includes("=")
|
|
172
|
+
? arg.slice("--num-predict=".length)
|
|
173
|
+
: takeValue(args, "--num-predict");
|
|
174
|
+
throughputNumPredict = Math.trunc(parseNumberFlag(raw, "--num-predict"));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (arg === "--num-ctx" || arg.startsWith("--num-ctx=")) {
|
|
178
|
+
const raw = arg.includes("=") ? arg.slice("--num-ctx=".length) : takeValue(args, "--num-ctx");
|
|
179
|
+
settings.num_ctx = Math.trunc(parseNumberFlag(raw, "--num-ctx"));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (arg === "--keep-alive" || arg.startsWith("--keep-alive=")) {
|
|
183
|
+
const raw = arg.includes("=")
|
|
184
|
+
? arg.slice("--keep-alive=".length)
|
|
185
|
+
: takeValue(args, "--keep-alive");
|
|
186
|
+
const asNum = Number(raw);
|
|
187
|
+
settings.keep_alive =
|
|
188
|
+
raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (arg === "--judge-model" || arg.startsWith("--judge-model=")) {
|
|
192
|
+
judgeModel = arg.includes("=")
|
|
193
|
+
? arg.slice("--judge-model=".length)
|
|
194
|
+
: takeValue(args, "--judge-model");
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (arg.startsWith("-")) {
|
|
198
|
+
console.error(`Unknown flag: ${arg}`);
|
|
199
|
+
usage();
|
|
200
|
+
}
|
|
201
|
+
positional.push(arg);
|
|
202
|
+
}
|
|
203
|
+
if (positional.length < 1)
|
|
204
|
+
usage();
|
|
205
|
+
const machine = positional[0];
|
|
206
|
+
const models = positional.slice(1);
|
|
207
|
+
if (judge && !judgeModel) {
|
|
208
|
+
console.error("--judge requires --judge-model <name> (no default).");
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
if (throughputNumPredict < 64) {
|
|
212
|
+
console.error(`Warning: --num-predict ${throughputNumPredict} is very short; tok/s will be mostly overhead. Prefer ≥64 (default 256).`);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
machine,
|
|
216
|
+
models,
|
|
217
|
+
all,
|
|
218
|
+
includeVision,
|
|
219
|
+
suite,
|
|
220
|
+
runs,
|
|
221
|
+
warmup,
|
|
222
|
+
coldLoad,
|
|
223
|
+
json,
|
|
224
|
+
save,
|
|
225
|
+
failFast,
|
|
226
|
+
failOnError,
|
|
227
|
+
judge,
|
|
228
|
+
judgeModel,
|
|
229
|
+
throughputNumPredict,
|
|
230
|
+
settings,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Raw capabilities from /api/show. `undefined` = key absent / show failed
|
|
235
|
+
* (omitempty) → treat as completion-capable. Empty array is also permissive.
|
|
236
|
+
*/
|
|
237
|
+
async function fetchCapabilities(baseUrl, name) {
|
|
238
|
+
try {
|
|
239
|
+
const shown = await ollamaShow(baseUrl, name, Math.min(BENCH_TIMEOUT_MS, 10_000));
|
|
240
|
+
if (!shown || !("capabilities" in shown) || shown.capabilities == null) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return shown.capabilities;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function runColdLoad(baseUrl, model, keepAlive) {
|
|
250
|
+
const started = Date.now();
|
|
251
|
+
try {
|
|
252
|
+
let ps = await ollamaPs(baseUrl, 5000);
|
|
253
|
+
if (psHasModel(ps, model)) {
|
|
254
|
+
await ollamaUnload(baseUrl, model, BENCH_TIMEOUT_MS);
|
|
255
|
+
const waited = await waitUntilUnloaded(baseUrl, model, UNLOAD_WAIT_MS);
|
|
256
|
+
if (!waited.unloaded) {
|
|
257
|
+
return { load_ms: 0, error: `unload timed out after ${waited.waitedMs}ms`, ps_poll_ms: waited.waitedMs };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const wall0 = Date.now();
|
|
261
|
+
const { chunk } = await ollamaChat({
|
|
262
|
+
baseUrl,
|
|
263
|
+
model,
|
|
264
|
+
messages: [{ role: "user", content: "x" }],
|
|
265
|
+
stream: false,
|
|
266
|
+
writeStdout: false,
|
|
267
|
+
timeoutMs: BENCH_TIMEOUT_MS,
|
|
268
|
+
settings: {
|
|
269
|
+
temperature: 0,
|
|
270
|
+
seed: 0,
|
|
271
|
+
num_predict: 1,
|
|
272
|
+
think: false,
|
|
273
|
+
keep_alive: keepAlive ?? "5m",
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const loadNs = chunk.load_duration ?? 0;
|
|
277
|
+
const loadMs = loadNs / 1e6;
|
|
278
|
+
return {
|
|
279
|
+
load_ms: loadMs,
|
|
280
|
+
ollama: {
|
|
281
|
+
load_duration: chunk.load_duration,
|
|
282
|
+
eval_count: chunk.eval_count,
|
|
283
|
+
total_duration: chunk.total_duration,
|
|
284
|
+
},
|
|
285
|
+
ps_poll_ms: Date.now() - started - (Date.now() - wall0),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
return {
|
|
290
|
+
load_ms: 0,
|
|
291
|
+
error: err instanceof Error ? err.message : String(err),
|
|
292
|
+
ps_poll_ms: Date.now() - started,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function runCaseAttempt(opts) {
|
|
297
|
+
const numPredict = opts.caseDef.role === "throughput"
|
|
298
|
+
? opts.throughputNumPredict
|
|
299
|
+
: (opts.caseDef.num_predict ?? 32);
|
|
300
|
+
const settings = mergeSettings(opts.settings, {
|
|
301
|
+
temperature: 0,
|
|
302
|
+
seed: 0,
|
|
303
|
+
num_predict: numPredict,
|
|
304
|
+
think: opts.settings.think === true,
|
|
305
|
+
...(opts.caseDef.format ? { format: opts.caseDef.format } : {}),
|
|
306
|
+
});
|
|
307
|
+
const wall0 = Date.now();
|
|
308
|
+
try {
|
|
309
|
+
const { content, chunk } = await ollamaChat({
|
|
310
|
+
baseUrl: opts.baseUrl,
|
|
311
|
+
model: opts.model,
|
|
312
|
+
messages: [{ role: "user", content: opts.caseDef.prompt }],
|
|
313
|
+
stream: false,
|
|
314
|
+
writeStdout: false,
|
|
315
|
+
timeoutMs: BENCH_TIMEOUT_MS,
|
|
316
|
+
settings,
|
|
317
|
+
});
|
|
318
|
+
const wallMs = Date.now() - wall0;
|
|
319
|
+
const doneReason = chunk.done_reason;
|
|
320
|
+
const earlyStop = opts.caseDef.role === "throughput" && doneReason != null && doneReason !== "length";
|
|
321
|
+
const tok_s = opts.caseDef.role === "throughput" ? tokPerSec(chunk) : undefined;
|
|
322
|
+
let quality;
|
|
323
|
+
if (opts.caseDef.check) {
|
|
324
|
+
const result = opts.caseDef.check(content);
|
|
325
|
+
quality = {
|
|
326
|
+
pass: result.ok,
|
|
327
|
+
checks: [{ id: opts.caseDef.id, ok: result.ok, detail: result.detail }],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
let judge_score;
|
|
331
|
+
if (opts.judgeModel && opts.caseDef.role === "check" && quality?.pass) {
|
|
332
|
+
try {
|
|
333
|
+
const judged = await ollamaChat({
|
|
334
|
+
baseUrl: opts.baseUrl,
|
|
335
|
+
model: opts.judgeModel,
|
|
336
|
+
stream: false,
|
|
337
|
+
writeStdout: false,
|
|
338
|
+
timeoutMs: BENCH_TIMEOUT_MS,
|
|
339
|
+
settings: { temperature: 0, seed: 0, num_predict: 8, think: false },
|
|
340
|
+
messages: [
|
|
341
|
+
{
|
|
342
|
+
role: "user",
|
|
343
|
+
content: "Score the assistant reply from 1-5 for correctness and instruction-following. " +
|
|
344
|
+
"Reply with only the integer.\n\n" +
|
|
345
|
+
`User: ${opts.caseDef.prompt}\nAssistant: ${content}`,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
judge_score = parseJudgeScore(judged.content);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// omit score
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
run: opts.run,
|
|
357
|
+
wall_ms: wallMs,
|
|
358
|
+
tok_s,
|
|
359
|
+
done_reason: doneReason,
|
|
360
|
+
early_stop: earlyStop || undefined,
|
|
361
|
+
quality,
|
|
362
|
+
content,
|
|
363
|
+
judge_score,
|
|
364
|
+
ollama: {
|
|
365
|
+
eval_count: chunk.eval_count,
|
|
366
|
+
eval_duration: chunk.eval_duration,
|
|
367
|
+
load_duration: chunk.load_duration,
|
|
368
|
+
total_duration: chunk.total_duration,
|
|
369
|
+
prompt_eval_count: chunk.prompt_eval_count,
|
|
370
|
+
done_reason: chunk.done_reason,
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
return {
|
|
376
|
+
run: opts.run,
|
|
377
|
+
wall_ms: Date.now() - wall0,
|
|
378
|
+
error: err instanceof Error ? err.message : String(err),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function summarizeModel(cases, cold, selfJudge) {
|
|
383
|
+
const throughput = cases.find((c) => c.id === "throughput");
|
|
384
|
+
const fullLen = throughput?.attempts.filter((a) => !a.error && !a.early_stop && a.tok_s != null) ?? [];
|
|
385
|
+
const toks = fullLen.map((a) => a.tok_s);
|
|
386
|
+
const earlyStopCount = throughput?.attempts.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length"))
|
|
387
|
+
.length ?? 0;
|
|
388
|
+
const checkAttempts = cases
|
|
389
|
+
.filter((c) => c.role === "check")
|
|
390
|
+
.flatMap((c) => c.attempts)
|
|
391
|
+
.filter((a) => !a.error && a.quality);
|
|
392
|
+
const passed = checkAttempts.filter((a) => a.quality?.pass).length;
|
|
393
|
+
const passRate = checkAttempts.length > 0 ? passed / checkAttempts.length : null;
|
|
394
|
+
return {
|
|
395
|
+
tok_s_median: median(toks) ?? null,
|
|
396
|
+
tok_s_min: toks.length ? Math.min(...toks) : null,
|
|
397
|
+
tok_s_max: toks.length ? Math.max(...toks) : null,
|
|
398
|
+
early_stop_count: earlyStopCount,
|
|
399
|
+
pass_rate: passRate,
|
|
400
|
+
load_ms: cold && cold.error == null && cold.load_ms > 0 ? cold.load_ms : cold?.error ? null : cold?.load_ms ?? null,
|
|
401
|
+
self_judge: selfJudge,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function printTable(machine, suite, runs, revision, compKey, models, skipped, coldLoad) {
|
|
405
|
+
const lines = [];
|
|
406
|
+
lines.push(`Bench: ${models.length} completion model(s) × suite=${suite} × runs=${runs}`);
|
|
407
|
+
lines.push(`comparability_key=${compKey} suite_revision=${revision} timeout: ${BENCH_TIMEOUT_MS}ms/case`);
|
|
408
|
+
if (coldLoad)
|
|
409
|
+
lines.push("Cold-load: on");
|
|
410
|
+
lines.push("");
|
|
411
|
+
const header = coldLoad
|
|
412
|
+
? pad("Model", MODEL_COL) +
|
|
413
|
+
pad("tok/s (med)", 12) +
|
|
414
|
+
pad("spread", 12) +
|
|
415
|
+
pad("load", 8) +
|
|
416
|
+
pad("pass", 8) +
|
|
417
|
+
"notes"
|
|
418
|
+
: pad("Model", MODEL_COL) +
|
|
419
|
+
pad("tok/s (med)", 12) +
|
|
420
|
+
pad("spread", 12) +
|
|
421
|
+
pad("pass", 8) +
|
|
422
|
+
"notes";
|
|
423
|
+
lines.push(header);
|
|
424
|
+
const sorted = [...models].sort((a, b) => {
|
|
425
|
+
const pa = a.summary.pass_rate;
|
|
426
|
+
const pb = b.summary.pass_rate;
|
|
427
|
+
if (pa == null && pb == null) {
|
|
428
|
+
/* both unknown */
|
|
429
|
+
}
|
|
430
|
+
else if (pa == null)
|
|
431
|
+
return 1;
|
|
432
|
+
else if (pb == null)
|
|
433
|
+
return -1;
|
|
434
|
+
else if (pb !== pa)
|
|
435
|
+
return pb - pa;
|
|
436
|
+
// Unavailable tok/s sinks below any defined median (including 0).
|
|
437
|
+
const ta = a.summary.tok_s_median;
|
|
438
|
+
const tb = b.summary.tok_s_median;
|
|
439
|
+
if (ta == null && tb == null)
|
|
440
|
+
return 0;
|
|
441
|
+
if (ta == null)
|
|
442
|
+
return 1;
|
|
443
|
+
if (tb == null)
|
|
444
|
+
return -1;
|
|
445
|
+
return tb - ta;
|
|
446
|
+
});
|
|
447
|
+
for (const m of sorted) {
|
|
448
|
+
const tok = m.summary.tok_s_median != null ? m.summary.tok_s_median.toFixed(1) : "—";
|
|
449
|
+
const spread = m.summary.tok_s_min != null && m.summary.tok_s_max != null
|
|
450
|
+
? `${m.summary.tok_s_min.toFixed(0)}–${m.summary.tok_s_max.toFixed(0)}`
|
|
451
|
+
: "—";
|
|
452
|
+
const pass = m.summary.pass_rate != null
|
|
453
|
+
? `${Math.round(m.summary.pass_rate * checkDenom(m))}/${checkDenom(m)}`
|
|
454
|
+
: "—";
|
|
455
|
+
const notes = [];
|
|
456
|
+
if (m.error)
|
|
457
|
+
notes.push(m.error.slice(0, 60));
|
|
458
|
+
if (m.summary.early_stop_count > 0)
|
|
459
|
+
notes.push(`${m.summary.early_stop_count}× early-stop`);
|
|
460
|
+
if (m.summary.self_judge)
|
|
461
|
+
notes.push("self-judge");
|
|
462
|
+
const load = m.summary.load_ms != null && m.summary.load_ms > 0
|
|
463
|
+
? `${(m.summary.load_ms / 1000).toFixed(1)}s`
|
|
464
|
+
: "—";
|
|
465
|
+
const row = coldLoad
|
|
466
|
+
? pad(m.name, MODEL_COL) +
|
|
467
|
+
pad(tok, 12) +
|
|
468
|
+
pad(spread, 12) +
|
|
469
|
+
pad(load, 8) +
|
|
470
|
+
pad(pass, 8) +
|
|
471
|
+
notes.join("; ")
|
|
472
|
+
: pad(m.name, MODEL_COL) +
|
|
473
|
+
pad(tok, 12) +
|
|
474
|
+
pad(spread, 12) +
|
|
475
|
+
pad(pass, 8) +
|
|
476
|
+
notes.join("; ");
|
|
477
|
+
lines.push(row);
|
|
478
|
+
}
|
|
479
|
+
if (skipped.length > 0) {
|
|
480
|
+
lines.push("");
|
|
481
|
+
const byReason = (reason) => skipped.filter((s) => s.reason === reason);
|
|
482
|
+
const embed = byReason("non-completion");
|
|
483
|
+
const vision = byReason("vision");
|
|
484
|
+
if (embed.length) {
|
|
485
|
+
lines.push(`Skipped ${embed.length} non-completion model(s): ${embed.map((s) => s.name).join(", ")}`);
|
|
486
|
+
}
|
|
487
|
+
if (vision.length) {
|
|
488
|
+
lines.push(`Skipped ${vision.length} vision model(s): ${vision.map((s) => s.name).join(", ")}` +
|
|
489
|
+
" (text suite; pass --include-vision to bench)");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
493
|
+
}
|
|
494
|
+
function checkDenom(m) {
|
|
495
|
+
return m.cases.filter((c) => c.role === "check").flatMap((c) => c.attempts).length || 0;
|
|
496
|
+
}
|
|
497
|
+
function pad(s, n) {
|
|
498
|
+
return s.length >= n ? `${s.slice(0, n - 1)} ` : s + " ".repeat(n - s.length);
|
|
499
|
+
}
|
|
500
|
+
export async function main() {
|
|
501
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
502
|
+
const config = await loadConfig();
|
|
503
|
+
const { hosts: targets } = await discoverHosts({
|
|
504
|
+
hosts: config.hosts,
|
|
505
|
+
discovery: config.discovery,
|
|
506
|
+
});
|
|
507
|
+
const host = resolveHost(targets, parsed.machine);
|
|
508
|
+
const baseUrl = ollamaBaseUrl(host);
|
|
509
|
+
const machineLabel = shortName(host);
|
|
510
|
+
const tags = await ollamaTags(baseUrl, Math.min(BENCH_TIMEOUT_MS, 15_000));
|
|
511
|
+
if (tags.length === 0) {
|
|
512
|
+
throw new Error(`No models on ${machineLabel} (${baseUrl})`);
|
|
513
|
+
}
|
|
514
|
+
const tagNames = tags.map((t) => t.name);
|
|
515
|
+
const skipped = [];
|
|
516
|
+
let selected = [];
|
|
517
|
+
if (parsed.all) {
|
|
518
|
+
for (const t of tags) {
|
|
519
|
+
const caps = await fetchCapabilities(baseUrl, t.name);
|
|
520
|
+
if (!isCompletionCapable(caps)) {
|
|
521
|
+
skipped.push({ name: t.name, reason: "non-completion", capabilities: caps ?? [] });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Text-only suite: vision models (moondream, etc.) usually early-stop / fail checks.
|
|
525
|
+
if (!parsed.includeVision && isVisionCapable(caps)) {
|
|
526
|
+
skipped.push({ name: t.name, reason: "vision", capabilities: caps ?? [] });
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
selected.push(t.name);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
else if (parsed.models.length > 0) {
|
|
533
|
+
for (const q of parsed.models) {
|
|
534
|
+
const resolved = resolveModelName(tags, q);
|
|
535
|
+
if (!resolved) {
|
|
536
|
+
console.error(`Unknown model "${q}". Available: ${tagNames.join(", ")}`);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
const caps = await fetchCapabilities(baseUrl, resolved);
|
|
540
|
+
if (!isCompletionCapable(caps)) {
|
|
541
|
+
console.error(`Model "${resolved}" is not completion-capable (${(caps ?? []).join(", ") || "none"}).`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
selected.push(resolved);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
const def = defaultModelForHost(config, host);
|
|
549
|
+
if (!def) {
|
|
550
|
+
console.error(`No model specified for "${machineLabel}". Pass model names, use --all, or set defaultModels.`);
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
const name = resolveModelName(tags, def);
|
|
554
|
+
if (!name) {
|
|
555
|
+
console.error(`Default model "${def}" not found on host. Available: ${tagNames.join(", ")}`);
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
const caps = await fetchCapabilities(baseUrl, name);
|
|
559
|
+
if (!isCompletionCapable(caps)) {
|
|
560
|
+
console.error(`Default model "${name}" is not completion-capable.`);
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
selected = [name];
|
|
564
|
+
}
|
|
565
|
+
if (selected.length === 0) {
|
|
566
|
+
console.error("No completion models to benchmark.");
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
let judgeModel;
|
|
570
|
+
if (parsed.judge && parsed.judgeModel) {
|
|
571
|
+
judgeModel = resolveModelName(tags, parsed.judgeModel);
|
|
572
|
+
if (!judgeModel) {
|
|
573
|
+
console.error(`Judge model "${parsed.judgeModel}" not found.`);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const baseSettings = mergeSettings(config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), parsed.settings, { think: parsed.settings.think === true ? true : false });
|
|
578
|
+
const revision = suiteRevision(parsed.suite);
|
|
579
|
+
const compKey = comparabilityKey({
|
|
580
|
+
suite: parsed.suite,
|
|
581
|
+
throughputNumPredict: parsed.throughputNumPredict,
|
|
582
|
+
seed: 0,
|
|
583
|
+
temperature: 0,
|
|
584
|
+
think: baseSettings.think === true,
|
|
585
|
+
numCtx: baseSettings.num_ctx ?? null,
|
|
586
|
+
});
|
|
587
|
+
const suiteCases = getSuiteCases(parsed.suite);
|
|
588
|
+
const caseCount = selected.length *
|
|
589
|
+
(suiteCases.filter((c) => c.role !== "throughput").length +
|
|
590
|
+
suiteCases.filter((c) => c.role === "throughput").length * parsed.runs);
|
|
591
|
+
if (!parsed.json) {
|
|
592
|
+
const skipBits = [
|
|
593
|
+
skipped.filter((s) => s.reason === "non-completion").length
|
|
594
|
+
? `${skipped.filter((s) => s.reason === "non-completion").length} embedding/other`
|
|
595
|
+
: "",
|
|
596
|
+
skipped.filter((s) => s.reason === "vision").length
|
|
597
|
+
? `${skipped.filter((s) => s.reason === "vision").length} vision`
|
|
598
|
+
: "",
|
|
599
|
+
].filter(Boolean);
|
|
600
|
+
console.error(`Bench: ${selected.length} completion model(s)` +
|
|
601
|
+
(skipBits.length ? ` (skipped ${skipBits.join(", ")})` : "") +
|
|
602
|
+
` × suite=${parsed.suite} × runs=${parsed.runs}`);
|
|
603
|
+
console.error(`Cases: ~${caseCount} chat calls timeout: ${BENCH_TIMEOUT_MS}ms/case` +
|
|
604
|
+
` worst-case ceiling: ~${Math.ceil((caseCount * BENCH_TIMEOUT_MS) / 60000)}m if every call times out`);
|
|
605
|
+
if (!isLoopback(host)) {
|
|
606
|
+
console.error(`Note: benchmarking unloads models on ${machineLabel} between runs` +
|
|
607
|
+
(parsed.coldLoad ? " (and for --cold-load)" : "") +
|
|
608
|
+
".");
|
|
609
|
+
}
|
|
610
|
+
console.error("Proceeding…");
|
|
611
|
+
}
|
|
612
|
+
if (parsed.settings.think === true) {
|
|
613
|
+
for (const name of selected) {
|
|
614
|
+
const caps = await fetchCapabilities(baseUrl, name);
|
|
615
|
+
if (shouldWarnNoThinking(caps)) {
|
|
616
|
+
console.error(`Warning: ${name} has no thinking capability; --think may 400.`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const ollamaVer = await ollamaVersion(baseUrl, 5000);
|
|
621
|
+
const modelResults = [];
|
|
622
|
+
let anyError = false;
|
|
623
|
+
for (let mi = 0; mi < selected.length; mi += 1) {
|
|
624
|
+
const model = selected[mi];
|
|
625
|
+
const info = tags.find((t) => t.name === model);
|
|
626
|
+
const caps = await fetchCapabilities(baseUrl, model);
|
|
627
|
+
const selfJudge = Boolean(judgeModel && judgeModel === model);
|
|
628
|
+
if (!parsed.json) {
|
|
629
|
+
console.error(`\n→ ${model} (${mi + 1}/${selected.length})`);
|
|
630
|
+
}
|
|
631
|
+
const cold = parsed.coldLoad
|
|
632
|
+
? await runColdLoad(baseUrl, model, baseSettings.keep_alive)
|
|
633
|
+
: null;
|
|
634
|
+
if (cold?.error)
|
|
635
|
+
anyError = true;
|
|
636
|
+
if (parsed.warmup) {
|
|
637
|
+
try {
|
|
638
|
+
await ollamaChat({
|
|
639
|
+
baseUrl,
|
|
640
|
+
model,
|
|
641
|
+
messages: [{ role: "user", content: "hi" }],
|
|
642
|
+
stream: false,
|
|
643
|
+
writeStdout: false,
|
|
644
|
+
timeoutMs: BENCH_TIMEOUT_MS,
|
|
645
|
+
settings: mergeSettings(baseSettings, {
|
|
646
|
+
temperature: 0,
|
|
647
|
+
seed: 0,
|
|
648
|
+
num_predict: 8,
|
|
649
|
+
think: false,
|
|
650
|
+
}),
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
// warmup failure will likely repeat on cases
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const cases = [];
|
|
658
|
+
let modelError;
|
|
659
|
+
for (const caseDef of suiteCases) {
|
|
660
|
+
const attempts = [];
|
|
661
|
+
const reps = caseDef.role === "throughput" ? parsed.runs : 1;
|
|
662
|
+
for (let r = 1; r <= reps; r += 1) {
|
|
663
|
+
const attempt = await runCaseAttempt({
|
|
664
|
+
baseUrl,
|
|
665
|
+
model,
|
|
666
|
+
caseDef,
|
|
667
|
+
run: r,
|
|
668
|
+
settings: baseSettings,
|
|
669
|
+
throughputNumPredict: parsed.throughputNumPredict,
|
|
670
|
+
judgeModel,
|
|
671
|
+
selfJudge,
|
|
672
|
+
});
|
|
673
|
+
attempts.push(attempt);
|
|
674
|
+
if (attempt.error) {
|
|
675
|
+
anyError = true;
|
|
676
|
+
if (!modelError)
|
|
677
|
+
modelError = attempt.error;
|
|
678
|
+
if (parsed.failFast)
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
cases.push({ id: caseDef.id, role: caseDef.role, attempts });
|
|
683
|
+
if (parsed.failFast && modelError)
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
// Capture VRAM + context while still loaded (after timed cases, before unload).
|
|
687
|
+
const ps = await ollamaPs(baseUrl, 5000);
|
|
688
|
+
const summary = summarizeModel(cases, cold, selfJudge);
|
|
689
|
+
if (cold && !cold.error && cold.load_ms > 0) {
|
|
690
|
+
summary.load_ms = cold.load_ms;
|
|
691
|
+
}
|
|
692
|
+
else if (!parsed.coldLoad) {
|
|
693
|
+
summary.load_ms = null;
|
|
694
|
+
}
|
|
695
|
+
else if (cold?.error) {
|
|
696
|
+
summary.load_ms = null;
|
|
697
|
+
}
|
|
698
|
+
modelResults.push({
|
|
699
|
+
name: model,
|
|
700
|
+
digest: info?.digest,
|
|
701
|
+
parameter_size: info?.details?.parameter_size,
|
|
702
|
+
quantization_level: info?.details?.quantization_level,
|
|
703
|
+
...(caps ? { capabilities: caps } : {}),
|
|
704
|
+
size_vram: vramForModel(ps, model),
|
|
705
|
+
context_length: contextLengthForModel(ps, model),
|
|
706
|
+
cold_load: cold,
|
|
707
|
+
summary,
|
|
708
|
+
error: modelError,
|
|
709
|
+
cases,
|
|
710
|
+
});
|
|
711
|
+
if (parsed.failFast && modelError)
|
|
712
|
+
break;
|
|
713
|
+
// Unload before next model when ≥2 models remain after this one / multi-model run
|
|
714
|
+
if (selected.length >= 2 && mi < selected.length - 1) {
|
|
715
|
+
try {
|
|
716
|
+
await ollamaUnload(baseUrl, model, BENCH_TIMEOUT_MS);
|
|
717
|
+
await waitUntilUnloaded(baseUrl, model, UNLOAD_WAIT_MS);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// best-effort
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
let ollanetVersion = "0.2.0";
|
|
725
|
+
try {
|
|
726
|
+
const pkg = JSON.parse(await readFile(projectPath("package.json"), "utf8"));
|
|
727
|
+
if (pkg.version)
|
|
728
|
+
ollanetVersion = pkg.version;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// keep fallback
|
|
732
|
+
}
|
|
733
|
+
const result = {
|
|
734
|
+
id: newBenchId(),
|
|
735
|
+
version: 1,
|
|
736
|
+
created_at: new Date().toISOString(),
|
|
737
|
+
ollanet: ollanetVersion,
|
|
738
|
+
suite: parsed.suite,
|
|
739
|
+
suite_revision: revision,
|
|
740
|
+
comparability_key: compKey,
|
|
741
|
+
runs: parsed.runs,
|
|
742
|
+
machine: machineLabel,
|
|
743
|
+
host: { ip: host.ip, port: host.port },
|
|
744
|
+
ollama_version: ollamaVer,
|
|
745
|
+
settings: {
|
|
746
|
+
temperature: 0,
|
|
747
|
+
seed: 0,
|
|
748
|
+
throughput_num_predict: parsed.throughputNumPredict,
|
|
749
|
+
num_ctx: baseSettings.num_ctx ?? null,
|
|
750
|
+
warmup: parsed.warmup,
|
|
751
|
+
cold_load: parsed.coldLoad,
|
|
752
|
+
think: baseSettings.think === true,
|
|
753
|
+
bench_timeout_ms: BENCH_TIMEOUT_MS,
|
|
754
|
+
},
|
|
755
|
+
skipped_models: skipped,
|
|
756
|
+
judge: judgeModel
|
|
757
|
+
? {
|
|
758
|
+
name: judgeModel,
|
|
759
|
+
digest: tags.find((t) => t.name === judgeModel)?.digest,
|
|
760
|
+
}
|
|
761
|
+
: null,
|
|
762
|
+
models: modelResults,
|
|
763
|
+
};
|
|
764
|
+
if (parsed.save) {
|
|
765
|
+
const file = await saveBenchmark(result);
|
|
766
|
+
if (!parsed.json)
|
|
767
|
+
console.error(`saved ${file}`);
|
|
768
|
+
}
|
|
769
|
+
if (parsed.json) {
|
|
770
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
process.stdout.write("\n");
|
|
774
|
+
printTable(machineLabel, parsed.suite, parsed.runs, revision, compKey, modelResults, skipped, parsed.coldLoad);
|
|
775
|
+
if (parsed.save)
|
|
776
|
+
console.error(`benchmarks dir: ${benchmarksDir()}`);
|
|
777
|
+
}
|
|
778
|
+
if (parsed.failOnError && anyError) {
|
|
779
|
+
process.exitCode = 2;
|
|
780
|
+
}
|
|
781
|
+
}
|