@otto-code/brain 0.8.10 → 0.8.13
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/dist/commands/bench.js +2 -2
- package/dist/commands/calibrate.js +11 -2
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +12 -3
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +12 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +2 -2
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +294 -43
- package/dist/config/profiles.d.ts +19 -3
- package/dist/config/profiles.js +52 -4
- package/dist/config/schema.d.ts +616 -0
- package/dist/config/schema.js +65 -3
- package/dist/config/store.js +7 -4
- package/dist/gguf.d.ts +7 -0
- package/dist/gguf.js +15 -2
- package/dist/models/download.d.ts +1 -1
- package/dist/models/download.js +2 -2
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +27 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.js +4 -3
- package/dist/ops/archive.d.ts +14 -1
- package/dist/ops/archive.js +9 -5
- package/dist/ops/calibrate.d.ts +38 -3
- package/dist/ops/calibrate.js +68 -19
- package/dist/ops/report.js +51 -1
- package/dist/ops/results.d.ts +77 -11
- package/dist/ops/results.js +84 -14
- package/dist/ops/sweep.d.ts +38 -1
- package/dist/ops/sweep.js +61 -10
- package/dist/runtime/args.d.ts +15 -2
- package/dist/runtime/args.js +60 -5
- package/dist/runtime/managed.js +2 -2
- package/dist/service/activity.d.ts +19 -0
- package/dist/service/activity.js +47 -4
- package/dist/service/host-api.d.ts +28 -4
- package/dist/service/host-api.js +109 -28
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/process-pool.d.ts +45 -0
- package/dist/service/process-pool.js +271 -0
- package/dist/service/router.d.ts +74 -3
- package/dist/service/router.js +277 -51
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +250 -31
- package/dist/service/scheduler.js +408 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +376 -142
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +112 -12
- package/dist/service/supervisor.d.ts +9 -7
- package/dist/service/supervisor.js +37 -12
- package/dist/sysmon.d.ts +15 -0
- package/dist/sysmon.js +56 -9
- package/dist/tui/app.d.ts +8 -2
- package/dist/tui/app.js +83 -26
- package/dist/types.d.ts +18 -0
- package/dist/vram.d.ts +37 -0
- package/dist/vram.js +57 -18
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Screen, box, meter, style, pad, truncate, width, onKeys } from "./screen.js";
|
|
2
2
|
import { scanModels, managedModelsDir, diskUsage, totalModelBytes, planDelete, deleteModelFiles, listRepoQuants, searchModels, downloadRepoFiles, resolveHfToken, } from "../models/index.js";
|
|
3
3
|
import * as profiles from "../config/profiles.js";
|
|
4
|
-
import { formatReasoningBudget, loadBrainConfig, loadProfilesStore, saveProfilesStore, UNRESTRICTED_REASONING_BUDGET, } from "../config/index.js";
|
|
4
|
+
import { formatReasoningBudget, loadBrainConfig, loadProfilesStore, profileWarnings, saveProfilesStore, UNRESTRICTED_REASONING_BUDGET, } from "../config/index.js";
|
|
5
5
|
import * as vram from "../vram.js";
|
|
6
6
|
import * as gpu from "../gpu.js";
|
|
7
7
|
import { calibrate } from "../ops/calibrate.js";
|
|
@@ -61,6 +61,27 @@ export const FIELDS = [
|
|
|
61
61
|
values: CACHE_CYCLE,
|
|
62
62
|
format: (p) => p.cacheTypeV,
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
key: "parallelSlots",
|
|
66
|
+
label: "Parallel slots",
|
|
67
|
+
kind: "number",
|
|
68
|
+
step: 1,
|
|
69
|
+
min: 1,
|
|
70
|
+
max: () => 16,
|
|
71
|
+
format: (p) => String(p.parallelSlots),
|
|
72
|
+
note: (p) => p.parallelSlots > 1 ? `${p.parallelSlots} concurrent requests, sharing one KV pool` : null,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: "cachedChats",
|
|
76
|
+
label: "Cached KVs",
|
|
77
|
+
kind: "number",
|
|
78
|
+
step: 1,
|
|
79
|
+
min: 0,
|
|
80
|
+
max: () => 64,
|
|
81
|
+
format: (p) => (p.cachedChats ?? 0) > 0 ? String(p.cachedChats) : `default ${style.grey}(0)${style.reset}`,
|
|
82
|
+
// No inline note: the RAM estimate is the warning's job, and the warning
|
|
83
|
+
// lives in one place (profile-edit.ts). The panel reads it from there.
|
|
84
|
+
},
|
|
64
85
|
{
|
|
65
86
|
key: "flashAttention",
|
|
66
87
|
label: "Flash attention",
|
|
@@ -97,6 +118,21 @@ export const FIELDS = [
|
|
|
97
118
|
},
|
|
98
119
|
note: (p) => p.reasoningBudget === -1 ? "unrestricted budget can consume every token on thinking" : null,
|
|
99
120
|
},
|
|
121
|
+
{
|
|
122
|
+
key: "preserveReasoning",
|
|
123
|
+
label: "Preserve reasoning",
|
|
124
|
+
kind: "cycle",
|
|
125
|
+
// The stored tri-state, in the order PRESERVE_REASONING_CYCLE names it.
|
|
126
|
+
values: [null, true, false],
|
|
127
|
+
format: (p) => p.preserveReasoning === true
|
|
128
|
+
? `${style.brightGreen}on${style.reset}`
|
|
129
|
+
: p.preserveReasoning === false
|
|
130
|
+
? `${style.yellow}off${style.reset}`
|
|
131
|
+
: `${style.grey}template default${style.reset}`,
|
|
132
|
+
// Offered wherever the template has thinking to preserve, not only where it
|
|
133
|
+
// spells the setting as a kwarg. See profileFieldDescriptors for why.
|
|
134
|
+
enabled: (ctx) => Boolean(ctx.model?.metadata?.reasoning || ctx.model?.reasoningPreservation),
|
|
135
|
+
},
|
|
100
136
|
{
|
|
101
137
|
key: "gpuLayers",
|
|
102
138
|
label: "GPU layers",
|
|
@@ -106,16 +142,6 @@ export const FIELDS = [
|
|
|
106
142
|
max: () => 999,
|
|
107
143
|
format: (p) => p.gpuLayers >= 999 ? `all ${style.grey}(999)${style.reset}` : String(p.gpuLayers),
|
|
108
144
|
},
|
|
109
|
-
{
|
|
110
|
-
key: "parallelSlots",
|
|
111
|
-
label: "Parallel slots",
|
|
112
|
-
kind: "number",
|
|
113
|
-
step: 1,
|
|
114
|
-
min: 1,
|
|
115
|
-
max: () => 16,
|
|
116
|
-
format: (p) => String(p.parallelSlots),
|
|
117
|
-
note: (p) => p.parallelSlots > 1 ? `${p.parallelSlots} concurrent requests, sharing one KV pool` : null,
|
|
118
|
-
},
|
|
119
145
|
];
|
|
120
146
|
export class App {
|
|
121
147
|
constructor({ runtime, listenPort, listenHost }) {
|
|
@@ -220,7 +246,7 @@ export class App {
|
|
|
220
246
|
const fit = vram.fitToBudget({
|
|
221
247
|
model: target,
|
|
222
248
|
profile,
|
|
223
|
-
calibration: profiles.
|
|
249
|
+
calibration: profiles.getCalibrationForBudget(this.store, target, profile),
|
|
224
250
|
totalVramBytes: info.totalBytes,
|
|
225
251
|
});
|
|
226
252
|
if (!fit.adjusted && !fit.budget.fits)
|
|
@@ -404,7 +430,7 @@ export class App {
|
|
|
404
430
|
const profile = this.profile;
|
|
405
431
|
if (!model || !profile)
|
|
406
432
|
return null;
|
|
407
|
-
return profiles.
|
|
433
|
+
return profiles.getCalibrationForBudget(this.store, model, profile);
|
|
408
434
|
}
|
|
409
435
|
get budget() {
|
|
410
436
|
const model = this.model;
|
|
@@ -1179,11 +1205,14 @@ export class App {
|
|
|
1179
1205
|
return;
|
|
1180
1206
|
return this.guard("calibration", async () => {
|
|
1181
1207
|
await this.supervisor.stop();
|
|
1208
|
+
this.supervisor.recordLog(`operation calibrate: ${model.displayName}`);
|
|
1182
1209
|
const measurement = await calibrate({
|
|
1183
1210
|
runtime: this.runtime,
|
|
1184
1211
|
model,
|
|
1185
1212
|
profile,
|
|
1213
|
+
supervisor: this.supervisor,
|
|
1186
1214
|
onProgress: (p) => {
|
|
1215
|
+
this.supervisor.recordLog(`operation calibrate: ${p.phase} ${p.contextSize.toLocaleString()}${p.reason ? `: ${p.reason}` : ""}${p.error ? `: ${p.error}` : ""}`);
|
|
1187
1216
|
if (p.phase === "loading")
|
|
1188
1217
|
this.setStatus(`calibrating: loading at ${p.contextSize.toLocaleString()} ctx…`, "info");
|
|
1189
1218
|
if (p.phase === "measured")
|
|
@@ -1217,6 +1246,7 @@ export class App {
|
|
|
1217
1246
|
this.setStatus(appliedTo !== null
|
|
1218
1247
|
? `${measuredMsg} - context ${before.toLocaleString()} -> ${appliedTo.toLocaleString()}`
|
|
1219
1248
|
: measuredMsg, "good");
|
|
1249
|
+
this.supervisor.recordLog(`operation calibrate: saved measurement for ${model.displayName}`);
|
|
1220
1250
|
});
|
|
1221
1251
|
}
|
|
1222
1252
|
runSweep() {
|
|
@@ -1226,11 +1256,14 @@ export class App {
|
|
|
1226
1256
|
return;
|
|
1227
1257
|
return this.guard("sweep", async () => {
|
|
1228
1258
|
await this.supervisor.stop();
|
|
1259
|
+
this.supervisor.recordLog(`operation sweep: ${model.displayName}`);
|
|
1229
1260
|
const result = await sweep({
|
|
1230
1261
|
runtime: this.runtime,
|
|
1231
1262
|
model,
|
|
1232
1263
|
profile,
|
|
1264
|
+
supervisor: this.supervisor,
|
|
1233
1265
|
onProgress: (p) => {
|
|
1266
|
+
this.supervisor.recordLog(`operation sweep: budget ${p.budget} ${p.phase}${p.error ? `: ${p.error}` : ""}`);
|
|
1234
1267
|
if (p.phase === "loading")
|
|
1235
1268
|
this.setStatus(`sweep: loading with budget ${p.budget}…`, "info");
|
|
1236
1269
|
if (p.phase === "generating")
|
|
@@ -1245,6 +1278,7 @@ export class App {
|
|
|
1245
1278
|
profile.reasoningBudget = result.recommended;
|
|
1246
1279
|
this.persist();
|
|
1247
1280
|
this.setStatus(`sweep complete - reasoning budget set to ${result.recommended}`, "good");
|
|
1281
|
+
this.supervisor.recordLog(`operation sweep: saved budget ${result.recommended}`);
|
|
1248
1282
|
}
|
|
1249
1283
|
else {
|
|
1250
1284
|
this.setStatus("sweep produced no usable result", "warn");
|
|
@@ -1364,7 +1398,9 @@ export class App {
|
|
|
1364
1398
|
// Only when it belongs to the model actually being benchmarked - the
|
|
1365
1399
|
// model may have been resident since before this fit was computed.
|
|
1366
1400
|
fit: this.lastFit?.modelId === model.id ? this.lastFit.fit : null,
|
|
1367
|
-
calibration: profile
|
|
1401
|
+
calibration: profile
|
|
1402
|
+
? profiles.getCalibrationForBudget(this.store, model, profile)
|
|
1403
|
+
: null,
|
|
1368
1404
|
suite: {
|
|
1369
1405
|
// The TUI runs the full static suite; only concurrency varies, and
|
|
1370
1406
|
// it tracks the profile's slot count the same way runSuite is called.
|
|
@@ -1626,14 +1662,14 @@ export class App {
|
|
|
1626
1662
|
const header = this.header(cols);
|
|
1627
1663
|
const status = this.statusPanel(cols - 2);
|
|
1628
1664
|
const footer = this.keybindings(cols - 1);
|
|
1629
|
-
const title = truncate(`${style.brightCyan}
|
|
1665
|
+
const title = truncate(`${style.brightCyan}Brain log${style.reset} ` +
|
|
1630
1666
|
`${style.grey}${s.state}${logs.length ? ` · ${logs.length} lines` : ""} · esc or l to go back${style.reset}`, cols - 1);
|
|
1631
1667
|
// header + blank + title + blank + [body] + blank + status + blank + footer.
|
|
1632
1668
|
const overhead = 1 + 1 + 1 + 1 + 1 + status.length + 1 + footer.length;
|
|
1633
1669
|
const bodyRows = Math.max(1, this.screen.rows - 1 - overhead);
|
|
1634
1670
|
const body = [];
|
|
1635
1671
|
if (logs.length === 0) {
|
|
1636
|
-
body.push(`${style.grey}no logs yet - start a model
|
|
1672
|
+
body.push(`${style.grey}no logs yet - start a model or operation to see Brain output${style.reset}`);
|
|
1637
1673
|
}
|
|
1638
1674
|
else {
|
|
1639
1675
|
for (const line of logs.slice(Math.max(0, logs.length - bodyRows)))
|
|
@@ -1739,9 +1775,23 @@ export class App {
|
|
|
1739
1775
|
if (focused)
|
|
1740
1776
|
focusLine = content.length;
|
|
1741
1777
|
content.push(`${arrow} ${pad(field.label, 17)} ${value}`);
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1778
|
+
// The cachedChats estimate is a ProfileWarning, not a field note: it is
|
|
1779
|
+
// computed once in profile-edit.ts, colored by how much of the machine's
|
|
1780
|
+
// RAM it would take, and the app editor renders the very same object.
|
|
1781
|
+
const cachedWarning = field.key === "cachedChats"
|
|
1782
|
+
? profileWarnings(profile, model, this.store).find((w) => w.field === "cachedChats")
|
|
1783
|
+
: undefined;
|
|
1784
|
+
const note = cachedWarning?.message ?? field.note?.(profile);
|
|
1785
|
+
if (note) {
|
|
1786
|
+
const tone = cachedWarning?.severity === "error"
|
|
1787
|
+
? style.red
|
|
1788
|
+
: cachedWarning?.severity === "warn"
|
|
1789
|
+
? style.yellow
|
|
1790
|
+
: field.key === "cachedChats"
|
|
1791
|
+
? style.grey
|
|
1792
|
+
: style.yellow;
|
|
1793
|
+
content.push(` ${tone} ${note}${style.reset}`);
|
|
1794
|
+
}
|
|
1745
1795
|
}
|
|
1746
1796
|
const sweepResult = this.sweepResult;
|
|
1747
1797
|
if (sweepResult) {
|
|
@@ -1770,8 +1820,12 @@ export class App {
|
|
|
1770
1820
|
const start = Math.max(0, Math.min(focusLine - Math.floor(rows / 2), content.length - rows));
|
|
1771
1821
|
lines = content.slice(start, start + rows);
|
|
1772
1822
|
}
|
|
1823
|
+
const stale = Boolean(this.profile?.calibrationRequired ||
|
|
1824
|
+
(this.model &&
|
|
1825
|
+
this.profile &&
|
|
1826
|
+
profiles.hasStaleCalibration(this.store, this.model, this.profile)));
|
|
1773
1827
|
return box({
|
|
1774
|
-
title: `Configuration${this.calibration ? ` ${style.brightGreen}calibrated${style.reset}` : ` ${style.yellow}not calibrated${style.reset}`}`,
|
|
1828
|
+
title: `Configuration${stale ? ` ${style.yellow}recalibrate${style.reset}` : this.calibration ? ` ${style.brightGreen}calibrated${style.reset}` : ` ${style.yellow}not calibrated${style.reset}`}`,
|
|
1775
1829
|
lines,
|
|
1776
1830
|
innerWidth,
|
|
1777
1831
|
footer: `${style.grey}←→ change · enter edit${style.reset}`,
|
|
@@ -1799,18 +1853,21 @@ export class App {
|
|
|
1799
1853
|
` = ${style.bold}${vram.formatGiB(b.totalBytes)}${style.reset}`;
|
|
1800
1854
|
const kvLabel = `kv ${(b.kvBytesPerToken / 1024).toFixed(1)} KB/token`;
|
|
1801
1855
|
const cal = this.calibration;
|
|
1856
|
+
const stale = Boolean(this.profile?.calibrationRequired ||
|
|
1857
|
+
(this.model &&
|
|
1858
|
+
this.profile &&
|
|
1859
|
+
profiles.hasStaleCalibration(this.store, this.model, this.profile)));
|
|
1802
1860
|
let sourceNote;
|
|
1803
1861
|
if (b.source === "measured") {
|
|
1804
1862
|
sourceNote = cal?.inherited
|
|
1805
1863
|
? `${style.yellow}${kvLabel} (measured on a relative - press c to calibrate this model)${style.reset}`
|
|
1806
|
-
:
|
|
1864
|
+
: stale
|
|
1865
|
+
? `${style.yellow}${kvLabel} (last measurement - press c to recalibrate)${style.reset}`
|
|
1866
|
+
: `${style.grey}${kvLabel} (measured)${style.reset}`;
|
|
1807
1867
|
}
|
|
1808
1868
|
else {
|
|
1809
|
-
const stale = this.model && this.profile
|
|
1810
|
-
? profiles.hasStaleCalibration(this.store, this.model, this.profile)
|
|
1811
|
-
: false;
|
|
1812
1869
|
const hint = stale
|
|
1813
|
-
? "
|
|
1870
|
+
? "recalibrate to refresh this value"
|
|
1814
1871
|
: "press c to calibrate; usually unlocks more context";
|
|
1815
1872
|
sourceNote = `${style.yellow}${kvLabel} (theoretical - ${hint})${style.reset}`;
|
|
1816
1873
|
}
|
|
@@ -1923,7 +1980,7 @@ export class App {
|
|
|
1923
1980
|
item("u", "reset the selected model's name to its default (asks to confirm)");
|
|
1924
1981
|
section("Views");
|
|
1925
1982
|
item("b", "benchmark mode - rank models, run the coding suite");
|
|
1926
|
-
item("l", "view the live
|
|
1983
|
+
item("l", "view the live Brain log");
|
|
1927
1984
|
item("/", "filter the model list (Enter apply, Esc clear)");
|
|
1928
1985
|
item("r", "rescan the models folder");
|
|
1929
1986
|
item("?", "this help");
|
package/dist/types.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface ModelMetadata {
|
|
|
21
21
|
* flags a reasoning model - works for any local model, catalog or not.
|
|
22
22
|
*/
|
|
23
23
|
reasoning?: boolean;
|
|
24
|
+
/** Native template key detected from `preserve_thinking` or `preserve_reasoning`. */
|
|
25
|
+
reasoningPreservationArgument?: string;
|
|
24
26
|
[key: string]: unknown;
|
|
25
27
|
}
|
|
26
28
|
export interface ModelFeatures {
|
|
@@ -28,6 +30,16 @@ export interface ModelFeatures {
|
|
|
28
30
|
imatrix: boolean;
|
|
29
31
|
distilled: boolean;
|
|
30
32
|
}
|
|
33
|
+
/** Catalog-declared native chat-template argument names for reasoning controls. */
|
|
34
|
+
export interface ReasoningTemplate {
|
|
35
|
+
enableThinkingArgument: string;
|
|
36
|
+
effortArgument: string;
|
|
37
|
+
}
|
|
38
|
+
/** The native argument behind Brain's provider-neutral Preserve reasoning setting. */
|
|
39
|
+
export interface ReasoningPreservation {
|
|
40
|
+
templateArgument: string;
|
|
41
|
+
default?: boolean;
|
|
42
|
+
}
|
|
31
43
|
export type ModelComponentRole = "vision_projector" | "speculative_drafter";
|
|
32
44
|
/** A catalog-declared companion artifact resolved against local disk. */
|
|
33
45
|
export interface ModelComponent {
|
|
@@ -69,6 +81,12 @@ export interface Model {
|
|
|
69
81
|
thinking?: boolean;
|
|
70
82
|
/** Per-request reasoning levels accepted by the model's OpenAI-compatible API. */
|
|
71
83
|
reasoningEfforts?: string[];
|
|
84
|
+
/** The model-native default among `reasoningEfforts`, when one is declared. */
|
|
85
|
+
reasoningEffortDefault?: string;
|
|
86
|
+
/** Template argument names needed to apply this model's reasoning controls. */
|
|
87
|
+
reasoningTemplate?: ReasoningTemplate;
|
|
88
|
+
/** Native template argument used by the model-profile Preserve reasoning setting. */
|
|
89
|
+
reasoningPreservation?: ReasoningPreservation;
|
|
72
90
|
/** The catalog's advertised max context, if known. */
|
|
73
91
|
contextMax?: number;
|
|
74
92
|
/** Back-reference: the id of the reconciled catalog entry, if matched. */
|
package/dist/vram.d.ts
CHANGED
|
@@ -18,6 +18,43 @@ export declare function listCacheTypes(): string[];
|
|
|
18
18
|
/** Worst-case KV bytes per token, assuming every layer holds a full cache. */
|
|
19
19
|
export declare function theoreticalKvBytesPerToken(metadata: ModelMetadata | null, cacheTypeK: string, cacheTypeV: string): number | null;
|
|
20
20
|
export type BudgetSource = "measured" | "theoretical" | "unknown";
|
|
21
|
+
/**
|
|
22
|
+
* KV bytes per token for a profile: the measurement when one exists, otherwise
|
|
23
|
+
* the theoretical bound.
|
|
24
|
+
*
|
|
25
|
+
* The two are not interchangeable. The theoretical formula overestimates badly
|
|
26
|
+
* on architectures that only keep a full cache on a subset of layers - measured
|
|
27
|
+
* at 3.5x for Qwen3.8-27B and 20x for Gemma-4-31B on this repo's own stored
|
|
28
|
+
* calibrations - which is the whole reason `calibrate` exists. Callers that
|
|
29
|
+
* spend real memory on this number must therefore branch on `source` rather
|
|
30
|
+
* than treating the theoretical figure as an answer.
|
|
31
|
+
*/
|
|
32
|
+
export declare function kvBytesPerToken(model: Model, profile: Profile, calibration?: Calibration | null): {
|
|
33
|
+
value: number;
|
|
34
|
+
source: BudgetSource;
|
|
35
|
+
theoretical: number | null;
|
|
36
|
+
};
|
|
37
|
+
export interface PromptCacheSize {
|
|
38
|
+
/** One chat at its full per-slot window - the worst case for a single entry. */
|
|
39
|
+
perChatBytes: number;
|
|
40
|
+
/** What `--cache-ram` is set to, in bytes. */
|
|
41
|
+
totalBytes: number;
|
|
42
|
+
source: BudgetSource;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Turn `cachedChats` into a `--cache-ram` byte budget.
|
|
46
|
+
*
|
|
47
|
+
* llama-server parks the KV state of a chat that loses its slot in host RAM and
|
|
48
|
+
* copies it back when that chat returns, instead of re-prefilling its whole
|
|
49
|
+
* conversation. An entry is at most one slot's worth of KV, and a slot is
|
|
50
|
+
* `contextSize / parallelSlots` tokens, so the count the user picks is the
|
|
51
|
+
* count of chats guaranteed to survive. Real conversations rarely fill their
|
|
52
|
+
* window, so in practice more of them fit: the number is a floor, not a cap.
|
|
53
|
+
*
|
|
54
|
+
* Returns null when the size cannot be computed - `cachedChats` is 0 (leave
|
|
55
|
+
* llama.cpp's default alone) or nothing is known about this model's KV cost.
|
|
56
|
+
*/
|
|
57
|
+
export declare function promptCacheSize(model: Model, profile: Profile, calibration?: Calibration | null): PromptCacheSize | null;
|
|
21
58
|
export interface Budget {
|
|
22
59
|
weightsBytes: number;
|
|
23
60
|
mmprojBytes: number;
|
package/dist/vram.js
CHANGED
|
@@ -32,6 +32,52 @@ export function theoreticalKvBytesPerToken(metadata, cacheTypeK, cacheTypeV) {
|
|
|
32
32
|
(keyLength * cacheTypeBytes(cacheTypeK) +
|
|
33
33
|
valueLength * cacheTypeBytes(cacheTypeV)));
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* KV bytes per token for a profile: the measurement when one exists, otherwise
|
|
37
|
+
* the theoretical bound.
|
|
38
|
+
*
|
|
39
|
+
* The two are not interchangeable. The theoretical formula overestimates badly
|
|
40
|
+
* on architectures that only keep a full cache on a subset of layers - measured
|
|
41
|
+
* at 3.5x for Qwen3.8-27B and 20x for Gemma-4-31B on this repo's own stored
|
|
42
|
+
* calibrations - which is the whole reason `calibrate` exists. Callers that
|
|
43
|
+
* spend real memory on this number must therefore branch on `source` rather
|
|
44
|
+
* than treating the theoretical figure as an answer.
|
|
45
|
+
*/
|
|
46
|
+
export function kvBytesPerToken(model, profile, calibration) {
|
|
47
|
+
const theoretical = theoreticalKvBytesPerToken(model.metadata, profile.cacheTypeK, profile.cacheTypeV);
|
|
48
|
+
if (calibration && calibration.kvBytesPerToken > 0) {
|
|
49
|
+
return { value: calibration.kvBytesPerToken, source: "measured", theoretical };
|
|
50
|
+
}
|
|
51
|
+
if (theoretical !== null)
|
|
52
|
+
return { value: theoretical, source: "theoretical", theoretical };
|
|
53
|
+
return { value: 0, source: "unknown", theoretical };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Turn `cachedChats` into a `--cache-ram` byte budget.
|
|
57
|
+
*
|
|
58
|
+
* llama-server parks the KV state of a chat that loses its slot in host RAM and
|
|
59
|
+
* copies it back when that chat returns, instead of re-prefilling its whole
|
|
60
|
+
* conversation. An entry is at most one slot's worth of KV, and a slot is
|
|
61
|
+
* `contextSize / parallelSlots` tokens, so the count the user picks is the
|
|
62
|
+
* count of chats guaranteed to survive. Real conversations rarely fill their
|
|
63
|
+
* window, so in practice more of them fit: the number is a floor, not a cap.
|
|
64
|
+
*
|
|
65
|
+
* Returns null when the size cannot be computed - `cachedChats` is 0 (leave
|
|
66
|
+
* llama.cpp's default alone) or nothing is known about this model's KV cost.
|
|
67
|
+
*/
|
|
68
|
+
export function promptCacheSize(model, profile, calibration) {
|
|
69
|
+
const chats = Math.floor(profile.cachedChats ?? 0);
|
|
70
|
+
if (chats < 1)
|
|
71
|
+
return null;
|
|
72
|
+
const { value, source } = kvBytesPerToken(model, profile, calibration);
|
|
73
|
+
if (value <= 0 || profile.contextSize <= 0)
|
|
74
|
+
return null;
|
|
75
|
+
const perSlotTokens = Math.floor(profile.contextSize / Math.max(1, profile.parallelSlots || 1));
|
|
76
|
+
if (perSlotTokens <= 0)
|
|
77
|
+
return null;
|
|
78
|
+
const perChatBytes = value * perSlotTokens;
|
|
79
|
+
return { perChatBytes, totalBytes: perChatBytes * chats, source };
|
|
80
|
+
}
|
|
35
81
|
/** Compute a VRAM budget for a profile. */
|
|
36
82
|
export function budget({ model, profile, calibration = null, totalVramBytes, reserveBytes = 1.5 * GIB, }) {
|
|
37
83
|
const weights = model.sizeBytes;
|
|
@@ -46,22 +92,8 @@ export function budget({ model, profile, calibration = null, totalVramBytes, res
|
|
|
46
92
|
: 0;
|
|
47
93
|
const mmproj = projector.reduce((total, component) => total + component.bytes, 0) ||
|
|
48
94
|
(components.length ? 0 : componentBytes);
|
|
49
|
-
const theoretical =
|
|
50
|
-
|
|
51
|
-
let source;
|
|
52
|
-
if (calibration && calibration.kvBytesPerToken > 0) {
|
|
53
|
-
kvBytesPerToken = calibration.kvBytesPerToken;
|
|
54
|
-
source = "measured";
|
|
55
|
-
}
|
|
56
|
-
else if (theoretical !== null) {
|
|
57
|
-
kvBytesPerToken = theoretical;
|
|
58
|
-
source = "theoretical";
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
kvBytesPerToken = 0;
|
|
62
|
-
source = "unknown";
|
|
63
|
-
}
|
|
64
|
-
const kv = kvBytesPerToken * profile.contextSize;
|
|
95
|
+
const { value: perToken, source, theoretical } = kvBytesPerToken(model, profile, calibration);
|
|
96
|
+
const kv = perToken * profile.contextSize;
|
|
65
97
|
// The speculative decoder keeps its own KV cache. Until component-specific
|
|
66
98
|
// calibration exists, reserve the same conservative per-token cost.
|
|
67
99
|
const drafterKv = drafters.length * kv;
|
|
@@ -84,7 +116,7 @@ export function budget({ model, profile, calibration = null, totalVramBytes, res
|
|
|
84
116
|
usableBytes: usable,
|
|
85
117
|
totalVramBytes,
|
|
86
118
|
reserveBytes,
|
|
87
|
-
kvBytesPerToken,
|
|
119
|
+
kvBytesPerToken: perToken,
|
|
88
120
|
source,
|
|
89
121
|
theoreticalKvBytesPerToken: theoretical,
|
|
90
122
|
fits: total <= usable,
|
|
@@ -107,7 +139,14 @@ export function maxContextThatFits({ model, profile, calibration, totalVramBytes
|
|
|
107
139
|
const room = probe.usableBytes - fixed;
|
|
108
140
|
if (room <= 0)
|
|
109
141
|
return null;
|
|
110
|
-
|
|
142
|
+
// More than one KV pool can scale with the context - a speculative drafter
|
|
143
|
+
// keeps its own. Derive the per-token cost from the probe rather than from
|
|
144
|
+
// `kvBytesPerToken` alone, so this can never hand back a context that
|
|
145
|
+
// `budget()` then declares does not fit.
|
|
146
|
+
const perToken = (probe.kvBytes + probe.drafterKvBytes) / step;
|
|
147
|
+
if (perToken <= 0)
|
|
148
|
+
return null;
|
|
149
|
+
const tokens = Math.floor(room / perToken);
|
|
111
150
|
const native = model.metadata?.contextLength;
|
|
112
151
|
const contextLimit = typeof native === "number" && native > 0 ? native * profile.contextMultiplier : tokens;
|
|
113
152
|
const capped = Math.min(tokens, contextLimit);
|
package/package.json
CHANGED