@jmcombs/pi-steward 0.0.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/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- package/ui/steward.css +2008 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot occupancy, derived from the server's own log instead of polled.
|
|
3
|
+
*
|
|
4
|
+
* `/slots?model=X` and `/metrics?model=X` are per-model, so the router proxies
|
|
5
|
+
* each one to the child and writes a `proxy_reques:` line for it. Asking both,
|
|
6
|
+
* for every loaded model, on the dashboard's 1.6 s repaint clock made Steward
|
|
7
|
+
* the single loudest writer in the log it exists to display — a measured 86.9%
|
|
8
|
+
* of one real corpus, ~1 line per second, most of it Steward watching itself.
|
|
9
|
+
*
|
|
10
|
+
* It was also *sampled*, and that is the part an operator actually noticed: a
|
|
11
|
+
* request shorter than the poll interval begins and ends between two reads and
|
|
12
|
+
* is never seen at all. On the measured workload — 11,343 requests averaging
|
|
13
|
+
* ~0.9 s against a 1.6 s clock — the slots panel read idle while the server was
|
|
14
|
+
* working. (Under continuous load the same panel reads busy every time, which is
|
|
15
|
+
* how we know it is a sampling limit and not a regression.)
|
|
16
|
+
*
|
|
17
|
+
* The log has the answer already, is already being tailed, and costs nothing
|
|
18
|
+
* more to read. Every `SLT_*` macro emits one shared, byte-stable frame —
|
|
19
|
+
* `id %2d | task %d | ` — which the parser has already lifted off the message,
|
|
20
|
+
* and the events that bracket a request are unambiguous:
|
|
21
|
+
*
|
|
22
|
+
* ```
|
|
23
|
+
* slot get_availabl: id 0 | task -1 | selected slot by LCP similarity, …
|
|
24
|
+
* slot launch_slot_: id 0 | task 836989 | processing task, is_child = 0
|
|
25
|
+
* slot print_timing: id 0 | task 836989 | prompt processing, n_tokens = 16385, progress = 0.85, …
|
|
26
|
+
* slot print_timing: id 0 | task 836989 | n_decoded = 663, tg = 110.43 t/s, tg_3s = 109.88 t/s
|
|
27
|
+
* slot print_timing: id 0 | task 836989 | eval time = 697.41 ms / 90 tokens (…, 129.05 tokens per second)
|
|
28
|
+
* slot release: id 0 | task 836989 | stop processing: n_tokens = 163, truncated = 0
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Two shapes in there are worth calling out, because a parser written from the
|
|
32
|
+
* llama.cpp source rather than from a real log gets both wrong. The function
|
|
33
|
+
* name is printed right-aligned in a twelve-wide field, so it is
|
|
34
|
+
* `slot release:` with five leading spaces and never `slot release:` — but
|
|
35
|
+
* that is the *frame's* problem, not this module's: every rule below reads the
|
|
36
|
+
* post-frame message, where the payload is. And `prompt eval time = …` (prefill)
|
|
37
|
+
* sits one word away from `eval time = …` (generation); only the second is a
|
|
38
|
+
* generation rate, so the generation rule is anchored to the start of the
|
|
39
|
+
* message where the word `prompt` cannot precede it.
|
|
40
|
+
*
|
|
41
|
+
* The same lines also say how much the server GENERATED, and that is a different
|
|
42
|
+
* reading from occupancy. `n_decoded` on a live readout and the `N tokens` an
|
|
43
|
+
* `eval time` line prints are both counts of tokens produced, so they are
|
|
44
|
+
* ledgered here as TOKENS rather than kept as a rate. A rate cannot be kept: on
|
|
45
|
+
* the corpus above, `eval time` is printed 17 microseconds before the `release`
|
|
46
|
+
* that ends the request, and a reader sampling every 1.6 s can only see a
|
|
47
|
+
* reading that exists for 17 µs by landing inside it. Tokens survive the gap —
|
|
48
|
+
* {@link SlotActivity.takeGeneratedTokens} hands each one to exactly one reader,
|
|
49
|
+
* exactly once — and tokens over wall clock is what throughput means anyway.
|
|
50
|
+
*
|
|
51
|
+
* What this module deliberately does NOT do is invent the parts the log does not
|
|
52
|
+
* carry. `requests_deferred` — the queue behind the slots — has no log line at
|
|
53
|
+
* all, so nothing here reports it. How many slots a model has and how large each
|
|
54
|
+
* one's context is are *structure*, not occupancy: they come from `/v1/models`,
|
|
55
|
+
* which the router answers itself and logs nothing for.
|
|
56
|
+
*
|
|
57
|
+
* Keep this module free of Node and DOM APIs — see `./types.ts`. It holds no
|
|
58
|
+
* clock either: every time it needs one it is given the caller's.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import type { LogLine, SlotState } from "./types.js";
|
|
62
|
+
|
|
63
|
+
/** One slot's occupancy as the event stream last established it. */
|
|
64
|
+
export interface SlotActivityState {
|
|
65
|
+
/** Per-model slot index, from 0. */
|
|
66
|
+
slot: number;
|
|
67
|
+
state: SlotState;
|
|
68
|
+
/** The task the slot is running, or `null` when it holds none. */
|
|
69
|
+
task: number | null;
|
|
70
|
+
/** Tokens the slot's context holds, or `null` when nothing has said. */
|
|
71
|
+
promptTokens: number | null;
|
|
72
|
+
/** Tokens generated so far this turn, or `null` while unmeasured. */
|
|
73
|
+
decoded: number | null;
|
|
74
|
+
/**
|
|
75
|
+
* Measured generation rate for the request in this slot, or `null`. Only ever
|
|
76
|
+
* a reading: it is cleared when a request starts and when one ends, so it can
|
|
77
|
+
* never outlive the request it was measured for.
|
|
78
|
+
*/
|
|
79
|
+
rateTps: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One slot of a one-shot `/slots` read, as {@link SlotActivity.applySeed} takes it. */
|
|
83
|
+
export interface SlotActivitySeed {
|
|
84
|
+
slot: number;
|
|
85
|
+
/** What the read said, `unknown` included — a body with no `is_processing`
|
|
86
|
+
* establishes nothing, and seeding it as idle would invent the answer. */
|
|
87
|
+
state: SlotState;
|
|
88
|
+
promptTokens: number | null;
|
|
89
|
+
decoded: number | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How many one-shot `/slots` reads a port may spend establishing itself before
|
|
94
|
+
* Steward gives up and waits for the log to say something. Bounded so a router
|
|
95
|
+
* that answers `/slots` with an error can never turn the seed into the poll it
|
|
96
|
+
* replaced.
|
|
97
|
+
*/
|
|
98
|
+
export const MAX_SEED_ATTEMPTS = 3;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How long a slot may sit `processing` with nothing further said about it before
|
|
102
|
+
* its state is treated as `unknown` rather than believed.
|
|
103
|
+
*
|
|
104
|
+
* This is the answer to a missed `release`. A stream can lose lines — the tailer
|
|
105
|
+
* re-anchors when the file is replaced or truncated, a restarted Steward starts
|
|
106
|
+
* from a backlog window, `com.apple.tmp_cleaner` deletes the file outright — and
|
|
107
|
+
* a `release` in the lost window would otherwise leave a slot busy forever.
|
|
108
|
+
*
|
|
109
|
+
* **A running request does NOT reliably re-arm this, and nothing here should be
|
|
110
|
+
* written as though it does.** llama.cpp's `print_timings_tg()` is gated on BOTH
|
|
111
|
+
* `n_decoded >= 100` AND ~3 s elapsed, so a generation that is slow but short —
|
|
112
|
+
* 60 tokens at 0.4 t/s is 150 s of work — emits no running readout at all and
|
|
113
|
+
* crosses this bound in complete silence. The `tg_3s` line only covers requests
|
|
114
|
+
* that are long in TOKENS, which is a strict subset of the requests that are long
|
|
115
|
+
* in TIME.
|
|
116
|
+
*
|
|
117
|
+
* What actually protects a long request is the seed. Crossing the bound makes
|
|
118
|
+
* {@link SlotActivity.needsSeed} true, and the caller's `needsSeed` check runs
|
|
119
|
+
* BEFORE its `resolve` in the same snapshot — so a one-shot `/slots` read lands
|
|
120
|
+
* first, answers `is_processing: true`, and restores `processing` with a fresh
|
|
121
|
+
* timestamp. The demotion is real but never reaches the dashboard. Delete that
|
|
122
|
+
* seed fallback believing this bound is self-correcting and a slow short
|
|
123
|
+
* generation starts misreporting as `unknown` — the two mechanisms are one
|
|
124
|
+
* design, not a mechanism and a redundancy.
|
|
125
|
+
*
|
|
126
|
+
* Resolving to `unknown` — never to `idle` — is what keeps the fallback honest
|
|
127
|
+
* when the seed cannot answer either: a timeout is evidence that we lost track,
|
|
128
|
+
* not evidence that the request finished.
|
|
129
|
+
*/
|
|
130
|
+
export const SLOT_STALE_MS = 120_000;
|
|
131
|
+
|
|
132
|
+
export interface SlotActivity {
|
|
133
|
+
/**
|
|
134
|
+
* Folds one log line into slot state. Lines that are not a slot event — no
|
|
135
|
+
* `[port]` prefix, no pipe frame, a payload none of the rules match — are
|
|
136
|
+
* ignored, which is most of the log.
|
|
137
|
+
*/
|
|
138
|
+
observe(line: LogLine): void;
|
|
139
|
+
/**
|
|
140
|
+
* Whether a one-shot `/slots` read is warranted for `port`: it has never been
|
|
141
|
+
* established, a lane went stale, or a lane is still unresolved. False once the
|
|
142
|
+
* port is settled and its budget is spent.
|
|
143
|
+
*
|
|
144
|
+
* `expectedLanes` is what the model's `--parallel` says it has, or `null` when
|
|
145
|
+
* that is not stated. The tracker cannot know it on its own — it only learns a
|
|
146
|
+
* lane exists when the log mentions one — so a `--parallel 4` model whose
|
|
147
|
+
* traffic all lands in lane 0 looks fully settled from in here. Passing the
|
|
148
|
+
* declared count is what lets lanes 1–3 be recognised as never established.
|
|
149
|
+
*
|
|
150
|
+
* This is never a timer. A never-seen port costs at most
|
|
151
|
+
* {@link MAX_SEED_ATTEMPTS} reads; after that a port with unresolved lanes is
|
|
152
|
+
* retried no more often than once per {@link SLOT_STALE_MS}, which is ~75×
|
|
153
|
+
* slower than the snapshot clock this replaced.
|
|
154
|
+
*/
|
|
155
|
+
needsSeed(port: number, now: number, expectedLanes?: number | null): boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Records that a seed read is being issued and returns the watermark to stamp
|
|
158
|
+
* it with — the sequence number of the last line already folded in. The read
|
|
159
|
+
* is asynchronous, so events can land while it is in flight; passing this back
|
|
160
|
+
* to {@link applySeed} is what stops an older HTTP answer overwriting a newer
|
|
161
|
+
* event.
|
|
162
|
+
*/
|
|
163
|
+
beginSeed(port: number, now: number): number;
|
|
164
|
+
/**
|
|
165
|
+
* Applies a completed seed read, slot by slot, skipping any slot a log event
|
|
166
|
+
* has spoken for since `watermark`. Marks the port established.
|
|
167
|
+
*/
|
|
168
|
+
applySeed(port: number, watermark: number, slots: readonly SlotActivitySeed[], now: number): void;
|
|
169
|
+
/**
|
|
170
|
+
* This port's slots, keyed by slot index, with staleness applied as of `now`.
|
|
171
|
+
* A port nothing is known about yields an empty map — the caller renders its
|
|
172
|
+
* model's slots `unknown`, which is what it is.
|
|
173
|
+
*/
|
|
174
|
+
resolve(port: number, now: number): ReadonlyMap<number, SlotActivityState>;
|
|
175
|
+
/**
|
|
176
|
+
* Tokens the log has reported generated since the last call, across every
|
|
177
|
+
* port, and 0 afterwards — reading the ledger clears it.
|
|
178
|
+
*
|
|
179
|
+
* This is a COUNT, not a rate, and it is drained rather than read for a
|
|
180
|
+
* reason. The rate a completed request prints exists for the 17 microseconds
|
|
181
|
+
* between its `eval time` line and its `release`; a sampler on any realistic
|
|
182
|
+
* clock will miss it, and a sampler that held it instead would be showing a
|
|
183
|
+
* finished request's speed as if it were the server's current one. A count
|
|
184
|
+
* cannot go stale and cannot be double-counted: whichever reader asks next
|
|
185
|
+
* gets the tokens, and no later reader gets them again.
|
|
186
|
+
*
|
|
187
|
+
* Both readings feed it, so nothing is counted twice and nothing is missed. A
|
|
188
|
+
* long request's live `n_decoded` readouts are ledgered as they arrive, by
|
|
189
|
+
* difference, and its `eval time` total contributes only the tail that no
|
|
190
|
+
* readout covered. A short request — 99.4% of one measured 16,517-request
|
|
191
|
+
* corpus generated under 100 tokens, below llama.cpp's live-readout threshold
|
|
192
|
+
* — has no readouts at all, so its `eval time` total is the whole of it.
|
|
193
|
+
*/
|
|
194
|
+
takeGeneratedTokens(): number;
|
|
195
|
+
/**
|
|
196
|
+
* Forgets every port not in `ports` — a child that exited. Its slot ids and
|
|
197
|
+
* task ids belong to a process that no longer exists, and a model reloaded on
|
|
198
|
+
* a fresh port must not inherit them.
|
|
199
|
+
*
|
|
200
|
+
* The token ledger is NOT touched: those tokens were generated, and the model
|
|
201
|
+
* having since unloaded does not make that less true.
|
|
202
|
+
*/
|
|
203
|
+
retain(ports: Iterable<number>): void;
|
|
204
|
+
/**
|
|
205
|
+
* Declares the event stream discontinuous: every slot goes `unknown`, every
|
|
206
|
+
* port becomes eligible to be seeded again, and the token ledger is dropped.
|
|
207
|
+
* For a tailer that reconnected, a log file that came back, or any other break
|
|
208
|
+
* across which state cannot be carried.
|
|
209
|
+
*
|
|
210
|
+
* The ledger goes with it because the per-slot running totals go with it. A
|
|
211
|
+
* request in flight across the break would otherwise report its `eval time`
|
|
212
|
+
* total against a slot that no longer remembers how much of it was already
|
|
213
|
+
* counted, and the tokens counted before the break would be counted a second
|
|
214
|
+
* time after it. Dropping both halves is the only answer that invents nothing.
|
|
215
|
+
*/
|
|
216
|
+
resync(): void;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `processing task, is_child = 0` — the slot took a task. */
|
|
220
|
+
const LAUNCH = /^processing task\b/;
|
|
221
|
+
|
|
222
|
+
/** `stop processing: n_tokens = 165, truncated = 0` — the slot gave it back. */
|
|
223
|
+
const RELEASE = /^stop processing\b/;
|
|
224
|
+
|
|
225
|
+
/** The `n_tokens = N` a release reports: the context the slot now holds. */
|
|
226
|
+
const RELEASE_TOKENS = /\bn_tokens\s*=\s*(\d+)/;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `n_decoded = 663, tg = 110.43 t/s, tg_3s = 109.88 t/s` — the in-flight readout,
|
|
230
|
+
* and the only live generation rate the log carries. See {@link SLOT_STALE_MS}
|
|
231
|
+
* for what it does NOT cover: it needs 100 decoded tokens as well as ~3 s.
|
|
232
|
+
*
|
|
233
|
+
* Two rates are printed and they are not the same measurement. `tg` is the mean
|
|
234
|
+
* since the request began; `tg_3s` is the last ~3 seconds. A tile that says what
|
|
235
|
+
* the server is doing *now* wants the second, so it is preferred and `tg` is the
|
|
236
|
+
* fallback for a build that prints only the one. On the first readout of a
|
|
237
|
+
* request the two are equal, so nothing jumps when the window fills.
|
|
238
|
+
*/
|
|
239
|
+
const LIVE_DECODED = /\bn_decoded\s*=\s*(\d+)/;
|
|
240
|
+
const LIVE_TG_3S = /\btg_3s\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*t\/s/;
|
|
241
|
+
const LIVE_TG = /\btg\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*t\/s/;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* ` eval time = 697.41 ms / 90 tokens (…, 129.05 tokens per second)`
|
|
245
|
+
* — the completed request's generation timing.
|
|
246
|
+
*
|
|
247
|
+
* Anchored at the start of the message so `prompt eval time = …`, which is
|
|
248
|
+
* prefill and not a generation rate, cannot match it.
|
|
249
|
+
*/
|
|
250
|
+
const EVAL_TIMING =
|
|
251
|
+
/^\s*eval time\s*=\s*[0-9.]+\s*ms\s*\/\s*(\d+)\s*tokens\s*\([^)]*?([0-9]+(?:\.[0-9]+)?)\s*tokens per second/;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `prompt processing, n_tokens = 16385, progress = 0.85, t = 13.94 s / 1175.06 tokens per second`
|
|
255
|
+
* — live prefill progress. Rare (long prompts only), so nothing depends on it;
|
|
256
|
+
* when it is there it is the one thing that reports a slot's context filling up
|
|
257
|
+
* while the request is still running.
|
|
258
|
+
*/
|
|
259
|
+
const PROMPT_PROGRESS = /^\s*prompt processing,\s*n_tokens\s*=\s*(\d+)/;
|
|
260
|
+
|
|
261
|
+
/** One slot's tracked state, plus the bookkeeping that keeps it honest. */
|
|
262
|
+
interface SlotRecord {
|
|
263
|
+
state: SlotState;
|
|
264
|
+
task: number | null;
|
|
265
|
+
promptTokens: number | null;
|
|
266
|
+
decoded: number | null;
|
|
267
|
+
rateTps: number | null;
|
|
268
|
+
/**
|
|
269
|
+
* Tokens of the request CURRENTLY in this slot that the ledger has already
|
|
270
|
+
* been given, so a second reading of the same request contributes only what is
|
|
271
|
+
* new. Reset every time the slot changes hands, which is what stops one
|
|
272
|
+
* request's total being subtracted from the next one's.
|
|
273
|
+
*/
|
|
274
|
+
counted: number;
|
|
275
|
+
/** `seq` of the last log line that spoke for this slot; 0 when only seeded. */
|
|
276
|
+
lastSeq: number;
|
|
277
|
+
/** When this record was last established, for the staleness bound. */
|
|
278
|
+
updatedAt: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface PortRecord {
|
|
282
|
+
slots: Map<number, SlotRecord>;
|
|
283
|
+
seedAttempts: number;
|
|
284
|
+
seeded: boolean;
|
|
285
|
+
/** Whether the current run of staleness has already refreshed the budget. */
|
|
286
|
+
staleHandled: boolean;
|
|
287
|
+
/** When the last seed read was issued, so a retry can be paced off the clock. */
|
|
288
|
+
lastAttemptAt: number | null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function blankRecord(now: number): SlotRecord {
|
|
292
|
+
return {
|
|
293
|
+
state: "unknown",
|
|
294
|
+
task: null,
|
|
295
|
+
promptTokens: null,
|
|
296
|
+
decoded: null,
|
|
297
|
+
rateTps: null,
|
|
298
|
+
counted: 0,
|
|
299
|
+
lastSeq: 0,
|
|
300
|
+
updatedAt: now,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A capture group as a finite number, or `null`. */
|
|
305
|
+
function toNumber(raw: string | undefined): number | null {
|
|
306
|
+
if (raw === undefined) return null;
|
|
307
|
+
const value = Number(raw);
|
|
308
|
+
return Number.isFinite(value) ? value : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Tracks slot occupancy across every model the router runs, keyed by the child's
|
|
313
|
+
* port.
|
|
314
|
+
*
|
|
315
|
+
* The port is the identity, not the model id: task ids and slot ids are
|
|
316
|
+
* per-process counters that restart at 0 in every child, so a model unloaded and
|
|
317
|
+
* reloaded gets a fresh port and, with it, a clean slate. Joining port to model
|
|
318
|
+
* is the caller's job — it already has `/v1/models`, which states each loaded
|
|
319
|
+
* child's `--port`.
|
|
320
|
+
*/
|
|
321
|
+
export function createSlotActivity(): SlotActivity {
|
|
322
|
+
const ports = new Map<number, PortRecord>();
|
|
323
|
+
|
|
324
|
+
/** The highest `seq` folded in, so a seed read can be stamped against it. */
|
|
325
|
+
let watermark = 0;
|
|
326
|
+
|
|
327
|
+
/** Tokens reported since the ledger was last drained. See {@link SlotActivity.takeGeneratedTokens}. */
|
|
328
|
+
let generated = 0;
|
|
329
|
+
|
|
330
|
+
function port(number: number): PortRecord {
|
|
331
|
+
const existing = ports.get(number);
|
|
332
|
+
if (existing !== undefined) return existing;
|
|
333
|
+
const created: PortRecord = {
|
|
334
|
+
slots: new Map(),
|
|
335
|
+
seedAttempts: 0,
|
|
336
|
+
seeded: false,
|
|
337
|
+
staleHandled: false,
|
|
338
|
+
lastAttemptAt: null,
|
|
339
|
+
};
|
|
340
|
+
ports.set(number, created);
|
|
341
|
+
return created;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function slot(record: PortRecord, id: number, now: number): SlotRecord {
|
|
345
|
+
const existing = record.slots.get(id);
|
|
346
|
+
if (existing !== undefined) return existing;
|
|
347
|
+
const created = blankRecord(now);
|
|
348
|
+
record.slots.set(id, created);
|
|
349
|
+
return created;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Ledgers the part of `total` this slot has not been credited with yet.
|
|
354
|
+
*
|
|
355
|
+
* Every reading llama.cpp prints is cumulative FOR ITS REQUEST — `n_decoded`
|
|
356
|
+
* counts up while the request runs and `eval time`'s token count is where it
|
|
357
|
+
* finished — so the ledger takes differences, never the figure itself. A
|
|
358
|
+
* reading for a task other than the one the slot was following starts a fresh
|
|
359
|
+
* count rather than a negative one: that happens when a `launch` was lost, and
|
|
360
|
+
* the new request's tokens are its own, not this slot's previous request's.
|
|
361
|
+
*
|
|
362
|
+
* A slot holding NO task is left alone, because that is the seed's state: a
|
|
363
|
+
* `/slots` read says how far along a request is without naming it, and its
|
|
364
|
+
* figure is exactly the part of the request that must not be counted. Treating
|
|
365
|
+
* "no task named" as "a different task" would throw that credit away and hand
|
|
366
|
+
* the whole of a request Steward watched only the end of to one window.
|
|
367
|
+
*
|
|
368
|
+
* `total` moving backwards or not moving contributes nothing, so a repeated or
|
|
369
|
+
* out-of-order line can only ever add zero.
|
|
370
|
+
*/
|
|
371
|
+
function count(entry: SlotRecord, task: number, total: number | null): void {
|
|
372
|
+
if (entry.task !== null && entry.task !== task) entry.counted = 0;
|
|
373
|
+
if (total === null || total <= entry.counted) return;
|
|
374
|
+
generated += total - entry.counted;
|
|
375
|
+
entry.counted = total;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Whether a lane has been `processing` past the bound. This is the edge that
|
|
380
|
+
* reopens a settled port for another seed read, and it is why a missed
|
|
381
|
+
* `release` self-heals instead of pinning a chip on `busy` forever.
|
|
382
|
+
*/
|
|
383
|
+
function hasStale(record: PortRecord, now: number): boolean {
|
|
384
|
+
for (const entry of record.slots.values()) {
|
|
385
|
+
if (entry.state === "processing" && now - entry.updatedAt > SLOT_STALE_MS) return true;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Whether any lane's occupancy is still not established — one the log has
|
|
392
|
+
* never mentioned, or one explicitly `unknown`.
|
|
393
|
+
*
|
|
394
|
+
* Staleness alone is not enough to catch this. A lane that is `unknown`
|
|
395
|
+
* (because every seed read failed while the router was unwell) is not
|
|
396
|
+
* `processing`, so it never trips the stale edge, and llama.cpp's LCP slot
|
|
397
|
+
* affinity will happily send every request to lane 0 for hours — so no event
|
|
398
|
+
* ever names lanes 1–3 either. Without this check those lanes sit `unknown`
|
|
399
|
+
* for the life of the child process with nothing left that could ever ask.
|
|
400
|
+
*/
|
|
401
|
+
function hasUnresolved(record: PortRecord, expectedLanes: number | null): boolean {
|
|
402
|
+
for (const entry of record.slots.values()) {
|
|
403
|
+
if (entry.state === "unknown") return true;
|
|
404
|
+
}
|
|
405
|
+
if (expectedLanes === null) return false;
|
|
406
|
+
for (let id = 0; id < expectedLanes; id += 1) {
|
|
407
|
+
if (!record.slots.has(id)) return true;
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
observe(line: LogLine): void {
|
|
414
|
+
if (line.seq > watermark) watermark = line.seq;
|
|
415
|
+
|
|
416
|
+
// A slot event is a child line carrying the SLT_* pipe frame. Everything
|
|
417
|
+
// else in the log — the router's own lines, the boot banners, the proxy
|
|
418
|
+
// records, a child's vocab warnings — has nothing to say about occupancy.
|
|
419
|
+
const frame = line.frame;
|
|
420
|
+
if (frame === undefined || line.port === undefined) return;
|
|
421
|
+
// A frame llama.cpp emitted with no real slot (a future "none available"
|
|
422
|
+
// path) is not a slot we can track, and tracking it as slot -1 would put a
|
|
423
|
+
// phantom lane on a chip.
|
|
424
|
+
if (frame.slot < 0) return;
|
|
425
|
+
|
|
426
|
+
const record = port(line.port);
|
|
427
|
+
const message = line.message;
|
|
428
|
+
|
|
429
|
+
// `get_available_slot` runs before a task is attached, which is why its
|
|
430
|
+
// frame reads `task -1` — and it only ever names a slot it found FREE. So
|
|
431
|
+
// this line is a direct, exact idle observation, and the cheapest re-sync
|
|
432
|
+
// the log offers: if we thought the slot was busy, its release was one of
|
|
433
|
+
// the lines we lost.
|
|
434
|
+
if (frame.task === -1) {
|
|
435
|
+
const entry = slot(record, frame.slot, line.ts);
|
|
436
|
+
entry.state = "idle";
|
|
437
|
+
entry.task = null;
|
|
438
|
+
entry.decoded = null;
|
|
439
|
+
entry.rateTps = null;
|
|
440
|
+
entry.counted = 0;
|
|
441
|
+
entry.lastSeq = line.seq;
|
|
442
|
+
entry.updatedAt = line.ts;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (LAUNCH.test(message)) {
|
|
447
|
+
const entry = slot(record, frame.slot, line.ts);
|
|
448
|
+
// A launch for a different task than the one we hold means that one's
|
|
449
|
+
// release was missed. The new task is the truth; nothing of the old
|
|
450
|
+
// one's readings survives into it.
|
|
451
|
+
entry.state = "processing";
|
|
452
|
+
entry.task = frame.task;
|
|
453
|
+
entry.decoded = null;
|
|
454
|
+
entry.rateTps = null;
|
|
455
|
+
entry.counted = 0;
|
|
456
|
+
entry.lastSeq = line.seq;
|
|
457
|
+
entry.updatedAt = line.ts;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (RELEASE.test(message)) {
|
|
462
|
+
const entry = slot(record, frame.slot, line.ts);
|
|
463
|
+
entry.state = "idle";
|
|
464
|
+
entry.task = null;
|
|
465
|
+
// `n_tokens` here is the context the slot is left holding, and it stays
|
|
466
|
+
// true until the next request reuses or evicts it — so it is the idle
|
|
467
|
+
// slot's occupancy, not a leftover from a request that ended.
|
|
468
|
+
entry.promptTokens = toNumber(RELEASE_TOKENS.exec(message)?.[1]) ?? entry.promptTokens;
|
|
469
|
+
entry.decoded = null;
|
|
470
|
+
entry.rateTps = null;
|
|
471
|
+
// The request is over and its tokens are already in the ledger — the
|
|
472
|
+
// `eval time` line that precedes this one by microseconds put them
|
|
473
|
+
// there. What resets is only the running total, so the next request in
|
|
474
|
+
// this slot is counted from zero.
|
|
475
|
+
entry.counted = 0;
|
|
476
|
+
entry.lastSeq = line.seq;
|
|
477
|
+
entry.updatedAt = line.ts;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const decoded = LIVE_DECODED.exec(message);
|
|
482
|
+
// The last ~3 seconds if the build prints it, else the mean since the
|
|
483
|
+
// request began — both are measurements of this request, and neither is
|
|
484
|
+
// ever carried past its release.
|
|
485
|
+
const tg = LIVE_TG_3S.exec(message) ?? LIVE_TG.exec(message);
|
|
486
|
+
if (decoded !== null && tg !== null) {
|
|
487
|
+
// A running readout is also proof of occupancy: whatever we thought, this
|
|
488
|
+
// slot is generating right now, on this task.
|
|
489
|
+
const entry = slot(record, frame.slot, line.ts);
|
|
490
|
+
const total = toNumber(decoded[1]);
|
|
491
|
+
// Ledgered before the task is stamped on: `count` needs to know whether
|
|
492
|
+
// this reading continues the request the slot was already following.
|
|
493
|
+
count(entry, frame.task, total);
|
|
494
|
+
entry.state = "processing";
|
|
495
|
+
entry.task = frame.task;
|
|
496
|
+
entry.decoded = total;
|
|
497
|
+
entry.rateTps = toNumber(tg[1]);
|
|
498
|
+
entry.lastSeq = line.seq;
|
|
499
|
+
entry.updatedAt = line.ts;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const timing = EVAL_TIMING.exec(message);
|
|
504
|
+
if (timing !== null) {
|
|
505
|
+
// The completed request's own rate, printed microseconds before its
|
|
506
|
+
// release. It counts while the slot is still holding the task and is
|
|
507
|
+
// cleared by the release that follows — it is never carried into the
|
|
508
|
+
// next request, or into the idle gap after this one. The TOKENS on the
|
|
509
|
+
// same line outlive it: they go to the ledger, where the throughput the
|
|
510
|
+
// dashboard reports is measured from tokens over wall clock rather than
|
|
511
|
+
// from a rate that only exists for the microseconds before the release.
|
|
512
|
+
const entry = slot(record, frame.slot, line.ts);
|
|
513
|
+
const total = toNumber(timing[1]);
|
|
514
|
+
count(entry, frame.task, total);
|
|
515
|
+
entry.state = "processing";
|
|
516
|
+
entry.task = frame.task;
|
|
517
|
+
entry.decoded = total;
|
|
518
|
+
entry.rateTps = toNumber(timing[2]);
|
|
519
|
+
entry.lastSeq = line.seq;
|
|
520
|
+
entry.updatedAt = line.ts;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const progress = PROMPT_PROGRESS.exec(message);
|
|
525
|
+
if (progress !== null) {
|
|
526
|
+
const entry = slot(record, frame.slot, line.ts);
|
|
527
|
+
entry.state = "processing";
|
|
528
|
+
entry.task = frame.task;
|
|
529
|
+
entry.promptTokens = toNumber(progress[1]);
|
|
530
|
+
entry.lastSeq = line.seq;
|
|
531
|
+
entry.updatedAt = line.ts;
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
needsSeed(number: number, now: number, expectedLanes: number | null = null): boolean {
|
|
536
|
+
const record = ports.get(number);
|
|
537
|
+
if (record === undefined) return true;
|
|
538
|
+
|
|
539
|
+
// A lane we have lost track of means the stream and reality have come
|
|
540
|
+
// apart, so the port is worth establishing again — and it gets a fresh
|
|
541
|
+
// budget, because this is new uncertainty and not a retry of the old one.
|
|
542
|
+
//
|
|
543
|
+
// The budget is reset on the EDGE, once, and not while the condition
|
|
544
|
+
// persists. Resetting it on every call would hand a port whose `/slots`
|
|
545
|
+
// read keeps failing an unlimited retry on the snapshot clock, which is
|
|
546
|
+
// the polling loop this change removed, rebuilt by accident.
|
|
547
|
+
const stale = hasStale(record, now);
|
|
548
|
+
if (stale && !record.staleHandled) {
|
|
549
|
+
record.staleHandled = true;
|
|
550
|
+
record.seeded = false;
|
|
551
|
+
record.seedAttempts = 0;
|
|
552
|
+
} else if (!stale) {
|
|
553
|
+
record.staleHandled = false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!record.seeded && record.seedAttempts < MAX_SEED_ATTEMPTS) return true;
|
|
557
|
+
|
|
558
|
+
// The budget is spent, but a lane is still unresolved and no event is
|
|
559
|
+
// coming for it. Try again — paced by the staleness bound, so this can
|
|
560
|
+
// never approach the cadence it replaced. At worst a permanently
|
|
561
|
+
// unresolvable port costs MAX_SEED_ATTEMPTS reads every SLOT_STALE_MS
|
|
562
|
+
// (~1 read per 40 s), against the ~2 per 1.6 s the old path spent on a
|
|
563
|
+
// model that was working perfectly.
|
|
564
|
+
if (!hasUnresolved(record, expectedLanes)) return false;
|
|
565
|
+
const since = record.lastAttemptAt;
|
|
566
|
+
if (since !== null && now - since < SLOT_STALE_MS) return false;
|
|
567
|
+
record.seeded = false;
|
|
568
|
+
record.seedAttempts = 0;
|
|
569
|
+
return true;
|
|
570
|
+
},
|
|
571
|
+
|
|
572
|
+
beginSeed(number: number, now: number): number {
|
|
573
|
+
const record = port(number);
|
|
574
|
+
record.seedAttempts += 1;
|
|
575
|
+
record.lastAttemptAt = now;
|
|
576
|
+
return watermark;
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
applySeed(
|
|
580
|
+
number: number,
|
|
581
|
+
stamp: number,
|
|
582
|
+
slots: readonly SlotActivitySeed[],
|
|
583
|
+
now: number,
|
|
584
|
+
): void {
|
|
585
|
+
const record = port(number);
|
|
586
|
+
record.seeded = true;
|
|
587
|
+
record.seedAttempts = 0;
|
|
588
|
+
for (const seeded of slots) {
|
|
589
|
+
if (seeded.slot < 0) continue;
|
|
590
|
+
const entry = slot(record, seeded.slot, now);
|
|
591
|
+
// An event that landed while the read was in flight is newer than the
|
|
592
|
+
// read and wins. Without this the answer to a 4 s HTTP call could undo
|
|
593
|
+
// four seconds of live events.
|
|
594
|
+
if (entry.lastSeq > stamp) continue;
|
|
595
|
+
entry.state = seeded.state;
|
|
596
|
+
entry.task = null;
|
|
597
|
+
entry.promptTokens = seeded.promptTokens;
|
|
598
|
+
entry.decoded = seeded.decoded;
|
|
599
|
+
// A request already in flight when Steward attached has generated tokens
|
|
600
|
+
// nobody here watched it generate. Crediting the seed's `n_decoded` as
|
|
601
|
+
// already counted is what stops its eventual `eval time` total landing
|
|
602
|
+
// in the ledger as if every one of those tokens had been produced in the
|
|
603
|
+
// window that happened to catch the end of it.
|
|
604
|
+
entry.counted = seeded.decoded ?? 0;
|
|
605
|
+
// A rate is never seeded: `/slots` does not carry one, and the
|
|
606
|
+
// `/metrics` gauge that does holds its last value after generation ends,
|
|
607
|
+
// which is exactly the stale number this change exists to stop showing.
|
|
608
|
+
entry.rateTps = null;
|
|
609
|
+
entry.updatedAt = now;
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
resolve(number: number, now: number): ReadonlyMap<number, SlotActivityState> {
|
|
614
|
+
const record = ports.get(number);
|
|
615
|
+
if (record === undefined) return new Map();
|
|
616
|
+
const resolved = new Map<number, SlotActivityState>();
|
|
617
|
+
for (const [id, entry] of record.slots) {
|
|
618
|
+
// Past the bound we have not been told this slot is still working; we
|
|
619
|
+
// have only not been told that it stopped. Those are different, and only
|
|
620
|
+
// one of them is `processing`.
|
|
621
|
+
const stale = entry.state === "processing" && now - entry.updatedAt > SLOT_STALE_MS;
|
|
622
|
+
resolved.set(id, {
|
|
623
|
+
slot: id,
|
|
624
|
+
state: stale ? "unknown" : entry.state,
|
|
625
|
+
task: stale ? null : entry.task,
|
|
626
|
+
promptTokens: entry.promptTokens,
|
|
627
|
+
decoded: stale ? null : entry.decoded,
|
|
628
|
+
rateTps: stale ? null : entry.rateTps,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return resolved;
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
takeGeneratedTokens(): number {
|
|
635
|
+
const total = generated;
|
|
636
|
+
generated = 0;
|
|
637
|
+
return total;
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
retain(keep: Iterable<number>): void {
|
|
641
|
+
const live = new Set(keep);
|
|
642
|
+
for (const number of [...ports.keys()]) {
|
|
643
|
+
if (!live.has(number)) ports.delete(number);
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
resync(): void {
|
|
648
|
+
generated = 0;
|
|
649
|
+
for (const record of ports.values()) {
|
|
650
|
+
record.seeded = false;
|
|
651
|
+
record.seedAttempts = 0;
|
|
652
|
+
record.staleHandled = false;
|
|
653
|
+
record.lastAttemptAt = null;
|
|
654
|
+
record.slots.clear();
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
}
|