@vzn/vx-dte 0.0.651 → 0.0.653
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 +164 -60
- package/package.json +3 -3
- package/src/dispatch.ts +540 -30
- package/src/forecast.ts +1 -2
- package/src/github.ts +0 -3
- package/src/index.ts +1 -0
- package/src/init.ts +9 -10
- package/src/local.ts +112 -28
- package/src/messages.ts +26 -5
- package/src/orchestrator.ts +678 -233
- package/src/planner.ts +1470 -192
- package/src/plugin.ts +242 -33
- package/src/seal.ts +159 -43
- package/src/server-fit.ts +80 -0
- package/src/tree.ts +215 -7
- package/src/usage.ts +65 -3
- package/src/warm-ups.ts +72 -6
- package/src/wire.ts +119 -49
- package/src/worker.ts +174 -19
package/src/planner.ts
CHANGED
|
@@ -18,6 +18,9 @@ import {
|
|
|
18
18
|
} from './dispatch.js'
|
|
19
19
|
import { DEFAULT_STRATEGY, type Strategy } from './strategy.js'
|
|
20
20
|
|
|
21
|
+
/** Where a task naming no platform runs, or (several worker types) where each task runs. */
|
|
22
|
+
type Home = string | ((node: TaskNode) => string | undefined)
|
|
23
|
+
|
|
21
24
|
/**
|
|
22
25
|
* A task's duration when history has none: a test suite's, so a run with
|
|
23
26
|
* no history starts workers for its work rather than one for all of it.
|
|
@@ -50,8 +53,34 @@ export interface PlanTask {
|
|
|
50
53
|
readonly deps: readonly string[]
|
|
51
54
|
/** The platform core placed it on (`TaskNode.platform`): only a worker of it runs it. */
|
|
52
55
|
readonly platform?: string | undefined
|
|
56
|
+
/** Its number in the run's graph and its deps' (`planTasks`): a plan's shape reads them, not ids. */
|
|
57
|
+
readonly n?: number
|
|
58
|
+
readonly depsN?: Int32Array
|
|
59
|
+
/** Its servers' bases (`Need.servers`): booked once on each worker. */
|
|
60
|
+
readonly servers?: Need['servers']
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The run's tasks numbered once, and each one's deps by number. */
|
|
64
|
+
interface Numbered {
|
|
65
|
+
readonly of: Map<string, Node>
|
|
66
|
+
readonly ids: readonly string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A task of the run's graph: its number, whether it runs, the tasks that run it waits on. */
|
|
70
|
+
interface Node {
|
|
71
|
+
readonly n: number
|
|
72
|
+
readonly node: TaskNode
|
|
73
|
+
readonly runs: boolean
|
|
74
|
+
deps?: string[]
|
|
75
|
+
depsN?: Int32Array
|
|
53
76
|
}
|
|
54
77
|
|
|
78
|
+
/** The numbering each `depsN` is of. */
|
|
79
|
+
const numberedOf = new WeakMap<Int32Array, Numbered>()
|
|
80
|
+
|
|
81
|
+
/** A number for each of `nodes`, once a run. */
|
|
82
|
+
const numbers = new WeakMap<ReadonlyMap<string, TaskNode>, Numbered>()
|
|
83
|
+
|
|
55
84
|
/** A worker the plan can place on: from `at` ms on, with what it runs until each ends. */
|
|
56
85
|
export interface PlanWorker {
|
|
57
86
|
readonly id: string
|
|
@@ -63,6 +92,7 @@ export interface PlanWorker {
|
|
|
63
92
|
readonly id?: string
|
|
64
93
|
readonly cores: number
|
|
65
94
|
readonly memory: number
|
|
95
|
+
/** Below 0: past its time by as much. */
|
|
66
96
|
readonly endsIn: number
|
|
67
97
|
}[]
|
|
68
98
|
/** Holding no task: the run may let it go. */
|
|
@@ -72,6 +102,8 @@ export interface PlanWorker {
|
|
|
72
102
|
readonly platform?: string | undefined
|
|
73
103
|
/** The size it was started as (`Size.name`): what its warm-up is learned by. */
|
|
74
104
|
readonly size?: string | undefined
|
|
105
|
+
/** The servers it runs, each with the base booked there. */
|
|
106
|
+
readonly serving?: ReadonlyMap<string, number> | undefined
|
|
75
107
|
}
|
|
76
108
|
|
|
77
109
|
/** A worker's warm-up, one for all, or by its size (`Size.name`; none for one that joined on its own). */
|
|
@@ -96,6 +128,33 @@ export interface Plan {
|
|
|
96
128
|
readonly againInMs: number | undefined
|
|
97
129
|
}
|
|
98
130
|
|
|
131
|
+
/**
|
|
132
|
+
* What a plan foresees past its decisions, so the run follows it without
|
|
133
|
+
* playing it again: when each worker's last task in it ends (one idle
|
|
134
|
+
* before that is between tasks), when each start it wants later is due,
|
|
135
|
+
* and when each started now ends its work.
|
|
136
|
+
*/
|
|
137
|
+
interface Ahead {
|
|
138
|
+
/** When the window it played moves on, from now: plan again then. */
|
|
139
|
+
readonly untilInMs: number | undefined
|
|
140
|
+
readonly freeInMs: ReadonlyMap<string, number>
|
|
141
|
+
/** The tasks it gives each worker. */
|
|
142
|
+
readonly given: ReadonlyMap<string, readonly string[]>
|
|
143
|
+
readonly later: readonly {
|
|
144
|
+
readonly size: Size
|
|
145
|
+
readonly inMs: number
|
|
146
|
+
readonly freeInMs: number
|
|
147
|
+
readonly joinInMs: number
|
|
148
|
+
readonly given: readonly string[]
|
|
149
|
+
}[]
|
|
150
|
+
/** For each started now, when its work ends, when it joins and what it runs. */
|
|
151
|
+
readonly started: readonly {
|
|
152
|
+
readonly freeInMs: number
|
|
153
|
+
readonly joinInMs: number
|
|
154
|
+
readonly given: readonly string[]
|
|
155
|
+
}[]
|
|
156
|
+
}
|
|
157
|
+
|
|
99
158
|
/** History the run's finished tasks must have had before `slowdown` says anything. */
|
|
100
159
|
const SLOWDOWN_MS = 20_000
|
|
101
160
|
|
|
@@ -157,30 +216,58 @@ export function planTasks(
|
|
|
157
216
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
158
217
|
need: (id: string) => Need,
|
|
159
218
|
durationMs: (id: string) => number | undefined,
|
|
160
|
-
home?:
|
|
219
|
+
home?: Home,
|
|
161
220
|
): PlanTask[] {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
221
|
+
// The graph is the run's, fixed: each task is numbered and its deps
|
|
222
|
+
// walked once a run, not at each plan (2026-11-05: a 5000-task sim
|
|
223
|
+
// spent a quarter here), and read by one lookup (2026-11-06).
|
|
224
|
+
let num = numbers.get(nodes)
|
|
225
|
+
if (num === undefined) {
|
|
226
|
+
const of = new Map<string, Node>()
|
|
227
|
+
const all: string[] = []
|
|
228
|
+
for (const [id, node] of nodes) {
|
|
229
|
+
of.set(id, { n: all.length, node, runs: node.config.exec?.command !== undefined })
|
|
230
|
+
all.push(id)
|
|
231
|
+
}
|
|
232
|
+
numbers.set(nodes, (num = { of, ids: all }))
|
|
233
|
+
}
|
|
234
|
+
const { of } = num
|
|
235
|
+
const depsOf = (e: Node): string[] => {
|
|
236
|
+
if (e.deps !== undefined) return e.deps
|
|
237
|
+
e.deps = []
|
|
238
|
+
const out: string[] = []
|
|
239
|
+
for (const d of e.node.deps) {
|
|
240
|
+
const x = of.get(d)
|
|
241
|
+
if (x === undefined) continue
|
|
242
|
+
if (x.runs) out.push(d)
|
|
243
|
+
else for (const dd of depsOf(x)) out.push(dd)
|
|
244
|
+
}
|
|
245
|
+
return (e.deps = out)
|
|
171
246
|
}
|
|
172
|
-
|
|
247
|
+
const out: PlanTask[] = []
|
|
248
|
+
for (const id of ids) {
|
|
249
|
+
const e = of.get(id)
|
|
250
|
+
if (e === undefined || !e.runs) continue
|
|
173
251
|
const n = need(id)
|
|
174
|
-
|
|
252
|
+
const deps = depsOf(e)
|
|
253
|
+
if (e.depsN === undefined) {
|
|
254
|
+
e.depsN = Int32Array.from(deps, (d) => of.get(d)!.n)
|
|
255
|
+
numberedOf.set(e.depsN, num)
|
|
256
|
+
}
|
|
257
|
+
out.push({
|
|
175
258
|
id,
|
|
176
259
|
cores: n.ask?.cores ?? n.cores,
|
|
177
260
|
memory: n.ask?.memory ?? n.memory,
|
|
178
261
|
above: n.above,
|
|
179
262
|
ms: durationMs(id),
|
|
180
|
-
deps
|
|
181
|
-
platform:
|
|
182
|
-
|
|
183
|
-
|
|
263
|
+
deps,
|
|
264
|
+
platform: typeof home === 'function' ? home(e.node) : (e.node.platform ?? home),
|
|
265
|
+
n: e.n,
|
|
266
|
+
depsN: e.depsN,
|
|
267
|
+
...(n.servers === undefined ? {} : { servers: n.servers }),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
return out
|
|
184
271
|
}
|
|
185
272
|
|
|
186
273
|
/**
|
|
@@ -200,7 +287,7 @@ export function missedWork(
|
|
|
200
287
|
executed: ReadonlySet<string>,
|
|
201
288
|
need: (id: string) => Need,
|
|
202
289
|
durationMs: (id: string) => number | undefined,
|
|
203
|
-
home?:
|
|
290
|
+
home?: Home,
|
|
204
291
|
): PlanTask[] {
|
|
205
292
|
const changed = new Map<string, boolean>()
|
|
206
293
|
// Whether `id`'s key changed: a cached task that missed, or one after it.
|
|
@@ -209,20 +296,56 @@ export function missedWork(
|
|
|
209
296
|
if (known !== undefined) return known
|
|
210
297
|
changed.set(id, false)
|
|
211
298
|
const n = nodes.get(id)
|
|
212
|
-
const c = (executed.has(id) && n?.config.cache
|
|
299
|
+
const c = (executed.has(id) && n?.config.cache === true) || (n?.deps ?? []).some(changes)
|
|
213
300
|
changed.set(id, c)
|
|
214
301
|
return c
|
|
215
302
|
}
|
|
216
303
|
const missed = (id: string): boolean => {
|
|
217
304
|
const n = nodes.get(id)
|
|
218
305
|
return (
|
|
219
|
-
executed.has(id) ||
|
|
220
|
-
(n !== undefined && (n.config.cache === undefined || n.deps.some(changes)))
|
|
306
|
+
executed.has(id) || (n !== undefined && (n.config.cache !== true || n.deps.some(changes)))
|
|
221
307
|
)
|
|
222
308
|
}
|
|
223
309
|
return planTasks([...remaining].filter(missed), nodes, need, durationMs, home)
|
|
224
310
|
}
|
|
225
311
|
|
|
312
|
+
/**
|
|
313
|
+
* `workers` as a plan may let them go: of a platform whose sizes can start
|
|
314
|
+
* no other (`max` spent; one let go still counts), the last idle one stays
|
|
315
|
+
* while a task of that platform has not ended (`left`). A hit core's probe
|
|
316
|
+
* found can still miss when its restore fails, and only a worker of its
|
|
317
|
+
* platform runs it: main let its one arm worker go, check.binary.linux-arm64
|
|
318
|
+
* came back as a miss, and the run waited out its timeout (2026-11-06).
|
|
319
|
+
*/
|
|
320
|
+
export function keepLast(
|
|
321
|
+
workers: readonly PlanWorker[],
|
|
322
|
+
sizes: readonly Size[],
|
|
323
|
+
started: ReadonlyMap<string, number>,
|
|
324
|
+
left: (platform: string) => boolean,
|
|
325
|
+
): readonly PlanWorker[] {
|
|
326
|
+
const of = new Map<string, PlanWorker[]>()
|
|
327
|
+
for (const w of workers) {
|
|
328
|
+
if (w.platform === undefined) continue
|
|
329
|
+
const all = of.get(w.platform)
|
|
330
|
+
if (all === undefined) of.set(w.platform, [w])
|
|
331
|
+
else all.push(w)
|
|
332
|
+
}
|
|
333
|
+
let kept: Set<PlanWorker> | undefined
|
|
334
|
+
for (const [platform, all] of of) {
|
|
335
|
+
// A busy one is not let go; one starting, cancelled, frees its start.
|
|
336
|
+
if (all.some((w) => !w.idle && w.starting !== true)) continue
|
|
337
|
+
const idle = all.find((w) => w.idle && w.starting !== true)
|
|
338
|
+
if (idle === undefined) continue
|
|
339
|
+
const own = sizes.filter((s) => s.platform === platform)
|
|
340
|
+
if (own.length === 0 || own.some((s) => (started.get(s.name) ?? 0) < s.max)) continue
|
|
341
|
+
if (!left(platform)) continue
|
|
342
|
+
;(kept ??= new Set()).add(idle)
|
|
343
|
+
}
|
|
344
|
+
return kept === undefined
|
|
345
|
+
? workers
|
|
346
|
+
: workers.map((w) => (kept!.has(w) ? { ...w, idle: false } : w))
|
|
347
|
+
}
|
|
348
|
+
|
|
226
349
|
/**
|
|
227
350
|
* The tasks a plan counts as missed: those handed over to run, the
|
|
228
351
|
* misses core's probe found, and of the commit's forecast (`likely`) only
|
|
@@ -244,6 +367,12 @@ interface Slot extends Bin {
|
|
|
244
367
|
readonly at: number
|
|
245
368
|
/** When it is first given a task. */
|
|
246
369
|
first?: number
|
|
370
|
+
/** When its last task ends. */
|
|
371
|
+
free?: number
|
|
372
|
+
/** When its last task given in the play ends. */
|
|
373
|
+
last?: number
|
|
374
|
+
/** The tasks given it in the play. */
|
|
375
|
+
given?: string[]
|
|
247
376
|
}
|
|
248
377
|
|
|
249
378
|
/**
|
|
@@ -264,61 +393,55 @@ function play(
|
|
|
264
393
|
tasks: readonly PlanTask[],
|
|
265
394
|
workers: readonly PlanWorker[],
|
|
266
395
|
how: Strategy,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
396
|
+
// Played no further: past it the rest ends no sooner than its longest
|
|
397
|
+
// chain from there (2026-11-05: "We can plan as we go till next level";
|
|
398
|
+
// "it can look ahead 2 warmups").
|
|
399
|
+
horizon = Infinity,
|
|
400
|
+
): {
|
|
401
|
+
end: number
|
|
402
|
+
/** It stopped at `horizon` with work left. */
|
|
403
|
+
cut?: boolean
|
|
404
|
+
first: Map<string, number>
|
|
405
|
+
ends: Map<string | undefined, number>
|
|
406
|
+
free: () => Map<string, number>
|
|
407
|
+
given: () => Map<string, readonly string[]>
|
|
408
|
+
} {
|
|
271
409
|
// A dependency running now ends when it does; one ended or a hit, now.
|
|
272
410
|
const runningIds = new Set<string>()
|
|
273
411
|
for (const w of workers) for (const r of w.running) if (r.id !== undefined) runningIds.add(r.id)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// 40 million copies a play (2026-10-25).
|
|
281
|
-
const list = dependants.get(d)
|
|
282
|
-
if (list === undefined) dependants.set(d, [t.id])
|
|
283
|
-
else list.push(t.id)
|
|
284
|
-
}
|
|
285
|
-
waits.set(t.id, n)
|
|
286
|
-
}
|
|
287
|
-
const tail = new Map<string, number>()
|
|
288
|
-
const tailOf = (id: string): number => {
|
|
289
|
-
const known = tail.get(id)
|
|
290
|
-
if (known !== undefined) return known
|
|
291
|
-
let after = 0
|
|
292
|
-
for (const d of dependants.get(id) ?? []) after = Math.max(after, tailOf(d))
|
|
293
|
-
const t = after + (byId.get(id)!.ms ?? UNKNOWN_MS)
|
|
294
|
-
tail.set(id, t)
|
|
295
|
-
return t
|
|
296
|
-
}
|
|
412
|
+
const { byId, after, afterAt, indexOf, waitedAt, tailOf, tailAt, kinds } = shapeOf(
|
|
413
|
+
tasks,
|
|
414
|
+
runningIds,
|
|
415
|
+
)
|
|
416
|
+
// By place, not id: maps keyed by id were a 5000-task play's most time (2026-11-05).
|
|
417
|
+
const waits = waitedAt.slice()
|
|
297
418
|
const slots: Slot[] = workers.map((w) => ({
|
|
298
419
|
id: w.id,
|
|
299
420
|
cores: w.cores,
|
|
300
421
|
memory: w.memory,
|
|
301
422
|
platform: w.platform,
|
|
302
423
|
at: w.at,
|
|
303
|
-
used: { cores: 0, memory: 0 },
|
|
424
|
+
used: { cores: [...(w.serving?.values() ?? [])].reduce((a, b) => a + b, 0), memory: 0 },
|
|
304
425
|
running: 0,
|
|
426
|
+
serving: new Set(w.serving?.keys()),
|
|
305
427
|
}))
|
|
306
428
|
// Ends to come: [at, slot, cores, memory, task or undefined].
|
|
307
|
-
const ends
|
|
429
|
+
const ends = new Ends()
|
|
308
430
|
workers.forEach((w, i) => {
|
|
309
431
|
const s = slots[i]!
|
|
310
432
|
for (const r of w.running) {
|
|
311
433
|
s.used.cores += r.cores
|
|
312
434
|
s.used.memory += r.memory
|
|
313
435
|
s.running++
|
|
314
|
-
ends.push(
|
|
436
|
+
ends.push(Math.max(0, r.endsIn), s, r.cores, r.memory, -1, r.id)
|
|
437
|
+
s.free = Math.max(s.free ?? 0, r.endsIn)
|
|
315
438
|
}
|
|
316
439
|
})
|
|
317
440
|
// Ready tasks, ordered again only once one joins (`graphene`: or
|
|
318
441
|
// leaves): each step sorted every ready task, and a run of 9000 spent
|
|
319
442
|
// its orchestrator planning (2026-10-25).
|
|
320
|
-
const ready = new Ready(tasks)
|
|
321
|
-
let joining = tasks.filter((
|
|
443
|
+
const ready = new Ready(tasks, kinds)
|
|
444
|
+
let joining = tasks.filter((_, i) => waits[i] === 0)
|
|
322
445
|
let readyCount = joining.length
|
|
323
446
|
const spent = new Set<string>()
|
|
324
447
|
const bounds: Bounds = { done: false, open: true, free: 0, cores: 0, memory: 0 }
|
|
@@ -345,9 +468,33 @@ function play(
|
|
|
345
468
|
const widest = Math.max(1, ...workers.map((w) => w.cores))
|
|
346
469
|
let left = tasks.length
|
|
347
470
|
let now = 0
|
|
348
|
-
let last = Math.max(0,
|
|
471
|
+
let last = Math.max(0, ends.last())
|
|
472
|
+
// Core-ms of the tasks not yet given.
|
|
473
|
+
const workLeft = new Map<string | undefined, number>()
|
|
474
|
+
const spend = (t: PlanTask, by: number): void => {
|
|
475
|
+
const w = by * t.cores * (t.ms ?? UNKNOWN_MS)
|
|
476
|
+
workLeft.set(undefined, (workLeft.get(undefined) ?? 0) + w)
|
|
477
|
+
if (t.platform !== undefined) workLeft.set(t.platform, (workLeft.get(t.platform) ?? 0) + w)
|
|
478
|
+
}
|
|
479
|
+
if (horizon < Infinity) for (const t of tasks) spend(t, 1)
|
|
349
480
|
// When each platform's last task ends.
|
|
350
|
-
const byPlatform =
|
|
481
|
+
const byPlatform = (): Map<string | undefined, number> => {
|
|
482
|
+
const out = new Map<string | undefined, number>()
|
|
483
|
+
for (const s of slots)
|
|
484
|
+
if (s.last !== undefined) out.set(s.platform, Math.max(out.get(s.platform) ?? 0, s.last))
|
|
485
|
+
return out
|
|
486
|
+
}
|
|
487
|
+
// The joined slots, read again once the next one joins.
|
|
488
|
+
let joined: Slot[] = []
|
|
489
|
+
let nextJoin = -Infinity
|
|
490
|
+
// A task that filled a size waits for a bigger one still to join.
|
|
491
|
+
const later = (above: { cores: number; memory: number }, platform?: string): boolean =>
|
|
492
|
+
slots.some(
|
|
493
|
+
(s) =>
|
|
494
|
+
s.at > now &&
|
|
495
|
+
(platform === undefined || s.platform === platform) &&
|
|
496
|
+
(s.cores > above.cores || s.memory > above.memory),
|
|
497
|
+
)
|
|
351
498
|
for (;;) {
|
|
352
499
|
if (joining.length > 0 || ready.stale) {
|
|
353
500
|
// By the longest chain, those still ready keep their order and the
|
|
@@ -365,21 +512,21 @@ function play(
|
|
|
365
512
|
else ready.reset(order)
|
|
366
513
|
joining = []
|
|
367
514
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
slots.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
515
|
+
if (now >= nextJoin) {
|
|
516
|
+
joined = slots.filter((s) => s.at <= now)
|
|
517
|
+
nextJoin = Infinity
|
|
518
|
+
for (const s of slots) if (s.at > now) nextJoin = Math.min(nextJoin, s.at)
|
|
519
|
+
}
|
|
520
|
+
// With none ready nothing is given: a third of a sim's steps (2026-11-05).
|
|
521
|
+
const to =
|
|
522
|
+
readyCount === 0
|
|
523
|
+
? NONE
|
|
524
|
+
: assign(ready.asks(spent, bounds, needs.size), joined, later, how.place, {
|
|
525
|
+
floor: (floor ??= floorOf(needs.values())),
|
|
526
|
+
kinds: needs.size,
|
|
527
|
+
spent,
|
|
528
|
+
bounds,
|
|
529
|
+
})
|
|
383
530
|
// `graphene` ranks against the longest chain still ready.
|
|
384
531
|
if (to.size > 0 && how.order === 'graphene') ready.stale = true
|
|
385
532
|
for (const [id, best] of to) {
|
|
@@ -388,46 +535,385 @@ function play(
|
|
|
388
535
|
readyCount--
|
|
389
536
|
count(t, -1)
|
|
390
537
|
best.first ??= now
|
|
538
|
+
;(best.given ??= []).push(id)
|
|
539
|
+
best.free = Math.max(best.free ?? 0, now + (t.ms ?? UNKNOWN_MS))
|
|
391
540
|
const end = now + (t.ms ?? UNKNOWN_MS)
|
|
392
|
-
ends.push(
|
|
541
|
+
ends.push(
|
|
542
|
+
end,
|
|
543
|
+
best,
|
|
544
|
+
Math.min(t.cores, best.cores),
|
|
545
|
+
Math.min(t.memory, best.memory),
|
|
546
|
+
indexOf.get(id)!,
|
|
547
|
+
undefined,
|
|
548
|
+
)
|
|
393
549
|
last = Math.max(last, end)
|
|
394
|
-
|
|
550
|
+
best.last = Math.max(best.last ?? 0, end)
|
|
551
|
+
if (horizon < Infinity) spend(t, -1)
|
|
395
552
|
}
|
|
396
553
|
const first = (): Map<string, number> =>
|
|
397
554
|
new Map(slots.flatMap((s) => (s.first === undefined ? [] : [[s.id, s.first] as const])))
|
|
398
|
-
|
|
555
|
+
const free = (): Map<string, number> =>
|
|
556
|
+
new Map(slots.flatMap((s) => (s.free === undefined ? [] : [[s.id, s.free] as const])))
|
|
557
|
+
const given = (): Map<string, readonly string[]> =>
|
|
558
|
+
new Map(slots.flatMap((s) => (s.given === undefined ? [] : [[s.id, s.given] as const])))
|
|
559
|
+
if (left === 0 && readyCount === 0)
|
|
560
|
+
return { end: last, first: first(), ends: byPlatform(), free, given }
|
|
399
561
|
// The next moment anything changes: a task ends or a worker joins.
|
|
400
|
-
let next =
|
|
401
|
-
for (const e of ends) next = Math.min(next, e[0])
|
|
562
|
+
let next = ends.next()
|
|
402
563
|
for (const s of slots) if (s.at > now) next = Math.min(next, s.at)
|
|
564
|
+
if (next >= horizon && next < Infinity) {
|
|
565
|
+
// No sooner than its longest chain, nor than its work spread on
|
|
566
|
+
// every core it has: the workers it asks later are the next plan's.
|
|
567
|
+
// Of each platform on its own workers, and all of it on all.
|
|
568
|
+
const work = new Map(workLeft)
|
|
569
|
+
const cores = new Map<string | undefined, number>()
|
|
570
|
+
for (const s of slots) {
|
|
571
|
+
cores.set(undefined, (cores.get(undefined) ?? 0) + s.cores)
|
|
572
|
+
if (s.platform !== undefined) cores.set(s.platform, (cores.get(s.platform) ?? 0) + s.cores)
|
|
573
|
+
}
|
|
574
|
+
// A running task's rest on its own platform too: counted on all
|
|
575
|
+
// alone, a Mac fewer read the Linux end later (2026-11-07).
|
|
576
|
+
for (const e of ends.all()) {
|
|
577
|
+
const w = e.cores * (e.at - now)
|
|
578
|
+
work.set(undefined, (work.get(undefined) ?? 0) + w)
|
|
579
|
+
const p = e.s.platform
|
|
580
|
+
if (p !== undefined) work.set(p, (work.get(p) ?? 0) + w)
|
|
581
|
+
}
|
|
582
|
+
let end = last
|
|
583
|
+
for (const [p, w] of work) end = Math.max(end, now + w / Math.max(cores.get(p) ?? 0, 1e-9))
|
|
584
|
+
for (const t of ready.live()) end = Math.max(end, now + tailOf(t.id))
|
|
585
|
+
for (const e of ends.all())
|
|
586
|
+
for (const d of (e.place >= 0 ? afterAt(e.place) : after.get(e.id!)) ?? [])
|
|
587
|
+
end = Math.max(end, e.at + tailAt(d))
|
|
588
|
+
return { end, cut: true, first: first(), ends: byPlatform(), free, given }
|
|
589
|
+
}
|
|
403
590
|
if (next === Infinity)
|
|
404
|
-
return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform }
|
|
591
|
+
return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform(), free, given }
|
|
405
592
|
now = next
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
if (at > now) {
|
|
409
|
-
i++
|
|
410
|
-
continue
|
|
411
|
-
}
|
|
412
|
-
ends.splice(i, 1)
|
|
593
|
+
while (ends.next() <= now) {
|
|
594
|
+
const { s, cores, memory, place, id } = ends.pop()
|
|
413
595
|
s.used.cores -= cores
|
|
414
596
|
s.used.memory -= memory
|
|
415
597
|
s.running--
|
|
416
|
-
if (
|
|
417
|
-
if (
|
|
418
|
-
for (const d of
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (n === 0) {
|
|
422
|
-
joining.push(byId.get(d)!)
|
|
598
|
+
if (place >= 0) left--
|
|
599
|
+
else if (id === undefined) continue
|
|
600
|
+
for (const d of (place >= 0 ? afterAt(place) : after.get(id!)) ?? []) {
|
|
601
|
+
if (--waits[d]! === 0) {
|
|
602
|
+
joining.push(tasks[d]!)
|
|
423
603
|
readyCount++
|
|
424
|
-
count(
|
|
604
|
+
count(tasks[d]!, 1)
|
|
425
605
|
}
|
|
426
606
|
}
|
|
427
607
|
}
|
|
428
608
|
}
|
|
429
609
|
}
|
|
430
610
|
|
|
611
|
+
const NONE: ReadonlyMap<string, Slot> = new Map()
|
|
612
|
+
|
|
613
|
+
interface End {
|
|
614
|
+
at: number
|
|
615
|
+
seq: number
|
|
616
|
+
s: Slot
|
|
617
|
+
cores: number
|
|
618
|
+
memory: number
|
|
619
|
+
/** The task's place in the play, or -1 for one running when it began (`id`). */
|
|
620
|
+
place: number
|
|
621
|
+
id: string | undefined
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* A play's ends to come, soonest first, ties in the order given: a scan
|
|
626
|
+
* of every running task at each step was most of a 20 000-task play
|
|
627
|
+
* (2026-11-05).
|
|
628
|
+
*/
|
|
629
|
+
class Ends {
|
|
630
|
+
private readonly heap: End[] = []
|
|
631
|
+
private seq = 0
|
|
632
|
+
private readonly before = (a: End, b: End): boolean =>
|
|
633
|
+
a.at < b.at || (a.at === b.at && a.seq < b.seq)
|
|
634
|
+
push(
|
|
635
|
+
at: number,
|
|
636
|
+
s: Slot,
|
|
637
|
+
cores: number,
|
|
638
|
+
memory: number,
|
|
639
|
+
place: number,
|
|
640
|
+
id: string | undefined,
|
|
641
|
+
): void {
|
|
642
|
+
const h = this.heap
|
|
643
|
+
const e: End = { at, seq: this.seq++, s, cores, memory, place, id }
|
|
644
|
+
let i = h.length
|
|
645
|
+
h.push(e)
|
|
646
|
+
while (i > 0) {
|
|
647
|
+
const p = (i - 1) >> 1
|
|
648
|
+
if (!this.before(e, h[p]!)) break
|
|
649
|
+
h[i] = h[p]!
|
|
650
|
+
i = p
|
|
651
|
+
}
|
|
652
|
+
h[i] = e
|
|
653
|
+
}
|
|
654
|
+
next(): number {
|
|
655
|
+
return this.heap[0]?.at ?? Infinity
|
|
656
|
+
}
|
|
657
|
+
all(): readonly End[] {
|
|
658
|
+
return this.heap
|
|
659
|
+
}
|
|
660
|
+
last(): number {
|
|
661
|
+
let most = 0
|
|
662
|
+
for (const e of this.heap) most = Math.max(most, e.at)
|
|
663
|
+
return most
|
|
664
|
+
}
|
|
665
|
+
pop(): End {
|
|
666
|
+
const h = this.heap
|
|
667
|
+
const top = h[0]!
|
|
668
|
+
const e = h.pop()!
|
|
669
|
+
if (h.length > 0) {
|
|
670
|
+
let i = 0
|
|
671
|
+
for (;;) {
|
|
672
|
+
const l = 2 * i + 1
|
|
673
|
+
if (l >= h.length) break
|
|
674
|
+
const c = l + 1 < h.length && this.before(h[l + 1]!, h[l]!) ? l + 1 : l
|
|
675
|
+
if (!this.before(h[c]!, e)) break
|
|
676
|
+
h[i] = h[c]!
|
|
677
|
+
i = c
|
|
678
|
+
}
|
|
679
|
+
h[i] = e
|
|
680
|
+
}
|
|
681
|
+
return top
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** What `play` reads of `tasks` alone, for each play of one plan. */
|
|
686
|
+
interface Shape {
|
|
687
|
+
readonly byId: { get(id: string): PlanTask | undefined }
|
|
688
|
+
/** Each running task's dependants, by their place in the tasks. */
|
|
689
|
+
readonly after: ReadonlyMap<string, readonly number[]>
|
|
690
|
+
/** Each task's dependants by its place, and its place by id: a lookup by id at each end was most of a play. */
|
|
691
|
+
readonly afterAt: (place: number) => Int32Array
|
|
692
|
+
readonly indexOf: { get(id: string): number | undefined }
|
|
693
|
+
/** How many dependencies each task, by its place, waits on before the play begins. */
|
|
694
|
+
readonly waitedAt: Int32Array
|
|
695
|
+
readonly tailOf: (id: string) => number
|
|
696
|
+
readonly tailAt: (place: number) => number
|
|
697
|
+
readonly kinds: Kinds
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* `tasks`' shape, made once while what runs stays the same: a plan plays
|
|
702
|
+
* them 20 to 40 times, and each play made it again, its tails and kinds
|
|
703
|
+
* too (2026-11-05).
|
|
704
|
+
*/
|
|
705
|
+
function shapeOf(tasks: readonly PlanTask[], running: ReadonlySet<string>): Shape {
|
|
706
|
+
const runs = [...running].sort().join('\0')
|
|
707
|
+
const had = shapes.get(tasks)
|
|
708
|
+
if (had !== undefined && had.runs === runs) return had.shape
|
|
709
|
+
// Of a running task only, by id: one in the work is by its place.
|
|
710
|
+
const after = new Map<string, number[]>()
|
|
711
|
+
const waitedAt = new Int32Array(tasks.length)
|
|
712
|
+
const first = tasks[0]?.depsN
|
|
713
|
+
const num =
|
|
714
|
+
first !== undefined && tasks.every((t) => t.depsN !== undefined)
|
|
715
|
+
? numberedOf.get(first)
|
|
716
|
+
: undefined
|
|
717
|
+
let edges = 0
|
|
718
|
+
for (let i = 0; i < tasks.length; i++) edges += tasks[i]!.deps.length
|
|
719
|
+
// Each dependency's place in the work, -1 when not in it.
|
|
720
|
+
const depAt = new Int32Array(edges)
|
|
721
|
+
let indexOf: { get(id: string): number | undefined }
|
|
722
|
+
if (num !== undefined) {
|
|
723
|
+
// By the run's numbers, not ids: a map of a million ids, made and
|
|
724
|
+
// read at each plan, was most of its time (2026-11-06).
|
|
725
|
+
const place = new Int32Array(num.ids.length).fill(-1)
|
|
726
|
+
for (let i = 0; i < tasks.length; i++) place[tasks[i]!.n!] = i
|
|
727
|
+
// Indexed: iterators over a million tasks' deps were a third of the shape.
|
|
728
|
+
for (let e = 0, i = 0; i < tasks.length; i++) {
|
|
729
|
+
const ds = tasks[i]!.depsN!
|
|
730
|
+
for (let k = 0; k < ds.length; k++) depAt[e++] = place[ds[k]!]!
|
|
731
|
+
}
|
|
732
|
+
indexOf = {
|
|
733
|
+
get: (id) => {
|
|
734
|
+
const n = num.of.get(id)?.n
|
|
735
|
+
const j = n === undefined ? -1 : place[n]!
|
|
736
|
+
return j < 0 ? undefined : j
|
|
737
|
+
},
|
|
738
|
+
}
|
|
739
|
+
} else {
|
|
740
|
+
// One map by id, the rest by place: a map of each by id made a
|
|
741
|
+
// 10 000-task plan's shape a third of its time (2026-11-05).
|
|
742
|
+
const of = new Map<string, number>()
|
|
743
|
+
for (let i = 0; i < tasks.length; i++) of.set(tasks[i]!.id, i)
|
|
744
|
+
let e = 0
|
|
745
|
+
for (const t of tasks) for (const d of t.deps) depAt[e++] = of.get(d) ?? -1
|
|
746
|
+
indexOf = of
|
|
747
|
+
}
|
|
748
|
+
// Each task's dependants in one array, by place: an array each, pushed
|
|
749
|
+
// to, was most of a million-task shape (2026-11-06).
|
|
750
|
+
const start = new Int32Array(tasks.length + 1)
|
|
751
|
+
for (let e = 0, i = 0; i < tasks.length; i++) {
|
|
752
|
+
const deps = tasks[i]!.deps
|
|
753
|
+
let n = 0
|
|
754
|
+
for (let k = 0; k < deps.length; k++, e++) {
|
|
755
|
+
const j = depAt[e]!
|
|
756
|
+
if (j >= 0) {
|
|
757
|
+
start[j + 1]!++
|
|
758
|
+
n++
|
|
759
|
+
} else if (running.has(deps[k]!)) {
|
|
760
|
+
n++
|
|
761
|
+
const at = after.get(deps[k]!)
|
|
762
|
+
if (at === undefined) after.set(deps[k]!, [i])
|
|
763
|
+
else at.push(i)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
waitedAt[i] = n
|
|
767
|
+
}
|
|
768
|
+
for (let i = 0; i < tasks.length; i++) start[i + 1]! += start[i]!
|
|
769
|
+
const list = new Int32Array(start[tasks.length]!)
|
|
770
|
+
const fill = start.slice(0, tasks.length)
|
|
771
|
+
for (let e = 0, i = 0; i < tasks.length; i++)
|
|
772
|
+
for (let k = tasks[i]!.deps.length; k > 0; k--, e++) {
|
|
773
|
+
const j = depAt[e]!
|
|
774
|
+
if (j >= 0) list[fill[j]!++] = i
|
|
775
|
+
}
|
|
776
|
+
const afterAt = (i: number): Int32Array => list.subarray(start[i]!, start[i + 1]!)
|
|
777
|
+
const tail = new Float64Array(tasks.length).fill(-1)
|
|
778
|
+
const tailAt = (i: number): number => {
|
|
779
|
+
if (tail[i]! >= 0) return tail[i]!
|
|
780
|
+
let most = 0
|
|
781
|
+
for (let k = start[i]!; k < start[i + 1]!; k++) most = Math.max(most, tailAt(list[k]!))
|
|
782
|
+
return (tail[i] = most + (tasks[i]!.ms ?? UNKNOWN_MS))
|
|
783
|
+
}
|
|
784
|
+
const tailOf = (id: string): number => tailAt(indexOf.get(id)!)
|
|
785
|
+
const byId = { get: (id: string) => tasks[indexOf.get(id)!] }
|
|
786
|
+
const shape = {
|
|
787
|
+
byId,
|
|
788
|
+
after,
|
|
789
|
+
afterAt,
|
|
790
|
+
indexOf,
|
|
791
|
+
waitedAt,
|
|
792
|
+
tailOf,
|
|
793
|
+
tailAt,
|
|
794
|
+
kinds: { at: indexOf, kindAt: lazyKinds(tasks) },
|
|
795
|
+
}
|
|
796
|
+
shapes.set(tasks, { runs, shape })
|
|
797
|
+
return shape
|
|
798
|
+
}
|
|
799
|
+
const shapes = new WeakMap<readonly PlanTask[], { runs: string; shape: Shape }>()
|
|
800
|
+
|
|
801
|
+
/** Each task's place in `tasks` and its kind (`kindOf`). */
|
|
802
|
+
interface Kinds {
|
|
803
|
+
readonly at: { get(id: string): number | undefined }
|
|
804
|
+
readonly kindAt: (place: number) => string
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Each task's kind, made as a play first reads it: made for every task,
|
|
809
|
+
* a million float-to-string keys were a fifth of a plan, and a play
|
|
810
|
+
* windowed to two warm-ups reads few of them (2026-11-06).
|
|
811
|
+
*/
|
|
812
|
+
function lazyKinds(tasks: readonly PlanTask[]): (place: number) => string {
|
|
813
|
+
const kinds: (string | undefined)[] = []
|
|
814
|
+
return (i) => (kinds[i] ??= kindOf(tasks[i]!))
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function kindsOf(tasks: readonly PlanTask[]): Kinds {
|
|
818
|
+
return { at: new Map(tasks.map((t, i) => [t.id, i])), kindAt: lazyKinds(tasks) }
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* No play of `tasks` ends sooner: each chain of them in a row, from when
|
|
823
|
+
* the running tasks it waits on end, and each running task's end.
|
|
824
|
+
*/
|
|
825
|
+
function chainMs(tasks: readonly PlanTask[], workers: readonly PlanWorker[]): number {
|
|
826
|
+
const runningIds = new Set<string>()
|
|
827
|
+
const endsAt = new Map<string, number>()
|
|
828
|
+
for (const w of workers)
|
|
829
|
+
for (const r of w.running)
|
|
830
|
+
if (r.id !== undefined) {
|
|
831
|
+
runningIds.add(r.id)
|
|
832
|
+
endsAt.set(r.id, Math.max(endsAt.get(r.id) ?? 0, r.endsIn))
|
|
833
|
+
}
|
|
834
|
+
const { tailAt, after } = shapeOf(tasks, runningIds)
|
|
835
|
+
let most = 0
|
|
836
|
+
for (const w of workers) for (const r of w.running) most = Math.max(most, r.endsIn)
|
|
837
|
+
// From the running tasks' dependants: each task's deps read by id was
|
|
838
|
+
// most of a million-task plan's bound (2026-11-06).
|
|
839
|
+
const from = new Float64Array(tasks.length)
|
|
840
|
+
for (const [id, ds] of after)
|
|
841
|
+
for (const i of ds) from[i] = Math.max(from[i]!, endsAt.get(id) ?? 0)
|
|
842
|
+
for (let i = 0; i < tasks.length; i++) most = Math.max(most, from[i]! + tailAt(i))
|
|
843
|
+
return most
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Two workers a play cannot tell apart, but by their place. */
|
|
847
|
+
const twins = (a: PlanWorker, b: PlanWorker): boolean =>
|
|
848
|
+
a.cores === b.cores && a.memory === b.memory && a.platform === b.platform && a.at === b.at
|
|
849
|
+
|
|
850
|
+
type Played = ReturnType<typeof play>
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* `kept` less each of `added` (its last ones, in the order the run would
|
|
854
|
+
* start them) the run ends as soon without, tried last first. Sizes come in
|
|
855
|
+
* turns, so a prefix holds ones it ends as soon without: a second Mac the
|
|
856
|
+
* plan needed came after a second Linux worker it did not, and main e118ccf
|
|
857
|
+
* cancelled that one unjoined (2026-10-24). Dropped when the end moves no
|
|
858
|
+
* later and its platform's moves no further than the margin, or still ends
|
|
859
|
+
* two warm-ups before the run: room for runners the CI queues. To the run's
|
|
860
|
+
* end alone, a 110 s guess of an unmeasured chain cut every other platform
|
|
861
|
+
* to it; to its platform's alone, 4 Macs started for work 2 ended 40 s
|
|
862
|
+
* before the Linux work (main a1950a2, 2026-11-07: "we optimize for total
|
|
863
|
+
* wall time"). The live plan and the footer's advice both trim by it.
|
|
864
|
+
*/
|
|
865
|
+
function dropSpare(
|
|
866
|
+
added: readonly PlanWorker[],
|
|
867
|
+
kept: readonly PlanWorker[],
|
|
868
|
+
before: Played,
|
|
869
|
+
run: (ws: readonly PlanWorker[]) => Played,
|
|
870
|
+
): { kept: PlanWorker[]; before: Played } {
|
|
871
|
+
let out = [...kept]
|
|
872
|
+
for (let i = added.length - 1, dropped = false; i >= 0; i--) {
|
|
873
|
+
const w = added[i]!
|
|
874
|
+
// Its twin after it stayed, and nothing moved since: without this
|
|
875
|
+
// one the play is that one's again, so it stays too. Each was a
|
|
876
|
+
// play of the whole run (2026-11-05).
|
|
877
|
+
if (i < added.length - 1 && !dropped && twins(w, added[i + 1]!)) continue
|
|
878
|
+
const without = out.filter((k) => k !== w)
|
|
879
|
+
// Given no task in a whole play, the play without it is the same.
|
|
880
|
+
if (!before.first.has(w.id) && before.cut !== true) {
|
|
881
|
+
out = without
|
|
882
|
+
dropped = true
|
|
883
|
+
continue
|
|
884
|
+
}
|
|
885
|
+
const after = run(without)
|
|
886
|
+
const ends = after.ends.get(w.platform) ?? 0
|
|
887
|
+
dropped =
|
|
888
|
+
after.end <= before.end &&
|
|
889
|
+
(ends <= (before.ends.get(w.platform) ?? 0) + MARGIN_MS ||
|
|
890
|
+
ends <= before.end - Math.max(MARGIN_MS, 2 * w.at))
|
|
891
|
+
if (dropped) {
|
|
892
|
+
out = without
|
|
893
|
+
before = after
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return { kept: out, before }
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** A worker of `s` that joins `at`. */
|
|
900
|
+
function fresh(
|
|
901
|
+
s: { cpu: number; memory: number; platform?: string | undefined; size?: string | undefined },
|
|
902
|
+
id: string,
|
|
903
|
+
at: number,
|
|
904
|
+
): PlanWorker {
|
|
905
|
+
return {
|
|
906
|
+
id,
|
|
907
|
+
cores: s.cpu,
|
|
908
|
+
memory: s.memory,
|
|
909
|
+
platform: s.platform,
|
|
910
|
+
...(s.size === undefined ? {} : { size: s.size }),
|
|
911
|
+
at,
|
|
912
|
+
running: [],
|
|
913
|
+
idle: false,
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
431
917
|
/**
|
|
432
918
|
* The workers to start and to let go. The fewest new ones (`candidates`,
|
|
433
919
|
* in the order the run would start them, each joining after its size's `warmUpMs`)
|
|
@@ -449,30 +935,43 @@ export function plan(
|
|
|
449
935
|
// The latest an earlier plan of the same work allowed, from now.
|
|
450
936
|
limitMs = Infinity,
|
|
451
937
|
how: Strategy = DEFAULT_STRATEGY,
|
|
452
|
-
): Plan {
|
|
938
|
+
): Plan & Ahead {
|
|
453
939
|
const warm = warmOf(warmUpMs)
|
|
454
|
-
const fresh = (
|
|
455
|
-
s: { cpu: number; memory: number; platform?: string | undefined; size?: string | undefined },
|
|
456
|
-
id: string,
|
|
457
|
-
at: number,
|
|
458
|
-
): PlanWorker => ({
|
|
459
|
-
id,
|
|
460
|
-
cores: s.cpu,
|
|
461
|
-
memory: s.memory,
|
|
462
|
-
platform: s.platform,
|
|
463
|
-
...(s.size === undefined ? {} : { size: s.size }),
|
|
464
|
-
at,
|
|
465
|
-
running: [],
|
|
466
|
-
idle: false,
|
|
467
|
-
})
|
|
468
940
|
const added = candidates.map((s, i) => fresh({ ...s, size: s.name }, `\0new${i}`, warm(s.name)))
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
941
|
+
// The fewest new ones ending within the margin of the best, found by
|
|
942
|
+
// halving: more workers end the run no later, so the best is all of
|
|
943
|
+
// them. Trying each count in turn played the whole run some 50 times a
|
|
944
|
+
// plan (2026-11-05: "Why it replays 64 times to pick workers").
|
|
945
|
+
// Two warm-ups ahead: a worker asked now joins after one, and the next
|
|
946
|
+
// plan, a warm-up on, sees the one after.
|
|
947
|
+
const horizon =
|
|
948
|
+
2 *
|
|
949
|
+
Math.max(
|
|
950
|
+
warm(undefined),
|
|
951
|
+
...candidates.map((c) => warm(c.name)),
|
|
952
|
+
...workers.map((w) => w.at),
|
|
953
|
+
) +
|
|
954
|
+
MARGIN_MS +
|
|
955
|
+
// And a task given at its end runs through: work counted as cores
|
|
956
|
+
// spread over the rest asked too few workers for a few long tasks.
|
|
957
|
+
tasks.reduce((most, t) => Math.max(most, t.ms ?? UNKNOWN_MS), 0)
|
|
958
|
+
// Each set of workers played once a plan: the count search, the drop
|
|
959
|
+
// pass, the releases and the final play each played the same set again.
|
|
960
|
+
const plays = new Map<string, ReturnType<typeof play>>()
|
|
961
|
+
const run = (ws: readonly PlanWorker[]): ReturnType<typeof play> => {
|
|
962
|
+
const key = ws.map((w) => `${w.id}\0${w.at}`).join('\n')
|
|
963
|
+
let p = plays.get(key)
|
|
964
|
+
if (p === undefined) plays.set(key, (p = play(tasks, ws, how, horizon)))
|
|
965
|
+
return p
|
|
474
966
|
}
|
|
475
|
-
|
|
967
|
+
// Given no task in the play, and none past its window.
|
|
968
|
+
const unused = (p: ReturnType<typeof play>, id: string): boolean =>
|
|
969
|
+
!p.first.has(id) && p.cut !== true
|
|
970
|
+
const playOf = (n: number): ReturnType<typeof play> => run([...workers, ...added.slice(0, n)])
|
|
971
|
+
const endOf = (n: number): number => playOf(n).end
|
|
972
|
+
const floor = chainMs(tasks, workers)
|
|
973
|
+
const none = endOf(0)
|
|
974
|
+
const best = none <= floor ? none : Math.min(none, endOf(candidates.length))
|
|
476
975
|
// Margin spent at each decision adds up: releases 4 s apart each lost
|
|
477
976
|
// 4 s of 5 (tests/sim.test.ts, a growing graph). Spent once, unless the
|
|
478
977
|
// rest can no longer end by then.
|
|
@@ -480,31 +979,19 @@ export function plan(
|
|
|
480
979
|
best === Infinity
|
|
481
980
|
? Infinity
|
|
482
981
|
: Math.max(best, Math.min(best + Math.max(MARGIN_MS, best * MARGIN), limitMs))
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
)
|
|
982
|
+
let lo = 0
|
|
983
|
+
let hi = candidates.length
|
|
984
|
+
if (none <= bound) hi = 0
|
|
985
|
+
while (lo < hi) {
|
|
986
|
+
const mid = (lo + hi) >> 1
|
|
987
|
+
if (endOf(mid) <= bound) hi = mid
|
|
988
|
+
else lo = mid + 1
|
|
989
|
+
}
|
|
990
|
+
const n = lo
|
|
487
991
|
let kept = [...workers, ...added.slice(0, n)]
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
// Dropped only when neither the end nor its platform's moves past the
|
|
492
|
-
// margin: to the run's end alone, a 110 s guess of an unmeasured chain
|
|
493
|
-
// cut every other platform to it.
|
|
494
|
-
let before = best < Infinity && n > 0 ? play(tasks, kept, how) : undefined
|
|
495
|
-
if (before !== undefined)
|
|
496
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
497
|
-
const platform = added[i]!.platform
|
|
498
|
-
const without = kept.filter((k) => k !== added[i])
|
|
499
|
-
const after = play(tasks, without, how)
|
|
500
|
-
if (
|
|
501
|
-
after.end <= before.end &&
|
|
502
|
-
(after.ends.get(platform) ?? 0) <= (before.ends.get(platform) ?? 0) + MARGIN_MS
|
|
503
|
-
) {
|
|
504
|
-
kept = without
|
|
505
|
-
before = after
|
|
506
|
-
}
|
|
507
|
-
}
|
|
992
|
+
// The ones the run ends as soon without go (`dropSpare`).
|
|
993
|
+
let before = best < Infinity && n > 0 ? playOf(n) : undefined
|
|
994
|
+
if (before !== undefined) ({ kept, before } = dropSpare(added.slice(0, n), kept, before, run))
|
|
508
995
|
const release: string[] = []
|
|
509
996
|
// Played with the rest: left out, the next idle one and the plan's end
|
|
510
997
|
// counted on a worker the run lets go.
|
|
@@ -528,11 +1015,7 @@ export function plan(
|
|
|
528
1015
|
// margin, the next plan's estimates moved a second later and
|
|
529
1016
|
// cancelled 7 asked one at a time (PR #4214); ending the run no
|
|
530
1017
|
// sooner, main 005e8f2 cancelled a mac and asked one again 18 s on.
|
|
531
|
-
if (
|
|
532
|
-
twin === undefined
|
|
533
|
-
? !play(tasks, kept, how).first.has(w.id)
|
|
534
|
-
: simulate(tasks, without, how) <= bound
|
|
535
|
-
) {
|
|
1018
|
+
if (twin === undefined ? unused(run(kept), w.id) : run(without).end <= bound) {
|
|
536
1019
|
kept = without
|
|
537
1020
|
if (twin === undefined) release.push(w.id)
|
|
538
1021
|
}
|
|
@@ -552,7 +1035,9 @@ export function plan(
|
|
|
552
1035
|
})
|
|
553
1036
|
for (const w of idle) {
|
|
554
1037
|
const without = kept.filter((k) => k !== w)
|
|
555
|
-
const
|
|
1038
|
+
const played = run([...kept, ...standIns])
|
|
1039
|
+
// Given nothing in a window with work past it: needed past it, maybe.
|
|
1040
|
+
const next = played.first.get(w.id) ?? (played.cut === true ? horizon : undefined)
|
|
556
1041
|
if (next === undefined) {
|
|
557
1042
|
kept = without
|
|
558
1043
|
release.push(w.id)
|
|
@@ -571,7 +1056,7 @@ export function plan(
|
|
|
571
1056
|
w.id,
|
|
572
1057
|
next,
|
|
573
1058
|
)
|
|
574
|
-
if (
|
|
1059
|
+
if (run([...without, ...standIns, instead]).end <= bound) {
|
|
575
1060
|
kept = without
|
|
576
1061
|
release.push(w.id)
|
|
577
1062
|
standIns.push(instead)
|
|
@@ -579,20 +1064,51 @@ export function plan(
|
|
|
579
1064
|
}
|
|
580
1065
|
}
|
|
581
1066
|
}
|
|
582
|
-
const { end, first } =
|
|
1067
|
+
const { end, first, free, cut, given } = run([...kept, ...standIns])
|
|
583
1068
|
const start: Size[] = []
|
|
1069
|
+
const frees = free()
|
|
1070
|
+
const gave = given()
|
|
1071
|
+
const started: Ahead['started'][number][] = []
|
|
1072
|
+
const later: Ahead['later'][number][] = []
|
|
584
1073
|
let againInMs: number | undefined
|
|
585
1074
|
for (let i = 0; i < n; i++) {
|
|
586
|
-
const
|
|
1075
|
+
const id = added[i]!.id
|
|
1076
|
+
const at = first.get(id)
|
|
587
1077
|
if (at === undefined) continue
|
|
588
1078
|
// Given work within its warm-up and a margin: it starts now.
|
|
589
1079
|
const warmUp = added[i]!.at
|
|
590
1080
|
// Within a millisecond counts: `againInMs` of 1e-10 replanned the
|
|
591
1081
|
// same plan 100,000 times at one instant (tests/sim.test.ts).
|
|
592
|
-
if (at < warmUp + MARGIN_MS + 1)
|
|
593
|
-
|
|
1082
|
+
if (at < warmUp + MARGIN_MS + 1) {
|
|
1083
|
+
start.push(candidates[i]!)
|
|
1084
|
+
started.push({ freeInMs: frees.get(id)!, joinInMs: warmUp, given: gave.get(id)! })
|
|
1085
|
+
} else {
|
|
1086
|
+
const inMs = at - warmUp - MARGIN_MS
|
|
1087
|
+
againInMs = Math.min(againInMs ?? Infinity, inMs)
|
|
1088
|
+
later.push({
|
|
1089
|
+
size: candidates[i]!,
|
|
1090
|
+
inMs,
|
|
1091
|
+
freeInMs: frees.get(id)!,
|
|
1092
|
+
joinInMs: warmUp,
|
|
1093
|
+
given: gave.get(id)!,
|
|
1094
|
+
})
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
later.sort((a, b) => a.inMs - b.inMs)
|
|
1098
|
+
// Work past the window: plan again a warm-up on, the window moved.
|
|
1099
|
+
const untilInMs = cut === true ? (horizon - MARGIN_MS) / 2 : undefined
|
|
1100
|
+
return {
|
|
1101
|
+
start,
|
|
1102
|
+
release,
|
|
1103
|
+
endsInMs: end,
|
|
1104
|
+
boundMs: bound,
|
|
1105
|
+
againInMs: untilInMs === undefined ? againInMs : Math.min(againInMs ?? Infinity, untilInMs),
|
|
1106
|
+
untilInMs,
|
|
1107
|
+
freeInMs: frees,
|
|
1108
|
+
given: gave,
|
|
1109
|
+
later,
|
|
1110
|
+
started,
|
|
594
1111
|
}
|
|
595
|
-
return { start, release, endsInMs: end, boundMs: bound, againInMs }
|
|
596
1112
|
}
|
|
597
1113
|
|
|
598
1114
|
/**
|
|
@@ -669,15 +1185,42 @@ export function decide(input: {
|
|
|
669
1185
|
readonly known?: ReadonlySet<string>
|
|
670
1186
|
/** How it orders, places and scales (`need` books already). */
|
|
671
1187
|
readonly how?: Strategy
|
|
672
|
-
/** The platform a task naming none runs on (`homeOf`). */
|
|
673
|
-
readonly home?:
|
|
1188
|
+
/** The platform a task naming none runs on (`homeOf`), or where each task runs (several worker types). */
|
|
1189
|
+
readonly home?: Home | undefined
|
|
674
1190
|
/**
|
|
675
1191
|
* Sizes with a worker asked and not joined past its warm-up: the CI
|
|
676
1192
|
* queued it, and one asked now queues behind it. None of them starts.
|
|
677
1193
|
*/
|
|
678
1194
|
readonly queued?: ReadonlySet<string>
|
|
1195
|
+
/**
|
|
1196
|
+
* The run's last plan, kept here: while what it saw holds, the next is
|
|
1197
|
+
* that plan again, not played anew, and hands back no `tasks` when it
|
|
1198
|
+
* read none. With `now`, the run's clock.
|
|
1199
|
+
*/
|
|
1200
|
+
readonly last?: { forecast?: Forecast }
|
|
1201
|
+
readonly now?: number
|
|
679
1202
|
}): Plan & { readonly tasks: readonly PlanTask[] } {
|
|
680
1203
|
const how = input.how ?? DEFAULT_STRATEGY
|
|
1204
|
+
const now = input.now ?? 0
|
|
1205
|
+
const had = input.last?.forecast
|
|
1206
|
+
// Following a plan reads only what events move: the workers, their
|
|
1207
|
+
// joins, each task as it starts and ends. Every task read at each of a
|
|
1208
|
+
// 5000-task run's events made it quadratic (2026-11-05: "Why do we
|
|
1209
|
+
// recheck every task against plan? There is no point").
|
|
1210
|
+
if (how.scale !== 'backlog' && had !== undefined) {
|
|
1211
|
+
const step = follow(had, undefined, input, undefined, now)
|
|
1212
|
+
// No work read, none to hand back: each step handed back the plan's
|
|
1213
|
+
// whole work, and the run read it all at each event.
|
|
1214
|
+
// A start or a release hands back the work it plans for, as the run reports it.
|
|
1215
|
+
if (step !== undefined && step !== 'full')
|
|
1216
|
+
return shown(
|
|
1217
|
+
input.last!,
|
|
1218
|
+
had,
|
|
1219
|
+
step,
|
|
1220
|
+
now,
|
|
1221
|
+
step.start.length > 0 || step.release.length > 0 ? had.work : [],
|
|
1222
|
+
)
|
|
1223
|
+
}
|
|
681
1224
|
const tasks = missedWork(
|
|
682
1225
|
input.remaining,
|
|
683
1226
|
input.nodes,
|
|
@@ -702,7 +1245,9 @@ export function decide(input: {
|
|
|
702
1245
|
)
|
|
703
1246
|
if (how.scale === 'backlog')
|
|
704
1247
|
return { ...backlog(tasks, input.workers, sizes, input.warmUpMs, how), tasks }
|
|
705
|
-
const
|
|
1248
|
+
const step = had === undefined ? undefined : follow(had, tasks, input, sizes, now)
|
|
1249
|
+
if (step !== undefined && step !== 'full') return shown(input.last!, had!, step, now, tasks)
|
|
1250
|
+
const { freeInMs, given, later, started, untilInMs, ...p } = plan(
|
|
706
1251
|
tasks,
|
|
707
1252
|
input.workers,
|
|
708
1253
|
sizes,
|
|
@@ -712,9 +1257,368 @@ export function decide(input: {
|
|
|
712
1257
|
tasks.every((t) => input.known?.has(t.id) === true) ? input.limitMs : undefined,
|
|
713
1258
|
how,
|
|
714
1259
|
)
|
|
1260
|
+
if (input.last !== undefined)
|
|
1261
|
+
input.last.forecast = forecastOf(
|
|
1262
|
+
p,
|
|
1263
|
+
{ freeInMs, given, later, started, untilInMs },
|
|
1264
|
+
tasks,
|
|
1265
|
+
input,
|
|
1266
|
+
now,
|
|
1267
|
+
)
|
|
715
1268
|
return { ...p, tasks }
|
|
716
1269
|
}
|
|
717
1270
|
|
|
1271
|
+
/**
|
|
1272
|
+
* What a plan saw, to tell whether the next would differ: its work and
|
|
1273
|
+
* each task's time, its workers as starting, idle or busy, the tasks
|
|
1274
|
+
* already past their time, the sizes queued, when it wanted to look again.
|
|
1275
|
+
*/
|
|
1276
|
+
export interface Forecast {
|
|
1277
|
+
readonly at: number
|
|
1278
|
+
readonly plan: Plan
|
|
1279
|
+
/** The work it played. */
|
|
1280
|
+
readonly work: readonly PlanTask[]
|
|
1281
|
+
/** Each waiting task's time, and each waiting or running task's cores and memory. */
|
|
1282
|
+
readonly ms: ReadonlyMap<string, number | undefined>
|
|
1283
|
+
readonly need: ReadonlyMap<string, readonly [cores: number, memory: number]>
|
|
1284
|
+
/** The workers it saw, and those it started as they show. */
|
|
1285
|
+
readonly workers: ReadonlySet<string>
|
|
1286
|
+
/** How many workers the run has as planned. */
|
|
1287
|
+
readonly count: number
|
|
1288
|
+
/** When each worker's last planned task ends, on the run's clock. */
|
|
1289
|
+
readonly free: ReadonlyMap<string, number>
|
|
1290
|
+
/** The tasks planned on each worker. */
|
|
1291
|
+
readonly given: ReadonlyMap<string, readonly string[]>
|
|
1292
|
+
/** When each worker still starting joins, on the run's clock. */
|
|
1293
|
+
readonly join: ReadonlyMap<string, number>
|
|
1294
|
+
/** Those of each worker started and not seen yet, in the order started. */
|
|
1295
|
+
readonly fresh: readonly {
|
|
1296
|
+
readonly free: number
|
|
1297
|
+
readonly join: number
|
|
1298
|
+
readonly given: readonly string[]
|
|
1299
|
+
}[]
|
|
1300
|
+
/** Starts the plan wants later, each when due, on the run's clock. */
|
|
1301
|
+
readonly later: readonly {
|
|
1302
|
+
readonly size: Size
|
|
1303
|
+
readonly at: number
|
|
1304
|
+
readonly free: number
|
|
1305
|
+
readonly join: number
|
|
1306
|
+
readonly given: readonly string[]
|
|
1307
|
+
}[]
|
|
1308
|
+
/** Work it saw that has since been seen running: work that left otherwise moves the plan. */
|
|
1309
|
+
readonly seen: Set<string>
|
|
1310
|
+
/** When each task seen running ends, on the run's clock. */
|
|
1311
|
+
readonly ends: ReadonlyMap<string, number>
|
|
1312
|
+
/** How many tasks the run had handed over, and those it had when planned. */
|
|
1313
|
+
readonly executed: number
|
|
1314
|
+
readonly before: ReadonlySet<string>
|
|
1315
|
+
/**
|
|
1316
|
+
* How far it read the handed-over set, kept across its steps: a Set's
|
|
1317
|
+
* iterator sees what is added after it, so each id is read once. Each
|
|
1318
|
+
* step read the whole set, O(n) an event (2026-11-05).
|
|
1319
|
+
*/
|
|
1320
|
+
readonly scan: { of?: ReadonlySet<string> | undefined; it?: Iterator<string>; read: number }
|
|
1321
|
+
/** Each size's warm-up as it saw it: a join tells the run's own. */
|
|
1322
|
+
readonly warm: readonly number[]
|
|
1323
|
+
/** When its window moves on, on the run's clock. */
|
|
1324
|
+
readonly until: number
|
|
1325
|
+
readonly late: ReadonlySet<string>
|
|
1326
|
+
readonly queued: string
|
|
1327
|
+
readonly final: boolean
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
type Seen = Pick<
|
|
1331
|
+
Parameters<typeof decide>[0],
|
|
1332
|
+
'workers' | 'queued' | 'final' | 'sizes' | 'started' | 'warmUpMs' | 'executed' | 'nodes'
|
|
1333
|
+
>
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Running tasks past their time by more than `slack`: one that runs that
|
|
1337
|
+
* long moves the plan (2026-11-05: "Reality changes by leave some
|
|
1338
|
+
* margin. 5% or sth").
|
|
1339
|
+
*/
|
|
1340
|
+
function lateOf(workers: readonly PlanWorker[], slack: number): Set<string> {
|
|
1341
|
+
const out = new Set<string>()
|
|
1342
|
+
for (const w of workers)
|
|
1343
|
+
for (const r of w.running) if (r.endsIn < -slack && r.id !== undefined) out.add(r.id)
|
|
1344
|
+
return out
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Each waiting and running task's cores and memory, as a plan saw them. */
|
|
1348
|
+
function needsOf(
|
|
1349
|
+
tasks: readonly PlanTask[],
|
|
1350
|
+
workers: readonly PlanWorker[],
|
|
1351
|
+
): Map<string, readonly [number, number]> {
|
|
1352
|
+
const out = new Map<string, readonly [number, number]>(
|
|
1353
|
+
tasks.map((t) => [t.id, [t.cores, t.memory] as const]),
|
|
1354
|
+
)
|
|
1355
|
+
for (const w of workers)
|
|
1356
|
+
for (const r of w.running) if (r.id !== undefined) out.set(r.id, [r.cores, r.memory])
|
|
1357
|
+
return out
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Whether `is` moved from `was` past 5%, or past `least` when they are small. */
|
|
1361
|
+
const moved = (was: number, is: number, least: number): boolean =>
|
|
1362
|
+
Math.abs(is - was) > Math.max(least, Math.abs(was) * MARGIN)
|
|
1363
|
+
|
|
1364
|
+
/** How late a task may run, or how far its time may move, before the plan is made again: 5% of the run's. */
|
|
1365
|
+
const slackOf = (p: Plan): number => Math.max(MARGIN_MS / 5, p.endsInMs * MARGIN)
|
|
1366
|
+
|
|
1367
|
+
const warmsOf = (input: Seen): number[] => {
|
|
1368
|
+
const warm = warmOf(input.warmUpMs)
|
|
1369
|
+
return [warm(undefined), ...input.sizes.map((s) => warm(s.name))]
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function forecastOf(
|
|
1373
|
+
p: Plan,
|
|
1374
|
+
ahead: Ahead,
|
|
1375
|
+
tasks: readonly PlanTask[],
|
|
1376
|
+
input: Seen,
|
|
1377
|
+
now: number,
|
|
1378
|
+
): Forecast {
|
|
1379
|
+
return {
|
|
1380
|
+
at: now,
|
|
1381
|
+
plan: p,
|
|
1382
|
+
work: tasks,
|
|
1383
|
+
ms: new Map(tasks.map((t) => [t.id, t.ms])),
|
|
1384
|
+
need: needsOf(tasks, input.workers),
|
|
1385
|
+
workers: new Set(input.workers.map((w) => w.id).filter((id) => !p.release.includes(id))),
|
|
1386
|
+
count: input.workers.length + p.start.length - p.release.length,
|
|
1387
|
+
free: new Map([...ahead.freeInMs].map(([id, ms]) => [id, now + ms])),
|
|
1388
|
+
join: new Map(
|
|
1389
|
+
input.workers.flatMap((w) => (w.starting === true ? [[w.id, now + w.at] as const] : [])),
|
|
1390
|
+
),
|
|
1391
|
+
given: ahead.given,
|
|
1392
|
+
fresh: ahead.started.map((a) => ({
|
|
1393
|
+
free: now + a.freeInMs,
|
|
1394
|
+
join: now + a.joinInMs,
|
|
1395
|
+
given: a.given,
|
|
1396
|
+
})),
|
|
1397
|
+
later: ahead.later.map((l) => ({
|
|
1398
|
+
size: l.size,
|
|
1399
|
+
at: now + l.inMs,
|
|
1400
|
+
free: now + l.freeInMs,
|
|
1401
|
+
join: now + l.inMs + l.joinInMs,
|
|
1402
|
+
given: l.given,
|
|
1403
|
+
})),
|
|
1404
|
+
seen: new Set(),
|
|
1405
|
+
ends: new Map(
|
|
1406
|
+
input.workers.flatMap((w) =>
|
|
1407
|
+
w.running.flatMap((r) => (r.id === undefined ? [] : [[r.id, now + r.endsIn] as const])),
|
|
1408
|
+
),
|
|
1409
|
+
),
|
|
1410
|
+
executed: input.executed.size,
|
|
1411
|
+
before: new Set(input.executed),
|
|
1412
|
+
scan: { read: 0 },
|
|
1413
|
+
warm: warmsOf(input),
|
|
1414
|
+
until: ahead.untilInMs === undefined ? Infinity : now + ahead.untilInMs,
|
|
1415
|
+
late: lateOf(input.workers, slackOf(p)),
|
|
1416
|
+
queued: [...(input.queued ?? [])].sort().join('\0'),
|
|
1417
|
+
final: input.final,
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** The plan `had` made, followed a step: its decisions now, its times less the time since. */
|
|
1422
|
+
function shown(
|
|
1423
|
+
last: { forecast?: Forecast },
|
|
1424
|
+
had: Forecast,
|
|
1425
|
+
step: Step,
|
|
1426
|
+
now: number,
|
|
1427
|
+
tasks: readonly PlanTask[],
|
|
1428
|
+
): Plan & { readonly tasks: readonly PlanTask[] } {
|
|
1429
|
+
last.forecast = step.next
|
|
1430
|
+
const again = Math.min(step.next.later[0]?.at ?? Infinity, step.next.until)
|
|
1431
|
+
const since = now - had.at
|
|
1432
|
+
return {
|
|
1433
|
+
start: step.start,
|
|
1434
|
+
release: step.release,
|
|
1435
|
+
endsInMs: Math.max(0, had.plan.endsInMs - since),
|
|
1436
|
+
boundMs: had.plan.boundMs - since,
|
|
1437
|
+
againInMs: Number.isFinite(again) ? again - now : undefined,
|
|
1438
|
+
tasks,
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
type Step = { start: Size[]; release: string[]; next: Forecast }
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* The plan `f` made, followed one step on when it still stands: no new
|
|
1446
|
+
* work, no task's time, cores or memory moved past 5%, no worker joined
|
|
1447
|
+
* or left but as planned, no task ran past its time. Its starts come due
|
|
1448
|
+
* and its workers with their work done go, as it foresaw; undefined
|
|
1449
|
+
* plays it anew. Planned at every task's end, a run of 1000 tasks
|
|
1450
|
+
* replayed itself 10 000 times (2026-11-05: "It's just about filling
|
|
1451
|
+
* boxes to full"; "If simulated all goes with plan so there should be no
|
|
1452
|
+
* replans").
|
|
1453
|
+
*
|
|
1454
|
+
* Without `tasks` (and `sizes`), only what events move is read: work it
|
|
1455
|
+
* did not see is `full`, read every task first.
|
|
1456
|
+
*/
|
|
1457
|
+
function follow(
|
|
1458
|
+
f: Forecast,
|
|
1459
|
+
tasks: readonly PlanTask[] | undefined,
|
|
1460
|
+
input: Seen,
|
|
1461
|
+
sizes: readonly Size[] | undefined,
|
|
1462
|
+
now: number,
|
|
1463
|
+
): Step | 'full' | undefined {
|
|
1464
|
+
if (now >= f.until) return undefined
|
|
1465
|
+
if (f.final !== input.final) return undefined
|
|
1466
|
+
if ([...(input.queued ?? [])].sort().join('\0') !== f.queued) return undefined
|
|
1467
|
+
if (input.workers.length !== f.count) return undefined
|
|
1468
|
+
const warm = warmsOf(input)
|
|
1469
|
+
for (let i = 0; i < warm.length; i++)
|
|
1470
|
+
if (moved(f.warm[i]!, warm[i]!, MARGIN_MS / 5)) return undefined
|
|
1471
|
+
const slack = slackOf(f.plan)
|
|
1472
|
+
// Only what moved since is read: each task as it starts, runs and ends.
|
|
1473
|
+
// A waiting one's time moves with the run's, which those show.
|
|
1474
|
+
const ends = new Map<string, number>()
|
|
1475
|
+
let began = 0
|
|
1476
|
+
for (const w of input.workers)
|
|
1477
|
+
for (const r of w.running) {
|
|
1478
|
+
if (r.id === undefined) continue
|
|
1479
|
+
const was = f.need.get(r.id)
|
|
1480
|
+
// As for time, so for resources (2026-11-05: "And resource usage").
|
|
1481
|
+
if (
|
|
1482
|
+
was !== undefined &&
|
|
1483
|
+
(moved(was[0], r.cores, 0.05) || moved(was[1], r.memory, 64 * 2 ** 20))
|
|
1484
|
+
)
|
|
1485
|
+
return undefined
|
|
1486
|
+
const end = f.ends.get(r.id)
|
|
1487
|
+
if (end !== undefined) {
|
|
1488
|
+
ends.set(r.id, end)
|
|
1489
|
+
continue
|
|
1490
|
+
}
|
|
1491
|
+
// Started since: work the plan saw, as long as it played it.
|
|
1492
|
+
if (!f.ms.has(r.id)) return undefined
|
|
1493
|
+
const ms = f.ms.get(r.id)
|
|
1494
|
+
if (ms !== undefined && r.endsIn > ms + Math.max(MARGIN_MS / 5, ms * MARGIN)) return undefined
|
|
1495
|
+
ends.set(r.id, now + r.endsIn)
|
|
1496
|
+
f.seen.add(r.id)
|
|
1497
|
+
began++
|
|
1498
|
+
}
|
|
1499
|
+
// Ended since: sooner than played moves the plan, and so does one of
|
|
1500
|
+
// no known time ending.
|
|
1501
|
+
for (const [id, at] of f.ends)
|
|
1502
|
+
if (!ends.has(id) && (now < at - slack || (f.ms.has(id) && f.ms.get(id) === undefined)))
|
|
1503
|
+
return undefined
|
|
1504
|
+
for (const id of lateOf(input.workers, slack)) if (!f.late.has(id)) return undefined
|
|
1505
|
+
// More handed over than started, or fewer known: work it did not see,
|
|
1506
|
+
// maybe. Read it.
|
|
1507
|
+
if (input.executed.size > f.executed + began || input.executed.size < f.executed) {
|
|
1508
|
+
// Handed over and not started yet is work it saw: only one it did not
|
|
1509
|
+
// see sends the plan to read every task.
|
|
1510
|
+
if (tasks === undefined) {
|
|
1511
|
+
if (input.executed.size < f.executed) return 'full'
|
|
1512
|
+
const scan = f.scan
|
|
1513
|
+
if (scan.of !== input.executed) {
|
|
1514
|
+
scan.of = input.executed
|
|
1515
|
+
scan.it = input.executed.values()
|
|
1516
|
+
scan.read = 0
|
|
1517
|
+
}
|
|
1518
|
+
for (; scan.read < input.executed.size; scan.read++) {
|
|
1519
|
+
const r = scan.it!.next()
|
|
1520
|
+
// One left the set: the count no longer says where it read to.
|
|
1521
|
+
if (r.done === true) {
|
|
1522
|
+
scan.of = undefined
|
|
1523
|
+
return 'full'
|
|
1524
|
+
}
|
|
1525
|
+
const id = r.value
|
|
1526
|
+
if (
|
|
1527
|
+
!f.ms.has(id) &&
|
|
1528
|
+
!f.before.has(id) &&
|
|
1529
|
+
input.nodes.get(id)?.config.exec?.command !== undefined
|
|
1530
|
+
) {
|
|
1531
|
+
scan.read++
|
|
1532
|
+
return 'full'
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
} else {
|
|
1536
|
+
for (const t of tasks) if (!f.ms.has(t.id)) return undefined
|
|
1537
|
+
// Work that left without running (a hit, or no longer asked) frees
|
|
1538
|
+
// what the plan gave it.
|
|
1539
|
+
const waiting = new Set(tasks.map((t) => t.id))
|
|
1540
|
+
for (const id of f.ms.keys())
|
|
1541
|
+
if (!waiting.has(id) && !f.seen.has(id) && !ends.has(id)) return undefined
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const workers = new Set(f.workers)
|
|
1545
|
+
const free = new Map(f.free)
|
|
1546
|
+
const given = new Map(f.given)
|
|
1547
|
+
const join = new Map(f.join)
|
|
1548
|
+
const fresh = [...f.fresh]
|
|
1549
|
+
const release: string[] = []
|
|
1550
|
+
for (const w of input.workers) {
|
|
1551
|
+
if (!workers.has(w.id)) {
|
|
1552
|
+
// One the plan started, shown: it takes the next started's work.
|
|
1553
|
+
const next = fresh.shift()
|
|
1554
|
+
if (next === undefined) return undefined
|
|
1555
|
+
workers.add(w.id)
|
|
1556
|
+
free.set(w.id, next.free)
|
|
1557
|
+
given.set(w.id, next.given)
|
|
1558
|
+
join.set(w.id, next.join)
|
|
1559
|
+
}
|
|
1560
|
+
// Joined sooner or later than played, past the margin.
|
|
1561
|
+
const joins = join.get(w.id)
|
|
1562
|
+
if (joins !== undefined) {
|
|
1563
|
+
if (w.starting === true) {
|
|
1564
|
+
if (now > joins + slack) return undefined
|
|
1565
|
+
} else {
|
|
1566
|
+
if (now < joins - slack) return undefined
|
|
1567
|
+
join.delete(w.id)
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// Idle between its tasks, as played; with its work done, it goes.
|
|
1571
|
+
if (!w.idle || w.starting === true) continue
|
|
1572
|
+
const at = free.get(w.id)
|
|
1573
|
+
if (at === undefined || now >= at - slack) {
|
|
1574
|
+
// Its work done in a window with work past it: maybe needed there,
|
|
1575
|
+
// and one let go still counts against its size's max. A Mac let go
|
|
1576
|
+
// so left a Mac task no worker, and the run never ended (#4989).
|
|
1577
|
+
if (f.until < Infinity) return undefined
|
|
1578
|
+
// A task planned on it not started: its last one ended and the next
|
|
1579
|
+
// not handed to it yet. Let go, that one had no worker and the run
|
|
1580
|
+
// never ended (a two-task chain on a max-1 size, its rest within the
|
|
1581
|
+
// slack). Played again, the plan sees what waits for it.
|
|
1582
|
+
if (given.get(w.id)?.some((id) => !f.seen.has(id)) === true) return undefined
|
|
1583
|
+
release.push(w.id)
|
|
1584
|
+
workers.delete(w.id)
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
const start: Size[] = []
|
|
1588
|
+
const later = [...f.later]
|
|
1589
|
+
while (later.length > 0 && now >= later[0]!.at) {
|
|
1590
|
+
const { size, free: ends, join: joins, given: runs } = later.shift()!
|
|
1591
|
+
// Its size taken up since: the plan no longer fits the run. Read
|
|
1592
|
+
// from what the run started, not from every task: each start read
|
|
1593
|
+
// them all (2026-11-05).
|
|
1594
|
+
if (sizes === undefined) {
|
|
1595
|
+
const kind = input.sizes.find((s) => s.name === size.name)
|
|
1596
|
+
if (
|
|
1597
|
+
input.final ||
|
|
1598
|
+
kind === undefined ||
|
|
1599
|
+
input.queued?.has(size.name) === true ||
|
|
1600
|
+
(input.started.get(size.name) ?? 0) >= kind.max
|
|
1601
|
+
)
|
|
1602
|
+
return undefined
|
|
1603
|
+
} else if (!sizes.some((s) => s.name === size.name)) return undefined
|
|
1604
|
+
start.push(size)
|
|
1605
|
+
fresh.push({ free: ends, join: joins, given: runs })
|
|
1606
|
+
}
|
|
1607
|
+
const next: Forecast = {
|
|
1608
|
+
...f,
|
|
1609
|
+
workers,
|
|
1610
|
+
count: f.count + start.length - release.length,
|
|
1611
|
+
free,
|
|
1612
|
+
given,
|
|
1613
|
+
join,
|
|
1614
|
+
fresh,
|
|
1615
|
+
later,
|
|
1616
|
+
ends,
|
|
1617
|
+
executed: input.executed.size,
|
|
1618
|
+
}
|
|
1619
|
+
return { start, release, next }
|
|
1620
|
+
}
|
|
1621
|
+
|
|
718
1622
|
/**
|
|
719
1623
|
* Spark's dynamic allocation: workers enough for the cores that what is
|
|
720
1624
|
* ready or running asks, started at once; an idle one past that leaves.
|
|
@@ -794,8 +1698,17 @@ function backlog(
|
|
|
794
1698
|
return { start, release, endsInMs: end, boundMs: end, againInMs: undefined }
|
|
795
1699
|
}
|
|
796
1700
|
|
|
797
|
-
/** The most kinds `Ready.asks` jumps across; past it, it
|
|
1701
|
+
/** The most kinds `Ready.asks` jumps across; past it, it jumps across the cores' classes. */
|
|
798
1702
|
const MAX_JUMP_KINDS = 32
|
|
1703
|
+
/** Classes a doubling of cores spans (`classOf`). */
|
|
1704
|
+
const CLASSES = 4
|
|
1705
|
+
/** The class of a task that names a platform or a size to beat: never passed whole. */
|
|
1706
|
+
const ODD = -Infinity
|
|
1707
|
+
/** A task's cores' class: about 2^(c / CLASSES) to 2^((c + 1) / CLASSES), its span kept as joined. */
|
|
1708
|
+
const classOf = (t: PlanTask): number =>
|
|
1709
|
+
t.platform !== undefined || t.above !== undefined
|
|
1710
|
+
? ODD
|
|
1711
|
+
: Math.floor(Math.log2(Math.max(t.cores, 1e-6)) * CLASSES)
|
|
799
1712
|
|
|
800
1713
|
/**
|
|
801
1714
|
* Ready tasks in the order `ordered` gave, by kind (`kindOf`): `assign`
|
|
@@ -806,9 +1719,8 @@ const MAX_JUMP_KINDS = 32
|
|
|
806
1719
|
* task 17 million times a plan (2026-10-25).
|
|
807
1720
|
*/
|
|
808
1721
|
class Ready {
|
|
809
|
-
private readonly
|
|
810
|
-
private readonly
|
|
811
|
-
private readonly kindAt: readonly string[]
|
|
1722
|
+
private readonly at: Kinds['at']
|
|
1723
|
+
private readonly kindAt: (place: number) => string
|
|
812
1724
|
private readonly taken: Uint8Array
|
|
813
1725
|
/** The rank: the lower `primary` first, then the lower `seq`. */
|
|
814
1726
|
private readonly primary: Float64Array
|
|
@@ -817,28 +1729,53 @@ class Ready {
|
|
|
817
1729
|
/** In rank order; taken ones linger until the next join. */
|
|
818
1730
|
private order: number[] = []
|
|
819
1731
|
private kinds = new Map<string, number[]>()
|
|
1732
|
+
/**
|
|
1733
|
+
* Ready tasks by their cores' class (`classOf`), in rank order, each
|
|
1734
|
+
* class's first not taken and the most memory one asked: what `asks`
|
|
1735
|
+
* jumps across when kinds are too many.
|
|
1736
|
+
*/
|
|
1737
|
+
private classes = new Map<number, number[]>()
|
|
1738
|
+
private readonly classHead = new Map<number, number>()
|
|
1739
|
+
/** The fewest and most cores and the most memory a task of each class asked. */
|
|
1740
|
+
private readonly classSpan = new Map<number, [number, number, number]>()
|
|
820
1741
|
/** Each kind's first task not taken. */
|
|
821
1742
|
private readonly head = new Map<string, number>()
|
|
822
1743
|
/** No task before this place in `order` is left: a pass began at 0 and read 9000 taken ones (2026-10-25). */
|
|
823
1744
|
private lo = 0
|
|
824
1745
|
/** Order it again before the next step. */
|
|
825
1746
|
stale = false
|
|
1747
|
+
/** Taken ones still in `order`: past its live ones, it is read again without them. */
|
|
1748
|
+
private dead = 0
|
|
1749
|
+
/** Each kind's tasks not taken: a kind with none leaves `kinds`. */
|
|
1750
|
+
private readonly liveOf = new Map<string, number>()
|
|
826
1751
|
|
|
827
|
-
constructor(
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1752
|
+
constructor(
|
|
1753
|
+
private readonly tasks: readonly PlanTask[],
|
|
1754
|
+
kinds: Kinds = kindsOf(tasks),
|
|
1755
|
+
) {
|
|
1756
|
+
this.at = kinds.at
|
|
1757
|
+
this.kindAt = kinds.kindAt
|
|
831
1758
|
this.taken = new Uint8Array(tasks.length)
|
|
832
1759
|
this.primary = new Float64Array(tasks.length)
|
|
833
1760
|
this.seq = new Float64Array(tasks.length)
|
|
834
1761
|
}
|
|
835
1762
|
|
|
836
1763
|
kindOf(id: string): string {
|
|
837
|
-
return this.kindAt
|
|
1764
|
+
return this.kindAt(this.at.get(id)!)
|
|
838
1765
|
}
|
|
839
1766
|
|
|
840
1767
|
take(id: string): void {
|
|
841
|
-
this.
|
|
1768
|
+
const i = this.at.get(id)!
|
|
1769
|
+
this.taken[i] = 1
|
|
1770
|
+
this.dead++
|
|
1771
|
+
const k = this.kindAt(i)
|
|
1772
|
+
const n = this.liveOf.get(k)! - 1
|
|
1773
|
+
if (n > 0) this.liveOf.set(k, n)
|
|
1774
|
+
else {
|
|
1775
|
+
this.liveOf.delete(k)
|
|
1776
|
+
this.kinds.delete(k)
|
|
1777
|
+
this.head.delete(k)
|
|
1778
|
+
}
|
|
842
1779
|
}
|
|
843
1780
|
|
|
844
1781
|
live(): PlanTask[] {
|
|
@@ -861,6 +1798,11 @@ class Ready {
|
|
|
861
1798
|
this.order = []
|
|
862
1799
|
this.kinds = new Map()
|
|
863
1800
|
this.head.clear()
|
|
1801
|
+
this.classes = new Map()
|
|
1802
|
+
this.classHead.clear()
|
|
1803
|
+
this.classSpan.clear()
|
|
1804
|
+
this.liveOf.clear()
|
|
1805
|
+
this.dead = 0
|
|
864
1806
|
this.stale = false
|
|
865
1807
|
const batch = ids.map((id) => {
|
|
866
1808
|
const i = this.at.get(id)!
|
|
@@ -891,26 +1833,99 @@ class Ready {
|
|
|
891
1833
|
}
|
|
892
1834
|
|
|
893
1835
|
private join(batch: readonly number[]): void {
|
|
894
|
-
|
|
895
|
-
this.
|
|
1836
|
+
// Taken ones outnumber the rest: read without them once.
|
|
1837
|
+
if (this.dead > this.order.length / 2) {
|
|
1838
|
+
this.order = this.merge(this.order, [])
|
|
1839
|
+
for (const [k, q] of this.kinds) this.kinds.set(k, this.merge(q, []))
|
|
1840
|
+
this.head.clear()
|
|
1841
|
+
for (const [c, q] of this.classes) {
|
|
1842
|
+
const left = this.merge(q, [])
|
|
1843
|
+
if (left.length > 0) this.classes.set(c, left)
|
|
1844
|
+
else this.classes.delete(c)
|
|
1845
|
+
}
|
|
1846
|
+
this.classHead.clear()
|
|
1847
|
+
this.dead = 0
|
|
1848
|
+
this.lo = 0
|
|
1849
|
+
}
|
|
1850
|
+
const len = this.order.length + batch.length
|
|
1851
|
+
this.lo = Math.min(this.lo, this.insert(this.order, batch))
|
|
1852
|
+
// A merge left the taken ones out.
|
|
1853
|
+
this.dead -= len - this.order.length
|
|
896
1854
|
const by = new Map<string, number[]>()
|
|
897
1855
|
for (const i of batch) {
|
|
898
|
-
const k = this.kindAt
|
|
1856
|
+
const k = this.kindAt(i)
|
|
1857
|
+
this.liveOf.set(k, (this.liveOf.get(k) ?? 0) + 1)
|
|
899
1858
|
const q = by.get(k)
|
|
900
1859
|
if (q === undefined) by.set(k, [i])
|
|
901
1860
|
else q.push(i)
|
|
902
1861
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
1862
|
+
const byClass = new Map<number, number[]>()
|
|
1863
|
+
for (const i of batch) {
|
|
1864
|
+
const t = this.tasks[i]!
|
|
1865
|
+
const c = classOf(t)
|
|
1866
|
+
const span = this.classSpan.get(c)
|
|
1867
|
+
if (span === undefined) this.classSpan.set(c, [t.cores, t.cores, t.memory])
|
|
1868
|
+
else {
|
|
1869
|
+
span[0] = Math.min(span[0], t.cores)
|
|
1870
|
+
span[1] = Math.max(span[1], t.cores)
|
|
1871
|
+
span[2] = Math.max(span[2], t.memory)
|
|
1872
|
+
}
|
|
1873
|
+
const q = byClass.get(c)
|
|
1874
|
+
if (q === undefined) byClass.set(c, [i])
|
|
1875
|
+
else q.push(i)
|
|
910
1876
|
}
|
|
911
|
-
for (const [
|
|
912
|
-
|
|
913
|
-
|
|
1877
|
+
for (const [c, q] of byClass) {
|
|
1878
|
+
const had = this.classes.get(c)
|
|
1879
|
+
if (had === undefined) {
|
|
1880
|
+
this.classes.set(c, q)
|
|
1881
|
+
this.classHead.delete(c)
|
|
1882
|
+
} else {
|
|
1883
|
+
const first = this.insert(had, q)
|
|
1884
|
+
const h = this.classHead.get(c)
|
|
1885
|
+
if (h !== undefined && first < h) this.classHead.set(c, first)
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
// Only the kinds it joins: each join read every kind and every ready
|
|
1889
|
+
// task, and a 6000-task tree spent 2 s of a 5 s plan on it (2026-11-05).
|
|
1890
|
+
for (const [k, q] of by) {
|
|
1891
|
+
const had = this.kinds.get(k)
|
|
1892
|
+
if (had === undefined) {
|
|
1893
|
+
this.kinds.set(k, q)
|
|
1894
|
+
this.head.delete(k)
|
|
1895
|
+
} else {
|
|
1896
|
+
const first = this.insert(had, q)
|
|
1897
|
+
const h = this.head.get(k)
|
|
1898
|
+
if (h !== undefined && first < h) this.head.set(k, first)
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/**
|
|
1904
|
+
* `batch`, in rank order, into `into`, in rank order: merged when it is
|
|
1905
|
+
* as big, each put in its place when it is few. The first place it took.
|
|
1906
|
+
*/
|
|
1907
|
+
private insert(into: number[], batch: readonly number[]): number {
|
|
1908
|
+
if (batch.length === 0) return into.length
|
|
1909
|
+
if (batch.length * 8 > into.length) {
|
|
1910
|
+
const merged = this.merge(into, batch)
|
|
1911
|
+
into.length = 0
|
|
1912
|
+
for (const i of merged) into.push(i)
|
|
1913
|
+
return 0
|
|
1914
|
+
}
|
|
1915
|
+
let first = into.length
|
|
1916
|
+
let lo = 0
|
|
1917
|
+
for (const b of batch) {
|
|
1918
|
+
let hi = into.length
|
|
1919
|
+
while (lo < hi) {
|
|
1920
|
+
const mid = (lo + hi) >> 1
|
|
1921
|
+
if (this.before(into[mid]!, b)) lo = mid + 1
|
|
1922
|
+
else hi = mid
|
|
1923
|
+
}
|
|
1924
|
+
into.splice(lo, 0, b)
|
|
1925
|
+
first = Math.min(first, lo)
|
|
1926
|
+
lo++
|
|
1927
|
+
}
|
|
1928
|
+
return first
|
|
914
1929
|
}
|
|
915
1930
|
|
|
916
1931
|
/** In order, past each kind in `spent` as `assign` reads it then. */
|
|
@@ -918,43 +1933,116 @@ class Ready {
|
|
|
918
1933
|
// Measured needs are unrounded, so kinds can be as many as tasks: a
|
|
919
1934
|
// jump across every kind then cost more than a read of every task.
|
|
920
1935
|
if (this.kinds.size > MAX_JUMP_KINDS) {
|
|
921
|
-
|
|
922
|
-
for (let p = this.lo; p < this.order.length; p++) {
|
|
923
|
-
const i = this.order[p]!
|
|
924
|
-
const kind = this.kindAt[i]!
|
|
925
|
-
if (this.taken[i] === 1 || spent.has(kind)) continue
|
|
926
|
-
if (bounds?.done === true || (kinds !== undefined && spent.size >= kinds)) return
|
|
927
|
-
const t = this.tasks[i]!
|
|
928
|
-
if (bounds !== undefined && dead(t, bounds)) spent.add(kind)
|
|
929
|
-
else yield { id: t.id, need: t, kind }
|
|
930
|
-
}
|
|
1936
|
+
yield* this.byClass(spent, bounds, kinds)
|
|
931
1937
|
return
|
|
932
1938
|
}
|
|
933
|
-
|
|
1939
|
+
// Arrays, not the maps: the scan below is most of a sim's time (2026-11-05).
|
|
1940
|
+
const ks: string[] = []
|
|
1941
|
+
const qs: (readonly number[])[] = []
|
|
1942
|
+
for (const [k, q] of this.kinds) {
|
|
1943
|
+
ks.push(k)
|
|
1944
|
+
qs.push(q)
|
|
1945
|
+
}
|
|
1946
|
+
const at = new Int32Array(ks.length).fill(-1)
|
|
934
1947
|
let cursor = -1
|
|
935
1948
|
for (;;) {
|
|
936
1949
|
let next = -1
|
|
937
|
-
let
|
|
938
|
-
for (
|
|
1950
|
+
let nextAt = -1
|
|
1951
|
+
for (let x = 0; x < ks.length; x++) {
|
|
1952
|
+
const k = ks[x]!
|
|
939
1953
|
if (spent.has(k)) continue
|
|
940
|
-
|
|
1954
|
+
const q = qs[x]!
|
|
1955
|
+
let p = at[x]! === -1 ? this.headOf(k, q) : at[x]!
|
|
941
1956
|
if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
|
|
942
1957
|
p = this.after(q, p, cursor)
|
|
943
1958
|
while (p < q.length && this.taken[q[p]!] === 1) p++
|
|
944
|
-
at
|
|
945
|
-
if (p
|
|
1959
|
+
at[x] = p
|
|
1960
|
+
if (p === q.length) continue
|
|
1961
|
+
// A kind no bin fits or holds for is spent here, as `assign` would
|
|
1962
|
+
// spend it once read: yielded, each cost a sim of 300 tasks a
|
|
1963
|
+
// pass over the bins (2026-11-05). Bounds only shrink until a
|
|
1964
|
+
// bin is given, which clears `spent`.
|
|
1965
|
+
if (bounds !== undefined && dead(this.tasks[q[p]!]!, bounds)) {
|
|
1966
|
+
spent.add(k)
|
|
1967
|
+
continue
|
|
1968
|
+
}
|
|
1969
|
+
if (next === -1 || this.before(q[p]!, next)) {
|
|
946
1970
|
next = q[p]!
|
|
947
|
-
|
|
1971
|
+
nextAt = x
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
if (next === -1) return
|
|
1975
|
+
at[nextAt]!++
|
|
1976
|
+
cursor = next
|
|
1977
|
+
const t = this.tasks[next]!
|
|
1978
|
+
yield { id: t.id, need: t, kind: ks[nextAt]! }
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* As a read of every ready task in rank order, past each taken, spent
|
|
1984
|
+
* or `dead` one, but jumping across the cores' classes: a class whose
|
|
1985
|
+
* every task no bin fits, by the span its tasks asked, is passed whole. Read one by one, the ready
|
|
1986
|
+
* tasks of a 5000-task play were most of its time (2026-11-05). A task
|
|
1987
|
+
* found dead stays dead until a bin is given, and a give only shrinks
|
|
1988
|
+
* the bins' room, so the read goes past it for the rest of the pass.
|
|
1989
|
+
*/
|
|
1990
|
+
private *byClass(spent: Set<string>, bounds?: Bounds, kinds?: number): Generator<Ask> {
|
|
1991
|
+
const cs: number[] = []
|
|
1992
|
+
const qs: (readonly number[])[] = []
|
|
1993
|
+
for (const [c, q] of this.classes) {
|
|
1994
|
+
cs.push(c)
|
|
1995
|
+
qs.push(q)
|
|
1996
|
+
}
|
|
1997
|
+
const at = new Int32Array(cs.length).fill(-1)
|
|
1998
|
+
let cursor = -1
|
|
1999
|
+
for (;;) {
|
|
2000
|
+
if (bounds?.done === true || (kinds !== undefined && spent.size >= kinds)) return
|
|
2001
|
+
let next = -1
|
|
2002
|
+
for (let x = 0; x < cs.length; x++) {
|
|
2003
|
+
const c = cs[x]!
|
|
2004
|
+
if (
|
|
2005
|
+
bounds !== undefined &&
|
|
2006
|
+
c !== ODD &&
|
|
2007
|
+
!bounds.open &&
|
|
2008
|
+
this.classSpan.get(c)![0] > bounds.free + 1e-9 &&
|
|
2009
|
+
this.classSpan.get(c)![1] < bounds.cores &&
|
|
2010
|
+
this.classSpan.get(c)![2] <= bounds.memory
|
|
2011
|
+
)
|
|
2012
|
+
continue
|
|
2013
|
+
const q = qs[x]!
|
|
2014
|
+
let p = at[x]! === -1 ? this.classHeadOf(c, q) : at[x]!
|
|
2015
|
+
if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
|
|
2016
|
+
p = this.after(q, p, cursor)
|
|
2017
|
+
for (; p < q.length; p++) {
|
|
2018
|
+
const i = q[p]!
|
|
2019
|
+
if (this.taken[i] === 1) continue
|
|
2020
|
+
const t = this.tasks[i]!
|
|
2021
|
+
if (bounds === undefined || !dead(t, bounds)) break
|
|
2022
|
+
spent.add(this.kindAt(i))
|
|
2023
|
+
}
|
|
2024
|
+
at[x] = p
|
|
2025
|
+
// A spent kind is passed here only: a give clears `spent`.
|
|
2026
|
+
let r = p
|
|
2027
|
+
while (r < q.length && (this.taken[q[r]!] === 1 || spent.has(this.kindAt(q[r]!)))) r++
|
|
2028
|
+
if (r < q.length && (next === -1 || this.before(q[r]!, next))) {
|
|
2029
|
+
next = q[r]!
|
|
948
2030
|
}
|
|
949
2031
|
}
|
|
950
2032
|
if (next === -1) return
|
|
951
|
-
at.set(nextKind, at.get(nextKind)! + 1)
|
|
952
2033
|
cursor = next
|
|
953
2034
|
const t = this.tasks[next]!
|
|
954
|
-
yield { id: t.id, need: t, kind:
|
|
2035
|
+
yield { id: t.id, need: t, kind: this.kindAt(next) }
|
|
955
2036
|
}
|
|
956
2037
|
}
|
|
957
2038
|
|
|
2039
|
+
private classHeadOf(c: number, q: readonly number[]): number {
|
|
2040
|
+
let h = this.classHead.get(c) ?? 0
|
|
2041
|
+
while (h < q.length && this.taken[q[h]!] === 1) h++
|
|
2042
|
+
this.classHead.set(c, h)
|
|
2043
|
+
return h
|
|
2044
|
+
}
|
|
2045
|
+
|
|
958
2046
|
private headOf(k: string, q: readonly number[]): number {
|
|
959
2047
|
let h = this.head.get(k) ?? 0
|
|
960
2048
|
while (h < q.length && this.taken[q[h]!] === 1) h++
|
|
@@ -986,3 +2074,193 @@ function floorOf(needs: Iterable<{ cores: number; memory: number }>): {
|
|
|
986
2074
|
}
|
|
987
2075
|
return { cores, memory }
|
|
988
2076
|
}
|
|
2077
|
+
|
|
2078
|
+
/** Workers of one shape. */
|
|
2079
|
+
export interface Fleet {
|
|
2080
|
+
readonly count: number
|
|
2081
|
+
readonly cores: number
|
|
2082
|
+
readonly memory: number
|
|
2083
|
+
readonly platform?: string | undefined
|
|
2084
|
+
/** The declared size, when it is one. */
|
|
2085
|
+
readonly size?: string | undefined
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/** Workers that end `tasks` soonest, and when. */
|
|
2089
|
+
export interface Fleets {
|
|
2090
|
+
readonly fleet: readonly Fleet[]
|
|
2091
|
+
readonly endMs: number
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* What would have run `tasks` best, played from an empty run (`advise`):
|
|
2096
|
+
* on the declared sizes, how many of each; past their `max`, how many it
|
|
2097
|
+
* would take; and of any cores, the shape that ends as soon for the
|
|
2098
|
+
* least core time.
|
|
2099
|
+
*/
|
|
2100
|
+
export interface Advice {
|
|
2101
|
+
readonly declared?: Fleets | undefined
|
|
2102
|
+
/** Only when more than the sizes' `max` end it past the margin sooner. */
|
|
2103
|
+
readonly more?: Fleets | undefined
|
|
2104
|
+
readonly any?: Fleets | undefined
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/** The cores a shape of `Advice.any` may have. */
|
|
2108
|
+
const SHAPES = [2, 4, 8, 16, 32, 64]
|
|
2109
|
+
/** Memory a core of a shape has when nothing declares any: as GitHub's larger runners. */
|
|
2110
|
+
const MEMORY_PER_CORE = 4 * 2 ** 30
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* The run's workers, judged after it: `tasks` as each took, played from
|
|
2114
|
+
* nothing on the fewest of `sizes`, in the order the live plan starts
|
|
2115
|
+
* them (`candidates`), that end within its margin of all of them; again
|
|
2116
|
+
* with each size's `max` lifted; and on one shape a platform of any
|
|
2117
|
+
* cores. Each worker joins after its platform's `warm` warm-up.
|
|
2118
|
+
*/
|
|
2119
|
+
export function advise(
|
|
2120
|
+
tasks: readonly PlanTask[],
|
|
2121
|
+
sizes: readonly Size[],
|
|
2122
|
+
warm: (platform: string | undefined) => number,
|
|
2123
|
+
how: Strategy = DEFAULT_STRATEGY,
|
|
2124
|
+
): Advice {
|
|
2125
|
+
if (tasks.length === 0) return {}
|
|
2126
|
+
const perPlatform = new Map<string | undefined, number>()
|
|
2127
|
+
for (const t of tasks) perPlatform.set(t.platform, (perPlatform.get(t.platform) ?? 0) + 1)
|
|
2128
|
+
const wants = new Map(
|
|
2129
|
+
[...perPlatform].flatMap(([p, n]) => (p === undefined ? [] : [[p, n] as const])),
|
|
2130
|
+
)
|
|
2131
|
+
const atOf = (s: Size): number => warm(s.platform)
|
|
2132
|
+
const fleetOf = (kept: readonly PlanWorker[], of: readonly Size[]): Fleet[] => {
|
|
2133
|
+
const by = new Map<string, number>()
|
|
2134
|
+
for (const k of kept) if (k.size !== undefined) by.set(k.size, (by.get(k.size) ?? 0) + 1)
|
|
2135
|
+
return of.flatMap((s) => {
|
|
2136
|
+
const count = by.get(s.name) ?? 0
|
|
2137
|
+
return count === 0
|
|
2138
|
+
? []
|
|
2139
|
+
: [{ count, cores: s.cpu, memory: s.memory, platform: s.platform, size: s.name }]
|
|
2140
|
+
})
|
|
2141
|
+
}
|
|
2142
|
+
const byName = (w: readonly Size[]) => (size: string | undefined) =>
|
|
2143
|
+
atOf(w.find((s) => s.name === size)!)
|
|
2144
|
+
// More workers than run at once end it no sooner: of `cores` on a
|
|
2145
|
+
// platform, at most twice its work over the longest chain, a task each
|
|
2146
|
+
// at most. Without the cap 1000 tasks played 1000 workers (70 s).
|
|
2147
|
+
const chain = longestMs(tasks)
|
|
2148
|
+
const work = new Map<string | undefined, number>()
|
|
2149
|
+
for (const t of tasks)
|
|
2150
|
+
work.set(t.platform, (work.get(t.platform) ?? 0) + t.cores * (t.ms ?? UNKNOWN_MS))
|
|
2151
|
+
const capOf = (cores: number, platform: string | undefined): number => {
|
|
2152
|
+
let w = 0
|
|
2153
|
+
let n = 0
|
|
2154
|
+
for (const [p, c] of perPlatform)
|
|
2155
|
+
if (platform === undefined || p === platform) {
|
|
2156
|
+
w += work.get(p) ?? 0
|
|
2157
|
+
n += c
|
|
2158
|
+
}
|
|
2159
|
+
return Math.min(n, 2 * Math.ceil(w / cores / Math.max(1, chain)) + 1)
|
|
2160
|
+
}
|
|
2161
|
+
/** The fewest of `of`, each up to its `max`, ending within the margin of all of them: halving. */
|
|
2162
|
+
const halved = (of: readonly Size[]): { fleets: Fleets; kept: PlanWorker[] } | undefined => {
|
|
2163
|
+
const at = byName(of)
|
|
2164
|
+
const all = candidates(of, new Map(), [], Infinity, wants).map((s, i) =>
|
|
2165
|
+
fresh({ ...s, size: s.name }, `\0advice${i}`, at(s.name)),
|
|
2166
|
+
)
|
|
2167
|
+
const best = simulate(tasks, all, how)
|
|
2168
|
+
if (!Number.isFinite(best)) return undefined
|
|
2169
|
+
const bound = best + Math.max(MARGIN_MS, best * MARGIN)
|
|
2170
|
+
let lo = 1
|
|
2171
|
+
let hi = all.length
|
|
2172
|
+
while (lo < hi) {
|
|
2173
|
+
const mid = (lo + hi) >> 1
|
|
2174
|
+
if (simulate(tasks, all.slice(0, mid), how) <= bound) hi = mid
|
|
2175
|
+
else lo = mid + 1
|
|
2176
|
+
}
|
|
2177
|
+
// Then each the run ends as soon without, as the live plan trims.
|
|
2178
|
+
const { kept, before } = dropSpare(
|
|
2179
|
+
all.slice(0, lo),
|
|
2180
|
+
all.slice(0, lo),
|
|
2181
|
+
play(tasks, all.slice(0, lo), how),
|
|
2182
|
+
(ws) => play(tasks, ws, how),
|
|
2183
|
+
)
|
|
2184
|
+
return { fleets: { fleet: fleetOf(kept, of), endMs: before.end }, kept }
|
|
2185
|
+
}
|
|
2186
|
+
const declared = sizes.length === 0 ? undefined : halved(sizes)?.fleets
|
|
2187
|
+
const lifted = sizes.map((s) => ({ ...s, max: Math.max(s.max, capOf(s.cpu, s.platform)) }))
|
|
2188
|
+
const wider = lifted.some((s, i) => s.max > sizes[i]!.max) ? halved(lifted)?.fleets : undefined
|
|
2189
|
+
const more =
|
|
2190
|
+
wider !== undefined &&
|
|
2191
|
+
declared !== undefined &&
|
|
2192
|
+
wider.endMs + Math.max(MARGIN_MS, declared.endMs * MARGIN) < declared.endMs
|
|
2193
|
+
? wider
|
|
2194
|
+
: undefined
|
|
2195
|
+
// Of any cores: each platform's memory a core as its sizes have most.
|
|
2196
|
+
const ratio = new Map<string | undefined, number>()
|
|
2197
|
+
for (const s of sizes)
|
|
2198
|
+
ratio.set(s.platform, Math.max(ratio.get(s.platform) ?? 0, s.memory / s.cpu))
|
|
2199
|
+
const most = new Map<string | undefined, number>()
|
|
2200
|
+
for (const t of tasks) most.set(t.platform, Math.max(most.get(t.platform) ?? 0, t.memory))
|
|
2201
|
+
const tried: { fleets: Fleets; coreMs: number; workers: number }[] = []
|
|
2202
|
+
for (const cores of SHAPES) {
|
|
2203
|
+
const shapes: Size[] = [...perPlatform.keys()].map((platform) => ({
|
|
2204
|
+
name: `${cores}#${platform ?? ''}`,
|
|
2205
|
+
max: capOf(cores, platform),
|
|
2206
|
+
cpu: cores,
|
|
2207
|
+
memory: Math.max(
|
|
2208
|
+
cores * (ratio.get(platform) ?? ratio.get(undefined) ?? MEMORY_PER_CORE),
|
|
2209
|
+
most.get(platform) ?? 0,
|
|
2210
|
+
),
|
|
2211
|
+
platform,
|
|
2212
|
+
}))
|
|
2213
|
+
const got = halved(shapes)
|
|
2214
|
+
if (got === undefined) continue
|
|
2215
|
+
let coreMs = 0
|
|
2216
|
+
for (const k of got.kept) coreMs += k.cores * Math.max(0, got.fleets.endMs - k.at)
|
|
2217
|
+
tried.push({ fleets: got.fleets, coreMs, workers: got.kept.length })
|
|
2218
|
+
}
|
|
2219
|
+
let any: Fleets | undefined
|
|
2220
|
+
if (tried.length > 0) {
|
|
2221
|
+
const soonest = Math.min(...tried.map((t) => t.fleets.endMs))
|
|
2222
|
+
const bound = soonest + Math.max(MARGIN_MS, soonest * MARGIN)
|
|
2223
|
+
let cheapest: (typeof tried)[number] | undefined
|
|
2224
|
+
for (const t of tried)
|
|
2225
|
+
if (
|
|
2226
|
+
t.fleets.endMs <= bound &&
|
|
2227
|
+
(cheapest === undefined ||
|
|
2228
|
+
t.coreMs < cheapest.coreMs ||
|
|
2229
|
+
// As cheap, fewer machines.
|
|
2230
|
+
(t.coreMs === cheapest.coreMs && t.workers < cheapest.workers))
|
|
2231
|
+
)
|
|
2232
|
+
cheapest = t
|
|
2233
|
+
any = {
|
|
2234
|
+
fleet: cheapest!.fleets.fleet.map((f) => ({
|
|
2235
|
+
count: f.count,
|
|
2236
|
+
cores: f.cores,
|
|
2237
|
+
memory: f.memory,
|
|
2238
|
+
platform: f.platform,
|
|
2239
|
+
})),
|
|
2240
|
+
endMs: cheapest!.fleets.endMs,
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
return { declared, more, any }
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/** The longest chain of `tasks` by their `ms`, through the deps among them. */
|
|
2247
|
+
function longestMs(tasks: readonly PlanTask[]): number {
|
|
2248
|
+
const byId = new Map(tasks.map((t) => [t.id, t]))
|
|
2249
|
+
const memo = new Map<string, number>()
|
|
2250
|
+
const of = (t: PlanTask): number => {
|
|
2251
|
+
const had = memo.get(t.id)
|
|
2252
|
+
if (had !== undefined) return had
|
|
2253
|
+
memo.set(t.id, 0)
|
|
2254
|
+
let up = 0
|
|
2255
|
+
for (const d of t.deps) {
|
|
2256
|
+
const u = byId.get(d)
|
|
2257
|
+
if (u !== undefined) up = Math.max(up, of(u))
|
|
2258
|
+
}
|
|
2259
|
+
const out = up + (t.ms ?? UNKNOWN_MS)
|
|
2260
|
+
memo.set(t.id, out)
|
|
2261
|
+
return out
|
|
2262
|
+
}
|
|
2263
|
+
let most = 0
|
|
2264
|
+
for (const t of tasks) most = Math.max(most, of(t))
|
|
2265
|
+
return most
|
|
2266
|
+
}
|